diff --git a/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs b/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs index 990ac27ec2..295db790e3 100644 --- a/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs +++ b/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs @@ -1,8 +1,11 @@ using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.Enums; -using Bit.Core.Models.Business; +using Bit.Core.Exceptions; using Bit.Core.Models.Data; using Bit.Core.Utilities; +using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite; namespace Bit.Scim.Models; @@ -10,7 +13,8 @@ public class ScimUserRequestModel : BaseScimUserModel { public ScimUserRequestModel() : base(false) - { } + { + } public OrganizationUserInvite ToOrganizationUserInvite(ScimProviderType scimProvider) { @@ -25,6 +29,31 @@ public class ScimUserRequestModel : BaseScimUserModel }; } + public InviteOrganizationUsersRequest ToRequest( + ScimProviderType scimProvider, + InviteOrganization inviteOrganization, + DateTimeOffset performedAt) + { + var email = EmailForInvite(scimProvider); + + if (string.IsNullOrWhiteSpace(email) || !Active) + { + throw new BadRequestException(); + } + + return new InviteOrganizationUsersRequest( + invites: + [ + new Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite( + email: email, + externalId: ExternalIdForInvite() + ) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, // SCIM does not have a user id + performedAt: performedAt); + } + private string EmailForInvite(ScimProviderType scimProvider) { var email = PrimaryEmail?.ToLowerInvariant(); diff --git a/bitwarden_license/src/Scim/Users/PostUserCommand.cs b/bitwarden_license/src/Scim/Users/PostUserCommand.cs index 26ddd20512..46116a46ae 100644 --- a/bitwarden_license/src/Scim/Users/PostUserCommand.cs +++ b/bitwarden_license/src/Scim/Users/PostUserCommand.cs @@ -1,39 +1,99 @@ -using Bit.Core.Enums; +#nullable enable + +using Bit.Core; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.Billing.Pricing; +using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Commands; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Scim.Context; using Bit.Scim.Models; using Bit.Scim.Users.Interfaces; +using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors.ErrorMapper; namespace Bit.Scim.Users; -public class PostUserCommand : IPostUserCommand +public class PostUserCommand( + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationService organizationService, + IPaymentService paymentService, + IScimContext scimContext, + IFeatureService featureService, + IInviteOrganizationUsersCommand inviteOrganizationUsersCommand, + TimeProvider timeProvider, + IPricingClient pricingClient) + : IPostUserCommand { - private readonly IOrganizationRepository _organizationRepository; - private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IOrganizationService _organizationService; - private readonly IPaymentService _paymentService; - private readonly IScimContext _scimContext; - - public PostUserCommand( - IOrganizationRepository organizationRepository, - IOrganizationUserRepository organizationUserRepository, - IOrganizationService organizationService, - IPaymentService paymentService, - IScimContext scimContext) + public async Task PostUserAsync(Guid organizationId, ScimUserRequestModel model) { - _organizationRepository = organizationRepository; - _organizationUserRepository = organizationUserRepository; - _organizationService = organizationService; - _paymentService = paymentService; - _scimContext = scimContext; + if (featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization) is false) + { + return await InviteScimOrganizationUserAsync(model, organizationId, scimContext.RequestScimProvider); + } + + return await InviteScimOrganizationUserAsync_vNext(model, organizationId, scimContext.RequestScimProvider); } - public async Task PostUserAsync(Guid organizationId, ScimUserRequestModel model) + private async Task InviteScimOrganizationUserAsync_vNext( + ScimUserRequestModel model, + Guid organizationId, + ScimProviderType scimProvider) + { + var organization = await organizationRepository.GetByIdAsync(organizationId); + + if (organization is null) + { + throw new NotFoundException(); + } + + var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); + + var request = model.ToRequest( + scimProvider: scimProvider, + inviteOrganization: new InviteOrganization(organization, plan), + performedAt: timeProvider.GetUtcNow()); + + var orgUsers = await organizationUserRepository + .GetManyDetailsByOrganizationAsync(request.InviteOrganization.OrganizationId); + + if (orgUsers.Any(existingUser => + request.Invites.First().Email.Equals(existingUser.Email, StringComparison.OrdinalIgnoreCase) || + request.Invites.First().ExternalId.Equals(existingUser.ExternalId, StringComparison.OrdinalIgnoreCase))) + { + throw new ConflictException("User already exists."); + } + + var result = await inviteOrganizationUsersCommand.InviteScimOrganizationUserAsync(request); + + var invitedOrganizationUserId = result switch + { + Success success => success.Value.InvitedUser.Id, + Failure failure when failure.Errors + .Any(x => x.Message == NoUsersToInviteError.Code) => (Guid?)null, + Failure failure when failure.Errors.Length != 0 => throw MapToBitException(failure.Errors), + _ => throw new InvalidOperationException() + }; + + var organizationUser = invitedOrganizationUserId.HasValue + ? await organizationUserRepository.GetDetailsByIdAsync(invitedOrganizationUserId.Value) + : null; + + return organizationUser; + } + + private async Task InviteScimOrganizationUserAsync( + ScimUserRequestModel model, + Guid organizationId, + ScimProviderType scimProvider) { - var scimProvider = _scimContext.RequestScimProvider; var invite = model.ToOrganizationUserInvite(scimProvider); var email = invite.Emails.Single(); @@ -44,7 +104,7 @@ public class PostUserCommand : IPostUserCommand throw new BadRequestException(); } - var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); + var orgUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); var orgUserByEmail = orgUsers.FirstOrDefault(ou => ou.Email?.ToLowerInvariant() == email); if (orgUserByEmail != null) { @@ -57,13 +117,21 @@ public class PostUserCommand : IPostUserCommand throw new ConflictException(); } - var organization = await _organizationRepository.GetByIdAsync(organizationId); - var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization); + var organization = await organizationRepository.GetByIdAsync(organizationId); + + if (organization == null) + { + throw new NotFoundException(); + } + + var hasStandaloneSecretsManager = await paymentService.HasSecretsManagerStandalone(organization); invite.AccessSecretsManager = hasStandaloneSecretsManager; - var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM, - invite, externalId); - var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id); + var invitedOrgUser = await organizationService.InviteUserAsync(organizationId, invitingUserId: null, + EventSystemUser.SCIM, + invite, + externalId); + var orgUser = await organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id); return orgUser; } diff --git a/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/UsersControllerTests.cs b/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/UsersControllerTests.cs index 4c4fda952a..1f86d99b63 100644 --- a/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/UsersControllerTests.cs +++ b/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/UsersControllerTests.cs @@ -1,9 +1,12 @@ using System.Text.Json; +using Bit.Core; using Bit.Core.Enums; +using Bit.Core.Services; using Bit.Scim.IntegrationTest.Factories; using Bit.Scim.Models; using Bit.Scim.Utilities; using Bit.Test.Common.Helpers; +using NSubstitute; using Xunit; namespace Bit.Scim.IntegrationTest.Controllers.v2; @@ -276,9 +279,18 @@ public class UsersControllerTests : IClassFixture, IAsyn AssertHelper.AssertPropertyEqual(expectedResponse, responseModel); } - [Fact] - public async Task Post_Success() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Post_Success(bool isScimInviteUserOptimizationEnabled) { + var localFactory = new ScimApplicationFactory(); + localFactory.SubstituteService((IFeatureService featureService) + => featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization) + .Returns(isScimInviteUserOptimizationEnabled)); + + localFactory.ReinitializeDbForTests(localFactory.GetDatabaseContext()); + var email = "user5@example.com"; var displayName = "Test User 5"; var externalId = "UE"; @@ -306,7 +318,7 @@ public class UsersControllerTests : IClassFixture, IAsyn Schemas = new List { ScimConstants.Scim2SchemaUser } }; - var context = await _factory.UsersPostAsync(ScimApplicationFactory.TestOrganizationId1, inputModel); + var context = await localFactory.UsersPostAsync(ScimApplicationFactory.TestOrganizationId1, inputModel); Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode); @@ -316,7 +328,7 @@ public class UsersControllerTests : IClassFixture, IAsyn var responseModel = JsonSerializer.Deserialize(context.Response.Body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); AssertHelper.AssertPropertyEqual(expectedResponse, responseModel, "Id"); - var databaseContext = _factory.GetDatabaseContext(); + var databaseContext = localFactory.GetDatabaseContext(); Assert.Equal(_initialUserCount + 1, databaseContext.OrganizationUsers.Count()); } diff --git a/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs b/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs index 71ad6361bd..ac23e7ecc1 100644 --- a/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs +++ b/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs @@ -27,7 +27,7 @@ public class PostUserCommandTests ExternalId = externalId, Emails = emails, Active = true, - Schemas = new List { ScimConstants.Scim2SchemaUser } + Schemas = [ScimConstants.Scim2SchemaUser] }; sutProvider.GetDependency() @@ -39,13 +39,16 @@ public class PostUserCommandTests sutProvider.GetDependency().HasSecretsManagerStandalone(organization).Returns(true); sutProvider.GetDependency() - .InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM, + .InviteUserAsync(organizationId, + invitingUserId: null, + EventSystemUser.SCIM, Arg.Is(i => i.Emails.Single().Equals(scimUserRequestModel.PrimaryEmail.ToLowerInvariant()) && i.Type == OrganizationUserType.User && !i.Collections.Any() && !i.Groups.Any() && - i.AccessSecretsManager), externalId) + i.AccessSecretsManager), + externalId) .Returns(newUser); var user = await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel); diff --git a/src/Core/AdminConsole/Errors/Error.cs b/src/Core/AdminConsole/Errors/Error.cs index 6c8eed41a4..7ad057d6ed 100644 --- a/src/Core/AdminConsole/Errors/Error.cs +++ b/src/Core/AdminConsole/Errors/Error.cs @@ -1,3 +1,8 @@ namespace Bit.Core.AdminConsole.Errors; public record Error(string Message, T ErroredValue); + +public static class ErrorMappers +{ + public static Error ToError(this Error errorA, B erroredValue) => new(errorA.Message, erroredValue); +} diff --git a/src/Core/AdminConsole/Errors/InvalidResultTypeError.cs b/src/Core/AdminConsole/Errors/InvalidResultTypeError.cs new file mode 100644 index 0000000000..67b5b634fb --- /dev/null +++ b/src/Core/AdminConsole/Errors/InvalidResultTypeError.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.AdminConsole.Errors; + +public record InvalidResultTypeError(T Value) : Error(Code, Value) +{ + public const string Code = "Invalid result type."; +}; diff --git a/src/Core/AdminConsole/Models/Business/InviteOrganization.cs b/src/Core/AdminConsole/Models/Business/InviteOrganization.cs new file mode 100644 index 0000000000..175ee07a9f --- /dev/null +++ b/src/Core/AdminConsole/Models/Business/InviteOrganization.cs @@ -0,0 +1,35 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Models.StaticStore; + +namespace Bit.Core.AdminConsole.Models.Business; + +public record InviteOrganization +{ + public Guid OrganizationId { get; init; } + public int? Seats { get; init; } + public int? MaxAutoScaleSeats { get; init; } + public int? SmSeats { get; init; } + public int? SmMaxAutoScaleSeats { get; init; } + public Plan Plan { get; init; } + public string GatewayCustomerId { get; init; } + public string GatewaySubscriptionId { get; init; } + public bool UseSecretsManager { get; init; } + + public InviteOrganization() + { + + } + + public InviteOrganization(Organization organization, Plan plan) + { + OrganizationId = organization.Id; + Seats = organization.Seats; + MaxAutoScaleSeats = organization.MaxAutoscaleSeats; + SmSeats = organization.SmSeats; + SmMaxAutoScaleSeats = organization.MaxAutoscaleSmSeats; + Plan = plan; + GatewayCustomerId = organization.GatewayCustomerId; + GatewaySubscriptionId = organization.GatewaySubscriptionId; + UseSecretsManager = organization.UseSecretsManager; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/ErrorMapper.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/ErrorMapper.cs new file mode 100644 index 0000000000..c66d366de5 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/ErrorMapper.cs @@ -0,0 +1,37 @@ +using Bit.Core.AdminConsole.Errors; +using Bit.Core.Exceptions; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors; + +public static class ErrorMapper +{ + + /// + /// Maps the ErrorT to a Bit.Exception class. + /// + /// + /// + /// + public static Exception MapToBitException(Error error) => + error switch + { + UserAlreadyExistsError alreadyExistsError => new ConflictException(alreadyExistsError.Message), + _ => new BadRequestException(error.Message) + }; + + /// + /// This maps the ErrorT object to the Bit.Exception class. + /// + /// This should be replaced by an IActionResult mapper when possible. + /// + /// + /// + /// + public static Exception MapToBitException(ICollection> errors) => + errors switch + { + not null when errors.Count == 1 => MapToBitException(errors.First()), + not null when errors.Count > 1 => new BadRequestException(string.Join(' ', errors.Select(e => e.Message))), + _ => new BadRequestException() + }; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/FailedToInviteUsersError.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/FailedToInviteUsersError.cs new file mode 100644 index 0000000000..810ef744c9 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/FailedToInviteUsersError.cs @@ -0,0 +1,9 @@ +using Bit.Core.AdminConsole.Errors; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors; + +public record FailedToInviteUsersError(InviteOrganizationUsersResponse Response) : Error(Code, Response) +{ + public const string Code = "Failed to invite users"; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/NoUsersToInviteError.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/NoUsersToInviteError.cs new file mode 100644 index 0000000000..52697572e6 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/NoUsersToInviteError.cs @@ -0,0 +1,9 @@ +using Bit.Core.AdminConsole.Errors; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors; + +public record NoUsersToInviteError(InviteOrganizationUsersResponse Response) : Error(Code, Response) +{ + public const string Code = "No users to invite"; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/UserAlreadyExistsError.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/UserAlreadyExistsError.cs new file mode 100644 index 0000000000..475ad4a886 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Errors/UserAlreadyExistsError.cs @@ -0,0 +1,9 @@ +using Bit.Core.AdminConsole.Errors; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors; + +public record UserAlreadyExistsError(ScimInviteOrganizationUsersResponse Response) : Error(Code, Response) +{ + public const string Code = "User already exists"; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IInviteOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IInviteOrganizationUsersCommand.cs new file mode 100644 index 0000000000..3e4c7652a5 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IInviteOrganizationUsersCommand.cs @@ -0,0 +1,22 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.Models.Commands; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +/// +/// Defines the contract for inviting organization users via SCIM (System for Cross-domain Identity Management). +/// Provides functionality for handling single email invitation requests within an organization context. +/// +public interface IInviteOrganizationUsersCommand +{ + /// + /// Sends an invitation to add an organization user via SCIM (System for Cross-domain Identity Management) system. + /// This can be a Success or a Failure. Failure will contain the Error along with a representation of the errored value. + /// Success will be the successful return object. + /// + /// + /// Contains the details for inviting a single organization user via email. + /// + /// Response from InviteScimOrganiation + Task> InviteScimOrganizationUserAsync(InviteOrganizationUsersRequest request); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/ISendOrganizationInvitesCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/ISendOrganizationInvitesCommand.cs new file mode 100644 index 0000000000..090317640f --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/ISendOrganizationInvitesCommand.cs @@ -0,0 +1,16 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +/// +/// This is for sending the invite to an organization user. +/// +public interface ISendOrganizationInvitesCommand +{ + /// + /// This sends emails out to organization users for a given organization. + /// + /// + /// + Task SendInvitesAsync(SendInvitesRequest request); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs new file mode 100644 index 0000000000..4eacb9386a --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs @@ -0,0 +1,282 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Errors; +using Bit.Core.AdminConsole.Interfaces; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Models.Business; +using Bit.Core.Models.Commands; +using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Business; +using Bit.Core.Tools.Services; +using Microsoft.Extensions.Logging; +using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +public class InviteOrganizationUsersCommand(IEventService eventService, + IOrganizationUserRepository organizationUserRepository, + IInviteUsersValidator inviteUsersValidator, + IPaymentService paymentService, + IOrganizationRepository organizationRepository, + IReferenceEventService referenceEventService, + ICurrentContext currentContext, + IApplicationCacheService applicationCacheService, + IMailService mailService, + ILogger logger, + IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, + ISendOrganizationInvitesCommand sendOrganizationInvitesCommand, + IProviderOrganizationRepository providerOrganizationRepository, + IProviderUserRepository providerUserRepository + ) : IInviteOrganizationUsersCommand +{ + + public const string IssueNotifyingOwnersOfSeatLimitReached = "Error encountered notifying organization owners of seat limit reached."; + + public async Task> InviteScimOrganizationUserAsync(InviteOrganizationUsersRequest request) + { + var result = await InviteOrganizationUsersAsync(request); + + switch (result) + { + case Failure failure: + return new Failure( + failure.Errors.Select(error => new Error(error.Message, + new ScimInviteOrganizationUsersResponse + { + InvitedUser = error.ErroredValue.InvitedUsers.FirstOrDefault() + }))); + + case Success success when success.Value.InvitedUsers.Any(): + var user = success.Value.InvitedUsers.First(); + + await eventService.LogOrganizationUserEventAsync( + organizationUser: user, + type: EventType.OrganizationUser_Invited, + systemUser: EventSystemUser.SCIM, + date: request.PerformedAt.UtcDateTime); + + return new Success(new ScimInviteOrganizationUsersResponse + { + InvitedUser = user + }); + + default: + return new Failure( + new InvalidResultTypeError( + new ScimInviteOrganizationUsersResponse())); + } + } + + private async Task> InviteOrganizationUsersAsync(InviteOrganizationUsersRequest request) + { + var invitesToSend = (await FilterExistingUsersAsync(request)).ToArray(); + + if (invitesToSend.Length == 0) + { + return new Failure(new NoUsersToInviteError( + new InviteOrganizationUsersResponse(request.InviteOrganization.OrganizationId))); + } + + var validationResult = await inviteUsersValidator.ValidateAsync(new InviteOrganizationUsersValidationRequest + { + Invites = invitesToSend.ToArray(), + InviteOrganization = request.InviteOrganization, + PerformedBy = request.PerformedBy, + PerformedAt = request.PerformedAt, + OccupiedPmSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId), + OccupiedSmSeats = await organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId) + }); + + if (validationResult is Invalid invalid) + { + return invalid.MapToFailure(r => new InviteOrganizationUsersResponse(r)); + } + + var validatedRequest = validationResult as Valid; + + var organizationUserToInviteEntities = invitesToSend + .Select(x => x.MapToDataModel(request.PerformedAt, validatedRequest!.Value.InviteOrganization)) + .ToArray(); + + var organization = await organizationRepository.GetByIdAsync(validatedRequest!.Value.InviteOrganization.OrganizationId); + + try + { + await organizationUserRepository.CreateManyAsync(organizationUserToInviteEntities); + + await AdjustPasswordManagerSeatsAsync(validatedRequest, organization); + + await AdjustSecretsManagerSeatsAsync(validatedRequest); + + await SendAdditionalEmailsAsync(validatedRequest, organization); + + await SendInvitesAsync(organizationUserToInviteEntities, organization); + + await PublishReferenceEventAsync(validatedRequest, organization); + } + catch (Exception ex) + { + logger.LogError(ex, FailedToInviteUsersError.Code); + + await organizationUserRepository.DeleteManyAsync(organizationUserToInviteEntities.Select(x => x.OrganizationUser.Id)); + + // Do this first so that SmSeats never exceed PM seats (due to current billing requirements) + await RevertSecretsManagerChangesAsync(validatedRequest, organization, validatedRequest.Value.InviteOrganization.SmSeats); + + await RevertPasswordManagerChangesAsync(validatedRequest, organization); + + return new Failure( + new FailedToInviteUsersError( + new InviteOrganizationUsersResponse(validatedRequest.Value))); + } + + return new Success( + new InviteOrganizationUsersResponse( + invitedOrganizationUsers: organizationUserToInviteEntities.Select(x => x.OrganizationUser).ToArray(), + organizationId: organization!.Id)); + } + + private async Task> FilterExistingUsersAsync(InviteOrganizationUsersRequest request) + { + var existingEmails = new HashSet(await organizationUserRepository.SelectKnownEmailsAsync( + request.InviteOrganization.OrganizationId, request.Invites.Select(i => i.Email), false), + StringComparer.OrdinalIgnoreCase); + + return request.Invites + .Where(invite => !existingEmails.Contains(invite.Email)) + .ToArray(); + } + + private async Task RevertPasswordManagerChangesAsync(Valid validatedResult, Organization organization) + { + if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd > 0) + { + // When reverting seats, we have to tell payments service that the seats are going back down by what we attempted to add. + // However, this might lead to a problem if we don't actually update stripe but throw any ways. + // stripe could not be updated, and then we would decrement the number of seats in stripe accidentally. + var seatsToRemove = validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd; + await paymentService.AdjustSeatsAsync(organization, validatedResult.Value.InviteOrganization.Plan, -seatsToRemove); + + organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats; + + await organizationRepository.ReplaceAsync(organization); + await applicationCacheService.UpsertOrganizationAbilityAsync(organization); + } + } + + private async Task RevertSecretsManagerChangesAsync(Valid validatedResult, Organization organization, int? initialSmSeats) + { + if (validatedResult.Value.SecretsManagerSubscriptionUpdate?.SmSeatsChanged is true) + { + var smSubscriptionUpdateRevert = new SecretsManagerSubscriptionUpdate( + organization: organization, + plan: validatedResult.Value.InviteOrganization.Plan, + autoscaling: false) + { + SmSeats = initialSmSeats + }; + + await updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(smSubscriptionUpdateRevert); + } + } + + private async Task PublishReferenceEventAsync(Valid validatedResult, + Organization organization) => + await referenceEventService.RaiseEventAsync( + new ReferenceEvent(ReferenceEventType.InvitedUsers, organization, currentContext) + { + Users = validatedResult.Value.Invites.Length + }); + + private async Task SendInvitesAsync(IEnumerable users, Organization organization) => + await sendOrganizationInvitesCommand.SendInvitesAsync( + new SendInvitesRequest( + users.Select(x => x.OrganizationUser), + organization)); + + private async Task SendAdditionalEmailsAsync(Valid validatedResult, Organization organization) + { + await SendPasswordManagerMaxSeatLimitEmailsAsync(validatedResult, organization); + } + + private async Task SendPasswordManagerMaxSeatLimitEmailsAsync(Valid validatedResult, Organization organization) + { + if (!validatedResult.Value.PasswordManagerSubscriptionUpdate.MaxSeatsReached) + { + return; + } + + try + { + var ownerEmails = await GetOwnerEmailAddressesAsync(validatedResult.Value.InviteOrganization); + + await mailService.SendOrganizationMaxSeatLimitReachedEmailAsync(organization, + validatedResult.Value.PasswordManagerSubscriptionUpdate.MaxAutoScaleSeats!.Value, ownerEmails); + } + catch (Exception ex) + { + logger.LogError(ex, IssueNotifyingOwnersOfSeatLimitReached); + } + } + + private async Task> GetOwnerEmailAddressesAsync(InviteOrganization organization) + { + var providerOrganization = await providerOrganizationRepository + .GetByOrganizationId(organization.OrganizationId); + + if (providerOrganization == null) + { + return (await organizationUserRepository + .GetManyByMinimumRoleAsync(organization.OrganizationId, OrganizationUserType.Owner)) + .Select(x => x.Email) + .Distinct(); + } + + return (await providerUserRepository + .GetManyDetailsByProviderAsync(providerOrganization.ProviderId, ProviderUserStatusType.Confirmed)) + .Select(u => u.Email).Distinct(); + } + + private async Task AdjustSecretsManagerSeatsAsync(Valid validatedResult) + { + if (validatedResult.Value.SecretsManagerSubscriptionUpdate?.SmSeatsChanged is true) + { + await updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(validatedResult.Value.SecretsManagerSubscriptionUpdate); + } + + } + + private async Task AdjustPasswordManagerSeatsAsync(Valid validatedResult, Organization organization) + { + if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd <= 0) + { + return; + } + + await paymentService.AdjustSeatsAsync(organization, validatedResult.Value.InviteOrganization.Plan, validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd); + + organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal; + + await organizationRepository.ReplaceAsync(organization); // could optimize this with only a property update + await applicationCacheService.UpsertOrganizationAbilityAsync(organization); + + await referenceEventService.RaiseEventAsync( + new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, currentContext) + { + PlanName = validatedResult.Value.InviteOrganization.Plan.Name, + PlanType = validatedResult.Value.InviteOrganization.Plan.Type, + Seats = validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal, + PreviousSeats = validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats + }); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUser.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUser.cs new file mode 100644 index 0000000000..a55db3958a --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUser.cs @@ -0,0 +1,15 @@ +using Bit.Core.Entities; +using Bit.Core.Models.Data; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +/// +/// Object for associating the with their assigned collections +/// and Group Ids. +/// +public class CreateOrganizationUser +{ + public OrganizationUser OrganizationUser { get; set; } + public CollectionAccessSelection[] Collections { get; set; } = []; + public Guid[] Groups { get; set; } = []; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUserExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUserExtensions.cs new file mode 100644 index 0000000000..23c38a51cb --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUserExtensions.cs @@ -0,0 +1,30 @@ +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +public static class CreateOrganizationUserExtensions +{ + public static CreateOrganizationUser MapToDataModel(this OrganizationUserInvite organizationUserInvite, + DateTimeOffset performedAt, + InviteOrganization organization) => + new() + { + OrganizationUser = new OrganizationUser + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.OrganizationId, + Email = organizationUserInvite.Email.ToLowerInvariant(), + Type = organizationUserInvite.Type, + Status = OrganizationUserStatusType.Invited, + AccessSecretsManager = organizationUserInvite.AccessSecretsManager, + ExternalId = string.IsNullOrWhiteSpace(organizationUserInvite.ExternalId) ? null : organizationUserInvite.ExternalId, + CreationDate = performedAt.UtcDateTime, + RevisionDate = performedAt.UtcDateTime + }, + Collections = organizationUserInvite.AssignedCollections, + Groups = organizationUserInvite.Groups + }; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUserErrorMessages.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUserErrorMessages.cs new file mode 100644 index 0000000000..bc75e244fc --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUserErrorMessages.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +public static class InviteOrganizationUserErrorMessages +{ + public const string InvalidEmailErrorMessage = "The email address is not valid."; + public const string InvalidCollectionConfigurationErrorMessage = "The Manage property is mutually exclusive and cannot be true while the ReadOnly or HidePasswords properties are also true."; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersRequest.cs new file mode 100644 index 0000000000..84b350c551 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersRequest.cs @@ -0,0 +1,22 @@ +using Bit.Core.AdminConsole.Models.Business; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +public class InviteOrganizationUsersRequest +{ + public OrganizationUserInvite[] Invites { get; } = []; + public InviteOrganization InviteOrganization { get; } + public Guid PerformedBy { get; } + public DateTimeOffset PerformedAt { get; } + + public InviteOrganizationUsersRequest(OrganizationUserInvite[] invites, + InviteOrganization inviteOrganization, + Guid performedBy, + DateTimeOffset performedAt) + { + Invites = invites; + InviteOrganization = inviteOrganization; + PerformedBy = performedBy; + PerformedAt = performedAt; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersResponse.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersResponse.cs new file mode 100644 index 0000000000..ac7d864dd4 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersResponse.cs @@ -0,0 +1,42 @@ +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +public class InviteOrganizationUsersResponse(Guid organizationId) +{ + public IEnumerable InvitedUsers { get; } = []; + public Guid OrganizationId { get; } = organizationId; + + public InviteOrganizationUsersResponse(InviteOrganizationUsersValidationRequest usersValidationRequest) + : this(usersValidationRequest.InviteOrganization.OrganizationId) + { + InvitedUsers = usersValidationRequest.Invites.Select(x => new OrganizationUser { Email = x.Email }); + } + + public InviteOrganizationUsersResponse(IEnumerable invitedOrganizationUsers, Guid organizationId) + : this(organizationId) + { + InvitedUsers = invitedOrganizationUsers; + } +} + +public class ScimInviteOrganizationUsersResponse +{ + public OrganizationUser InvitedUser { get; init; } + + public ScimInviteOrganizationUsersResponse() + { + + } + + public ScimInviteOrganizationUsersResponse(InviteOrganizationUsersRequest request) + { + var userToInvite = request.Invites.First(); + + InvitedUser = new OrganizationUser + { + Email = userToInvite.Email, + ExternalId = userToInvite.ExternalId + }; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersValidationRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersValidationRequest.cs new file mode 100644 index 0000000000..f45c705cab --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersValidationRequest.cs @@ -0,0 +1,40 @@ +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; +using Bit.Core.Models.Business; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +public class InviteOrganizationUsersValidationRequest +{ + public InviteOrganizationUsersValidationRequest() + { + } + + public InviteOrganizationUsersValidationRequest(InviteOrganizationUsersValidationRequest request) + { + Invites = request.Invites; + InviteOrganization = request.InviteOrganization; + PerformedBy = request.PerformedBy; + PerformedAt = request.PerformedAt; + OccupiedPmSeats = request.OccupiedPmSeats; + OccupiedSmSeats = request.OccupiedSmSeats; + } + + public InviteOrganizationUsersValidationRequest(InviteOrganizationUsersValidationRequest request, + PasswordManagerSubscriptionUpdate subscriptionUpdate, + SecretsManagerSubscriptionUpdate smSubscriptionUpdate) + : this(request) + { + PasswordManagerSubscriptionUpdate = subscriptionUpdate; + SecretsManagerSubscriptionUpdate = smSubscriptionUpdate; + } + + public OrganizationUserInvite[] Invites { get; init; } = []; + public InviteOrganization InviteOrganization { get; init; } + public Guid PerformedBy { get; init; } + public DateTimeOffset PerformedAt { get; init; } + public int OccupiedPmSeats { get; init; } + public int OccupiedSmSeats { get; init; } + public PasswordManagerSubscriptionUpdate PasswordManagerSubscriptionUpdate { get; set; } + public SecretsManagerSubscriptionUpdate SecretsManagerSubscriptionUpdate { get; set; } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInvite.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInvite.cs new file mode 100644 index 0000000000..0b83680aa5 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInvite.cs @@ -0,0 +1,77 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data; +using Bit.Core.Utilities; +using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.InviteOrganizationUserErrorMessages; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +public class OrganizationUserInvite +{ + public string Email { get; private init; } + public CollectionAccessSelection[] AssignedCollections { get; private init; } + public OrganizationUserType Type { get; private init; } + public Permissions Permissions { get; private init; } + public string ExternalId { get; private init; } + public bool AccessSecretsManager { get; private init; } + public Guid[] Groups { get; private init; } + + public OrganizationUserInvite(string email, string externalId) : + this( + email: email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + false) + { + } + + public OrganizationUserInvite(OrganizationUserInvite invite, bool accessSecretsManager) : + this(invite.Email, + invite.AssignedCollections, + invite.Groups, + invite.Type, + invite.Permissions, + invite.ExternalId, + accessSecretsManager) + { + + } + + public OrganizationUserInvite(string email, + IEnumerable assignedCollections, + IEnumerable groups, + OrganizationUserType type, + Permissions permissions, + string externalId, + bool accessSecretsManager) + { + ValidateEmailAddress(email); + + var collections = assignedCollections?.ToArray() ?? []; + + if (collections.Any(x => x.IsValidCollectionAccessConfiguration())) + { + throw new BadRequestException(InvalidCollectionConfigurationErrorMessage); + } + + Email = email; + AssignedCollections = collections; + Groups = groups.ToArray(); + Type = type; + Permissions = permissions ?? new Permissions(); + ExternalId = externalId; + AccessSecretsManager = accessSecretsManager; + } + + private static void ValidateEmailAddress(string email) + { + if (!email.IsValidEmail()) + { + throw new BadRequestException($"{email} {InvalidEmailErrorMessage}"); + } + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/SendInvitesRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/SendInvitesRequest.cs new file mode 100644 index 0000000000..2be6430512 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/SendInvitesRequest.cs @@ -0,0 +1,33 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +/// +/// Represents a request to send invitations to a group of organization users. +/// +public class SendInvitesRequest +{ + public SendInvitesRequest(IEnumerable users, Organization organization) => + (Users, Organization) = (users.ToArray(), organization); + + public SendInvitesRequest(IEnumerable users, Organization organization, bool initOrganization) => + (Users, Organization, InitOrganization) = (users.ToArray(), organization, initOrganization); + + /// + /// Organization Users to send emails to. + /// + public OrganizationUser[] Users { get; set; } = []; + + /// + /// The organization to invite the users to. + /// + public Organization Organization { get; init; } + + /// + /// This is for when the organization is being created and this is the owners initial invite + /// + public bool InitOrganization { get; init; } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs new file mode 100644 index 0000000000..ba85ce1d8a --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs @@ -0,0 +1,80 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Models.Business; +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.Repositories; +using Bit.Core.Entities; +using Bit.Core.Models.Mail; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Tokens; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +public class SendOrganizationInvitesCommand( + IUserRepository userRepository, + ISsoConfigRepository ssoConfigurationRepository, + IPolicyRepository policyRepository, + IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory, + IDataProtectorTokenFactory dataProtectorTokenFactory, + IMailService mailService) : ISendOrganizationInvitesCommand +{ + public async Task SendInvitesAsync(SendInvitesRequest request) + { + var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(request.Users, request.Organization, request.InitOrganization); + + await mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo); + } + + private async Task BuildOrganizationInvitesInfoAsync(IEnumerable orgUsers, + Organization organization, bool initOrganization = false) + { + // Materialize the sequence into a list to avoid multiple enumeration warnings + var orgUsersList = orgUsers.ToList(); + + // Email links must include information about the org and user for us to make routing decisions client side + // Given an org user, determine if existing BW user exists + var orgUserEmails = orgUsersList.Select(ou => ou.Email).ToList(); + var existingUsers = await userRepository.GetManyByEmailsAsync(orgUserEmails); + + // hash existing users emails list for O(1) lookups + var existingUserEmailsHashSet = new HashSet(existingUsers.Select(u => u.Email)); + + // Create a dictionary of org user guids and bools for whether or not they have an existing BW user + var orgUserHasExistingUserDict = orgUsersList.ToDictionary( + ou => ou.Id, + ou => existingUserEmailsHashSet.Contains(ou.Email) + ); + + // Determine if org has SSO enabled and if user is required to login with SSO + // Note: we only want to call the DB after checking if the org can use SSO per plan and if they have any policies enabled. + var orgSsoEnabled = organization.UseSso && (await ssoConfigurationRepository.GetByOrganizationIdAsync(organization.Id))?.Enabled == true; + // Even though the require SSO policy can be turned on regardless of SSO being enabled, for this logic, we only + // need to check the policy if the org has SSO enabled. + var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled && + organization.UsePolicies && + (await policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.RequireSso))?.Enabled == true; + + // Generate the list of org users and expiring tokens + // create helper function to create expiring tokens + (OrganizationUser, ExpiringToken) MakeOrgUserExpiringTokenPair(OrganizationUser orgUser) + { + var orgUserInviteTokenable = orgUserInviteTokenableFactory.CreateToken(orgUser); + var protectedToken = dataProtectorTokenFactory.Protect(orgUserInviteTokenable); + return (orgUser, new ExpiringToken(protectedToken, orgUserInviteTokenable.ExpirationDate)); + } + + var orgUsersWithExpTokens = orgUsers.Select(MakeOrgUserExpiringTokenPair); + + return new OrganizationInvitesInfo( + organization, + orgSsoEnabled, + orgSsoLoginRequiredPolicyEnabled, + orgUsersWithExpTokens, + orgUserHasExistingUserDict, + initOrganization + ); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/CollectionAccessSelectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/CollectionAccessSelectionExtensions.cs new file mode 100644 index 0000000000..7500ade672 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/CollectionAccessSelectionExtensions.cs @@ -0,0 +1,12 @@ +using Bit.Core.Models.Data; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; + +public static class CollectionAccessSelectionExtensions +{ + /// + /// This validates the permissions on the given assigned collection + /// + public static bool IsValidCollectionAccessConfiguration(this CollectionAccessSelection collectionAccessSelection) => + collectionAccessSelection.Manage && (collectionAccessSelection.ReadOnly || collectionAccessSelection.HidePasswords); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/GlobalSettings/CannotAutoScaleOnSelfHostError.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/GlobalSettings/CannotAutoScaleOnSelfHostError.cs new file mode 100644 index 0000000000..0624ffe027 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/GlobalSettings/CannotAutoScaleOnSelfHostError.cs @@ -0,0 +1,8 @@ +using Bit.Core.AdminConsole.Errors; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings; + +public record CannotAutoScaleOnSelfHostError(EnvironmentRequest Invalid) : Error(Code, Invalid) +{ + public const string Code = "Cannot auto scale self-host."; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/GlobalSettings/EnvironmentRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/GlobalSettings/EnvironmentRequest.cs new file mode 100644 index 0000000000..9c1ff43d17 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/GlobalSettings/EnvironmentRequest.cs @@ -0,0 +1,18 @@ +#nullable enable + +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings; + +public class EnvironmentRequest +{ + public bool IsSelfHosted { get; init; } + public PasswordManagerSubscriptionUpdate PasswordManagerSubscriptionUpdate { get; init; } + + public EnvironmentRequest(IGlobalSettings globalSettings, PasswordManagerSubscriptionUpdate passwordManagerSubscriptionUpdate) + { + IsSelfHosted = globalSettings.SelfHosted; + PasswordManagerSubscriptionUpdate = passwordManagerSubscriptionUpdate; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/GlobalSettings/InviteUsersEnvironmentValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/GlobalSettings/InviteUsersEnvironmentValidator.cs new file mode 100644 index 0000000000..fd0441753a --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/GlobalSettings/InviteUsersEnvironmentValidator.cs @@ -0,0 +1,14 @@ +using Bit.Core.AdminConsole.Shared.Validation; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings; + +public interface IInviteUsersEnvironmentValidator : IValidator; + +public class InviteUsersEnvironmentValidator : IInviteUsersEnvironmentValidator +{ + public Task> ValidateAsync(EnvironmentRequest value) => + Task.FromResult>( + value.IsSelfHosted && value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd > 0 ? + new Invalid(new CannotAutoScaleOnSelfHostError(value)) : + new Valid(value)); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs new file mode 100644 index 0000000000..79a3487d19 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs @@ -0,0 +1,108 @@ +using Bit.Core.AdminConsole.Errors; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; +using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.Models.Business; +using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; +using Bit.Core.Repositories; +using Bit.Core.Services; +using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; + +public interface IInviteUsersValidator : IValidator; + +public class InviteOrganizationUsersValidator( + IOrganizationRepository organizationRepository, + IInviteUsersPasswordManagerValidator inviteUsersPasswordManagerValidator, + IUpdateSecretsManagerSubscriptionCommand secretsManagerSubscriptionCommand, + IPaymentService paymentService) : IInviteUsersValidator +{ + public async Task> ValidateAsync( + InviteOrganizationUsersValidationRequest request) + { + var subscriptionUpdate = new PasswordManagerSubscriptionUpdate(request); + + var passwordManagerValidationResult = + await inviteUsersPasswordManagerValidator.ValidateAsync(subscriptionUpdate); + + if (passwordManagerValidationResult is Invalid invalidSubscriptionUpdate) + { + return invalidSubscriptionUpdate.Map(request); + } + + // If the organization has the Secrets Manager Standalone Discount, all users are added to secrets manager. + // This is an expensive call, so we're doing it now to delay the check as long as possible. + if (await paymentService.HasSecretsManagerStandalone(request.InviteOrganization)) + { + request = new InviteOrganizationUsersValidationRequest(request) + { + Invites = request.Invites + .Select(x => new OrganizationUserInvite(x, accessSecretsManager: true)) + .ToArray() + }; + } + + if (request.InviteOrganization.UseSecretsManager && request.Invites.Any(x => x.AccessSecretsManager)) + { + return await ValidateSecretsManagerSubscriptionUpdateAsync(request, subscriptionUpdate); + } + + return new Valid(new InviteOrganizationUsersValidationRequest( + request, + subscriptionUpdate, + null)); + } + + private async Task> ValidateSecretsManagerSubscriptionUpdateAsync( + InviteOrganizationUsersValidationRequest request, + PasswordManagerSubscriptionUpdate subscriptionUpdate) + { + try + { + + var smSubscriptionUpdate = new SecretsManagerSubscriptionUpdate( + organization: await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId), + plan: request.InviteOrganization.Plan, + autoscaling: true); + + var seatsToAdd = GetSecretManagerSeatAdjustment(request); + + if (seatsToAdd > 0) + { + smSubscriptionUpdate.AdjustSeats(seatsToAdd); + + await secretsManagerSubscriptionCommand.ValidateUpdateAsync(smSubscriptionUpdate); + } + + return new Valid(new InviteOrganizationUsersValidationRequest( + request, + subscriptionUpdate, + smSubscriptionUpdate)); + } + catch (Exception ex) + { + return new Invalid( + new Error(ex.Message, request)); + } + } + + /// + /// This calculates the number of SM seats to add to the organization seat total. + /// + /// If they have a current seat limit (it can be null), we want to figure out how many are available (seats - + /// occupied seats). Then, we'll subtract the available seats from the number of users we're trying to invite. + /// + /// If it's negative, we have available seats and do not need to increase, so we go with 0. + /// + /// + /// + private static int GetSecretManagerSeatAdjustment(InviteOrganizationUsersValidationRequest request) => + request.InviteOrganization.SmSeats.HasValue + ? Math.Max( + request.Invites.Count(x => x.AccessSecretsManager) - + (request.InviteOrganization.SmSeats.Value - + request.OccupiedSmSeats), + 0) + : 0; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Organization/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Organization/Errors.cs new file mode 100644 index 0000000000..5d072ca17d --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Organization/Errors.cs @@ -0,0 +1,16 @@ +using Bit.Core.AdminConsole.Errors; +using Bit.Core.AdminConsole.Models.Business; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization; + +public record OrganizationNoPaymentMethodFoundError(InviteOrganization InvalidRequest) + : Error(Code, InvalidRequest) +{ + public const string Code = "No payment method found."; +} + +public record OrganizationNoSubscriptionFoundError(InviteOrganization InvalidRequest) + : Error(Code, InvalidRequest) +{ + public const string Code = "No subscription found."; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Organization/InviteUsersOrganizationValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Organization/InviteUsersOrganizationValidator.cs new file mode 100644 index 0000000000..9e2ca8d9a6 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Organization/InviteUsersOrganizationValidator.cs @@ -0,0 +1,32 @@ +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.Shared.Validation; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization; + +public interface IInviteUsersOrganizationValidator : IValidator; + +public class InviteUsersOrganizationValidator : IInviteUsersOrganizationValidator +{ + public Task> ValidateAsync(InviteOrganization inviteOrganization) + { + if (inviteOrganization.Seats is null) + { + return Task.FromResult>( + new Valid(inviteOrganization)); + } + + if (string.IsNullOrWhiteSpace(inviteOrganization.GatewayCustomerId)) + { + return Task.FromResult>( + new Invalid(new OrganizationNoPaymentMethodFoundError(inviteOrganization))); + } + + if (string.IsNullOrWhiteSpace(inviteOrganization.GatewaySubscriptionId)) + { + return Task.FromResult>( + new Invalid(new OrganizationNoSubscriptionFoundError(inviteOrganization))); + } + + return Task.FromResult>(new Valid(inviteOrganization)); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/Errors.cs new file mode 100644 index 0000000000..6ff7181456 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/Errors.cs @@ -0,0 +1,30 @@ +using Bit.Core.AdminConsole.Errors; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; + +public record PasswordManagerSeatLimitHasBeenReachedError(PasswordManagerSubscriptionUpdate InvalidRequest) + : Error(Code, InvalidRequest) +{ + public const string Code = "Seat limit has been reached."; +} + +public record PasswordManagerPlanDoesNotAllowAdditionalSeatsError(PasswordManagerSubscriptionUpdate InvalidRequest) + : Error(Code, InvalidRequest) +{ + public const string Code = "Plan does not allow additional seats."; +} + +public record PasswordManagerPlanOnlyAllowsMaxAdditionalSeatsError(PasswordManagerSubscriptionUpdate InvalidRequest) + : Error(GetErrorMessage(InvalidRequest), InvalidRequest) +{ + private static string GetErrorMessage(PasswordManagerSubscriptionUpdate invalidRequest) => + string.Format(Code, invalidRequest.PasswordManagerPlan.MaxAdditionalSeats); + + public const string Code = "Organization plan allows a maximum of {0} additional seats."; +} + +public record PasswordManagerMustHaveSeatsError(PasswordManagerSubscriptionUpdate InvalidRequest) + : Error(Code, InvalidRequest) +{ + public const string Code = "You do not have any Password Manager seats!"; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs new file mode 100644 index 0000000000..1867a2808e --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs @@ -0,0 +1,117 @@ +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; + +public interface IInviteUsersPasswordManagerValidator : IValidator; + +public class InviteUsersPasswordManagerValidator( + IGlobalSettings globalSettings, + IInviteUsersEnvironmentValidator inviteUsersEnvironmentValidator, + IInviteUsersOrganizationValidator inviteUsersOrganizationValidator, + IProviderRepository providerRepository, + IPaymentService paymentService, + IOrganizationRepository organizationRepository + ) : IInviteUsersPasswordManagerValidator +{ + /// + /// This is for validating if the organization can add additional users. + /// + /// + /// + public static ValidationResult ValidatePasswordManager(PasswordManagerSubscriptionUpdate subscriptionUpdate) + { + if (subscriptionUpdate.Seats is null) + { + return new Valid(subscriptionUpdate); + } + + if (subscriptionUpdate.SeatsRequiredToAdd == 0) + { + return new Valid(subscriptionUpdate); + } + + if (subscriptionUpdate.PasswordManagerPlan.BaseSeats + subscriptionUpdate.SeatsRequiredToAdd <= 0) + { + return new Invalid(new PasswordManagerMustHaveSeatsError(subscriptionUpdate)); + } + + if (subscriptionUpdate.MaxSeatsReached) + { + return new Invalid( + new PasswordManagerSeatLimitHasBeenReachedError(subscriptionUpdate)); + } + + if (subscriptionUpdate.PasswordManagerPlan.HasAdditionalSeatsOption is false) + { + return new Invalid( + new PasswordManagerPlanDoesNotAllowAdditionalSeatsError(subscriptionUpdate)); + } + + // Apparently MaxAdditionalSeats is never set. Can probably be removed. + if (subscriptionUpdate.UpdatedSeatTotal - subscriptionUpdate.PasswordManagerPlan.BaseSeats > subscriptionUpdate.PasswordManagerPlan.MaxAdditionalSeats) + { + return new Invalid( + new PasswordManagerPlanOnlyAllowsMaxAdditionalSeatsError(subscriptionUpdate)); + } + + return new Valid(subscriptionUpdate); + } + + public async Task> ValidateAsync(PasswordManagerSubscriptionUpdate request) + { + switch (ValidatePasswordManager(request)) + { + case Valid valid + when valid.Value.SeatsRequiredToAdd is 0: + return new Valid(request); + + case Invalid invalid: + return invalid; + } + + if (await inviteUsersEnvironmentValidator.ValidateAsync(new EnvironmentRequest(globalSettings, request)) is Invalid invalidEnvironment) + { + return invalidEnvironment.Map(request); + } + + var organizationValidationResult = await inviteUsersOrganizationValidator.ValidateAsync(request.InviteOrganization); + + if (organizationValidationResult is Invalid organizationValidation) + { + return organizationValidation.Map(request); + } + + var provider = await providerRepository.GetByOrganizationIdAsync(request.InviteOrganization.OrganizationId); + if (provider is not null) + { + var providerValidationResult = InvitingUserOrganizationProviderValidator.Validate(new InviteOrganizationProvider(provider)); + + if (providerValidationResult is Invalid invalidProviderValidation) + { + return invalidProviderValidation.Map(request); + } + } + + var paymentSubscription = await paymentService.GetSubscriptionAsync( + await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId)); + + var paymentValidationResult = InviteUserPaymentValidation.Validate( + new PaymentsSubscription(paymentSubscription, request.InviteOrganization)); + + if (paymentValidationResult is Invalid invalidPaymentValidation) + { + return invalidPaymentValidation.Map(request); + } + + return new Valid(request); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/PasswordManagerSubscriptionUpdate.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/PasswordManagerSubscriptionUpdate.cs new file mode 100644 index 0000000000..f6126fd8f5 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/PasswordManagerSubscriptionUpdate.cs @@ -0,0 +1,89 @@ +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.Models.StaticStore; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; + +public class PasswordManagerSubscriptionUpdate +{ + /// + /// Seats the organization has + /// + public int? Seats { get; } + + /// + /// Max number of seats that the organization can have + /// + public int? MaxAutoScaleSeats { get; } + + /// + /// Seats currently occupied by current users + /// + public int OccupiedSeats { get; } + + /// + /// Users to add to the organization seats + /// + public int NewUsersToAdd { get; } + + /// + /// Number of seats available for users + /// + public int? AvailableSeats => Seats - OccupiedSeats; + + /// + /// Number of seats to scale the organization by. + /// + /// If Organization has no seat limit (Seats is null), then there are no new seats to add. + /// + public int SeatsRequiredToAdd => AvailableSeats.HasValue ? Math.Max(NewUsersToAdd - AvailableSeats.Value, 0) : 0; + + /// + /// New total of seats for the organization + /// + public int? UpdatedSeatTotal => Seats + SeatsRequiredToAdd; + + /// + /// If the new seat total is equal to the organization's auto-scale seat count + /// + public bool MaxSeatsReached => UpdatedSeatTotal.HasValue && MaxAutoScaleSeats.HasValue && UpdatedSeatTotal.Value >= MaxAutoScaleSeats.Value; + + public Plan.PasswordManagerPlanFeatures PasswordManagerPlan { get; } + + public InviteOrganization InviteOrganization { get; } + + private PasswordManagerSubscriptionUpdate(int? organizationSeats, + int? organizationAutoScaleSeatLimit, + int currentSeats, + int newUsersToAdd, + Plan.PasswordManagerPlanFeatures plan, + InviteOrganization inviteOrganization) + { + Seats = organizationSeats; + MaxAutoScaleSeats = organizationAutoScaleSeatLimit; + OccupiedSeats = currentSeats; + NewUsersToAdd = newUsersToAdd; + PasswordManagerPlan = plan; + InviteOrganization = inviteOrganization; + } + + public PasswordManagerSubscriptionUpdate(InviteOrganization inviteOrganization, int occupiedSeats, int newUsersToAdd) : + this( + organizationSeats: inviteOrganization.Seats, + organizationAutoScaleSeatLimit: inviteOrganization.MaxAutoScaleSeats, + currentSeats: occupiedSeats, + newUsersToAdd: newUsersToAdd, + plan: inviteOrganization.Plan.PasswordManager, + inviteOrganization: inviteOrganization) + { } + + public PasswordManagerSubscriptionUpdate(InviteOrganizationUsersValidationRequest usersValidationRequest) : + this( + organizationSeats: usersValidationRequest.InviteOrganization.Seats, + organizationAutoScaleSeatLimit: usersValidationRequest.InviteOrganization.MaxAutoScaleSeats, + currentSeats: usersValidationRequest.OccupiedPmSeats, + newUsersToAdd: usersValidationRequest.Invites.Length, + plan: usersValidationRequest.InviteOrganization.Plan.PasswordManager, + inviteOrganization: usersValidationRequest.InviteOrganization) + { } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/Errors.cs new file mode 100644 index 0000000000..c74d1048ad --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/Errors.cs @@ -0,0 +1,10 @@ +using Bit.Core.AdminConsole.Errors; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments; + +public record PaymentCancelledSubscriptionError(PaymentsSubscription InvalidRequest) + : Error(Code, InvalidRequest) +{ + public const string Code = "You do not have an active subscription. Reinstate your subscription to make changes."; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/InviteUserPaymentValidation.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/InviteUserPaymentValidation.cs new file mode 100644 index 0000000000..cc17a673f9 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/InviteUserPaymentValidation.cs @@ -0,0 +1,25 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments; +using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; + +public static class InviteUserPaymentValidation +{ + public static ValidationResult Validate(PaymentsSubscription subscription) + { + if (subscription.ProductTierType is ProductTierType.Free) + { + return new Valid(subscription); + } + + if (subscription.SubscriptionStatus == StripeConstants.SubscriptionStatus.Canceled) + { + return new Invalid(new PaymentCancelledSubscriptionError(subscription)); + } + + return new Valid(subscription); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/PaymentsSubscription.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/PaymentsSubscription.cs new file mode 100644 index 0000000000..dea35c4ddd --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/PaymentsSubscription.cs @@ -0,0 +1,19 @@ +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.Billing.Enums; +using Bit.Core.Models.Business; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; + +public class PaymentsSubscription +{ + public ProductTierType ProductTierType { get; init; } + public string SubscriptionStatus { get; init; } + + public PaymentsSubscription() { } + + public PaymentsSubscription(SubscriptionInfo subscriptionInfo, InviteOrganization inviteOrganization) + { + SubscriptionStatus = subscriptionInfo?.Subscription?.Status ?? string.Empty; + ProductTierType = inviteOrganization.Plan.ProductTier; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Provider/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Provider/Errors.cs new file mode 100644 index 0000000000..104ce5cc7e --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Provider/Errors.cs @@ -0,0 +1,13 @@ +using Bit.Core.AdminConsole.Errors; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider; + +public record ProviderBillableSeatLimitError(InviteOrganizationProvider InvalidRequest) : Error(Code, InvalidRequest) +{ + public const string Code = "Seat limit has been reached. Please contact your provider to add more seats."; +} + +public record ProviderResellerSeatLimitError(InviteOrganizationProvider InvalidRequest) : Error(Code, InvalidRequest) +{ + public const string Code = "Seat limit has been reached. Contact your provider to purchase additional seats."; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Provider/InviteOrganizationProvider.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Provider/InviteOrganizationProvider.cs new file mode 100644 index 0000000000..b52218307d --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Provider/InviteOrganizationProvider.cs @@ -0,0 +1,19 @@ +using Bit.Core.AdminConsole.Enums.Provider; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider; + +public class InviteOrganizationProvider +{ + public Guid ProviderId { get; init; } + public ProviderType Type { get; init; } + public ProviderStatusType Status { get; init; } + public bool Enabled { get; init; } + + public InviteOrganizationProvider(Entities.Provider.Provider provider) + { + ProviderId = provider.Id; + Type = provider.Type; + Status = provider.Status; + Enabled = provider.Enabled; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Provider/InvitingUserOrganizationProviderValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Provider/InvitingUserOrganizationProviderValidator.cs new file mode 100644 index 0000000000..f84b25f76f --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Provider/InvitingUserOrganizationProviderValidator.cs @@ -0,0 +1,28 @@ +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.Billing.Extensions; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider; + +public static class InvitingUserOrganizationProviderValidator +{ + public static ValidationResult Validate(InviteOrganizationProvider inviteOrganizationProvider) + { + if (inviteOrganizationProvider is not { Enabled: true }) + { + return new Valid(inviteOrganizationProvider); + } + + if (inviteOrganizationProvider.IsBillable()) + { + return new Invalid(new ProviderBillableSeatLimitError(inviteOrganizationProvider)); + } + + if (inviteOrganizationProvider.Type == ProviderType.Reseller) + { + return new Invalid(new ProviderResellerSeatLimitError(inviteOrganizationProvider)); + } + + return new Valid(inviteOrganizationProvider); + } +} diff --git a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs index 8825f9722a..108641b5e6 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.KeyManagement.UserKey; @@ -68,4 +69,6 @@ public interface IOrganizationUserRepository : IRepositoryThe role to search for /// A list of OrganizationUsersUserDetails with the specified role Task> GetManyDetailsByRoleAsync(Guid organizationId, OrganizationUserType role); + + Task CreateManyAsync(IEnumerable organizationUserCollection); } diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 78f880b15c..c9027b8030 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -6,13 +6,13 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Enums; -using Bit.Core.Auth.Models.Business; -using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Constants; @@ -26,18 +26,17 @@ using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; -using Bit.Core.Models.Mail; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Settings; -using Bit.Core.Tokens; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Business; using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Microsoft.Extensions.Logging; using Stripe; +using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite; namespace Bit.Core.Services; @@ -58,7 +57,6 @@ public class OrganizationService : IOrganizationService private readonly IPaymentService _paymentService; private readonly IPolicyRepository _policyRepository; private readonly IPolicyService _policyService; - private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ISsoUserRepository _ssoUserRepository; private readonly IReferenceEventService _referenceEventService; private readonly IGlobalSettings _globalSettings; @@ -70,13 +68,12 @@ public class OrganizationService : IOrganizationService private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery; private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; private readonly IProviderRepository _providerRepository; - private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory; - private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; private readonly IFeatureService _featureService; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; private readonly IPricingClient _pricingClient; private readonly IPolicyRequirementQuery _policyRequirementQuery; + private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand; public OrganizationService( IOrganizationRepository organizationRepository, @@ -94,7 +91,6 @@ public class OrganizationService : IOrganizationService IPaymentService paymentService, IPolicyRepository policyRepository, IPolicyService policyService, - ISsoConfigRepository ssoConfigRepository, ISsoUserRepository ssoUserRepository, IReferenceEventService referenceEventService, IGlobalSettings globalSettings, @@ -104,15 +100,14 @@ public class OrganizationService : IOrganizationService IProviderOrganizationRepository providerOrganizationRepository, IProviderUserRepository providerUserRepository, ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery, - IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory, - IDataProtectorTokenFactory orgUserInviteTokenDataFactory, IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, IProviderRepository providerRepository, IFeatureService featureService, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, IPricingClient pricingClient, - IPolicyRequirementQuery policyRequirementQuery) + IPolicyRequirementQuery policyRequirementQuery, + ISendOrganizationInvitesCommand sendOrganizationInvitesCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -129,7 +124,6 @@ public class OrganizationService : IOrganizationService _paymentService = paymentService; _policyRepository = policyRepository; _policyService = policyService; - _ssoConfigRepository = ssoConfigRepository; _ssoUserRepository = ssoUserRepository; _referenceEventService = referenceEventService; _globalSettings = globalSettings; @@ -141,13 +135,12 @@ public class OrganizationService : IOrganizationService _countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery; _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; _providerRepository = providerRepository; - _orgUserInviteTokenableFactory = orgUserInviteTokenableFactory; - _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; _featureService = featureService; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; _pricingClient = pricingClient; _policyRequirementQuery = policyRequirementQuery; + _sendOrganizationInvitesCommand = sendOrganizationInvitesCommand; } public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken, @@ -1055,74 +1048,14 @@ public class OrganizationService : IOrganizationService await SendInviteAsync(orgUser, org, initOrganization); } - private async Task SendInvitesAsync(IEnumerable orgUsers, Organization organization) - { - var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(orgUsers, organization); + private async Task SendInvitesAsync(IEnumerable orgUsers, Organization organization) => + await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(orgUsers, organization)); - await _mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo); - } - - private async Task SendInviteAsync(OrganizationUser orgUser, Organization organization, bool initOrganization) - { - // convert single org user into array of 1 org user - var orgUsers = new[] { orgUser }; - - var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(orgUsers, organization, initOrganization); - - await _mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo); - } - - private async Task BuildOrganizationInvitesInfoAsync( - IEnumerable orgUsers, - Organization organization, - bool initOrganization = false) - { - // Materialize the sequence into a list to avoid multiple enumeration warnings - var orgUsersList = orgUsers.ToList(); - - // Email links must include information about the org and user for us to make routing decisions client side - // Given an org user, determine if existing BW user exists - var orgUserEmails = orgUsersList.Select(ou => ou.Email).ToList(); - var existingUsers = await _userRepository.GetManyByEmailsAsync(orgUserEmails); - - // hash existing users emails list for O(1) lookups - var existingUserEmailsHashSet = new HashSet(existingUsers.Select(u => u.Email)); - - // Create a dictionary of org user guids and bools for whether or not they have an existing BW user - var orgUserHasExistingUserDict = orgUsersList.ToDictionary( - ou => ou.Id, - ou => existingUserEmailsHashSet.Contains(ou.Email) - ); - - // Determine if org has SSO enabled and if user is required to login with SSO - // Note: we only want to call the DB after checking if the org can use SSO per plan and if they have any policies enabled. - var orgSsoEnabled = organization.UseSso && (await _ssoConfigRepository.GetByOrganizationIdAsync(organization.Id))?.Enabled == true; - // Even though the require SSO policy can be turned on regardless of SSO being enabled, for this logic, we only - // need to check the policy if the org has SSO enabled. - var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled && - organization.UsePolicies && - (await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.RequireSso))?.Enabled == true; - - // Generate the list of org users and expiring tokens - // create helper function to create expiring tokens - (OrganizationUser, ExpiringToken) MakeOrgUserExpiringTokenPair(OrganizationUser orgUser) - { - var orgUserInviteTokenable = _orgUserInviteTokenableFactory.CreateToken(orgUser); - var protectedToken = _orgUserInviteTokenDataFactory.Protect(orgUserInviteTokenable); - return (orgUser, new ExpiringToken(protectedToken, orgUserInviteTokenable.ExpirationDate)); - } - - var orgUsersWithExpTokens = orgUsers.Select(MakeOrgUserExpiringTokenPair); - - return new OrganizationInvitesInfo( - organization, - orgSsoEnabled, - orgSsoLoginRequiredPolicyEnabled, - orgUsersWithExpTokens, - orgUserHasExistingUserDict, - initOrganization - ); - } + private async Task SendInviteAsync(OrganizationUser orgUser, Organization organization, bool initOrganization) => + await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest( + users: [orgUser], + organization: organization, + initOrganization: initOrganization)); internal async Task<(bool canScale, string failureReason)> CanScaleAsync( Organization organization, diff --git a/src/Core/AdminConsole/Shared/Validation/ValidationResult.cs b/src/Core/AdminConsole/Shared/Validation/ValidationResult.cs index e25103e701..ba78601637 100644 --- a/src/Core/AdminConsole/Shared/Validation/ValidationResult.cs +++ b/src/Core/AdminConsole/Shared/Validation/ValidationResult.cs @@ -6,10 +6,39 @@ public abstract record ValidationResult; public record Valid : ValidationResult { + public Valid() { } + + public Valid(T Value) + { + this.Value = Value; + } + public T Value { get; init; } } public record Invalid : ValidationResult { - public IEnumerable> Errors { get; init; } + public IEnumerable> Errors { get; init; } = []; + + public string ErrorMessageString => string.Join(" ", Errors.Select(e => e.Message)); + + public Invalid() { } + + public Invalid(Error error) : this([error]) { } + + public Invalid(IEnumerable> errors) + { + Errors = errors; + } +} + +public static class ValidationResultMappers +{ + public static ValidationResult Map(this ValidationResult validationResult, B invalidValue) => + validationResult switch + { + Valid => new Valid(invalidValue), + Invalid invalid => new Invalid(invalid.Errors.Select(x => x.ToError(invalidValue))), + _ => throw new ArgumentOutOfRangeException(nameof(validationResult), "Unhandled validation result type") + }; } diff --git a/src/Core/Billing/Extensions/BillingExtensions.cs b/src/Core/Billing/Extensions/BillingExtensions.cs index f6e65861cd..4fb97e1db7 100644 --- a/src/Core/Billing/Extensions/BillingExtensions.cs +++ b/src/Core/Billing/Extensions/BillingExtensions.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; @@ -28,6 +29,13 @@ public static class BillingExtensions Status: ProviderStatusType.Billable }; + public static bool IsBillable(this InviteOrganizationProvider inviteOrganizationProvider) => + inviteOrganizationProvider is + { + Type: ProviderType.Msp or ProviderType.MultiOrganizationEnterprise, + Status: ProviderStatusType.Billable + }; + public static bool SupportsConsolidatedBilling(this ProviderType providerType) => providerType is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise; diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 310b917bf7..57d695c105 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -109,6 +109,7 @@ public static class FeatureFlagKeys public const string PushSyncOrgKeysOnRevokeRestore = "pm-17168-push-sync-org-keys-on-revoke-restore"; public const string PolicyRequirements = "pm-14439-policy-requirements"; public const string SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility"; + public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast"; /* Auth Team */ public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence"; diff --git a/src/Core/Models/Commands/CommandResult.cs b/src/Core/Models/Commands/CommandResult.cs index a8ec772fc1..4a9477067e 100644 --- a/src/Core/Models/Commands/CommandResult.cs +++ b/src/Core/Models/Commands/CommandResult.cs @@ -1,6 +1,7 @@ #nullable enable using Bit.Core.AdminConsole.Errors; +using Bit.Core.AdminConsole.Shared.Validation; namespace Bit.Core.Models.Commands; @@ -40,10 +41,23 @@ public class Success(T value) : CommandResult public class Failure(IEnumerable errorMessages) : CommandResult { public List ErrorMessages { get; } = errorMessages.ToList(); + public Error[] Errors { get; set; } = []; public string ErrorMessage => string.Join(" ", ErrorMessages); - public Failure(string error) : this([error]) { } + public Failure(string error) : this([error]) + { + } + + public Failure(IEnumerable> errors) : this(errors.Select(e => e.Message)) + { + Errors = errors.ToArray(); + } + + public Failure(Error error) : this([error.Message]) + { + Errors = [error]; + } } public class Partial : CommandResult @@ -57,3 +71,18 @@ public class Partial : CommandResult Failures = failedItems.ToArray(); } } + +public static class CommandResultExtensions +{ + /// + /// This is to help map between the InvalidT ValidationResult and the FailureT CommandResult types. + /// + /// + /// This is the invalid type from validating the object. + /// This function will map between the two types for the inner ErrorT + /// Invalid object's type + /// Failure object's type + /// + public static CommandResult MapToFailure(this Invalid invalidResult, Func mappingFunction) => + new Failure(invalidResult.Errors.Select(errorA => errorA.ToError(mappingFunction(errorA.ErroredValue)))); +} diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 59cfdace65..c76345972a 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -13,6 +13,11 @@ 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.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; using Bit.Core.Models.Business.Tokenables; using Bit.Core.OrganizationFeatures.OrganizationCollections; @@ -174,6 +179,14 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } // TODO: move to OrganizationSubscriptionServiceCollectionExtensions when OrganizationUser methods are moved out of diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/Interface/IUpdateSecretsManagerSubscriptionCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/Interface/IUpdateSecretsManagerSubscriptionCommand.cs index 5c6758fd17..947f66a821 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/Interface/IUpdateSecretsManagerSubscriptionCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/Interface/IUpdateSecretsManagerSubscriptionCommand.cs @@ -5,4 +5,5 @@ namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; public interface IUpdateSecretsManagerSubscriptionCommand { Task UpdateSubscriptionAsync(SecretsManagerSubscriptionUpdate update); + Task ValidateUpdateAsync(SecretsManagerSubscriptionUpdate update); } diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs index 78ab35c38c..91f6516501 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs @@ -124,7 +124,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs } - private async Task ValidateUpdateAsync(SecretsManagerSubscriptionUpdate update) + public async Task ValidateUpdateAsync(SecretsManagerSubscriptionUpdate update) { if (_globalSettings.SelfHosted) { diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index bd7efdbad4..ded9f4cfd3 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Api.Requests.Accounts; using Bit.Core.Billing.Models.Api.Requests.Organizations; @@ -38,9 +39,28 @@ public interface IPaymentService Task GetSubscriptionAsync(ISubscriber subscriber); Task GetTaxInfoAsync(ISubscriber subscriber); Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo); - Task AddSecretsManagerToSubscription(Organization org, Plan plan, int additionalSmSeats, - int additionalServiceAccount); + Task AddSecretsManagerToSubscription(Organization org, Plan plan, int additionalSmSeats, int additionalServiceAccount); + /// + /// Secrets Manager Standalone is a discount in Stripe that is used to give an organization access to Secrets Manager. + /// Usually, this also implies that when they invite a user to their organization, they are doing so for both Password + /// Manager and Secrets Manger. + /// + /// This will not call out to Stripe if they don't have a GatewayId or if they don't have Secrets Manager. + /// + /// Organization Entity + /// If the organization has Secrets Manager and has the Standalone Stripe Discount Task HasSecretsManagerStandalone(Organization organization); + + /// + /// Secrets Manager Standalone is a discount in Stripe that is used to give an organization access to Secrets Manager. + /// Usually, this also implies that when they invite a user to their organization, they are doing so for both Password + /// Manager and Secrets Manger. + /// + /// This will not call out to Stripe if they don't have a GatewayId or if they don't have Secrets Manager. + /// + /// Organization Representation used for Inviting Organization Users + /// If the organization has Secrets Manager and has the Standalone Stripe Discount + Task HasSecretsManagerStandalone(InviteOrganization organization); Task PreviewInvoiceAsync(PreviewIndividualInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId); Task PreviewInvoiceAsync(PreviewOrganizationInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId); diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index d8889bca26..d82a4d60a7 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; @@ -1110,14 +1111,27 @@ public class StripePaymentService : IPaymentService new SecretsManagerSubscribeUpdate(org, plan, additionalSmSeats, additionalServiceAccount), true); - public async Task HasSecretsManagerStandalone(Organization organization) + public async Task HasSecretsManagerStandalone(Organization organization) => + await HasSecretsManagerStandaloneAsync(gatewayCustomerId: organization.GatewayCustomerId, + organizationHasSecretsManager: organization.UseSecretsManager); + + public async Task HasSecretsManagerStandalone(InviteOrganization organization) => + await HasSecretsManagerStandaloneAsync(gatewayCustomerId: organization.GatewayCustomerId, + organizationHasSecretsManager: organization.UseSecretsManager); + + private async Task HasSecretsManagerStandaloneAsync(string gatewayCustomerId, bool organizationHasSecretsManager) { - if (string.IsNullOrEmpty(organization.GatewayCustomerId)) + if (string.IsNullOrEmpty(gatewayCustomerId)) { return false; } - var customer = await _stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId); + if (organizationHasSecretsManager is false) + { + return false; + } + + var customer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId); return customer?.Discount?.Coupon?.Id == SecretsManagerStandaloneDiscountId; } diff --git a/src/Core/Utilities/EmailValidation.cs b/src/Core/Utilities/EmailValidation.cs new file mode 100644 index 0000000000..f6832945af --- /dev/null +++ b/src/Core/Utilities/EmailValidation.cs @@ -0,0 +1,44 @@ +using System.Text.RegularExpressions; +using MimeKit; + +namespace Bit.Core.Utilities; + +public static class EmailValidation +{ + public static bool IsValidEmail(this string emailAddress) + { + if (string.IsNullOrWhiteSpace(emailAddress)) + { + return false; + } + + try + { + var parsedEmailAddress = MailboxAddress.Parse(emailAddress).Address; + if (parsedEmailAddress != emailAddress) + { + return false; + } + } + catch (ParseException) + { + return false; + } + + // The regex below is intended to catch edge cases that are not handled by the general parsing check above. + // This enforces the following rules: + // * Requires ASCII only in the local-part (code points 0-127) + // * Requires an @ symbol + // * Allows any char in second-level domain name, including unicode and symbols + // * Requires at least one period (.) separating SLD from TLD + // * Must end in a letter (including unicode) + // See the unit tests for examples of what is allowed. + var emailFormat = @"^[\x00-\x7F]+@.+\.\p{L}+$"; + if (!Regex.IsMatch(emailAddress, emailFormat)) + { + return false; + } + + return true; + } +} diff --git a/src/Core/Utilities/StrictEmailAddressAttribute.cs b/src/Core/Utilities/StrictEmailAddressAttribute.cs index eeb95093d0..fce732ec9e 100644 --- a/src/Core/Utilities/StrictEmailAddressAttribute.cs +++ b/src/Core/Utilities/StrictEmailAddressAttribute.cs @@ -1,6 +1,4 @@ using System.ComponentModel.DataAnnotations; -using System.Text.RegularExpressions; -using MimeKit; namespace Bit.Core.Utilities; @@ -12,39 +10,8 @@ public class StrictEmailAddressAttribute : ValidationAttribute public override bool IsValid(object value) { - var emailAddress = value?.ToString(); - if (emailAddress == null) - { - return false; - } + var emailAddress = value?.ToString() ?? string.Empty; - try - { - var parsedEmailAddress = MailboxAddress.Parse(emailAddress).Address; - if (parsedEmailAddress != emailAddress) - { - return false; - } - } - catch (ParseException) - { - return false; - } - - // The regex below is intended to catch edge cases that are not handled by the general parsing check above. - // This enforces the following rules: - // * Requires ASCII only in the local-part (code points 0-127) - // * Requires an @ symbol - // * Allows any char in second-level domain name, including unicode and symbols - // * Requires at least one period (.) separating SLD from TLD - // * Must end in a letter (including unicode) - // See the unit tests for examples of what is allowed. - var emailFormat = @"^[\x00-\x7F]+@.+\.\p{L}+$"; - if (!Regex.IsMatch(emailAddress, emailFormat)) - { - return false; - } - - return new EmailAddressAttribute().IsValid(emailAddress); + return emailAddress.IsValidEmail() && new EmailAddressAttribute().IsValid(emailAddress); } } diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs index 07b55aa44a..8968d1d243 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -2,6 +2,7 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.KeyManagement.UserKey; @@ -580,4 +581,32 @@ public class OrganizationUserRepository : Repository, IO return results.ToList(); } } + + public async Task CreateManyAsync(IEnumerable organizationUserCollection) + { + await using var connection = new SqlConnection(_marsConnectionString); + + await connection.ExecuteAsync( + $"[{Schema}].[OrganizationUser_CreateManyWithCollectionsAndGroups]", + new + { + OrganizationUserData = JsonSerializer.Serialize(organizationUserCollection.Select(x => x.OrganizationUser)), + CollectionData = JsonSerializer.Serialize(organizationUserCollection + .SelectMany(x => x.Collections, (user, collection) => new CollectionUser + { + CollectionId = collection.Id, + OrganizationUserId = user.OrganizationUser.Id, + ReadOnly = collection.ReadOnly, + HidePasswords = collection.HidePasswords, + Manage = collection.Manage + })), + GroupData = JsonSerializer.Serialize(organizationUserCollection + .SelectMany(x => x.Groups, (user, group) => new GroupUser + { + GroupId = group, + OrganizationUserId = user.OrganizationUser.Id + })) + }, + commandType: CommandType.StoredProcedure); + } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs index 28e2f1a9e4..10d92357fe 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -1,5 +1,6 @@ using AutoMapper; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.Enums; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Models.Data; @@ -757,4 +758,28 @@ public class OrganizationUserRepository : Repository organizationUserCollection) + { + using var scope = ServiceScopeFactory.CreateScope(); + + await using var dbContext = GetDatabaseContext(scope); + + dbContext.OrganizationUsers.AddRange(Mapper.Map>(organizationUserCollection.Select(x => x.OrganizationUser))); + dbContext.CollectionUsers.AddRange(organizationUserCollection.SelectMany(x => x.Collections, (user, collection) => new CollectionUser + { + CollectionId = collection.Id, + HidePasswords = collection.HidePasswords, + OrganizationUserId = user.OrganizationUser.Id, + Manage = collection.Manage, + ReadOnly = collection.ReadOnly + })); + dbContext.GroupUsers.AddRange(organizationUserCollection.SelectMany(x => x.Groups, (user, group) => new GroupUser + { + GroupId = group, + OrganizationUserId = user.OrganizationUser.Id + })); + + await dbContext.SaveChangesAsync(); + } } diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql new file mode 100644 index 0000000000..78ff2933f6 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql @@ -0,0 +1,97 @@ +CREATE PROCEDURE [dbo].[OrganizationUser_CreateManyWithCollectionsAndGroups] + @organizationUserData NVARCHAR(MAX), + @collectionData NVARCHAR(MAX), + @groupData NVARCHAR(MAX) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[OrganizationUser] + ( + [Id], + [OrganizationId], + [UserId], + [Email], + [Key], + [Status], + [Type], + [ExternalId], + [CreationDate], + [RevisionDate], + [Permissions], + [ResetPasswordKey], + [AccessSecretsManager] + ) + SELECT + OUI.[Id], + OUI.[OrganizationId], + OUI.[UserId], + OUI.[Email], + OUI.[Key], + OUI.[Status], + OUI.[Type], + OUI.[ExternalId], + OUI.[CreationDate], + OUI.[RevisionDate], + OUI.[Permissions], + OUI.[ResetPasswordKey], + OUI.[AccessSecretsManager] + FROM + OPENJSON(@organizationUserData) + WITH ( + [Id] UNIQUEIDENTIFIER '$.Id', + [OrganizationId] UNIQUEIDENTIFIER '$.OrganizationId', + [UserId] UNIQUEIDENTIFIER '$.UserId', + [Email] NVARCHAR(256) '$.Email', + [Key] VARCHAR(MAX) '$.Key', + [Status] SMALLINT '$.Status', + [Type] TINYINT '$.Type', + [ExternalId] NVARCHAR(300) '$.ExternalId', + [CreationDate] DATETIME2(7) '$.CreationDate', + [RevisionDate] DATETIME2(7) '$.RevisionDate', + [Permissions] NVARCHAR (MAX) '$.Permissions', + [ResetPasswordKey] VARCHAR (MAX) '$.ResetPasswordKey', + [AccessSecretsManager] BIT '$.AccessSecretsManager' + ) OUI + + INSERT INTO [dbo].[GroupUser] + ( + [OrganizationUserId], + [GroupId] + ) + SELECT + OUG.OrganizationUserId, + OUG.GroupId + FROM + OPENJSON(@groupData) + WITH( + [OrganizationUserId] UNIQUEIDENTIFIER '$.OrganizationUserId', + [GroupId] UNIQUEIDENTIFIER '$.GroupId' + ) OUG + + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + OUC.[CollectionId], + OUC.[OrganizationUserId], + OUC.[ReadOnly], + OUC.[HidePasswords], + OUC.[Manage] + FROM + OPENJSON(@collectionData) + WITH( + [CollectionId] UNIQUEIDENTIFIER '$.CollectionId', + [OrganizationUserId] UNIQUEIDENTIFIER '$.OrganizationUserId', + [ReadOnly] BIT '$.ReadOnly', + [HidePasswords] BIT '$.HidePasswords', + [Manage] BIT '$.Manage' + ) OUC +END +go + diff --git a/test/Core.Test/AdminConsole/Models/InviteOrganizationUsersRequestTests.cs b/test/Core.Test/AdminConsole/Models/InviteOrganizationUsersRequestTests.cs new file mode 100644 index 0000000000..71b2b9766c --- /dev/null +++ b/test/Core.Test/AdminConsole/Models/InviteOrganizationUsersRequestTests.cs @@ -0,0 +1,67 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.InviteOrganizationUserErrorMessages; + +namespace Bit.Core.Test.AdminConsole.Models; + +public class InviteOrganizationUsersRequestTests +{ + [Theory] + [BitAutoData] + public void Constructor_WhenPassedInvalidEmail_ThrowsException(string email, OrganizationUserType type, Permissions permissions, string externalId) + { + var exception = Assert.Throws(() => + new OrganizationUserInvite(email, [], [], type, permissions, externalId, false)); + + Assert.Contains(InvalidEmailErrorMessage, exception.Message); + } + + [Fact] + public void Constructor_WhenPassedInvalidCollectionAccessConfiguration_ThrowsException() + { + const string validEmail = "test@email.com"; + + var invalidCollectionConfiguration = new CollectionAccessSelection + { + Manage = true, + HidePasswords = true + }; + + var exception = Assert.Throws(() => + new OrganizationUserInvite( + email: validEmail, + assignedCollections: [invalidCollectionConfiguration], + groups: [], + type: default, + permissions: new Permissions(), + externalId: string.Empty, + accessSecretsManager: false)); + + Assert.Equal(InvalidCollectionConfigurationErrorMessage, exception.Message); + } + + [Fact] + public void Constructor_WhenPassedValidArguments_ReturnsInvite() + { + const string validEmail = "test@email.com"; + var validCollectionConfiguration = new CollectionAccessSelection { Id = Guid.NewGuid(), Manage = true }; + + var invite = new OrganizationUserInvite( + email: validEmail, + assignedCollections: [validCollectionConfiguration], + groups: [], + type: default, + permissions: null, + externalId: null, + accessSecretsManager: false); + + Assert.NotNull(invite); + Assert.Contains(validEmail, invite.Email); + Assert.Contains(validCollectionConfiguration, invite.AssignedCollections); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Helpers/InviteUserOrganizationValidationRequestHelpers.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Helpers/InviteUserOrganizationValidationRequestHelpers.cs new file mode 100644 index 0000000000..f4f7cd5662 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Helpers/InviteUserOrganizationValidationRequestHelpers.cs @@ -0,0 +1,51 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; +using Bit.Core.Models.Business; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Helpers; + +public static class InviteUserOrganizationValidationRequestHelpers +{ + public static InviteOrganizationUsersValidationRequest GetInviteValidationRequestMock(InviteOrganizationUsersRequest request, + InviteOrganization inviteOrganization, Organization organization) => + new() + { + Invites = request.Invites, + InviteOrganization = inviteOrganization, + PerformedBy = Guid.Empty, + PerformedAt = request.PerformedAt, + OccupiedPmSeats = 0, + OccupiedSmSeats = 0, + PasswordManagerSubscriptionUpdate = new PasswordManagerSubscriptionUpdate(inviteOrganization, 0, 0), + SecretsManagerSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(organization, inviteOrganization.Plan, true) + .AdjustSeats(request.Invites.Count(x => x.AccessSecretsManager)) + }; + + public static InviteOrganizationUsersValidationRequest WithPasswordManagerUpdate(this InviteOrganizationUsersValidationRequest request, PasswordManagerSubscriptionUpdate passwordManagerSubscriptionUpdate) => + new() + { + Invites = request.Invites, + InviteOrganization = request.InviteOrganization, + PerformedBy = request.PerformedBy, + PerformedAt = request.PerformedAt, + OccupiedPmSeats = request.OccupiedPmSeats, + OccupiedSmSeats = request.OccupiedSmSeats, + PasswordManagerSubscriptionUpdate = passwordManagerSubscriptionUpdate, + SecretsManagerSubscriptionUpdate = request.SecretsManagerSubscriptionUpdate + }; + + public static InviteOrganizationUsersValidationRequest WithSecretsManagerUpdate(this InviteOrganizationUsersValidationRequest request, SecretsManagerSubscriptionUpdate secretsManagerSubscriptionUpdate) => + new() + { + Invites = request.Invites, + InviteOrganization = request.InviteOrganization, + PerformedBy = request.PerformedBy, + PerformedAt = request.PerformedAt, + OccupiedPmSeats = request.OccupiedPmSeats, + OccupiedSmSeats = request.OccupiedSmSeats, + PasswordManagerSubscriptionUpdate = request.PasswordManagerSubscriptionUpdate, + SecretsManagerSubscriptionUpdate = secretsManagerSubscriptionUpdate + }; +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs new file mode 100644 index 0000000000..ba7605d682 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs @@ -0,0 +1,613 @@ +using System.Net.Mail; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Errors; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.Billing.Models.StaticStore.Plans; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Business; +using Bit.Core.Models.Commands; +using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Models.StaticStore; +using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; +using static Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Helpers.InviteUserOrganizationValidationRequestHelpers; +using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +[SutProviderCustomize] +public class InviteOrganizationUserCommandTests +{ + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenEmailAlreadyExists_ThenNoInviteIsSentAndNoSeatsAreAdjusted( + MailAddress address, + Organization organization, + OrganizationUser user, + FakeTimeProvider timeProvider, + string externalId, + SutProvider sutProvider) + { + // Arrange + user.Email = address.Address; + + var inviteOrganization = new InviteOrganization(organization, new FreePlan()); + + var request = new InviteOrganizationUsersRequest( + invites: [ + new OrganizationUserInvite( + email: user.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + sutProvider.GetDependency() + .SelectKnownEmailsAsync(organization.Id, Arg.Any>(), false) + .Returns([user.Email]); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Valid(GetInviteValidationRequestMock(request, inviteOrganization, organization))); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert + Assert.IsType>(result); + Assert.Equal(NoUsersToInviteError.Code, (result as Failure).ErrorMessage); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .AdjustSeatsAsync(Arg.Any(), Arg.Any(), Arg.Any()); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SendInvitesAsync(Arg.Any()); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateSubscriptionAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenEmailDoesNotExistAndRequestIsValid_ThenUserIsSavedAndInviteIsSent( + MailAddress address, + Organization organization, + OrganizationUser orgUser, + FakeTimeProvider timeProvider, + string externalId, + SutProvider sutProvider) + { + // Arrange + orgUser.Email = address.Address; + + var inviteOrganization = new InviteOrganization(organization, new FreePlan()); + + var request = new InviteOrganizationUsersRequest( + invites: [ + new OrganizationUserInvite( + email: orgUser.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + sutProvider.GetDependency() + .SelectKnownEmailsAsync(organization.Id, Arg.Any>(), false) + .Returns([]); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Valid(GetInviteValidationRequestMock(request, inviteOrganization, organization))); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert + Assert.IsType>(result); + + await sutProvider.GetDependency() + .Received(1) + .CreateManyAsync(Arg.Is>(users => + users.Any(user => user.OrganizationUser.Email == request.Invites.First().Email))); + + await sutProvider.GetDependency() + .Received(1) + .SendInvitesAsync(Arg.Is(invite => + invite.Organization == organization && + invite.Users.Count(x => x.Email == orgUser.Email) == 1)); + } + + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenEmailIsNewAndRequestIsInvalid_ThenFailureIsReturnedWithValidationFailureReason( + MailAddress address, + Organization organization, + OrganizationUser user, + FakeTimeProvider timeProvider, + string externalId, + SutProvider sutProvider) + { + // Arrange + const string errorMessage = "Org cannot add user for some given reason"; + + user.Email = address.Address; + + var inviteOrganization = new InviteOrganization(organization, new FreePlan()); + + var request = new InviteOrganizationUsersRequest( + invites: [ + new OrganizationUserInvite( + email: user.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + var validationRequest = GetInviteValidationRequestMock(request, inviteOrganization, organization); + + sutProvider.GetDependency() + .SelectKnownEmailsAsync(organization.Id, Arg.Any>(), false) + .Returns([]); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Invalid( + new Error(errorMessage, validationRequest))); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert + Assert.IsType>(result); + var failure = result as Failure; + + Assert.Equal(errorMessage, failure!.ErrorMessage); + + await sutProvider.GetDependency() + .DidNotReceive() + .CreateManyAsync(Arg.Any>()); + + await sutProvider.GetDependency() + .DidNotReceive() + .SendInvitesAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenValidInviteCausesOrganizationToReachMaxSeats_ThenOrganizationOwnersShouldBeNotified( + MailAddress address, + Organization organization, + OrganizationUser user, + FakeTimeProvider timeProvider, + string externalId, + OrganizationUserUserDetails ownerDetails, + SutProvider sutProvider) + { + // Arrange + user.Email = address.Address; + organization.Seats = 1; + organization.MaxAutoscaleSeats = 2; + ownerDetails.Type = OrganizationUserType.Owner; + + var inviteOrganization = new InviteOrganization(organization, new FreePlan()); + + var request = new InviteOrganizationUsersRequest( + invites: [ + new OrganizationUserInvite( + email: user.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + var orgUserRepository = sutProvider.GetDependency(); + + orgUserRepository + .SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any>(), false) + .Returns([]); + orgUserRepository + .GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner) + .Returns([ownerDetails]); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Valid(GetInviteValidationRequestMock(request, inviteOrganization, organization) + .WithPasswordManagerUpdate(new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1)))); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert + Assert.IsType>(result); + + Assert.NotNull(inviteOrganization.MaxAutoScaleSeats); + + await sutProvider.GetDependency() + .Received(1) + .SendOrganizationMaxSeatLimitReachedEmailAsync(organization, + inviteOrganization.MaxAutoScaleSeats.Value, + Arg.Is>(emails => emails.Any(email => email == ownerDetails.Email))); + } + + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenValidInviteIncreasesSeats_ThenSeatTotalShouldBeUpdated( + MailAddress address, + Organization organization, + OrganizationUser user, + FakeTimeProvider timeProvider, + string externalId, + OrganizationUserUserDetails ownerDetails, + SutProvider sutProvider) + { + // Arrange + user.Email = address.Address; + organization.Seats = 1; + organization.MaxAutoscaleSeats = 2; + ownerDetails.Type = OrganizationUserType.Owner; + + var inviteOrganization = new InviteOrganization(organization, new FreePlan()); + + var request = new InviteOrganizationUsersRequest( + invites: [ + new OrganizationUserInvite( + email: user.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + var passwordManagerUpdate = new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1); + + var orgUserRepository = sutProvider.GetDependency(); + + orgUserRepository + .SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any>(), false) + .Returns([]); + orgUserRepository + .GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner) + .Returns([ownerDetails]); + + var orgRepository = sutProvider.GetDependency(); + + orgRepository.GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Valid(GetInviteValidationRequestMock(request, inviteOrganization, organization) + .WithPasswordManagerUpdate(passwordManagerUpdate))); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert + Assert.IsType>(result); + + await sutProvider.GetDependency() + .AdjustSeatsAsync(organization, inviteOrganization.Plan, passwordManagerUpdate.SeatsRequiredToAdd); + + await orgRepository.Received(1).ReplaceAsync(Arg.Is(x => x.Seats == passwordManagerUpdate.UpdatedSeatTotal)); + + await sutProvider.GetDependency() + .Received(1) + .UpsertOrganizationAbilityAsync(Arg.Is(x => x.Seats == passwordManagerUpdate.UpdatedSeatTotal)); + } + + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenValidInviteIncreasesSecretsManagerSeats_ThenSecretsManagerShouldBeUpdated( + MailAddress address, + Organization organization, + OrganizationUser user, + FakeTimeProvider timeProvider, + string externalId, + OrganizationUserUserDetails ownerDetails, + SutProvider sutProvider) + { + // Arrange + user.Email = address.Address; + organization.Seats = 1; + organization.SmSeats = 1; + organization.MaxAutoscaleSeats = 2; + organization.MaxAutoscaleSmSeats = 2; + ownerDetails.Type = OrganizationUserType.Owner; + + var inviteOrganization = new InviteOrganization(organization, new FreePlan()); + + var request = new InviteOrganizationUsersRequest( + invites: [ + new OrganizationUserInvite( + email: user.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + var secretsManagerSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(organization, inviteOrganization.Plan, true) + .AdjustSeats(request.Invites.Count(x => x.AccessSecretsManager)); + + var orgUserRepository = sutProvider.GetDependency(); + + orgUserRepository + .SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any>(), false) + .Returns([]); + orgUserRepository + .GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner) + .Returns([ownerDetails]); + orgUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(1); + orgUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id).Returns(1); + + var orgRepository = sutProvider.GetDependency(); + + orgRepository.GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Valid(GetInviteValidationRequestMock(request, inviteOrganization, organization) + .WithSecretsManagerUpdate(secretsManagerSubscriptionUpdate))); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert; + Assert.IsType>(result); + + await sutProvider.GetDependency() + .Received(1) + .UpdateSubscriptionAsync(secretsManagerSubscriptionUpdate); + } + + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenAnErrorOccursWhileInvitingUsers_ThenAnySeatChangesShouldBeReverted( + MailAddress address, + Organization organization, + OrganizationUser user, + FakeTimeProvider timeProvider, + string externalId, + OrganizationUserUserDetails ownerDetails, + SutProvider sutProvider) + { + // Arrange + user.Email = address.Address; + organization.Seats = 1; + organization.SmSeats = 1; + organization.MaxAutoscaleSeats = 2; + organization.MaxAutoscaleSmSeats = 2; + ownerDetails.Type = OrganizationUserType.Owner; + + var inviteOrganization = new InviteOrganization(organization, new FreePlan()); + + var request = new InviteOrganizationUsersRequest( + invites: [ + new OrganizationUserInvite( + email: user.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + var secretsManagerSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(organization, inviteOrganization.Plan, true) + .AdjustSeats(request.Invites.Count(x => x.AccessSecretsManager)); + + var passwordManagerSubscriptionUpdate = + new PasswordManagerSubscriptionUpdate(inviteOrganization, 1, request.Invites.Length); + + var orgUserRepository = sutProvider.GetDependency(); + + orgUserRepository + .SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any>(), false) + .Returns([]); + orgUserRepository + .GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner) + .Returns([ownerDetails]); + + var orgRepository = sutProvider.GetDependency(); + + orgRepository.GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Valid(GetInviteValidationRequestMock(request, inviteOrganization, organization) + .WithPasswordManagerUpdate(passwordManagerSubscriptionUpdate) + .WithSecretsManagerUpdate(secretsManagerSubscriptionUpdate))); + + sutProvider.GetDependency() + .SendInvitesAsync(Arg.Any()) + .Throws(new Exception("Something went wrong")); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert + Assert.IsType>(result); + Assert.Equal(FailedToInviteUsersError.Code, (result as Failure)!.ErrorMessage); + + // org user revert + await orgUserRepository.Received(1).DeleteManyAsync(Arg.Is>(x => x.Count() == 1)); + + // SM revert + await sutProvider.GetDependency() + .Received(2) + .UpdateSubscriptionAsync(Arg.Any()); + + // PM revert + await sutProvider.GetDependency() + .Received(2) + .AdjustSeatsAsync(Arg.Any(), Arg.Any(), Arg.Any()); + + await orgRepository.Received(2).ReplaceAsync(Arg.Any()); + + await sutProvider.GetDependency().Received(2) + .UpsertOrganizationAbilityAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenAnOrganizationIsManagedByAProvider_ThenAnEmailShouldBeSentToTheProvider( + MailAddress address, + Organization organization, + OrganizationUser user, + FakeTimeProvider timeProvider, + string externalId, + OrganizationUserUserDetails ownerDetails, + ProviderOrganization providerOrganization, + SutProvider sutProvider) + { + // Arrange + user.Email = address.Address; + organization.Seats = 1; + organization.SmSeats = 1; + organization.MaxAutoscaleSeats = 2; + organization.MaxAutoscaleSmSeats = 2; + ownerDetails.Type = OrganizationUserType.Owner; + + providerOrganization.OrganizationId = organization.Id; + + var inviteOrganization = new InviteOrganization(organization, new FreePlan()); + + var request = new InviteOrganizationUsersRequest( + invites: [ + new OrganizationUserInvite( + email: user.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + var secretsManagerSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(organization, inviteOrganization.Plan, true) + .AdjustSeats(request.Invites.Count(x => x.AccessSecretsManager)); + + var passwordManagerSubscriptionUpdate = + new PasswordManagerSubscriptionUpdate(inviteOrganization, 1, request.Invites.Length); + + var orgUserRepository = sutProvider.GetDependency(); + + orgUserRepository + .SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any>(), false) + .Returns([]); + orgUserRepository + .GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner) + .Returns([ownerDetails]); + + var orgRepository = sutProvider.GetDependency(); + + orgRepository.GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Valid(GetInviteValidationRequestMock(request, inviteOrganization, organization) + .WithPasswordManagerUpdate(passwordManagerSubscriptionUpdate) + .WithSecretsManagerUpdate(secretsManagerSubscriptionUpdate))); + + sutProvider.GetDependency() + .GetByOrganizationId(organization.Id) + .Returns(providerOrganization); + + sutProvider.GetDependency() + .GetManyDetailsByProviderAsync(providerOrganization.ProviderId, ProviderUserStatusType.Confirmed) + .Returns(new List + { + new() + { + Email = "provider@email.com" + } + }); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert + Assert.IsType>(result); + + sutProvider.GetDependency().Received(1) + .SendOrganizationMaxSeatLimitReachedEmailAsync(organization, 2, + Arg.Is>(emails => emails.Any(email => email == "provider@email.com"))); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommandTests.cs new file mode 100644 index 0000000000..23c1a32c03 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommandTests.cs @@ -0,0 +1,108 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.Repositories; +using Bit.Core.Billing.Enums; +using Bit.Core.Entities; +using Bit.Core.Models.Mail; +using Bit.Core.Services; +using Bit.Core.Test.AutoFixture.OrganizationFixtures; +using Bit.Core.Tokens; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.Fakes; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +[SutProviderCustomize] +public class SendOrganizationInvitesCommandTests +{ + private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory = new FakeDataProtectorTokenFactory(); + + [Theory] + [OrganizationInviteCustomize, OrganizationCustomize, BitAutoData] + public async Task SendInvitesAsync_SsoOrgWithNeverEnabledRequireSsoPolicy_SendsEmailWithoutRequiringSso( + Organization organization, + SsoConfig ssoConfig, + OrganizationUser invite, + SutProvider sutProvider) + { + // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks + sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); + sutProvider.Create(); + + // Org must be able to use SSO and policies to trigger this test case + organization.UseSso = true; + organization.UsePolicies = true; + + ssoConfig.Enabled = true; + sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id).Returns(ssoConfig); + + // Return null policy to mimic new org that's never turned on the require sso policy + sutProvider.GetDependency().GetManyByOrganizationIdAsync(organization.Id).ReturnsNull(); + + // Mock tokenable factory to return a token that expires in 5 days + sutProvider.GetDependency() + .CreateToken(Arg.Any()) + .Returns( + info => new OrgUserInviteTokenable(info.Arg()) + { + ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) + }); + + // Act + await sutProvider.Sut.SendInvitesAsync(new SendInvitesRequest([invite], organization)); + + // Assert + await sutProvider.GetDependency().Received(1) + .SendOrganizationInviteEmailsAsync(Arg.Is(info => + info.OrgUserTokenPairs.Count() == 1 && + info.OrgUserTokenPairs.FirstOrDefault(x => x.OrgUser.Email == invite.Email).OrgUser == invite && + info.IsFreeOrg == (organization.PlanType == PlanType.Free) && + info.OrganizationName == organization.Name && + info.OrgSsoLoginRequiredPolicyEnabled == false)); + } + + [Theory] + [OrganizationInviteCustomize, OrganizationCustomize, BitAutoData] + public async Task InviteUsers_SsoOrgWithNullSsoConfig_SendsInvite( + Organization organization, + OrganizationUser invite, + SutProvider sutProvider) + { + // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks + sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); + sutProvider.Create(); + + // Org must be able to use SSO to trigger this proper test case as we currently only call to retrieve + // an org's SSO config if the org can use SSO + organization.UseSso = true; + + // Return null for sso config + sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id).ReturnsNull(); + + // Mock tokenable factory to return a token that expires in 5 days + sutProvider.GetDependency() + .CreateToken(Arg.Any()) + .Returns( + info => new OrgUserInviteTokenable(info.Arg()) + { + ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) + }); + + await sutProvider.Sut.SendInvitesAsync(new SendInvitesRequest([invite], organization)); + + await sutProvider.GetDependency().Received(1) + .SendOrganizationInviteEmailsAsync(Arg.Is(info => + info.OrgUserTokenPairs.Count() == 1 && + info.OrgUserTokenPairs.FirstOrDefault(x => x.OrgUser.Email == invite.Email).OrgUser == invite && + info.IsFreeOrg == (organization.PlanType == PlanType.Free) && + info.OrganizationName == organization.Name)); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUsersValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUsersValidatorTests.cs new file mode 100644 index 0000000000..191ef05603 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUsersValidatorTests.cs @@ -0,0 +1,161 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; +using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.Billing.Models.StaticStore.Plans; +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; +using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; + +[SutProviderCustomize] +public class InviteOrganizationUsersValidatorTests +{ + [Theory] + [BitAutoData] + public async Task ValidateAsync_WhenOrganizationHasSecretsManagerInvitesAndDoesNotHaveEnoughSeatsAvailable_ThenShouldCorrectlyCalculateSeatsToAdd( + Organization organization, + SutProvider sutProvider + ) + { + organization.Seats = null; + organization.SmSeats = 10; + organization.UseSecretsManager = true; + + var request = new InviteOrganizationUsersValidationRequest + { + Invites = + [ + new OrganizationUserInvite( + email: "test@email.com", + externalId: "test-external-id"), + new OrganizationUserInvite( + email: "test2@email.com", + externalId: "test-external-id2"), + new OrganizationUserInvite( + email: "test3@email.com", + externalId: "test-external-id3") + ], + InviteOrganization = new InviteOrganization(organization, new Enterprise2023Plan(true)), + OccupiedPmSeats = 0, + OccupiedSmSeats = 9 + }; + + sutProvider.GetDependency() + .HasSecretsManagerStandalone(request.InviteOrganization) + .Returns(true); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + _ = await sutProvider.Sut.ValidateAsync(request); + + sutProvider.GetDependency() + .Received(1) + .ValidateUpdateAsync(Arg.Is(x => + x.SmSeatsChanged == true && x.SmSeats == 12)); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WhenOrganizationHasSecretsManagerInvitesAndHasSeatsAvailable_ThenShouldReturnValid( + Organization organization, + SutProvider sutProvider + ) + { + organization.Seats = null; + organization.SmSeats = 12; + organization.UseSecretsManager = true; + + var request = new InviteOrganizationUsersValidationRequest + { + Invites = + [ + new OrganizationUserInvite( + email: "test@email.com", + externalId: "test-external-id"), + new OrganizationUserInvite( + email: "test2@email.com", + externalId: "test-external-id2"), + new OrganizationUserInvite( + email: "test3@email.com", + externalId: "test-external-id3") + ], + InviteOrganization = new InviteOrganization(organization, new Enterprise2023Plan(true)), + OccupiedPmSeats = 0, + OccupiedSmSeats = 9 + }; + + sutProvider.GetDependency() + .HasSecretsManagerStandalone(request.InviteOrganization) + .Returns(true); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + var result = await sutProvider.Sut.ValidateAsync(request); + + Assert.IsType>(result); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WhenOrganizationHasSecretsManagerInvitesAndSmSeatUpdateFailsValidation_ThenShouldReturnInvalid( + Organization organization, + SutProvider sutProvider + ) + { + organization.Seats = null; + organization.SmSeats = 5; + organization.MaxAutoscaleSmSeats = 5; + organization.UseSecretsManager = true; + + var request = new InviteOrganizationUsersValidationRequest + { + Invites = + [ + new OrganizationUserInvite( + email: "test@email.com", + externalId: "test-external-id"), + new OrganizationUserInvite( + email: "test2@email.com", + externalId: "test-external-id2"), + new OrganizationUserInvite( + email: "test3@email.com", + externalId: "test-external-id3") + ], + InviteOrganization = new InviteOrganization(organization, new Enterprise2023Plan(true)), + OccupiedPmSeats = 0, + OccupiedSmSeats = 4 + }; + + sutProvider.GetDependency() + .HasSecretsManagerStandalone(request.InviteOrganization) + .Returns(true); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateUpdateAsync(Arg.Any()) + .Throws(new BadRequestException("Some Secrets Manager Failure")); + + var result = await sutProvider.Sut.ValidateAsync(request); + + Assert.IsType>(result); + Assert.Equal("Some Secrets Manager Failure", (result as Invalid)!.ErrorMessageString); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserOrganizationValidationTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserOrganizationValidationTests.cs new file mode 100644 index 0000000000..508b9f3cb0 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserOrganizationValidationTests.cs @@ -0,0 +1,58 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization; +using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.Billing.Models.StaticStore.Plans; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; + +[SutProviderCustomize] +public class InviteUserOrganizationValidationTests +{ + [Theory] + [BitAutoData] + public async Task Validate_WhenOrganizationIsFreeTier_ShouldReturnValidResponse(Organization organization, SutProvider sutProvider) + { + var inviteOrganization = new InviteOrganization(organization, new FreePlan()); + + var result = await sutProvider.Sut.ValidateAsync(inviteOrganization); + + Assert.IsType>(result); + } + + [Theory] + [BitAutoData] + public async Task Validate_WhenOrganizationDoesNotHavePaymentMethod_ShouldReturnInvalidResponseWithPaymentMethodMessage( + Organization organization, SutProvider sutProvider) + { + organization.GatewayCustomerId = string.Empty; + organization.Seats = 3; + + var inviteOrganization = new InviteOrganization(organization, new FreePlan()); + + var result = await sutProvider.Sut.ValidateAsync(inviteOrganization); + + Assert.IsType>(result); + Assert.Equal(OrganizationNoPaymentMethodFoundError.Code, (result as Invalid)!.ErrorMessageString); + } + + [Theory] + [BitAutoData] + public async Task Validate_WhenOrganizationDoesNotHaveSubscription_ShouldReturnInvalidResponseWithSubscriptionMessage( + Organization organization, SutProvider sutProvider) + { + organization.GatewaySubscriptionId = string.Empty; + organization.Seats = 3; + organization.MaxAutoscaleSeats = 4; + + var inviteOrganization = new InviteOrganization(organization, new FreePlan()); + + var result = await sutProvider.Sut.ValidateAsync(inviteOrganization); + + Assert.IsType>(result); + Assert.Equal(OrganizationNoSubscriptionFoundError.Code, (result as Invalid)!.ErrorMessageString); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserPaymentValidationTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserPaymentValidationTests.cs new file mode 100644 index 0000000000..bcca89e1d2 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserPaymentValidationTests.cs @@ -0,0 +1,56 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments; +using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models.StaticStore.Plans; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; + +public class InviteUserPaymentValidationTests +{ + [Theory] + [BitAutoData] + public void Validate_WhenPlanIsFree_ReturnsValidResponse(Organization organization) + { + organization.PlanType = PlanType.Free; + + var result = InviteUserPaymentValidation.Validate(new PaymentsSubscription + { + SubscriptionStatus = StripeConstants.SubscriptionStatus.Active, + ProductTierType = new InviteOrganization(organization, new FreePlan()).Plan.ProductTier + }); + + Assert.IsType>(result); + } + + [Fact] + public void Validate_WhenSubscriptionIsCanceled_ReturnsInvalidResponse() + { + var result = InviteUserPaymentValidation.Validate(new PaymentsSubscription + { + SubscriptionStatus = StripeConstants.SubscriptionStatus.Canceled, + ProductTierType = ProductTierType.Enterprise + }); + + Assert.IsType>(result); + Assert.Equal(PaymentCancelledSubscriptionError.Code, (result as Invalid)!.ErrorMessageString); + } + + [Fact] + public void Validate_WhenSubscriptionIsActive_ReturnsValidResponse() + { + var result = InviteUserPaymentValidation.Validate(new PaymentsSubscription + { + SubscriptionStatus = StripeConstants.SubscriptionStatus.Active, + ProductTierType = ProductTierType.Enterprise + }); + + Assert.IsType>(result); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManagerInviteUserValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManagerInviteUserValidatorTests.cs new file mode 100644 index 0000000000..c320ada8cb --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManagerInviteUserValidatorTests.cs @@ -0,0 +1,93 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; +using Bit.Core.AdminConsole.Shared.Validation; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models.StaticStore.Plans; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; + + +[SutProviderCustomize] +public class InviteUsersPasswordManagerValidatorTests +{ + [Theory] + [BitAutoData] + public async Task Validate_OrganizationDoesNotHaveSeatsLimit_ShouldReturnValidResult(Organization organization, + SutProvider sutProvider) + { + organization.Seats = null; + + var organizationDto = new InviteOrganization(organization, new FreePlan()); + + var subscriptionUpdate = new PasswordManagerSubscriptionUpdate(organizationDto, 0, 0); + + var result = await sutProvider.Sut.ValidateAsync(subscriptionUpdate); + + Assert.IsType>(result); + } + + [Theory] + [BitAutoData] + public async Task Validate_NumberOfSeatsToAddMatchesSeatsAvailable_ShouldReturnValidResult(Organization organization, + SutProvider sutProvider) + { + organization.Seats = 8; + organization.PlanType = PlanType.EnterpriseAnnually; + var seatsOccupiedByUsers = 4; + var additionalSeats = 4; + + var organizationDto = new InviteOrganization(organization, new Enterprise2023Plan(isAnnual: true)); + + var subscriptionUpdate = new PasswordManagerSubscriptionUpdate(organizationDto, seatsOccupiedByUsers, additionalSeats); + + var result = await sutProvider.Sut.ValidateAsync(subscriptionUpdate); + + Assert.IsType>(result); + } + + [Theory] + [BitAutoData] + public async Task Validate_NumberOfSeatsToAddIsGreaterThanMaxSeatsAllowed_ShouldBeInvalidWithSeatLimitMessage(Organization organization, + SutProvider sutProvider) + { + organization.Seats = 4; + organization.MaxAutoscaleSeats = 4; + organization.PlanType = PlanType.EnterpriseAnnually; + var seatsOccupiedByUsers = 4; + var additionalSeats = 1; + + var organizationDto = new InviteOrganization(organization, new Enterprise2023Plan(isAnnual: true)); + + var subscriptionUpdate = new PasswordManagerSubscriptionUpdate(organizationDto, seatsOccupiedByUsers, additionalSeats); + + var result = await sutProvider.Sut.ValidateAsync(subscriptionUpdate); + + Assert.IsType>(result); + Assert.Equal(PasswordManagerSeatLimitHasBeenReachedError.Code, (result as Invalid)!.ErrorMessageString); + } + + [Theory] + [BitAutoData] + public async Task Validate_GivenThePlanDoesNotAllowAdditionalSeats_ShouldBeInvalidMessageOfPlanNotAllowingSeats(Organization organization, + SutProvider sutProvider) + { + organization.Seats = 4; + organization.MaxAutoscaleSeats = 9; + var seatsOccupiedByUsers = 4; + var additionalSeats = 4; + organization.PlanType = PlanType.Free; + + var organizationDto = new InviteOrganization(organization, new FreePlan()); + + var subscriptionUpdate = new PasswordManagerSubscriptionUpdate(organizationDto, seatsOccupiedByUsers, additionalSeats); + + var result = await sutProvider.Sut.ValidateAsync(subscriptionUpdate); + + Assert.IsType>(result); + Assert.Equal(PasswordManagerPlanDoesNotAllowAdditionalSeatsError.Code, (result as Invalid)!.ErrorMessageString); + } +} diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index 53101900e5..e45643435d 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -2,10 +2,10 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Business.Tokenables; -using Bit.Core.Auth.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; using Bit.Core.Context; @@ -15,7 +15,6 @@ using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; -using Bit.Core.Models.Mail; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Platform.Push; using Bit.Core.Repositories; @@ -37,6 +36,7 @@ using NSubstitute.ReturnsExtensions; using Xunit; using Organization = Bit.Core.AdminConsole.Entities.Organization; using OrganizationUser = Bit.Core.Entities.OrganizationUser; +using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite; namespace Bit.Core.Test.Services; @@ -77,15 +77,6 @@ public class OrganizationServiceTests .Returns(true); sutProvider.GetDependency().ManageUsers(org.Id).Returns(true); - // Mock tokenable factory to return a token that expires in 5 days - sutProvider.GetDependency() - .CreateToken(Arg.Any()) - .Returns( - info => new OrgUserInviteTokenable(info.Arg()) - { - ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) - } - ); await sutProvider.Sut.ImportAsync(org.Id, null, newUsers, null, false, EventSystemUser.PublicApi); @@ -100,9 +91,11 @@ public class OrganizationServiceTests await sutProvider.GetDependency().Received(1) .CreateManyAsync(Arg.Is>(users => users.Count() == expectedNewUsersCount)); - await sutProvider.GetDependency().Received(1) - .SendOrganizationInviteEmailsAsync( - Arg.Is(info => info.OrgUserTokenPairs.Count() == expectedNewUsersCount && info.IsFreeOrg == (org.PlanType == PlanType.Free) && info.OrganizationName == org.Name)); + await sutProvider.GetDependency().Received(1) + .SendInvitesAsync( + Arg.Is( + info => info.Users.Length == expectedNewUsersCount && + info.Organization == org)); // Send events await sutProvider.GetDependency().Received(1) @@ -152,16 +145,6 @@ public class OrganizationServiceTests var currentContext = sutProvider.GetDependency(); currentContext.ManageUsers(org.Id).Returns(true); - // Mock tokenable factory to return a token that expires in 5 days - sutProvider.GetDependency() - .CreateToken(Arg.Any()) - .Returns( - info => new OrgUserInviteTokenable(info.Arg()) - { - ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) - } - ); - await sutProvider.Sut.ImportAsync(org.Id, null, newUsers, null, false, EventSystemUser.PublicApi); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() @@ -179,14 +162,15 @@ public class OrganizationServiceTests await sutProvider.GetDependency().Received(1) .CreateManyAsync(Arg.Is>(users => users.Count() == expectedNewUsersCount)); - await sutProvider.GetDependency().Received(1) - .SendOrganizationInviteEmailsAsync(Arg.Is(info => - info.OrgUserTokenPairs.Count() == expectedNewUsersCount && info.IsFreeOrg == (org.PlanType == PlanType.Free) && info.OrganizationName == org.Name)); + await sutProvider.GetDependency().Received(1) + .SendInvitesAsync(Arg.Is(request => + request.Users.Length == expectedNewUsersCount && + request.Organization == org)); // Sent events await sutProvider.GetDependency().Received(1) .LogOrganizationUserEventsAsync(Arg.Is>(events => - events.Where(e => e.Item2 == EventType.OrganizationUser_Invited).Count() == expectedNewUsersCount)); + events.Count(e => e.Item2 == EventType.OrganizationUser_Invited) == expectedNewUsersCount)); await sutProvider.GetDependency().Received(1) .RaiseEventAsync(Arg.Is(referenceEvent => referenceEvent.Type == ReferenceEventType.InvitedUsers && referenceEvent.Id == org.Id && @@ -270,125 +254,15 @@ public class OrganizationServiceTests // Must set guids in order for dictionary of guids to not throw aggregate exceptions SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); - // Mock tokenable factory to return a token that expires in 5 days - sutProvider.GetDependency() - .CreateToken(Arg.Any()) - .Returns( - info => new OrgUserInviteTokenable(info.Arg()) - { - ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) - } - ); - - await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) }); - await sutProvider.GetDependency().Received(1) - .SendOrganizationInviteEmailsAsync(Arg.Is(info => - info.OrgUserTokenPairs.Count() == invite.Emails.Distinct().Count() && - info.IsFreeOrg == (organization.PlanType == PlanType.Free) && - info.OrganizationName == organization.Name)); + await sutProvider.GetDependency().Received(1) + .SendInvitesAsync(Arg.Is(request => + request.Users.DistinctBy(x => x.Email).Count() == invite.Emails.Distinct().Count() && + request.Organization == organization)); } - [Theory] - [OrganizationInviteCustomize, OrganizationCustomize, BitAutoData] - public async Task InviteUsers_SsoOrgWithNullSsoConfig_Passes(Organization organization, OrganizationUser invitor, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, - OrganizationUserInvite invite, SutProvider sutProvider) - { - // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks - sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); - sutProvider.Create(); - - // Org must be able to use SSO to trigger this proper test case as we currently only call to retrieve - // an org's SSO config if the org can use SSO - organization.UseSso = true; - - // Return null for sso config - sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id).ReturnsNull(); - - sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); - sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(true); - sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); - var organizationUserRepository = sutProvider.GetDependency(); - organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner) - .Returns(new[] { owner }); - - // Must set guids in order for dictionary of guids to not throw aggregate exceptions - SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); - - // Mock tokenable factory to return a token that expires in 5 days - sutProvider.GetDependency() - .CreateToken(Arg.Any()) - .Returns( - info => new OrgUserInviteTokenable(info.Arg()) - { - ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) - } - ); - - - - await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) }); - - await sutProvider.GetDependency().Received(1) - .SendOrganizationInviteEmailsAsync(Arg.Is(info => - info.OrgUserTokenPairs.Count() == invite.Emails.Distinct().Count() && - info.IsFreeOrg == (organization.PlanType == PlanType.Free) && - info.OrganizationName == organization.Name)); - } - - [Theory] - [OrganizationInviteCustomize, OrganizationCustomize, BitAutoData] - public async Task InviteUsers_SsoOrgWithNeverEnabledRequireSsoPolicy_Passes(Organization organization, SsoConfig ssoConfig, OrganizationUser invitor, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, -OrganizationUserInvite invite, SutProvider sutProvider) - { - // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks - sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); - sutProvider.Create(); - - // Org must be able to use SSO and policies to trigger this test case - organization.UseSso = true; - organization.UsePolicies = true; - - sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); - sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(true); - sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); - var organizationUserRepository = sutProvider.GetDependency(); - organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner) - .Returns(new[] { owner }); - - ssoConfig.Enabled = true; - sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id).Returns(ssoConfig); - - - // Return null policy to mimic new org that's never turned on the require sso policy - sutProvider.GetDependency().GetManyByOrganizationIdAsync(organization.Id).ReturnsNull(); - - // Must set guids in order for dictionary of guids to not throw aggregate exceptions - SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); - - // Mock tokenable factory to return a token that expires in 5 days - sutProvider.GetDependency() - .CreateToken(Arg.Any()) - .Returns( - info => new OrgUserInviteTokenable(info.Arg()) - { - ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) - } - ); - - await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) }); - - await sutProvider.GetDependency().Received(1) - .SendOrganizationInviteEmailsAsync(Arg.Is(info => - info.OrgUserTokenPairs.Count() == invite.Emails.Distinct().Count() && - info.IsFreeOrg == (organization.PlanType == PlanType.Free) && - info.OrganizationName == organization.Name)); - } - [Theory] [OrganizationInviteCustomize( InviteeUserType = OrganizationUserType.Admin, @@ -637,14 +511,14 @@ OrganizationUserInvite invite, SutProvider sutProvider) organizationRepository.GetByIdAsync(organization.Id).Returns(organization); // Mock tokenable factory to return a token that expires in 5 days - sutProvider.GetDependency() - .CreateToken(Arg.Any()) - .Returns( - info => new OrgUserInviteTokenable(info.Arg()) - { - ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) - } - ); + // sutProvider.GetDependency() + // .CreateToken(Arg.Any()) + // .Returns( + // info => new OrgUserInviteTokenable(info.Arg()) + // { + // ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) + // } + // ); sutProvider.GetDependency() .HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any>()) @@ -655,11 +529,10 @@ OrganizationUserInvite invite, SutProvider sutProvider) await sutProvider.Sut.InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId); - await sutProvider.GetDependency().Received(1) - .SendOrganizationInviteEmailsAsync(Arg.Is(info => - info.OrgUserTokenPairs.Count() == 1 && - info.IsFreeOrg == (organization.PlanType == PlanType.Free) && - info.OrganizationName == organization.Name)); + await sutProvider.GetDependency().Received(1) + .SendInvitesAsync(Arg.Is(request => + request.Users.Length == 1 && + request.Organization == organization)); await sutProvider.GetDependency().Received(1).LogOrganizationUserEventsAsync(Arg.Any>()); } @@ -712,16 +585,6 @@ OrganizationUserInvite invite, SutProvider sutProvider) organizationRepository.GetByIdAsync(organization.Id).Returns(organization); - // Mock tokenable factory to return a token that expires in 5 days - sutProvider.GetDependency() - .CreateToken(Arg.Any()) - .Returns( - info => new OrgUserInviteTokenable(info.Arg()) - { - ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) - } - ); - sutProvider.GetDependency() .HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any>()) .Returns(true); @@ -733,12 +596,11 @@ OrganizationUserInvite invite, SutProvider sutProvider) .InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId)); Assert.Contains("This user has already been invited", exception.Message); - // MailService and EventService are still called, but with no OrgUsers - await sutProvider.GetDependency().Received(1) - .SendOrganizationInviteEmailsAsync(Arg.Is(info => - !info.OrgUserTokenPairs.Any() && - info.IsFreeOrg == (organization.PlanType == PlanType.Free) && - info.OrganizationName == organization.Name)); + // SendOrganizationInvitesCommand and EventService are still called, but with no OrgUsers + await sutProvider.GetDependency().Received(1) + .SendInvitesAsync(Arg.Is(info => + info.Organization == organization && + info.Users.Length == 0)); await sutProvider.GetDependency().Received(1) .LogOrganizationUserEventsAsync(Arg.Is>(events => !events.Any())); } @@ -787,16 +649,6 @@ OrganizationUserInvite invite, SutProvider sutProvider) organizationRepository.GetByIdAsync(organization.Id).Returns(organization); - // Mock tokenable factory to return a token that expires in 5 days - sutProvider.GetDependency() - .CreateToken(Arg.Any()) - .Returns( - info => new OrgUserInviteTokenable(info.Arg()) - { - ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) - } - ); - sutProvider.GetDependency() .HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any>()) .Returns(true); @@ -806,11 +658,10 @@ OrganizationUserInvite invite, SutProvider sutProvider) await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, invites); - await sutProvider.GetDependency().Received(1) - .SendOrganizationInviteEmailsAsync(Arg.Is(info => - info.OrgUserTokenPairs.Count() == invites.SelectMany(i => i.invite.Emails).Count() && - info.IsFreeOrg == (organization.PlanType == PlanType.Free) && - info.OrganizationName == organization.Name)); + await sutProvider.GetDependency().Received(1) + .SendInvitesAsync(Arg.Is(info => + info.Organization == organization && + info.Users.Length == invites.SelectMany(x => x.invite.Emails).Distinct().Count())); await sutProvider.GetDependency().Received(1).LogOrganizationUserEventsAsync(Arg.Any>()); } @@ -848,23 +699,12 @@ OrganizationUserInvite invite, SutProvider sutProvider) currentContext.ManageUsers(organization.Id).Returns(true); - // Mock tokenable factory to return a token that expires in 5 days - sutProvider.GetDependency() - .CreateToken(Arg.Any()) - .Returns( - info => new OrgUserInviteTokenable(info.Arg()) - { - ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) - } - ); - await sutProvider.Sut.InviteUsersAsync(organization.Id, invitingUserId: null, eventSystemUser, invites); - await sutProvider.GetDependency().Received(1) - .SendOrganizationInviteEmailsAsync(Arg.Is(info => - info.OrgUserTokenPairs.Count() == invites.SelectMany(i => i.invite.Emails).Count() && - info.IsFreeOrg == (organization.PlanType == PlanType.Free) && - info.OrganizationName == organization.Name)); + await sutProvider.GetDependency().Received(1) + .SendInvitesAsync(Arg.Is(info => + info.Users.Length == invites.SelectMany(i => i.invite.Emails).Count() && + info.Organization == organization)); await sutProvider.GetDependency().Received(1).LogOrganizationUserEventsAsync(Arg.Any>()); } diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs index 092ab95a14..14b6f50415 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs @@ -1,6 +1,9 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.Repositories; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Utilities; using Xunit; @@ -424,4 +427,173 @@ public class OrganizationUserRepositoryTests Assert.Equal(createdOrgUserIds.ToHashSet(), readOrgUserIds.ToHashSet()); } + + [DatabaseTheory, DatabaseData] + public async Task CreateManyAsync_WithCollectionAndGroup_SaveSuccessfully( + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + IGroupRepository groupRepository) + { + var requestTime = DateTime.UtcNow; + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + BillingEmail = "billing@test.com", // TODO: EF does not enfore this being NOT NULL + Plan = "Test", // TODO: EF does not enforce this being NOT NULl, + CreationDate = requestTime + }); + + var collection1 = await collectionRepository.CreateAsync(new Collection + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + Name = "Test Collection", + ExternalId = "external-collection-1", + CreationDate = requestTime, + RevisionDate = requestTime + }); + var collection2 = await collectionRepository.CreateAsync(new Collection + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + Name = "Test Collection", + ExternalId = "external-collection-1", + CreationDate = requestTime, + RevisionDate = requestTime + }); + var collection3 = await collectionRepository.CreateAsync(new Collection + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + Name = "Test Collection", + ExternalId = "external-collection-1", + CreationDate = requestTime, + RevisionDate = requestTime + }); + + var group1 = await groupRepository.CreateAsync(new Group + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + Name = "Test Group", + ExternalId = "external-group-1" + }); + var group2 = await groupRepository.CreateAsync(new Group + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + Name = "Test Group", + ExternalId = "external-group-1" + }); + var group3 = await groupRepository.CreateAsync(new Group + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + Name = "Test Group", + ExternalId = "external-group-1" + }); + + + var orgUserCollection = new List + { + new() + { + OrganizationUser = new OrganizationUser + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + Email = "test-user@test.com", + Status = OrganizationUserStatusType.Invited, + Type = OrganizationUserType.Owner, + ExternalId = "externalid-1", + Permissions = CoreHelpers.ClassToJsonData(new Permissions()), + AccessSecretsManager = false + }, + Collections = + [ + new CollectionAccessSelection + { + Id = collection1.Id, + ReadOnly = true, + HidePasswords = false, + Manage = false + } + ], + Groups = [group1.Id] + }, + new() + { + OrganizationUser = new OrganizationUser + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + Email = "test-user@test.com", + Status = OrganizationUserStatusType.Invited, + Type = OrganizationUserType.Owner, + ExternalId = "externalid-1", + Permissions = CoreHelpers.ClassToJsonData(new Permissions()), + AccessSecretsManager = false + }, + Collections = + [ + new CollectionAccessSelection + { + Id = collection2.Id, + ReadOnly = true, + HidePasswords = false, + Manage = false + } + ], + Groups = [group2.Id] + }, + new() + { + OrganizationUser = new OrganizationUser + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + Email = "test-user@test.com", + Status = OrganizationUserStatusType.Invited, + Type = OrganizationUserType.Owner, + ExternalId = "externalid-1", + Permissions = CoreHelpers.ClassToJsonData(new Permissions()), + AccessSecretsManager = false + }, + Collections = + [ + new CollectionAccessSelection + { + Id = collection3.Id, + ReadOnly = true, + HidePasswords = false, + Manage = false + } + ], + Groups = [group3.Id] + } + }; + + await organizationUserRepository.CreateManyAsync(orgUserCollection); + + var orgUser1 = await organizationUserRepository.GetDetailsByIdWithCollectionsAsync(orgUserCollection[0].OrganizationUser.Id); + var group1Database = await groupRepository.GetManyIdsByUserIdAsync(orgUserCollection[0].OrganizationUser.Id); + Assert.Equal(orgUserCollection[0].OrganizationUser.Id, orgUser1.OrganizationUser.Id); + Assert.Equal(collection1.Id, orgUser1.Collections.First().Id); + Assert.Equal(group1.Id, group1Database.First()); + + + var orgUser2 = await organizationUserRepository.GetDetailsByIdWithCollectionsAsync(orgUserCollection[1].OrganizationUser.Id); + var group2Database = await groupRepository.GetManyIdsByUserIdAsync(orgUserCollection[1].OrganizationUser.Id); + Assert.Equal(orgUserCollection[1].OrganizationUser.Id, orgUser2.OrganizationUser.Id); + Assert.Equal(collection2.Id, orgUser2.Collections.First().Id); + Assert.Equal(group2.Id, group2Database.First()); + + var orgUser3 = await organizationUserRepository.GetDetailsByIdWithCollectionsAsync(orgUserCollection[2].OrganizationUser.Id); + var group3Database = await groupRepository.GetManyIdsByUserIdAsync(orgUserCollection[2].OrganizationUser.Id); + Assert.Equal(orgUserCollection[2].OrganizationUser.Id, orgUser3.OrganizationUser.Id); + Assert.Equal(collection3.Id, orgUser3.Collections.First().Id); + Assert.Equal(group3.Id, group3Database.First()); + } } diff --git a/util/Migrator/DbScripts/2025-02-17_00_OrgUsers_CreateManyUsersCollectionsGroups.sql b/util/Migrator/DbScripts/2025-02-17_00_OrgUsers_CreateManyUsersCollectionsGroups.sql new file mode 100644 index 0000000000..ab7ab9cc88 --- /dev/null +++ b/util/Migrator/DbScripts/2025-02-17_00_OrgUsers_CreateManyUsersCollectionsGroups.sql @@ -0,0 +1,97 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_CreateManyWithCollectionsAndGroups] + @organizationUserData NVARCHAR(MAX), + @collectionData NVARCHAR(MAX), + @groupData NVARCHAR(MAX) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[OrganizationUser] + ( + [Id], + [OrganizationId], + [UserId], + [Email], + [Key], + [Status], + [Type], + [ExternalId], + [CreationDate], + [RevisionDate], + [Permissions], + [ResetPasswordKey], + [AccessSecretsManager] + ) + SELECT + OUI.[Id], + OUI.[OrganizationId], + OUI.[UserId], + OUI.[Email], + OUI.[Key], + OUI.[Status], + OUI.[Type], + OUI.[ExternalId], + OUI.[CreationDate], + OUI.[RevisionDate], + OUI.[Permissions], + OUI.[ResetPasswordKey], + OUI.[AccessSecretsManager] + FROM + OPENJSON(@organizationUserData) + WITH ( + [Id] UNIQUEIDENTIFIER '$.Id', + [OrganizationId] UNIQUEIDENTIFIER '$.OrganizationId', + [UserId] UNIQUEIDENTIFIER '$.UserId', + [Email] NVARCHAR(256) '$.Email', + [Key] VARCHAR(MAX) '$.Key', + [Status] SMALLINT '$.Status', + [Type] TINYINT '$.Type', + [ExternalId] NVARCHAR(300) '$.ExternalId', + [CreationDate] DATETIME2(7) '$.CreationDate', + [RevisionDate] DATETIME2(7) '$.RevisionDate', + [Permissions] NVARCHAR (MAX) '$.Permissions', + [ResetPasswordKey] VARCHAR (MAX) '$.ResetPasswordKey', + [AccessSecretsManager] BIT '$.AccessSecretsManager' + ) OUI + + INSERT INTO [dbo].[GroupUser] + ( + [OrganizationUserId], + [GroupId] + ) + SELECT + OUG.OrganizationUserId, + OUG.GroupId + FROM + OPENJSON(@groupData) + WITH( + [OrganizationUserId] UNIQUEIDENTIFIER '$.OrganizationUserId', + [GroupId] UNIQUEIDENTIFIER '$.GroupId' + ) OUG + + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + OUC.[CollectionId], + OUC.[OrganizationUserId], + OUC.[ReadOnly], + OUC.[HidePasswords], + OUC.[Manage] + FROM + OPENJSON(@collectionData) + WITH( + [CollectionId] UNIQUEIDENTIFIER '$.CollectionId', + [OrganizationUserId] UNIQUEIDENTIFIER '$.OrganizationUserId', + [ReadOnly] BIT '$.ReadOnly', + [HidePasswords] BIT '$.HidePasswords', + [Manage] BIT '$.Manage' + ) OUC +END +go +