1
0
mirror of https://github.com/bitwarden/server.git synced 2025-04-10 23:58:13 -05:00

WIP changes for Invite User optimization from Scim

This commit is contained in:
jrmccannon 2025-02-12 11:30:00 -06:00
parent 71f293138e
commit 4b6eba4523
No known key found for this signature in database
GPG Key ID: CF03F3DB01CE96A6
36 changed files with 1141 additions and 65 deletions

View File

@ -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();

View File

@ -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<OrganizationUserUserDetails> 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;
}

View File

@ -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);
};

View File

@ -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<CommandResult<OrganizationUser>> InviteScimOrganizationUserAsync(InviteScimOrganizationUserRequest request);
}
public class InviteOrganizationUsersCommand(IEventService eventService,
IOrganizationUserRepository organizationUserRepository,
IInviteUsersValidation inviteUsersValidation
) : IInviteOrganizationUsersCommand
{
public async Task<CommandResult<OrganizationUser>> 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<OrganizationUser>(result.Value.FirstOrDefault());
}
private async Task<CommandResult<IEnumerable<OrganizationUser>>> InviteOrganizationUsersAsync(InviteOrganizationUsersRequest request)
{
var existingEmails = new HashSet<string>(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;
}
}

View File

@ -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.";
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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; }
}

View File

@ -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<CollectionAccessSelection> 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<Guid> 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}");
}
}
}
}

View File

@ -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
};
}
}

View File

@ -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<CollectionAccessSelection> 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
};
}
}

View File

@ -0,0 +1,9 @@
using Bit.Core.Models.Data;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
public static class InviteOrganizationUserFunctions
{
public static Func<CollectionAccessSelection, bool> ValidateCollectionConfiguration => collectionAccessSelection =>
collectionAccessSelection.Manage && (collectionAccessSelection.ReadOnly || collectionAccessSelection.HidePasswords);
}

View File

@ -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<ValidationResult<InviteUserOrganizationValidationRequest>> ValidateAsync(InviteUserOrganizationValidationRequest request);
}
public class InviteUsersValidation(
IGlobalSettings globalSettings,
IProviderRepository providerRepository,
IPaymentService paymentService,
IOrganizationRepository organizationRepository) : IInviteUsersValidation
{
public async Task<ValidationResult<InviteUserOrganizationValidationRequest>> ValidateAsync(InviteUserOrganizationValidationRequest request)
{
if (ValidateEnvironment(globalSettings) is Invalid<IGlobalSettings> invalidEnvironment)
{
return new Invalid<InviteUserOrganizationValidationRequest>(invalidEnvironment.ErrorMessageString);
}
if (InvitingUserOrganizationValidation.Validate(request.Organization) is Invalid<OrganizationDto> organizationValidation)
{
return new Invalid<InviteUserOrganizationValidationRequest>(organizationValidation.ErrorMessageString);
}
var subscriptionUpdate = PasswordManagerSubscriptionUpdate.Create(request);
if (PasswordManagerInviteUserValidation.Validate(subscriptionUpdate) is
Invalid<PasswordManagerSubscriptionUpdate> invalidSubscriptionUpdate)
{
return new Invalid<InviteUserOrganizationValidationRequest>(invalidSubscriptionUpdate.ErrorMessageString);
}
var smSubscriptionUpdate = SecretsManagerSubscriptionUpdate.Create(request, subscriptionUpdate);
if (SecretsManagerInviteUserValidation.Validate(smSubscriptionUpdate) is
Invalid<SecretsManagerSubscriptionUpdate> invalidSmSubscriptionUpdate)
{
return new Invalid<InviteUserOrganizationValidationRequest>(invalidSmSubscriptionUpdate.ErrorMessageString);
}
var provider = await providerRepository.GetByOrganizationIdAsync(request.Organization.OrganizationId);
if (InvitingUserOrganizationProviderValidation.Validate(ProviderDto.FromProviderEntity(provider)) is
Invalid<ProviderDto> invalidProviderValidation)
{
return new Invalid<InviteUserOrganizationValidationRequest>(invalidProviderValidation.ErrorMessageString);
}
var paymentSubscription = await paymentService.GetSubscriptionAsync(await organizationRepository.GetByIdAsync(request.Organization.OrganizationId));
if (InviteUserPaymentValidation.Validate(PaymentSubscriptionDto.FromSubscriptionInfo(paymentSubscription, request.Organization)) is
Invalid<PaymentSubscriptionDto> invalidPaymentValidation)
{
return new Invalid<InviteUserOrganizationValidationRequest>(invalidPaymentValidation.ErrorMessageString);
}
return new Valid<InviteUserOrganizationValidationRequest>(null);
}
public static ValidationResult<IGlobalSettings> ValidateEnvironment(IGlobalSettings globalSettings) =>
globalSettings.SelfHosted
? new Invalid<IGlobalSettings>(CannotAutoScaleOnSelfHostedError)
: new Valid<IGlobalSettings>(globalSettings);
}

View File

@ -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<PaymentSubscriptionDto> Validate(PaymentSubscriptionDto subscription)
{
if (subscription.ProductTierType is ProductTierType.Free)
{
return new Valid<PaymentSubscriptionDto>(subscription);
}
if (subscription.SubscriptionStatus == StripeConstants.SubscriptionStatus.Canceled)
{
return new Invalid<PaymentSubscriptionDto>(InviteUserValidationErrorMessages.CancelledSubscriptionError);
}
return new Valid<PaymentSubscriptionDto>(subscription);
}
}

View File

@ -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.";
}

View File

@ -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<ProviderDto> Validate(ProviderDto provider)
{
if (provider is { Enabled: true })
{
if (provider.IsBillable())
{
return new Invalid<ProviderDto>(InviteUserValidationErrorMessages.ProviderBillableSeatLimitError);
}
if (provider.Type == ProviderType.Reseller)
{
return new Invalid<ProviderDto>(InviteUserValidationErrorMessages.ProviderResellerSeatLimitError);
}
}
return new Valid<ProviderDto>(provider);
}
}

View File

@ -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<OrganizationDto> Validate(OrganizationDto organization)
{
if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId))
{
return new Invalid<OrganizationDto>(NoPaymentMethodFoundError);
}
if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
{
return new Invalid<OrganizationDto>(NoSubscriptionFoundError);
}
return new Valid<OrganizationDto>(organization);
}
}

View File

@ -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
{
/// <summary>
/// Seats the organization has
/// </summary>
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);
}
}

View File

@ -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
};
}

View File

@ -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 };
}
}

View File

@ -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);
}
}

View File

@ -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<PasswordManagerSubscriptionUpdate> Validate(PasswordManagerSubscriptionUpdate subscriptionUpdate)
{
if (subscriptionUpdate.Seats is null)
{
return new Valid<PasswordManagerSubscriptionUpdate>(subscriptionUpdate);
}
if (subscriptionUpdate.AdditionalSeats == 0)
{
return new Valid<PasswordManagerSubscriptionUpdate>(subscriptionUpdate);
}
if (subscriptionUpdate.UpdatedSeatTotal is not null && subscriptionUpdate.MaxAutoScaleSeats is not null &&
subscriptionUpdate.UpdatedSeatTotal > subscriptionUpdate.MaxAutoScaleSeats)
{
return new Invalid<PasswordManagerSubscriptionUpdate>(SeatLimitHasBeenReachedError);
}
return new Valid<PasswordManagerSubscriptionUpdate>(subscriptionUpdate);
}
}

View File

@ -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<SecretsManagerSubscriptionUpdate> Validate(SecretsManagerSubscriptionUpdate subscriptionUpdate)
{
if (subscriptionUpdate.UseSecretsManger)
{
return new Invalid<SecretsManagerSubscriptionUpdate>(OrganizationNoSecretsManager);
}
if (subscriptionUpdate.Seats == null)
{
return new Valid<SecretsManagerSubscriptionUpdate>(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<SecretsManagerSubscriptionUpdate>(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<SecretsManagerSubscriptionUpdate>(SecretsManagerCannotExceedPasswordManager);
}
return new Valid<SecretsManagerSubscriptionUpdate>(subscriptionUpdate);
}
}

View File

@ -0,0 +1,15 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
public abstract record ValidationResult<T>(T Value, IEnumerable<string> Errors)
{
public bool IsValid => !Errors.Any();
public string ErrorMessageString => string.Join(" ", Errors);
}
public record Valid<T>(T Value) : ValidationResult<T>(Value, []);
public record Invalid<T>(IEnumerable<string> Errors) : ValidationResult<T>(default, Errors)
{
public Invalid(string error) : this([error]) { }
}

View File

@ -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;

View File

@ -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";

View File

@ -7,6 +7,10 @@ public class CommandResult(IEnumerable<string> errors)
public bool Success => ErrorMessages.Count == 0;
public bool HasErrors => ErrorMessages.Count > 0;
public List<string> ErrorMessages { get; } = errors.ToList();
public CommandResult() : this(Array.Empty<string>()) { }
}
public class CommandResult<T>(T value) : CommandResult
{
public T Value { get; set; } = value;
}

View File

@ -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<IAuthorizationHandler, OrganizationUserUserMiniDetailsAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, OrganizationUserUserDetailsAuthorizationHandler>();
services.AddScoped<IHasConfirmedOwnersExceptQuery, HasConfirmedOwnersExceptQuery>();
services.AddScoped<IInviteOrganizationUsersCommand, InviteOrganizationUsersCommand>();
services.AddScoped<IInviteUsersValidation, InviteUsersValidation>();
}
// TODO: move to OrganizationSubscriptionServiceCollectionExtensions when OrganizationUser methods are moved out of

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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<BadRequestException>(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<BadRequestException>(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);
}
}

View File

@ -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<BadRequestException>(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<BadRequestException>(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);
}
}

View File

@ -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<Valid<OrganizationDto>>(result);
}
[Theory]
[BitAutoData]
public void Validate_WhenOrganizationDoesNotHavePaymentMethod_ShouldReturnInvalidResponseWithPaymentMethodMessage(
Organization organization)
{
organization.GatewayCustomerId = string.Empty;
var result = InvitingUserOrganizationValidation.Validate(OrganizationDto.FromOrganization(organization));
Assert.IsType<Invalid<OrganizationDto>>(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<Invalid<OrganizationDto>>(result);
Assert.Equal(InviteUserValidationErrorMessages.NoSubscriptionFoundError, result.ErrorMessageString);
}
}

View File

@ -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<Valid<PaymentSubscriptionDto>>(result);
}
[Fact]
public void Validate_WhenSubscriptionIsCanceled_ReturnsInvalidResponse()
{
var result = InviteUserPaymentValidation.Validate(new PaymentSubscriptionDto
{
SubscriptionStatus = StripeConstants.SubscriptionStatus.Canceled,
ProductTierType = ProductTierType.Enterprise
});
Assert.IsType<Invalid<PaymentSubscriptionDto>>(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<Valid<PaymentSubscriptionDto>>(result);
}
}

View File

@ -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<Valid<PasswordManagerSubscriptionUpdate>>(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<Valid<PasswordManagerSubscriptionUpdate>>(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<Invalid<PasswordManagerSubscriptionUpdate>>(result);
Assert.Equal(InviteUserValidationErrorMessages.SeatLimitHasBeenReachedError, result.ErrorMessageString);
}
}

View File

@ -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;