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:
parent
71f293138e
commit
4b6eba4523
@ -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();
|
||||
|
@ -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;
|
||||
}
|
||||
|
31
src/Core/AdminConsole/Models/Business/OrganizationDto.cs
Normal file
31
src/Core/AdminConsole/Models/Business/OrganizationDto.cs
Normal 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);
|
||||
};
|
@ -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;
|
||||
}
|
||||
}
|
@ -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.";
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -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; }
|
||||
}
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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.";
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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
|
||||
};
|
||||
}
|
@ -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 };
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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]) { }
|
||||
}
|
@ -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;
|
||||
|
||||
|
@ -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";
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
|
||||
|
44
src/Core/Utilities/EmailValidation.cs
Normal file
44
src/Core/Utilities/EmailValidation.cs
Normal 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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
@ -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;
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user