diff --git a/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs b/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs index 990ac27ec2..cdd8053cf5 100644 --- a/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs +++ b/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs @@ -1,8 +1,9 @@ using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.Enums; -using Bit.Core.Models.Business; using Bit.Core.Models.Data; using Bit.Core.Utilities; +using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite; namespace Bit.Scim.Models; @@ -25,6 +26,14 @@ public class ScimUserRequestModel : BaseScimUserModel }; } + public OrganizationUserSingleEmailInvite ToInvite(ScimProviderType scimProvider, bool hasSecretsManager) => + OrganizationUserSingleEmailInvite.Create( + EmailForInvite(scimProvider), + [], + OrganizationUserType.User, + new Permissions(), + hasSecretsManager); + 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..a8a9199f56 100644 --- a/bitwarden_license/src/Scim/Users/PostUserCommand.cs +++ b/bitwarden_license/src/Scim/Users/PostUserCommand.cs @@ -1,4 +1,8 @@ -using Bit.Core.Enums; +using Bit.Core; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; @@ -9,31 +13,20 @@ using Bit.Scim.Users.Interfaces; 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) + : 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) - { - _organizationRepository = organizationRepository; - _organizationUserRepository = organizationUserRepository; - _organizationService = organizationService; - _paymentService = paymentService; - _scimContext = scimContext; - } - public async Task PostUserAsync(Guid organizationId, ScimUserRequestModel model) { - var scimProvider = _scimContext.RequestScimProvider; + var scimProvider = scimContext.RequestScimProvider; var invite = model.ToOrganizationUserInvite(scimProvider); var email = invite.Emails.Single(); @@ -44,7 +37,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 +50,34 @@ 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); + var hasStandaloneSecretsManager = await paymentService.HasSecretsManagerStandalone(organization); invite.AccessSecretsManager = hasStandaloneSecretsManager; - var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM, + if (featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization)) + { + var request = InviteScimOrganizationUserRequest.Create( + model.ToInvite(scimProvider, hasStandaloneSecretsManager), + OrganizationDto.FromOrganization(organization), + timeProvider.GetUtcNow(), + model.ExternalIdForInvite() + ); + + var result = await inviteOrganizationUsersCommand.InviteScimOrganizationUserAsync(request); + + if (result.Success) + { + var invitedUser = await organizationUserRepository.GetDetailsByIdAsync(result.Value.Id); + + return invitedUser; + } + + return null; + } + + var invitedOrgUser = await organizationService.InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM, invite, externalId); - var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id); + var orgUser = await organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id); return orgUser; } diff --git a/src/Core/AdminConsole/Models/Business/OrganizationDto.cs b/src/Core/AdminConsole/Models/Business/OrganizationDto.cs new file mode 100644 index 0000000000..02d730625b --- /dev/null +++ b/src/Core/AdminConsole/Models/Business/OrganizationDto.cs @@ -0,0 +1,31 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Models.StaticStore; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.Models.Business; + +public record OrganizationDto( + Guid OrganizationId, + bool UseCustomPermissions, + int? Seats, + int? MaxAutoScaleSeats, + int? SmSeats, + int? SmMaxAutoScaleSeats, + Plan Plan, + string GatewayCustomerId, + string GatewaySubscriptionId, + bool UseSecretsManager +) +{ + public static OrganizationDto FromOrganization(Organization organization) => + new(organization.Id, + organization.UseCustomPermissions, + organization.Seats, + organization.MaxAutoscaleSeats, + organization.SmSeats, + organization.MaxAutoscaleSmSeats, + StaticStore.GetPlan(organization.PlanType), + organization.GatewayCustomerId, + organization.GatewaySubscriptionId, + organization.UseSecretsManager); +}; 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..6c7ec8b777 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs @@ -0,0 +1,93 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Commands; +using Bit.Core.Repositories; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +public interface IInviteOrganizationUsersCommand +{ + Task> InviteScimOrganizationUserAsync(InviteScimOrganizationUserRequest request); +} + +public class InviteOrganizationUsersCommand(IEventService eventService, + IOrganizationUserRepository organizationUserRepository, + IInviteUsersValidation inviteUsersValidation + ) : IInviteOrganizationUsersCommand +{ + public async Task> InviteScimOrganizationUserAsync(InviteScimOrganizationUserRequest request) + { + var result = await InviteOrganizationUsersAsync(InviteOrganizationUsersRequest.Create(request)); + + if (result.Value.Any()) + { + (OrganizationUser User, EventType type, EventSystemUser system, DateTime performedAt) log = (result.Value.First(), EventType.OrganizationUser_Invited, EventSystemUser.SCIM, request.PerformedAt.UtcDateTime); + + await eventService.LogOrganizationUserEventsAsync([log]); + } + + return new CommandResult(result.Value.FirstOrDefault()); + } + + private async Task>> InviteOrganizationUsersAsync(InviteOrganizationUsersRequest request) + { + var existingEmails = new HashSet(await organizationUserRepository.SelectKnownEmailsAsync( + request.Organization.OrganizationId, request.Invites.SelectMany(i => i.Emails), false), + StringComparer.InvariantCultureIgnoreCase); + + var invitesToSend = request.Invites + .SelectMany(invite => invite.Emails + .Where(email => !existingEmails.Contains(email)) + .Select(email => OrganizationUserInviteDto.Create(email, invite)) + ); + + // Validate we can add those seats + var validationResult = await inviteUsersValidation.ValidateAsync(new InviteUserOrganizationValidationRequest + { + Invites = invitesToSend.ToArray(), + Organization = request.Organization, + PerformedBy = request.PerformedBy, + PerformedAt = request.PerformedAt, + OccupiedPmSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(request.Organization.OrganizationId), + OccupiedSmSeats = await organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(request.Organization.OrganizationId) + }); + + try + { + // save organization users + // org users + // collections + // groups + + // save new seat totals + // password manager + // secrets manager + // update stripe + + // send invites + + // notify owners + // seats added + // autoscaling + // max seat limit has been reached + + // publish events + // Reference events + + // update cache + } + catch (Exception) + { + // rollback saves + // remove org users + // remove collections + // remove groups + // correct stripe + } + + return null; + } +} 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..4462c81ebe --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersRequest.cs @@ -0,0 +1,28 @@ +using Bit.Core.AdminConsole.Models.Business; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +public class InviteOrganizationUsersRequest +{ + public OrganizationUserInvite[] Invites { get; } = []; + public OrganizationDto Organization { get; } + public Guid PerformedBy { get; } + public DateTimeOffset PerformedAt { get; } + + public InviteOrganizationUsersRequest(OrganizationUserInvite[] Invites, + OrganizationDto Organization, + Guid PerformedBy, + DateTimeOffset PerformedAt) + { + this.Invites = Invites; + this.Organization = Organization; + this.PerformedBy = PerformedBy; + this.PerformedAt = PerformedAt; + } + + public static InviteOrganizationUsersRequest Create(InviteScimOrganizationUserRequest request) => + new([OrganizationUserInvite.Create(request.Invite, request.ExternalId)], + request.Organization, + Guid.Empty, + request.PerformedAt); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteScimOrganizationUserRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteScimOrganizationUserRequest.cs new file mode 100644 index 0000000000..99a060d2cf --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteScimOrganizationUserRequest.cs @@ -0,0 +1,26 @@ +using Bit.Core.AdminConsole.Models.Business; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +public class InviteScimOrganizationUserRequest +{ + public OrganizationUserSingleEmailInvite Invite { get; } + public OrganizationDto Organization { get; } + public DateTimeOffset PerformedAt { get; } + public string ExternalId { get; } = string.Empty; + + private InviteScimOrganizationUserRequest(OrganizationUserSingleEmailInvite invite, + OrganizationDto organization, + DateTimeOffset performedAt, + string externalId) + { + Invite = invite; + Organization = organization; + PerformedAt = performedAt; + ExternalId = externalId; + } + + public static InviteScimOrganizationUserRequest Create(OrganizationUserSingleEmailInvite invite, + OrganizationDto organization, DateTimeOffset performedAt, string externalId) => + new(invite, organization, performedAt, externalId); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteUserOrganizationValidationRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteUserOrganizationValidationRequest.cs new file mode 100644 index 0000000000..866bee156d --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteUserOrganizationValidationRequest.cs @@ -0,0 +1,13 @@ +using Bit.Core.AdminConsole.Models.Business; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +public class InviteUserOrganizationValidationRequest +{ + public OrganizationUserInviteDto[] Invites { get; init; } = []; + public OrganizationDto Organization { get; init; } + public Guid PerformedBy { get; init; } + public DateTimeOffset PerformedAt { get; init; } + public int OccupiedPmSeats { get; init; } + public int OccupiedSmSeats { get; init; } +} 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..d11b7d599d --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInvite.cs @@ -0,0 +1,68 @@ +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; +using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.InviteOrganizationUserFunctions; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +public class OrganizationUserInvite +{ + public string[] Emails { get; private init; } = []; + public Guid[] AccessibleCollections { get; private init; } = []; + public OrganizationUserType Type { get; private init; } = OrganizationUserType.User; + + public Permissions Permissions { get; private init; } = new(); + public string ExternalId { get; private init; } = string.Empty; + public bool AccessSecretsManager { get; private init; } + + public static OrganizationUserInvite Create(string[] emails, + IEnumerable accessibleCollections, + OrganizationUserType type, + Permissions permissions, + string externalId, + bool accessSecretsManager) + { + if (accessibleCollections?.Any(ValidateCollectionConfiguration) ?? false) + { + throw new BadRequestException(InvalidCollectionConfigurationErrorMessage); + } + + return Create(emails, accessibleCollections?.Select(x => x.Id), type, permissions, externalId, accessSecretsManager); + } + + public static OrganizationUserInvite Create(OrganizationUserSingleEmailInvite invite, string externalId) => + Create([invite.Email], + invite.AccessibleCollections, + invite.Type, + invite.Permissions, + externalId, + invite.AccessSecretsManager); + + private static OrganizationUserInvite Create(string[] emails, IEnumerable accessibleCollections, OrganizationUserType type, Permissions permissions, string externalId, bool accessSecretsManager) + { + ValidateEmailAddresses(emails); + + return new OrganizationUserInvite + { + Emails = emails, + AccessibleCollections = accessibleCollections.ToArray(), + Type = type, + Permissions = permissions, + ExternalId = externalId, + AccessSecretsManager = accessSecretsManager + }; + } + + private static void ValidateEmailAddresses(string[] emails) + { + foreach (var email in emails) + { + if (!email.IsValidEmail()) + { + throw new BadRequestException($"{email} {InvalidEmailErrorMessage}"); + } + } + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInviteDto.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInviteDto.cs new file mode 100644 index 0000000000..b7265e6caf --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInviteDto.cs @@ -0,0 +1,27 @@ +using Bit.Core.Enums; +using Bit.Core.Models.Data; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +public class OrganizationUserInviteDto +{ + public string Email { get; private init; } = string.Empty; + public Guid[] AccessibleCollections { get; private init; } = []; + public string ExternalId { get; private init; } = string.Empty; + public Permissions Permissions { get; private init; } = new(); + public OrganizationUserType Type { get; private init; } = OrganizationUserType.User; + public bool AccessSecretsManager { get; private init; } + + public static OrganizationUserInviteDto Create(string email, OrganizationUserInvite invite) + { + return new OrganizationUserInviteDto + { + Email = email, + AccessibleCollections = invite.AccessibleCollections, + ExternalId = invite.ExternalId, + Type = invite.Type, + Permissions = invite.Permissions, + AccessSecretsManager = invite.AccessSecretsManager + }; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserSingleEmailInvite.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserSingleEmailInvite.cs new file mode 100644 index 0000000000..76eaafdcfd --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserSingleEmailInvite.cs @@ -0,0 +1,43 @@ +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; +using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.InviteOrganizationUserFunctions; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +public class OrganizationUserSingleEmailInvite +{ + public string Email { get; private init; } = string.Empty; + public Guid[] AccessibleCollections { get; private init; } = []; + public Permissions Permissions { get; private init; } = new(); + public OrganizationUserType Type { get; private init; } = OrganizationUserType.User; + public bool AccessSecretsManager { get; private init; } + + public static OrganizationUserSingleEmailInvite Create(string email, + IEnumerable accessibleCollections, + OrganizationUserType type, + Permissions permissions, + bool accessSecretsManager) + { + if (!email.IsValidEmail()) + { + throw new BadRequestException(InvalidEmailErrorMessage); + } + + if (accessibleCollections?.Any(ValidateCollectionConfiguration) ?? false) + { + throw new BadRequestException(InvalidCollectionConfigurationErrorMessage); + } + + return new OrganizationUserSingleEmailInvite + { + Email = email, + AccessibleCollections = accessibleCollections.Select(x => x.Id).ToArray(), + Type = type, + Permissions = permissions, + AccessSecretsManager = accessSecretsManager + }; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserFunctions.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserFunctions.cs new file mode 100644 index 0000000000..f689fe0757 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserFunctions.cs @@ -0,0 +1,9 @@ +using Bit.Core.Models.Data; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; + +public static class InviteOrganizationUserFunctions +{ + public static Func ValidateCollectionConfiguration => collectionAccessSelection => + collectionAccessSelection.Manage && (collectionAccessSelection.ReadOnly || collectionAccessSelection.HidePasswords); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidation.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidation.cs new file mode 100644 index 0000000000..a6b57bec1e --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidation.cs @@ -0,0 +1,75 @@ +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.InviteUserValidationErrorMessages; +using SecretsManagerSubscriptionUpdate = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models.SecretsManagerSubscriptionUpdate; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; + +public interface IInviteUsersValidation +{ + Task> ValidateAsync(InviteUserOrganizationValidationRequest request); +} + +public class InviteUsersValidation( + IGlobalSettings globalSettings, + IProviderRepository providerRepository, + IPaymentService paymentService, + IOrganizationRepository organizationRepository) : IInviteUsersValidation +{ + public async Task> ValidateAsync(InviteUserOrganizationValidationRequest request) + { + if (ValidateEnvironment(globalSettings) is Invalid invalidEnvironment) + { + return new Invalid(invalidEnvironment.ErrorMessageString); + } + + if (InvitingUserOrganizationValidation.Validate(request.Organization) is Invalid organizationValidation) + { + return new Invalid(organizationValidation.ErrorMessageString); + } + + var subscriptionUpdate = PasswordManagerSubscriptionUpdate.Create(request); + + if (PasswordManagerInviteUserValidation.Validate(subscriptionUpdate) is + Invalid invalidSubscriptionUpdate) + { + return new Invalid(invalidSubscriptionUpdate.ErrorMessageString); + } + + var smSubscriptionUpdate = SecretsManagerSubscriptionUpdate.Create(request, subscriptionUpdate); + + if (SecretsManagerInviteUserValidation.Validate(smSubscriptionUpdate) is + Invalid invalidSmSubscriptionUpdate) + { + return new Invalid(invalidSmSubscriptionUpdate.ErrorMessageString); + } + + var provider = await providerRepository.GetByOrganizationIdAsync(request.Organization.OrganizationId); + + if (InvitingUserOrganizationProviderValidation.Validate(ProviderDto.FromProviderEntity(provider)) is + Invalid invalidProviderValidation) + { + return new Invalid(invalidProviderValidation.ErrorMessageString); + } + + var paymentSubscription = await paymentService.GetSubscriptionAsync(await organizationRepository.GetByIdAsync(request.Organization.OrganizationId)); + + if (InviteUserPaymentValidation.Validate(PaymentSubscriptionDto.FromSubscriptionInfo(paymentSubscription, request.Organization)) is + Invalid invalidPaymentValidation) + { + return new Invalid(invalidPaymentValidation.ErrorMessageString); + } + + return new Valid(null); + } + + public static ValidationResult ValidateEnvironment(IGlobalSettings globalSettings) => + globalSettings.SelfHosted + ? new Invalid(CannotAutoScaleOnSelfHostedError) + : new Valid(globalSettings); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserPaymentValidation.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserPaymentValidation.cs new file mode 100644 index 0000000000..e6df990c90 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserPaymentValidation.cs @@ -0,0 +1,23 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; +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(PaymentSubscriptionDto subscription) + { + if (subscription.ProductTierType is ProductTierType.Free) + { + return new Valid(subscription); + } + + if (subscription.SubscriptionStatus == StripeConstants.SubscriptionStatus.Canceled) + { + return new Invalid(InviteUserValidationErrorMessages.CancelledSubscriptionError); + } + + return new Valid(subscription); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserValidationErrorMessages.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserValidationErrorMessages.cs new file mode 100644 index 0000000000..9d475b1dd6 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserValidationErrorMessages.cs @@ -0,0 +1,17 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; + +public static class InviteUserValidationErrorMessages +{ + public const string CannotAutoScaleOnSelfHostedError = "Cannot autoscale on self-hosted instance."; + public const string SeatLimitHasBeenReachedError = "Seat limit has been reached."; + public const string ProviderBillableSeatLimitError = "Seat limit has been reached. Please contact your provider to add more seats."; + public const string ProviderResellerSeatLimitError = "Seat limit has been reached. Contact your provider to purchase additional seats."; + public const string CancelledSubscriptionError = "Cannot autoscale with a canceled subscription."; + public const string NoPaymentMethodFoundError = "No payment method found."; + public const string NoSubscriptionFoundError = "No subscription found."; + + // Secrets Manager Invite Users Error Messages + public const string OrganizationNoSecretsManager = "Organization has no access to Secrets Manager"; + public const string SecretsManagerSeatLimitReached = "Secrets Manager seat limit has been reached."; + public const string SecretsManagerCannotExceedPasswordManager = "You cannot have more Secrets Manager seats than Password Manager seats."; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InvitingUserOrganizationProviderValidation.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InvitingUserOrganizationProviderValidation.cs new file mode 100644 index 0000000000..dc9b7202da --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InvitingUserOrganizationProviderValidation.cs @@ -0,0 +1,26 @@ +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; +using Bit.Core.Billing.Extensions; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; + +public static class InvitingUserOrganizationProviderValidation +{ + public static ValidationResult Validate(ProviderDto provider) + { + if (provider is { Enabled: true }) + { + if (provider.IsBillable()) + { + return new Invalid(InviteUserValidationErrorMessages.ProviderBillableSeatLimitError); + } + + if (provider.Type == ProviderType.Reseller) + { + return new Invalid(InviteUserValidationErrorMessages.ProviderResellerSeatLimitError); + } + } + + return new Valid(provider); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InvitingUserOrganizationValidation.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InvitingUserOrganizationValidation.cs new file mode 100644 index 0000000000..9efdd57299 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InvitingUserOrganizationValidation.cs @@ -0,0 +1,22 @@ +using Bit.Core.AdminConsole.Models.Business; +using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.InviteUserValidationErrorMessages; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; + +public static class InvitingUserOrganizationValidation +{ + public static ValidationResult Validate(OrganizationDto organization) + { + if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) + { + return new Invalid(NoPaymentMethodFoundError); + } + + if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) + { + return new Invalid(NoSubscriptionFoundError); + } + + return new Valid(organization); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Models/PasswordManagerSubscriptionUpdate.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Models/PasswordManagerSubscriptionUpdate.cs new file mode 100644 index 0000000000..aa350a5921 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Models/PasswordManagerSubscriptionUpdate.cs @@ -0,0 +1,43 @@ +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; + +public class PasswordManagerSubscriptionUpdate +{ + /// + /// Seats the organization has + /// + public int? Seats { get; private init; } + + public int? MaxAutoScaleSeats { get; private init; } + + public int OccupiedSeats { get; private init; } + + public int AdditionalSeats { get; private init; } + + public int? AvailableSeats => Seats - OccupiedSeats; + + public int SeatsRequiredToAdd => AdditionalSeats - AvailableSeats ?? 0; + + public int? UpdatedSeatTotal => Seats + SeatsRequiredToAdd; + + private PasswordManagerSubscriptionUpdate(int? organizationSeats, int? organizationAutoScaleSeatLimit, int currentSeats, int seatsToAdd) + { + Seats = organizationSeats; + MaxAutoScaleSeats = organizationAutoScaleSeatLimit; + OccupiedSeats = currentSeats; + AdditionalSeats = seatsToAdd; + } + + public static PasswordManagerSubscriptionUpdate Create(OrganizationDto organizationDto, int occupiedSeats, int seatsToAdd) + { + return new PasswordManagerSubscriptionUpdate(organizationDto.Seats, organizationDto.MaxAutoScaleSeats, occupiedSeats, seatsToAdd); + } + + public static PasswordManagerSubscriptionUpdate Create(InviteUserOrganizationValidationRequest refined) + { + return new PasswordManagerSubscriptionUpdate(refined.Organization.Seats, refined.Organization.MaxAutoScaleSeats, + refined.OccupiedPmSeats, refined.Invites.Length); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Models/PaymentSubscriptionDto.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Models/PaymentSubscriptionDto.cs new file mode 100644 index 0000000000..c4fd03031a --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Models/PaymentSubscriptionDto.cs @@ -0,0 +1,18 @@ +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 PaymentSubscriptionDto +{ + public ProductTierType ProductTierType { get; init; } + public string SubscriptionStatus { get; init; } + + public static PaymentSubscriptionDto FromSubscriptionInfo(SubscriptionInfo subscriptionInfo, OrganizationDto organizationDto) => + new() + { + SubscriptionStatus = subscriptionInfo?.Subscription?.Status ?? string.Empty, + ProductTierType = organizationDto.Plan.ProductTier + }; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Models/ProviderDto.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Models/ProviderDto.cs new file mode 100644 index 0000000000..35d255a0dd --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Models/ProviderDto.cs @@ -0,0 +1,17 @@ +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; + +public class ProviderDto +{ + public Guid ProviderId { get; init; } + public ProviderType Type { get; init; } + public ProviderStatusType Status { get; init; } + public bool Enabled { get; init; } + + public static ProviderDto FromProviderEntity(Provider provider) + { + return new ProviderDto { ProviderId = provider.Id, Type = provider.Type, Status = provider.Status, Enabled = provider.Enabled }; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Models/SecretsManagerSubscriptionUpdate.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Models/SecretsManagerSubscriptionUpdate.cs new file mode 100644 index 0000000000..afd6f3b77f --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Models/SecretsManagerSubscriptionUpdate.cs @@ -0,0 +1,35 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; + +public class SecretsManagerSubscriptionUpdate +{ + public bool UseSecretsManger { get; private init; } + public int? Seats { get; private init; } + public int? MaxAutoScaleSeats { get; private init; } + public int OccupiedSeats { get; private init; } + public int AdditionalSeats { get; private init; } + public PasswordManagerSubscriptionUpdate PasswordManagerSubscriptionUpdate { get; private init; } + public int? AvailableSeats => Seats - OccupiedSeats; + public int SeatsRequiredToAdd => AdditionalSeats - AvailableSeats ?? 0; + public int? UpdatedSeatTotal => Seats + SeatsRequiredToAdd; + + private SecretsManagerSubscriptionUpdate(bool useSecretsManger, int? organizationSeats, + int? organizationAutoScaleSeatLimit, int currentSeats, int seatsToAdd, PasswordManagerSubscriptionUpdate passwordManagerSeats) + { + UseSecretsManger = useSecretsManger; + Seats = organizationSeats; + MaxAutoScaleSeats = organizationAutoScaleSeatLimit; + OccupiedSeats = currentSeats; + AdditionalSeats = seatsToAdd; + PasswordManagerSubscriptionUpdate = passwordManagerSeats; + } + + public static SecretsManagerSubscriptionUpdate Create(InviteUserOrganizationValidationRequest refined, PasswordManagerSubscriptionUpdate passwordManagerSubscriptionUpdate) + { + return new SecretsManagerSubscriptionUpdate(refined.Organization.UseSecretsManager, + refined.Organization.SmSeats, refined.Organization.SmMaxAutoScaleSeats, + refined.OccupiedPmSeats, refined.Invites.Count(x => x.AccessSecretsManager), + passwordManagerSubscriptionUpdate); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManagerInviteUserValidation.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManagerInviteUserValidation.cs new file mode 100644 index 0000000000..5cb896d784 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManagerInviteUserValidation.cs @@ -0,0 +1,31 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; +using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.InviteUserValidationErrorMessages; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; + +public static class PasswordManagerInviteUserValidation +{ + + // TODO need to add plan validation from AdjustSeatsAsync + + public static ValidationResult Validate(PasswordManagerSubscriptionUpdate subscriptionUpdate) + { + if (subscriptionUpdate.Seats is null) + { + return new Valid(subscriptionUpdate); + } + + if (subscriptionUpdate.AdditionalSeats == 0) + { + return new Valid(subscriptionUpdate); + } + + if (subscriptionUpdate.UpdatedSeatTotal is not null && subscriptionUpdate.MaxAutoScaleSeats is not null && + subscriptionUpdate.UpdatedSeatTotal > subscriptionUpdate.MaxAutoScaleSeats) + { + return new Invalid(SeatLimitHasBeenReachedError); + } + + return new Valid(subscriptionUpdate); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/SecretsManagerInviteUserValidation.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/SecretsManagerInviteUserValidation.cs new file mode 100644 index 0000000000..92911f82aa --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/SecretsManagerInviteUserValidation.cs @@ -0,0 +1,84 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; +using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.InviteUserValidationErrorMessages; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; + +public class SecretsManagerInviteUserValidation +{ + // Do we need to check if they are attempting to subtract seats? (no I don't think so because this is for inviting a User) + public static ValidationResult Validate(SecretsManagerSubscriptionUpdate subscriptionUpdate) + { + if (subscriptionUpdate.UseSecretsManger) + { + return new Invalid(OrganizationNoSecretsManager); + } + + if (subscriptionUpdate.Seats == null) + { + return new Valid(subscriptionUpdate); // no need to adjust seats...continue on + } + + // if (update.Autoscaling && update.SmSeats.Value < organization.SmSeats.Value) + // { + // throw new BadRequestException("Cannot use autoscaling to subtract seats."); + // } + + // Might need to check plan + + // Check plan maximum seats + // if (!plan.SecretsManager.HasAdditionalSeatsOption || + // (plan.SecretsManager.MaxAdditionalSeats.HasValue && update.SmSeatsExcludingBase > plan.SecretsManager.MaxAdditionalSeats.Value)) + // { + // var planMaxSeats = plan.SecretsManager.BaseSeats + plan.SecretsManager.MaxAdditionalSeats.GetValueOrDefault(); + // throw new BadRequestException($"You have reached the maximum number of Secrets Manager seats ({planMaxSeats}) for this plan."); + // } + + // Check autoscale maximum seats + if (subscriptionUpdate.UpdatedSeatTotal is not null && subscriptionUpdate.MaxAutoScaleSeats is not null && + subscriptionUpdate.UpdatedSeatTotal > subscriptionUpdate.MaxAutoScaleSeats) + { + return new Invalid(SecretsManagerSeatLimitReached); + } + + // if (update.MaxAutoscaleSmSeats.HasValue && update.SmSeats.Value > update.MaxAutoscaleSmSeats.Value) + // { + // var message = update.Autoscaling + // ? "Secrets Manager seat limit has been reached." + // : "Cannot set max seat autoscaling below seat count."; + // throw new BadRequestException(message); + // } + + // Inviting a user... this shouldn't matter + // + // Check minimum seats included with plan + // if (plan.SecretsManager.BaseSeats > update.SmSeats.Value) + // { + // throw new BadRequestException($"Plan has a minimum of {plan.SecretsManager.BaseSeats} Secrets Manager seats."); + // } + + // Check minimum seats required by business logic + // if (update.SmSeats.Value <= 0) + // { + // throw new BadRequestException("You must have at least 1 Secrets Manager seat."); + // } + + // Check minimum seats currently in use by the organization + // if (organization.SmSeats.Value > update.SmSeats.Value) + // { + // var occupiedSeats = await _organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id); + // if (occupiedSeats > update.SmSeats.Value) + // { + // throw new BadRequestException($"{occupiedSeats} users are currently occupying Secrets Manager seats. " + + // "You cannot decrease your subscription below your current occupied seat count."); + // } + // } + + // Check that SM seats aren't greater than password manager seats + if (subscriptionUpdate.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal < subscriptionUpdate.UpdatedSeatTotal) + { + return new Invalid(SecretsManagerCannotExceedPasswordManager); + } + + return new Valid(subscriptionUpdate); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/ValidationResult.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/ValidationResult.cs new file mode 100644 index 0000000000..065eb90440 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/ValidationResult.cs @@ -0,0 +1,15 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; + +public abstract record ValidationResult(T Value, IEnumerable Errors) +{ + public bool IsValid => !Errors.Any(); + + public string ErrorMessageString => string.Join(" ", Errors); +} + +public record Valid(T Value) : ValidationResult(Value, []); + +public record Invalid(IEnumerable Errors) : ValidationResult(default, Errors) +{ + public Invalid(string error) : this([error]) { } +} diff --git a/src/Core/Billing/Extensions/BillingExtensions.cs b/src/Core/Billing/Extensions/BillingExtensions.cs index 39b92e95a2..56ec4aaec3 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.Models; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; @@ -17,6 +18,13 @@ public static class BillingExtensions Status: ProviderStatusType.Billable }; + public static bool IsBillable(this ProviderDto provider) => + provider 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 6b3d0485b0..99e1525e08 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -109,6 +109,7 @@ public static class FeatureFlagKeys public const string DeviceApprovalRequestAdminNotifications = "pm-15637-device-approval-request-admin-notifications"; public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission"; public const string PushSyncOrgKeysOnRevokeRestore = "pm-17168-push-sync-org-keys-on-revoke-restore"; + public const string ScimInviteUserOptimization = ""; /* Tools Team */ public const string ItemShare = "item-share"; diff --git a/src/Core/Models/Commands/CommandResult.cs b/src/Core/Models/Commands/CommandResult.cs index 9e5d91e09c..f75b8ae008 100644 --- a/src/Core/Models/Commands/CommandResult.cs +++ b/src/Core/Models/Commands/CommandResult.cs @@ -7,6 +7,10 @@ public class CommandResult(IEnumerable errors) public bool Success => ErrorMessages.Count == 0; public bool HasErrors => ErrorMessages.Count > 0; public List ErrorMessages { get; } = errors.ToList(); - public CommandResult() : this(Array.Empty()) { } } + +public class CommandResult(T value) : CommandResult +{ + public T Value { get; set; } = value; +} diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 9d2e6e51e6..538fdb5781 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -13,6 +13,8 @@ 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.Models.Business.Tokenables; using Bit.Core.OrganizationFeatures.OrganizationCollections; using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces; @@ -162,6 +164,10 @@ public static class OrganizationServiceCollectionExtensions 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/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/test/Core.Test/AdminConsole/Models/InviteOrganizationUserRequestTests.cs b/test/Core.Test/AdminConsole/Models/InviteOrganizationUserRequestTests.cs new file mode 100644 index 0000000000..eb9a6d6be7 --- /dev/null +++ b/test/Core.Test/AdminConsole/Models/InviteOrganizationUserRequestTests.cs @@ -0,0 +1,54 @@ +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 InviteOrganizationUserRequestTests +{ + [Theory] + [BitAutoData] + public void Create_WhenPassedInvalidEmail_ThrowsException(string email, + OrganizationUserType type, Permissions permissions, bool accessSecretsManager) + { + var action = () => OrganizationUserSingleEmailInvite.Create(email, [], type, permissions, accessSecretsManager); + + var exception = Assert.Throws(action); + + Assert.Equal(InvalidEmailErrorMessage, exception.Message); + } + + [Theory] + [BitAutoData] + public void Create_WhenPassedInvalidCollectionAccessConfiguration_ThrowsException(OrganizationUserType type, Permissions permissions, bool accessSecretsManager) + { + var validEmail = "test@email.com"; + + var invalidCollectionConfiguration = new CollectionAccessSelection { Manage = true, HidePasswords = true }; + + var action = () => + OrganizationUserSingleEmailInvite.Create(validEmail, [invalidCollectionConfiguration], type, permissions, accessSecretsManager); + + var exception = Assert.Throws(action); + + Assert.Equal(InvalidCollectionConfigurationErrorMessage, exception.Message); + } + + [Theory] + [BitAutoData] + public void Create_WhenPassedValidArguments_ReturnsInvite(OrganizationUserType type, Permissions permissions, bool accessSecretsManager) + { + const string validEmail = "test@email.com"; + var validCollectionConfiguration = new CollectionAccessSelection { Id = Guid.NewGuid(), Manage = true }; + + var invite = OrganizationUserSingleEmailInvite.Create(validEmail, [validCollectionConfiguration], type, permissions, accessSecretsManager); + + Assert.NotNull(invite); + Assert.Equal(validEmail, invite.Email); + Assert.Contains(validCollectionConfiguration.Id, invite.AccessibleCollections); + } +} diff --git a/test/Core.Test/AdminConsole/Models/InviteOrganizationUsersRequestTests.cs b/test/Core.Test/AdminConsole/Models/InviteOrganizationUsersRequestTests.cs new file mode 100644 index 0000000000..6d2be18815 --- /dev/null +++ b/test/Core.Test/AdminConsole/Models/InviteOrganizationUsersRequestTests.cs @@ -0,0 +1,55 @@ +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 Create_WhenPassedInvalidEmails_ThrowsException(string[] emails, OrganizationUserType type, Permissions permissions, string externalId) + { + var action = () => OrganizationUserInvite.Create(emails, [], type, permissions, externalId, false); + + var exception = Assert.Throws(action); + + Assert.Contains(InvalidEmailErrorMessage, exception.Message); + } + + [Fact] + public void Create_WhenPassedInvalidCollectionAccessConfiguration_ThrowsException() + { + const string validEmail = "test@email.com"; + + var invalidCollectionConfiguration = new CollectionAccessSelection + { + Manage = true, + HidePasswords = true + }; + + var action = () => OrganizationUserInvite.Create([validEmail], [invalidCollectionConfiguration], default, default, default, false); + + var exception = Assert.Throws(action); + + Assert.Equal(InvalidCollectionConfigurationErrorMessage, exception.Message); + } + + [Fact] + public void Create_WhenPassedValidArguments_ReturnsInvite() + { + const string validEmail = "test@email.com"; + var validCollectionConfiguration = new CollectionAccessSelection { Id = Guid.NewGuid(), Manage = true }; + + var invite = OrganizationUserInvite.Create([validEmail], [validCollectionConfiguration], default, default, default, false); + + Assert.NotNull(invite); + Assert.Contains(validEmail, invite.Emails); + Assert.Contains(validCollectionConfiguration.Id, invite.AccessibleCollections); + } +} 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..0eff87c15f --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserOrganizationValidationTests.cs @@ -0,0 +1,45 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; + +public class InviteUserOrganizationValidationTests +{ + [Theory] + [BitAutoData] + public void Validate_WhenOrganizationIsFreeTier_ShouldReturnValidResponse(Organization organization) + { + var result = InvitingUserOrganizationValidation.Validate(OrganizationDto.FromOrganization(organization)); + + Assert.IsType>(result); + } + + [Theory] + [BitAutoData] + public void Validate_WhenOrganizationDoesNotHavePaymentMethod_ShouldReturnInvalidResponseWithPaymentMethodMessage( + Organization organization) + { + organization.GatewayCustomerId = string.Empty; + + var result = InvitingUserOrganizationValidation.Validate(OrganizationDto.FromOrganization(organization)); + + Assert.IsType>(result); + Assert.Equal(InviteUserValidationErrorMessages.NoPaymentMethodFoundError, result.ErrorMessageString); + } + + [Theory] + [BitAutoData] + public void Validate_WhenOrganizationDoesNotHaveSubscription_ShouldReturnInvalidResponseWithSubscriptionMessage( + Organization organization) + { + organization.GatewaySubscriptionId = string.Empty; + + var result = InvitingUserOrganizationValidation.Validate(OrganizationDto.FromOrganization(organization)); + + Assert.IsType>(result); + Assert.Equal(InviteUserValidationErrorMessages.NoSubscriptionFoundError, result.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..8615aee379 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserPaymentValidationTests.cs @@ -0,0 +1,53 @@ +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.Billing.Constants; +using Bit.Core.Billing.Enums; +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 PaymentSubscriptionDto + { + SubscriptionStatus = StripeConstants.SubscriptionStatus.Active, + ProductTierType = OrganizationDto.FromOrganization(organization).Plan.ProductTier + }); + + Assert.IsType>(result); + } + + [Fact] + public void Validate_WhenSubscriptionIsCanceled_ReturnsInvalidResponse() + { + var result = InviteUserPaymentValidation.Validate(new PaymentSubscriptionDto + { + SubscriptionStatus = StripeConstants.SubscriptionStatus.Canceled, + ProductTierType = ProductTierType.Enterprise + }); + + Assert.IsType>(result); + Assert.Equal(InviteUserValidationErrorMessages.CancelledSubscriptionError, result.ErrorMessageString); + } + + [Fact] + public void Validate_WhenSubscriptionIsActive_ReturnsValidResponse() + { + var result = InviteUserPaymentValidation.Validate(new PaymentSubscriptionDto + { + SubscriptionStatus = StripeConstants.SubscriptionStatus.Active, + ProductTierType = ProductTierType.Enterprise + }); + + Assert.IsType>(result); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManagerInviteUserValidationTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManagerInviteUserValidationTests.cs new file mode 100644 index 0000000000..d3645abb58 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManagerInviteUserValidationTests.cs @@ -0,0 +1,64 @@ +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.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; + + +public class PasswordManagerInviteUserValidationTests +{ + [Theory] + [BitAutoData] + public void Validate_OrganizationDoesNotHaveSeatsLimit_ShouldReturnValidResult(Organization organization) + { + organization.Seats = null; + + var organizationDto = OrganizationDto.FromOrganization(organization); + + var subscriptionUpdate = PasswordManagerSubscriptionUpdate.Create(organizationDto, 0, 0); + + var result = PasswordManagerInviteUserValidation.Validate(subscriptionUpdate); + + Assert.IsType>(result); + } + + [Theory] + [BitAutoData] + public void Validate_NumberOfSeatsToAddMatchesSeatsAvailable_ShouldReturnValidResult(Organization organization) + { + organization.Seats = 8; + var seatsOccupiedByUsers = 4; + var additionalSeats = 4; + + var organizationDto = OrganizationDto.FromOrganization(organization); + + var subscriptionUpdate = PasswordManagerSubscriptionUpdate.Create(organizationDto, seatsOccupiedByUsers, additionalSeats); + + var result = PasswordManagerInviteUserValidation.Validate(subscriptionUpdate); + + Assert.IsType>(result); + } + + [Theory] + [BitAutoData] + public void Validate_NumberOfSeatsToAddIsGreaterThanMaxSeatsAllowed_ShouldBeInvalidWithSeatLimitMessage(Organization organization) + { + organization.Seats = 4; + organization.MaxAutoscaleSeats = 4; + var seatsOccupiedByUsers = 4; + var additionalSeats = 1; + + var organizationDto = OrganizationDto.FromOrganization(organization); + + var subscriptionUpdate = PasswordManagerSubscriptionUpdate.Create(organizationDto, seatsOccupiedByUsers, additionalSeats); + + var result = PasswordManagerInviteUserValidation.Validate(subscriptionUpdate); + + Assert.IsType>(result); + Assert.Equal(InviteUserValidationErrorMessages.SeatLimitHasBeenReachedError, result.ErrorMessageString); + } + +} diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index 52dd39e182..77db1d43ce 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -40,6 +40,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;