mirror of
https://github.com/bitwarden/server.git
synced 2025-05-23 04:21:05 -05:00
[PM-16811] - SCIM Invite Users Optimizations (#5398)
* WIP changes for Invite User optimization from Scim * feature flag string * Added plan validation to PasswordManagerInviteUserValidation. Cleaned up a few things. * Added Secrets Manager Validations and Tests. * Added bulk procedure for saving users, collections and groups from inviting. Added test to validate Ef and Sproc * Created SendOrganizationInvitesCommand and moved some tests from OrgServiceTests. Fixed some tests in org service in relation to moving out SendOrgInviteCommand code. Added side effects to InviteOrganizationUsersCommand * First test of new command. * Added test to verify valid request with a user calls db method and sends the invite * Added more tests for the updates * Added integration test around enabling feature and sending invite via scim. Did a bit of refactoring on the SM validation. Fixed couple bugs found. * Switching over to a local factory. * created response model and split interface out. * switched to initialization block * Moved to private method. Made ScimInvite inherit the single invite base model. Moved create methods to constructors. A few more CR changes included. * Moved `FromOrganization` mapper method to a constructor * Updated to use new pricing client. Supressed null dereference errors. * Fixing bad merge. * Rename of OrgDto * undoing this * Moved into class * turned into a switch statement * Separated into separate files. * Renamed dto and added ctor * Dto rename. Moved from static methods to ctors * Removed unused request model * changes from main * missed value * Fixed some compilation errors. * Fixed some changes. * Removed comment * fixed compiler warning. * Refactored to use new ValidationResult pattern. added mapping method. * Added throwing of Failure as the previous implementation would have. * Cleaned up return. * fixing test. * Made HasSecretsManagerStandalone return if org doesn't have sm. Added overload for lighter weight model and moved common code to private method. * Fixed tests. * Made public method private. added some comments. * Refactor validation parameter to improve clarity and consistency. Added XML doc * fixed test * Removed test only constructor from InviteOrganization * Separated old and new code explicitly. Moved old code checks down into new code as well. Added error and mapper to Failure<T> * Variable/Field/Property renames * Renamed InviteUsersValidation to InviteUsersValidator * Rename for InvitingUserOrganizationValidation to InvitingUserOrganizationValidator * PasswordManagerInviteUserValidation to PasswordManagerInviteUserValidator * Moved XML comment. Added check to see if additional seats are needed. * Fixing name. * Updated names. * Corrected double negation. * Added groups and collection and users checks. * Fixed comment. Fixed multiple enumeration. Changed variable name. * Cleaned up DTO models. Moved some validation steps around. A few quick fixes to address CR concerns. Still need to move a few things yet. * Fixed naming in subscription update models. * put back in the request for now. * Quick rename * Added provider email addresses as well. * Removed valid wrapper to pass in to validation methods. * fix tests * Code Review changes. * Removed unused classes * Using GetPlanOrThrow instead. * Switches to extension method * Made Revert and Adjust Sm methods consistent. Corrected string comparer. Added comment for revert sm. * Fixing compiler complaint. * Adding XML docs * Calculated seat addition for SM. * Fixing compiler complaints. * Renames for organization. * Fixing comparison issue. * Adding error and aligning message. * fixing name of method. * Made extension method. * Rearranged some things. Fixed the tests. * Added test around validating the revert. * Added test to validate the provider email is sent if org is managed by a provider. * Created new errors and removed references in business code to ErrorMessages property. This aligns Invite User code to use Errors instead of ErrorMessages * Delayed the hasSecretsManagerStandalone call as long as possible. * Corrected model name. Corrected SM seat calculation. Added test for it. * Corrected logic and added more tests.
This commit is contained in:
parent
3c56866a76
commit
0d7363c6af
@ -1,8 +1,11 @@
|
|||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Models.Business;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Models.Data;
|
using Bit.Core.Models.Data;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
|
using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite;
|
||||||
|
|
||||||
namespace Bit.Scim.Models;
|
namespace Bit.Scim.Models;
|
||||||
|
|
||||||
@ -10,7 +13,8 @@ public class ScimUserRequestModel : BaseScimUserModel
|
|||||||
{
|
{
|
||||||
public ScimUserRequestModel()
|
public ScimUserRequestModel()
|
||||||
: base(false)
|
: base(false)
|
||||||
{ }
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public OrganizationUserInvite ToOrganizationUserInvite(ScimProviderType scimProvider)
|
public OrganizationUserInvite ToOrganizationUserInvite(ScimProviderType scimProvider)
|
||||||
{
|
{
|
||||||
@ -25,6 +29,31 @@ public class ScimUserRequestModel : BaseScimUserModel
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public InviteOrganizationUsersRequest ToRequest(
|
||||||
|
ScimProviderType scimProvider,
|
||||||
|
InviteOrganization inviteOrganization,
|
||||||
|
DateTimeOffset performedAt)
|
||||||
|
{
|
||||||
|
var email = EmailForInvite(scimProvider);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(email) || !Active)
|
||||||
|
{
|
||||||
|
throw new BadRequestException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new InviteOrganizationUsersRequest(
|
||||||
|
invites:
|
||||||
|
[
|
||||||
|
new Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite(
|
||||||
|
email: email,
|
||||||
|
externalId: ExternalIdForInvite()
|
||||||
|
)
|
||||||
|
],
|
||||||
|
inviteOrganization: inviteOrganization,
|
||||||
|
performedBy: Guid.Empty, // SCIM does not have a user id
|
||||||
|
performedAt: performedAt);
|
||||||
|
}
|
||||||
|
|
||||||
private string EmailForInvite(ScimProviderType scimProvider)
|
private string EmailForInvite(ScimProviderType scimProvider)
|
||||||
{
|
{
|
||||||
var email = PrimaryEmail?.ToLowerInvariant();
|
var email = PrimaryEmail?.ToLowerInvariant();
|
||||||
|
@ -1,39 +1,99 @@
|
|||||||
using Bit.Core.Enums;
|
#nullable enable
|
||||||
|
|
||||||
|
using Bit.Core;
|
||||||
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
using Bit.Core.Billing.Pricing;
|
||||||
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Models.Commands;
|
||||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Scim.Context;
|
using Bit.Scim.Context;
|
||||||
using Bit.Scim.Models;
|
using Bit.Scim.Models;
|
||||||
using Bit.Scim.Users.Interfaces;
|
using Bit.Scim.Users.Interfaces;
|
||||||
|
using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors.ErrorMapper;
|
||||||
|
|
||||||
namespace Bit.Scim.Users;
|
namespace Bit.Scim.Users;
|
||||||
|
|
||||||
public class PostUserCommand : IPostUserCommand
|
public class PostUserCommand(
|
||||||
|
IOrganizationRepository organizationRepository,
|
||||||
|
IOrganizationUserRepository organizationUserRepository,
|
||||||
|
IOrganizationService organizationService,
|
||||||
|
IPaymentService paymentService,
|
||||||
|
IScimContext scimContext,
|
||||||
|
IFeatureService featureService,
|
||||||
|
IInviteOrganizationUsersCommand inviteOrganizationUsersCommand,
|
||||||
|
TimeProvider timeProvider,
|
||||||
|
IPricingClient pricingClient)
|
||||||
|
: IPostUserCommand
|
||||||
{
|
{
|
||||||
private readonly IOrganizationRepository _organizationRepository;
|
public async Task<OrganizationUserUserDetails?> PostUserAsync(Guid organizationId, ScimUserRequestModel model)
|
||||||
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;
|
if (featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization) is false)
|
||||||
_organizationUserRepository = organizationUserRepository;
|
{
|
||||||
_organizationService = organizationService;
|
return await InviteScimOrganizationUserAsync(model, organizationId, scimContext.RequestScimProvider);
|
||||||
_paymentService = paymentService;
|
}
|
||||||
_scimContext = scimContext;
|
|
||||||
|
return await InviteScimOrganizationUserAsync_vNext(model, organizationId, scimContext.RequestScimProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<OrganizationUserUserDetails> PostUserAsync(Guid organizationId, ScimUserRequestModel model)
|
private async Task<OrganizationUserUserDetails?> InviteScimOrganizationUserAsync_vNext(
|
||||||
|
ScimUserRequestModel model,
|
||||||
|
Guid organizationId,
|
||||||
|
ScimProviderType scimProvider)
|
||||||
|
{
|
||||||
|
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||||
|
|
||||||
|
if (organization is null)
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
|
||||||
|
|
||||||
|
var request = model.ToRequest(
|
||||||
|
scimProvider: scimProvider,
|
||||||
|
inviteOrganization: new InviteOrganization(organization, plan),
|
||||||
|
performedAt: timeProvider.GetUtcNow());
|
||||||
|
|
||||||
|
var orgUsers = await organizationUserRepository
|
||||||
|
.GetManyDetailsByOrganizationAsync(request.InviteOrganization.OrganizationId);
|
||||||
|
|
||||||
|
if (orgUsers.Any(existingUser =>
|
||||||
|
request.Invites.First().Email.Equals(existingUser.Email, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
request.Invites.First().ExternalId.Equals(existingUser.ExternalId, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
throw new ConflictException("User already exists.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await inviteOrganizationUsersCommand.InviteScimOrganizationUserAsync(request);
|
||||||
|
|
||||||
|
var invitedOrganizationUserId = result switch
|
||||||
|
{
|
||||||
|
Success<ScimInviteOrganizationUsersResponse> success => success.Value.InvitedUser.Id,
|
||||||
|
Failure<ScimInviteOrganizationUsersResponse> failure when failure.Errors
|
||||||
|
.Any(x => x.Message == NoUsersToInviteError.Code) => (Guid?)null,
|
||||||
|
Failure<ScimInviteOrganizationUsersResponse> failure when failure.Errors.Length != 0 => throw MapToBitException(failure.Errors),
|
||||||
|
_ => throw new InvalidOperationException()
|
||||||
|
};
|
||||||
|
|
||||||
|
var organizationUser = invitedOrganizationUserId.HasValue
|
||||||
|
? await organizationUserRepository.GetDetailsByIdAsync(invitedOrganizationUserId.Value)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return organizationUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<OrganizationUserUserDetails?> InviteScimOrganizationUserAsync(
|
||||||
|
ScimUserRequestModel model,
|
||||||
|
Guid organizationId,
|
||||||
|
ScimProviderType scimProvider)
|
||||||
{
|
{
|
||||||
var scimProvider = _scimContext.RequestScimProvider;
|
|
||||||
var invite = model.ToOrganizationUserInvite(scimProvider);
|
var invite = model.ToOrganizationUserInvite(scimProvider);
|
||||||
|
|
||||||
var email = invite.Emails.Single();
|
var email = invite.Emails.Single();
|
||||||
@ -44,7 +104,7 @@ public class PostUserCommand : IPostUserCommand
|
|||||||
throw new BadRequestException();
|
throw new BadRequestException();
|
||||||
}
|
}
|
||||||
|
|
||||||
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
|
var orgUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
|
||||||
var orgUserByEmail = orgUsers.FirstOrDefault(ou => ou.Email?.ToLowerInvariant() == email);
|
var orgUserByEmail = orgUsers.FirstOrDefault(ou => ou.Email?.ToLowerInvariant() == email);
|
||||||
if (orgUserByEmail != null)
|
if (orgUserByEmail != null)
|
||||||
{
|
{
|
||||||
@ -57,13 +117,21 @@ public class PostUserCommand : IPostUserCommand
|
|||||||
throw new ConflictException();
|
throw new ConflictException();
|
||||||
}
|
}
|
||||||
|
|
||||||
var organization = await _organizationRepository.GetByIdAsync(organizationId);
|
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||||
var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization);
|
|
||||||
|
if (organization == null)
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasStandaloneSecretsManager = await paymentService.HasSecretsManagerStandalone(organization);
|
||||||
invite.AccessSecretsManager = hasStandaloneSecretsManager;
|
invite.AccessSecretsManager = hasStandaloneSecretsManager;
|
||||||
|
|
||||||
var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM,
|
var invitedOrgUser = await organizationService.InviteUserAsync(organizationId, invitingUserId: null,
|
||||||
invite, externalId);
|
EventSystemUser.SCIM,
|
||||||
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
|
invite,
|
||||||
|
externalId);
|
||||||
|
var orgUser = await organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
|
||||||
|
|
||||||
return orgUser;
|
return orgUser;
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Bit.Core;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Services;
|
||||||
using Bit.Scim.IntegrationTest.Factories;
|
using Bit.Scim.IntegrationTest.Factories;
|
||||||
using Bit.Scim.Models;
|
using Bit.Scim.Models;
|
||||||
using Bit.Scim.Utilities;
|
using Bit.Scim.Utilities;
|
||||||
using Bit.Test.Common.Helpers;
|
using Bit.Test.Common.Helpers;
|
||||||
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Bit.Scim.IntegrationTest.Controllers.v2;
|
namespace Bit.Scim.IntegrationTest.Controllers.v2;
|
||||||
@ -276,9 +279,18 @@ public class UsersControllerTests : IClassFixture<ScimApplicationFactory>, IAsyn
|
|||||||
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel);
|
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Theory]
|
||||||
public async Task Post_Success()
|
[InlineData(true)]
|
||||||
|
[InlineData(false)]
|
||||||
|
public async Task Post_Success(bool isScimInviteUserOptimizationEnabled)
|
||||||
{
|
{
|
||||||
|
var localFactory = new ScimApplicationFactory();
|
||||||
|
localFactory.SubstituteService((IFeatureService featureService)
|
||||||
|
=> featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization)
|
||||||
|
.Returns(isScimInviteUserOptimizationEnabled));
|
||||||
|
|
||||||
|
localFactory.ReinitializeDbForTests(localFactory.GetDatabaseContext());
|
||||||
|
|
||||||
var email = "user5@example.com";
|
var email = "user5@example.com";
|
||||||
var displayName = "Test User 5";
|
var displayName = "Test User 5";
|
||||||
var externalId = "UE";
|
var externalId = "UE";
|
||||||
@ -306,7 +318,7 @@ public class UsersControllerTests : IClassFixture<ScimApplicationFactory>, IAsyn
|
|||||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||||
};
|
};
|
||||||
|
|
||||||
var context = await _factory.UsersPostAsync(ScimApplicationFactory.TestOrganizationId1, inputModel);
|
var context = await localFactory.UsersPostAsync(ScimApplicationFactory.TestOrganizationId1, inputModel);
|
||||||
|
|
||||||
Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode);
|
Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode);
|
||||||
|
|
||||||
@ -316,7 +328,7 @@ public class UsersControllerTests : IClassFixture<ScimApplicationFactory>, IAsyn
|
|||||||
var responseModel = JsonSerializer.Deserialize<ScimUserResponseModel>(context.Response.Body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
var responseModel = JsonSerializer.Deserialize<ScimUserResponseModel>(context.Response.Body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||||
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel, "Id");
|
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel, "Id");
|
||||||
|
|
||||||
var databaseContext = _factory.GetDatabaseContext();
|
var databaseContext = localFactory.GetDatabaseContext();
|
||||||
Assert.Equal(_initialUserCount + 1, databaseContext.OrganizationUsers.Count());
|
Assert.Equal(_initialUserCount + 1, databaseContext.OrganizationUsers.Count());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,7 +27,7 @@ public class PostUserCommandTests
|
|||||||
ExternalId = externalId,
|
ExternalId = externalId,
|
||||||
Emails = emails,
|
Emails = emails,
|
||||||
Active = true,
|
Active = true,
|
||||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
Schemas = [ScimConstants.Scim2SchemaUser]
|
||||||
};
|
};
|
||||||
|
|
||||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
@ -39,13 +39,16 @@ public class PostUserCommandTests
|
|||||||
sutProvider.GetDependency<IPaymentService>().HasSecretsManagerStandalone(organization).Returns(true);
|
sutProvider.GetDependency<IPaymentService>().HasSecretsManagerStandalone(organization).Returns(true);
|
||||||
|
|
||||||
sutProvider.GetDependency<IOrganizationService>()
|
sutProvider.GetDependency<IOrganizationService>()
|
||||||
.InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM,
|
.InviteUserAsync(organizationId,
|
||||||
|
invitingUserId: null,
|
||||||
|
EventSystemUser.SCIM,
|
||||||
Arg.Is<OrganizationUserInvite>(i =>
|
Arg.Is<OrganizationUserInvite>(i =>
|
||||||
i.Emails.Single().Equals(scimUserRequestModel.PrimaryEmail.ToLowerInvariant()) &&
|
i.Emails.Single().Equals(scimUserRequestModel.PrimaryEmail.ToLowerInvariant()) &&
|
||||||
i.Type == OrganizationUserType.User &&
|
i.Type == OrganizationUserType.User &&
|
||||||
!i.Collections.Any() &&
|
!i.Collections.Any() &&
|
||||||
!i.Groups.Any() &&
|
!i.Groups.Any() &&
|
||||||
i.AccessSecretsManager), externalId)
|
i.AccessSecretsManager),
|
||||||
|
externalId)
|
||||||
.Returns(newUser);
|
.Returns(newUser);
|
||||||
|
|
||||||
var user = await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel);
|
var user = await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel);
|
||||||
|
@ -1,3 +1,8 @@
|
|||||||
namespace Bit.Core.AdminConsole.Errors;
|
namespace Bit.Core.AdminConsole.Errors;
|
||||||
|
|
||||||
public record Error<T>(string Message, T ErroredValue);
|
public record Error<T>(string Message, T ErroredValue);
|
||||||
|
|
||||||
|
public static class ErrorMappers
|
||||||
|
{
|
||||||
|
public static Error<B> ToError<A, B>(this Error<A> errorA, B erroredValue) => new(errorA.Message, erroredValue);
|
||||||
|
}
|
||||||
|
6
src/Core/AdminConsole/Errors/InvalidResultTypeError.cs
Normal file
6
src/Core/AdminConsole/Errors/InvalidResultTypeError.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace Bit.Core.AdminConsole.Errors;
|
||||||
|
|
||||||
|
public record InvalidResultTypeError<T>(T Value) : Error<T>(Code, Value)
|
||||||
|
{
|
||||||
|
public const string Code = "Invalid result type.";
|
||||||
|
};
|
35
src/Core/AdminConsole/Models/Business/InviteOrganization.cs
Normal file
35
src/Core/AdminConsole/Models/Business/InviteOrganization.cs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.Models.StaticStore;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.Models.Business;
|
||||||
|
|
||||||
|
public record InviteOrganization
|
||||||
|
{
|
||||||
|
public Guid OrganizationId { get; init; }
|
||||||
|
public int? Seats { get; init; }
|
||||||
|
public int? MaxAutoScaleSeats { get; init; }
|
||||||
|
public int? SmSeats { get; init; }
|
||||||
|
public int? SmMaxAutoScaleSeats { get; init; }
|
||||||
|
public Plan Plan { get; init; }
|
||||||
|
public string GatewayCustomerId { get; init; }
|
||||||
|
public string GatewaySubscriptionId { get; init; }
|
||||||
|
public bool UseSecretsManager { get; init; }
|
||||||
|
|
||||||
|
public InviteOrganization()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public InviteOrganization(Organization organization, Plan plan)
|
||||||
|
{
|
||||||
|
OrganizationId = organization.Id;
|
||||||
|
Seats = organization.Seats;
|
||||||
|
MaxAutoScaleSeats = organization.MaxAutoscaleSeats;
|
||||||
|
SmSeats = organization.SmSeats;
|
||||||
|
SmMaxAutoScaleSeats = organization.MaxAutoscaleSmSeats;
|
||||||
|
Plan = plan;
|
||||||
|
GatewayCustomerId = organization.GatewayCustomerId;
|
||||||
|
GatewaySubscriptionId = organization.GatewaySubscriptionId;
|
||||||
|
UseSecretsManager = organization.UseSecretsManager;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,37 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.Exceptions;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
|
||||||
|
|
||||||
|
public static class ErrorMapper
|
||||||
|
{
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps the ErrorT to a Bit.Exception class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="error"></param>
|
||||||
|
/// <typeparam name="T"></typeparam>
|
||||||
|
/// <returns></returns>
|
||||||
|
public static Exception MapToBitException<T>(Error<T> error) =>
|
||||||
|
error switch
|
||||||
|
{
|
||||||
|
UserAlreadyExistsError alreadyExistsError => new ConflictException(alreadyExistsError.Message),
|
||||||
|
_ => new BadRequestException(error.Message)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This maps the ErrorT object to the Bit.Exception class.
|
||||||
|
///
|
||||||
|
/// This should be replaced by an IActionResult mapper when possible.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="errors"></param>
|
||||||
|
/// <typeparam name="T"></typeparam>
|
||||||
|
/// <returns></returns>
|
||||||
|
public static Exception MapToBitException<T>(ICollection<Error<T>> errors) =>
|
||||||
|
errors switch
|
||||||
|
{
|
||||||
|
not null when errors.Count == 1 => MapToBitException(errors.First()),
|
||||||
|
not null when errors.Count > 1 => new BadRequestException(string.Join(' ', errors.Select(e => e.Message))),
|
||||||
|
_ => new BadRequestException()
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
|
||||||
|
|
||||||
|
public record FailedToInviteUsersError(InviteOrganizationUsersResponse Response) : Error<InviteOrganizationUsersResponse>(Code, Response)
|
||||||
|
{
|
||||||
|
public const string Code = "Failed to invite users";
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
|
||||||
|
|
||||||
|
public record NoUsersToInviteError(InviteOrganizationUsersResponse Response) : Error<InviteOrganizationUsersResponse>(Code, Response)
|
||||||
|
{
|
||||||
|
public const string Code = "No users to invite";
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
|
||||||
|
|
||||||
|
public record UserAlreadyExistsError(ScimInviteOrganizationUsersResponse Response) : Error<ScimInviteOrganizationUsersResponse>(Code, Response)
|
||||||
|
{
|
||||||
|
public const string Code = "User already exists";
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
using Bit.Core.Models.Commands;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the contract for inviting organization users via SCIM (System for Cross-domain Identity Management).
|
||||||
|
/// Provides functionality for handling single email invitation requests within an organization context.
|
||||||
|
/// </summary>
|
||||||
|
public interface IInviteOrganizationUsersCommand
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sends an invitation to add an organization user via SCIM (System for Cross-domain Identity Management) system.
|
||||||
|
/// This can be a Success or a Failure. Failure will contain the Error along with a representation of the errored value.
|
||||||
|
/// Success will be the successful return object.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">
|
||||||
|
/// Contains the details for inviting a single organization user via email.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>Response from InviteScimOrganiation<see cref="ScimInviteOrganizationUsersResponse"/></returns>
|
||||||
|
Task<CommandResult<ScimInviteOrganizationUsersResponse>> InviteScimOrganizationUserAsync(InviteOrganizationUsersRequest request);
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This is for sending the invite to an organization user.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISendOrganizationInvitesCommand
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// This sends emails out to organization users for a given organization.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request"><see cref="SendInvitesRequest"/></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
Task SendInvitesAsync(SendInvitesRequest request);
|
||||||
|
}
|
@ -0,0 +1,282 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.Interfaces;
|
||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
using Bit.Core.Context;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Models.Business;
|
||||||
|
using Bit.Core.Models.Commands;
|
||||||
|
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Tools.Enums;
|
||||||
|
using Bit.Core.Tools.Models.Business;
|
||||||
|
using Bit.Core.Tools.Services;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
|
||||||
|
public class InviteOrganizationUsersCommand(IEventService eventService,
|
||||||
|
IOrganizationUserRepository organizationUserRepository,
|
||||||
|
IInviteUsersValidator inviteUsersValidator,
|
||||||
|
IPaymentService paymentService,
|
||||||
|
IOrganizationRepository organizationRepository,
|
||||||
|
IReferenceEventService referenceEventService,
|
||||||
|
ICurrentContext currentContext,
|
||||||
|
IApplicationCacheService applicationCacheService,
|
||||||
|
IMailService mailService,
|
||||||
|
ILogger<InviteOrganizationUsersCommand> logger,
|
||||||
|
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
|
||||||
|
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand,
|
||||||
|
IProviderOrganizationRepository providerOrganizationRepository,
|
||||||
|
IProviderUserRepository providerUserRepository
|
||||||
|
) : IInviteOrganizationUsersCommand
|
||||||
|
{
|
||||||
|
|
||||||
|
public const string IssueNotifyingOwnersOfSeatLimitReached = "Error encountered notifying organization owners of seat limit reached.";
|
||||||
|
|
||||||
|
public async Task<CommandResult<ScimInviteOrganizationUsersResponse>> InviteScimOrganizationUserAsync(InviteOrganizationUsersRequest request)
|
||||||
|
{
|
||||||
|
var result = await InviteOrganizationUsersAsync(request);
|
||||||
|
|
||||||
|
switch (result)
|
||||||
|
{
|
||||||
|
case Failure<InviteOrganizationUsersResponse> failure:
|
||||||
|
return new Failure<ScimInviteOrganizationUsersResponse>(
|
||||||
|
failure.Errors.Select(error => new Error<ScimInviteOrganizationUsersResponse>(error.Message,
|
||||||
|
new ScimInviteOrganizationUsersResponse
|
||||||
|
{
|
||||||
|
InvitedUser = error.ErroredValue.InvitedUsers.FirstOrDefault()
|
||||||
|
})));
|
||||||
|
|
||||||
|
case Success<InviteOrganizationUsersResponse> success when success.Value.InvitedUsers.Any():
|
||||||
|
var user = success.Value.InvitedUsers.First();
|
||||||
|
|
||||||
|
await eventService.LogOrganizationUserEventAsync<IOrganizationUser>(
|
||||||
|
organizationUser: user,
|
||||||
|
type: EventType.OrganizationUser_Invited,
|
||||||
|
systemUser: EventSystemUser.SCIM,
|
||||||
|
date: request.PerformedAt.UtcDateTime);
|
||||||
|
|
||||||
|
return new Success<ScimInviteOrganizationUsersResponse>(new ScimInviteOrganizationUsersResponse
|
||||||
|
{
|
||||||
|
InvitedUser = user
|
||||||
|
});
|
||||||
|
|
||||||
|
default:
|
||||||
|
return new Failure<ScimInviteOrganizationUsersResponse>(
|
||||||
|
new InvalidResultTypeError<ScimInviteOrganizationUsersResponse>(
|
||||||
|
new ScimInviteOrganizationUsersResponse()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult<InviteOrganizationUsersResponse>> InviteOrganizationUsersAsync(InviteOrganizationUsersRequest request)
|
||||||
|
{
|
||||||
|
var invitesToSend = (await FilterExistingUsersAsync(request)).ToArray();
|
||||||
|
|
||||||
|
if (invitesToSend.Length == 0)
|
||||||
|
{
|
||||||
|
return new Failure<InviteOrganizationUsersResponse>(new NoUsersToInviteError(
|
||||||
|
new InviteOrganizationUsersResponse(request.InviteOrganization.OrganizationId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var validationResult = await inviteUsersValidator.ValidateAsync(new InviteOrganizationUsersValidationRequest
|
||||||
|
{
|
||||||
|
Invites = invitesToSend.ToArray(),
|
||||||
|
InviteOrganization = request.InviteOrganization,
|
||||||
|
PerformedBy = request.PerformedBy,
|
||||||
|
PerformedAt = request.PerformedAt,
|
||||||
|
OccupiedPmSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId),
|
||||||
|
OccupiedSmSeats = await organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validationResult is Invalid<InviteOrganizationUsersValidationRequest> invalid)
|
||||||
|
{
|
||||||
|
return invalid.MapToFailure(r => new InviteOrganizationUsersResponse(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
var validatedRequest = validationResult as Valid<InviteOrganizationUsersValidationRequest>;
|
||||||
|
|
||||||
|
var organizationUserToInviteEntities = invitesToSend
|
||||||
|
.Select(x => x.MapToDataModel(request.PerformedAt, validatedRequest!.Value.InviteOrganization))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var organization = await organizationRepository.GetByIdAsync(validatedRequest!.Value.InviteOrganization.OrganizationId);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await organizationUserRepository.CreateManyAsync(organizationUserToInviteEntities);
|
||||||
|
|
||||||
|
await AdjustPasswordManagerSeatsAsync(validatedRequest, organization);
|
||||||
|
|
||||||
|
await AdjustSecretsManagerSeatsAsync(validatedRequest);
|
||||||
|
|
||||||
|
await SendAdditionalEmailsAsync(validatedRequest, organization);
|
||||||
|
|
||||||
|
await SendInvitesAsync(organizationUserToInviteEntities, organization);
|
||||||
|
|
||||||
|
await PublishReferenceEventAsync(validatedRequest, organization);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, FailedToInviteUsersError.Code);
|
||||||
|
|
||||||
|
await organizationUserRepository.DeleteManyAsync(organizationUserToInviteEntities.Select(x => x.OrganizationUser.Id));
|
||||||
|
|
||||||
|
// Do this first so that SmSeats never exceed PM seats (due to current billing requirements)
|
||||||
|
await RevertSecretsManagerChangesAsync(validatedRequest, organization, validatedRequest.Value.InviteOrganization.SmSeats);
|
||||||
|
|
||||||
|
await RevertPasswordManagerChangesAsync(validatedRequest, organization);
|
||||||
|
|
||||||
|
return new Failure<InviteOrganizationUsersResponse>(
|
||||||
|
new FailedToInviteUsersError(
|
||||||
|
new InviteOrganizationUsersResponse(validatedRequest.Value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Success<InviteOrganizationUsersResponse>(
|
||||||
|
new InviteOrganizationUsersResponse(
|
||||||
|
invitedOrganizationUsers: organizationUserToInviteEntities.Select(x => x.OrganizationUser).ToArray(),
|
||||||
|
organizationId: organization!.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IEnumerable<OrganizationUserInvite>> FilterExistingUsersAsync(InviteOrganizationUsersRequest request)
|
||||||
|
{
|
||||||
|
var existingEmails = new HashSet<string>(await organizationUserRepository.SelectKnownEmailsAsync(
|
||||||
|
request.InviteOrganization.OrganizationId, request.Invites.Select(i => i.Email), false),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
return request.Invites
|
||||||
|
.Where(invite => !existingEmails.Contains(invite.Email))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RevertPasswordManagerChangesAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
|
||||||
|
{
|
||||||
|
if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd > 0)
|
||||||
|
{
|
||||||
|
// When reverting seats, we have to tell payments service that the seats are going back down by what we attempted to add.
|
||||||
|
// However, this might lead to a problem if we don't actually update stripe but throw any ways.
|
||||||
|
// stripe could not be updated, and then we would decrement the number of seats in stripe accidentally.
|
||||||
|
var seatsToRemove = validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd;
|
||||||
|
await paymentService.AdjustSeatsAsync(organization, validatedResult.Value.InviteOrganization.Plan, -seatsToRemove);
|
||||||
|
|
||||||
|
organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats;
|
||||||
|
|
||||||
|
await organizationRepository.ReplaceAsync(organization);
|
||||||
|
await applicationCacheService.UpsertOrganizationAbilityAsync(organization);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RevertSecretsManagerChangesAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization, int? initialSmSeats)
|
||||||
|
{
|
||||||
|
if (validatedResult.Value.SecretsManagerSubscriptionUpdate?.SmSeatsChanged is true)
|
||||||
|
{
|
||||||
|
var smSubscriptionUpdateRevert = new SecretsManagerSubscriptionUpdate(
|
||||||
|
organization: organization,
|
||||||
|
plan: validatedResult.Value.InviteOrganization.Plan,
|
||||||
|
autoscaling: false)
|
||||||
|
{
|
||||||
|
SmSeats = initialSmSeats
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(smSubscriptionUpdateRevert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PublishReferenceEventAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult,
|
||||||
|
Organization organization) =>
|
||||||
|
await referenceEventService.RaiseEventAsync(
|
||||||
|
new ReferenceEvent(ReferenceEventType.InvitedUsers, organization, currentContext)
|
||||||
|
{
|
||||||
|
Users = validatedResult.Value.Invites.Length
|
||||||
|
});
|
||||||
|
|
||||||
|
private async Task SendInvitesAsync(IEnumerable<CreateOrganizationUser> users, Organization organization) =>
|
||||||
|
await sendOrganizationInvitesCommand.SendInvitesAsync(
|
||||||
|
new SendInvitesRequest(
|
||||||
|
users.Select(x => x.OrganizationUser),
|
||||||
|
organization));
|
||||||
|
|
||||||
|
private async Task SendAdditionalEmailsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
|
||||||
|
{
|
||||||
|
await SendPasswordManagerMaxSeatLimitEmailsAsync(validatedResult, organization);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendPasswordManagerMaxSeatLimitEmailsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
|
||||||
|
{
|
||||||
|
if (!validatedResult.Value.PasswordManagerSubscriptionUpdate.MaxSeatsReached)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ownerEmails = await GetOwnerEmailAddressesAsync(validatedResult.Value.InviteOrganization);
|
||||||
|
|
||||||
|
await mailService.SendOrganizationMaxSeatLimitReachedEmailAsync(organization,
|
||||||
|
validatedResult.Value.PasswordManagerSubscriptionUpdate.MaxAutoScaleSeats!.Value, ownerEmails);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, IssueNotifyingOwnersOfSeatLimitReached);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IEnumerable<string>> GetOwnerEmailAddressesAsync(InviteOrganization organization)
|
||||||
|
{
|
||||||
|
var providerOrganization = await providerOrganizationRepository
|
||||||
|
.GetByOrganizationId(organization.OrganizationId);
|
||||||
|
|
||||||
|
if (providerOrganization == null)
|
||||||
|
{
|
||||||
|
return (await organizationUserRepository
|
||||||
|
.GetManyByMinimumRoleAsync(organization.OrganizationId, OrganizationUserType.Owner))
|
||||||
|
.Select(x => x.Email)
|
||||||
|
.Distinct();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await providerUserRepository
|
||||||
|
.GetManyDetailsByProviderAsync(providerOrganization.ProviderId, ProviderUserStatusType.Confirmed))
|
||||||
|
.Select(u => u.Email).Distinct();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AdjustSecretsManagerSeatsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult)
|
||||||
|
{
|
||||||
|
if (validatedResult.Value.SecretsManagerSubscriptionUpdate?.SmSeatsChanged is true)
|
||||||
|
{
|
||||||
|
await updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(validatedResult.Value.SecretsManagerSubscriptionUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AdjustPasswordManagerSeatsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
|
||||||
|
{
|
||||||
|
if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await paymentService.AdjustSeatsAsync(organization, validatedResult.Value.InviteOrganization.Plan, validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd);
|
||||||
|
|
||||||
|
organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal;
|
||||||
|
|
||||||
|
await organizationRepository.ReplaceAsync(organization); // could optimize this with only a property update
|
||||||
|
await applicationCacheService.UpsertOrganizationAbilityAsync(organization);
|
||||||
|
|
||||||
|
await referenceEventService.RaiseEventAsync(
|
||||||
|
new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, currentContext)
|
||||||
|
{
|
||||||
|
PlanName = validatedResult.Value.InviteOrganization.Plan.Name,
|
||||||
|
PlanType = validatedResult.Value.InviteOrganization.Plan.Type,
|
||||||
|
Seats = validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal,
|
||||||
|
PreviousSeats = validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Models.Data;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Object for associating the <see cref="OrganizationUser"/> with their assigned collections
|
||||||
|
/// <see cref="CollectionAccessSelection"/> and Group Ids.
|
||||||
|
/// </summary>
|
||||||
|
public class CreateOrganizationUser
|
||||||
|
{
|
||||||
|
public OrganizationUser OrganizationUser { get; set; }
|
||||||
|
public CollectionAccessSelection[] Collections { get; set; } = [];
|
||||||
|
public Guid[] Groups { get; set; } = [];
|
||||||
|
}
|
@ -0,0 +1,30 @@
|
|||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
public static class CreateOrganizationUserExtensions
|
||||||
|
{
|
||||||
|
public static CreateOrganizationUser MapToDataModel(this OrganizationUserInvite organizationUserInvite,
|
||||||
|
DateTimeOffset performedAt,
|
||||||
|
InviteOrganization organization) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
OrganizationUser = new OrganizationUser
|
||||||
|
{
|
||||||
|
Id = CoreHelpers.GenerateComb(),
|
||||||
|
OrganizationId = organization.OrganizationId,
|
||||||
|
Email = organizationUserInvite.Email.ToLowerInvariant(),
|
||||||
|
Type = organizationUserInvite.Type,
|
||||||
|
Status = OrganizationUserStatusType.Invited,
|
||||||
|
AccessSecretsManager = organizationUserInvite.AccessSecretsManager,
|
||||||
|
ExternalId = string.IsNullOrWhiteSpace(organizationUserInvite.ExternalId) ? null : organizationUserInvite.ExternalId,
|
||||||
|
CreationDate = performedAt.UtcDateTime,
|
||||||
|
RevisionDate = performedAt.UtcDateTime
|
||||||
|
},
|
||||||
|
Collections = organizationUserInvite.AssignedCollections,
|
||||||
|
Groups = organizationUserInvite.Groups
|
||||||
|
};
|
||||||
|
}
|
@ -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,22 @@
|
|||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
public class InviteOrganizationUsersRequest
|
||||||
|
{
|
||||||
|
public OrganizationUserInvite[] Invites { get; } = [];
|
||||||
|
public InviteOrganization InviteOrganization { get; }
|
||||||
|
public Guid PerformedBy { get; }
|
||||||
|
public DateTimeOffset PerformedAt { get; }
|
||||||
|
|
||||||
|
public InviteOrganizationUsersRequest(OrganizationUserInvite[] invites,
|
||||||
|
InviteOrganization inviteOrganization,
|
||||||
|
Guid performedBy,
|
||||||
|
DateTimeOffset performedAt)
|
||||||
|
{
|
||||||
|
Invites = invites;
|
||||||
|
InviteOrganization = inviteOrganization;
|
||||||
|
PerformedBy = performedBy;
|
||||||
|
PerformedAt = performedAt;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,42 @@
|
|||||||
|
using Bit.Core.Entities;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
public class InviteOrganizationUsersResponse(Guid organizationId)
|
||||||
|
{
|
||||||
|
public IEnumerable<OrganizationUser> InvitedUsers { get; } = [];
|
||||||
|
public Guid OrganizationId { get; } = organizationId;
|
||||||
|
|
||||||
|
public InviteOrganizationUsersResponse(InviteOrganizationUsersValidationRequest usersValidationRequest)
|
||||||
|
: this(usersValidationRequest.InviteOrganization.OrganizationId)
|
||||||
|
{
|
||||||
|
InvitedUsers = usersValidationRequest.Invites.Select(x => new OrganizationUser { Email = x.Email });
|
||||||
|
}
|
||||||
|
|
||||||
|
public InviteOrganizationUsersResponse(IEnumerable<OrganizationUser> invitedOrganizationUsers, Guid organizationId)
|
||||||
|
: this(organizationId)
|
||||||
|
{
|
||||||
|
InvitedUsers = invitedOrganizationUsers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ScimInviteOrganizationUsersResponse
|
||||||
|
{
|
||||||
|
public OrganizationUser InvitedUser { get; init; }
|
||||||
|
|
||||||
|
public ScimInviteOrganizationUsersResponse()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public ScimInviteOrganizationUsersResponse(InviteOrganizationUsersRequest request)
|
||||||
|
{
|
||||||
|
var userToInvite = request.Invites.First();
|
||||||
|
|
||||||
|
InvitedUser = new OrganizationUser
|
||||||
|
{
|
||||||
|
Email = userToInvite.Email,
|
||||||
|
ExternalId = userToInvite.ExternalId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
|
||||||
|
using Bit.Core.Models.Business;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
public class InviteOrganizationUsersValidationRequest
|
||||||
|
{
|
||||||
|
public InviteOrganizationUsersValidationRequest()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public InviteOrganizationUsersValidationRequest(InviteOrganizationUsersValidationRequest request)
|
||||||
|
{
|
||||||
|
Invites = request.Invites;
|
||||||
|
InviteOrganization = request.InviteOrganization;
|
||||||
|
PerformedBy = request.PerformedBy;
|
||||||
|
PerformedAt = request.PerformedAt;
|
||||||
|
OccupiedPmSeats = request.OccupiedPmSeats;
|
||||||
|
OccupiedSmSeats = request.OccupiedSmSeats;
|
||||||
|
}
|
||||||
|
|
||||||
|
public InviteOrganizationUsersValidationRequest(InviteOrganizationUsersValidationRequest request,
|
||||||
|
PasswordManagerSubscriptionUpdate subscriptionUpdate,
|
||||||
|
SecretsManagerSubscriptionUpdate smSubscriptionUpdate)
|
||||||
|
: this(request)
|
||||||
|
{
|
||||||
|
PasswordManagerSubscriptionUpdate = subscriptionUpdate;
|
||||||
|
SecretsManagerSubscriptionUpdate = smSubscriptionUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OrganizationUserInvite[] Invites { get; init; } = [];
|
||||||
|
public InviteOrganization InviteOrganization { get; init; }
|
||||||
|
public Guid PerformedBy { get; init; }
|
||||||
|
public DateTimeOffset PerformedAt { get; init; }
|
||||||
|
public int OccupiedPmSeats { get; init; }
|
||||||
|
public int OccupiedSmSeats { get; init; }
|
||||||
|
public PasswordManagerSubscriptionUpdate PasswordManagerSubscriptionUpdate { get; set; }
|
||||||
|
public SecretsManagerSubscriptionUpdate SecretsManagerSubscriptionUpdate { get; set; }
|
||||||
|
}
|
@ -0,0 +1,77 @@
|
|||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Models.Data;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.InviteOrganizationUserErrorMessages;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
public class OrganizationUserInvite
|
||||||
|
{
|
||||||
|
public string Email { get; private init; }
|
||||||
|
public CollectionAccessSelection[] AssignedCollections { get; private init; }
|
||||||
|
public OrganizationUserType Type { get; private init; }
|
||||||
|
public Permissions Permissions { get; private init; }
|
||||||
|
public string ExternalId { get; private init; }
|
||||||
|
public bool AccessSecretsManager { get; private init; }
|
||||||
|
public Guid[] Groups { get; private init; }
|
||||||
|
|
||||||
|
public OrganizationUserInvite(string email, string externalId) :
|
||||||
|
this(
|
||||||
|
email: email,
|
||||||
|
assignedCollections: [],
|
||||||
|
groups: [],
|
||||||
|
type: OrganizationUserType.User,
|
||||||
|
permissions: new Permissions(),
|
||||||
|
externalId: externalId,
|
||||||
|
false)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public OrganizationUserInvite(OrganizationUserInvite invite, bool accessSecretsManager) :
|
||||||
|
this(invite.Email,
|
||||||
|
invite.AssignedCollections,
|
||||||
|
invite.Groups,
|
||||||
|
invite.Type,
|
||||||
|
invite.Permissions,
|
||||||
|
invite.ExternalId,
|
||||||
|
accessSecretsManager)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public OrganizationUserInvite(string email,
|
||||||
|
IEnumerable<CollectionAccessSelection> assignedCollections,
|
||||||
|
IEnumerable<Guid> groups,
|
||||||
|
OrganizationUserType type,
|
||||||
|
Permissions permissions,
|
||||||
|
string externalId,
|
||||||
|
bool accessSecretsManager)
|
||||||
|
{
|
||||||
|
ValidateEmailAddress(email);
|
||||||
|
|
||||||
|
var collections = assignedCollections?.ToArray() ?? [];
|
||||||
|
|
||||||
|
if (collections.Any(x => x.IsValidCollectionAccessConfiguration()))
|
||||||
|
{
|
||||||
|
throw new BadRequestException(InvalidCollectionConfigurationErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
Email = email;
|
||||||
|
AssignedCollections = collections;
|
||||||
|
Groups = groups.ToArray();
|
||||||
|
Type = type;
|
||||||
|
Permissions = permissions ?? new Permissions();
|
||||||
|
ExternalId = externalId;
|
||||||
|
AccessSecretsManager = accessSecretsManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateEmailAddress(string email)
|
||||||
|
{
|
||||||
|
if (!email.IsValidEmail())
|
||||||
|
{
|
||||||
|
throw new BadRequestException($"{email} {InvalidEmailErrorMessage}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,33 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a request to send invitations to a group of organization users.
|
||||||
|
/// </summary>
|
||||||
|
public class SendInvitesRequest
|
||||||
|
{
|
||||||
|
public SendInvitesRequest(IEnumerable<OrganizationUser> users, Organization organization) =>
|
||||||
|
(Users, Organization) = (users.ToArray(), organization);
|
||||||
|
|
||||||
|
public SendInvitesRequest(IEnumerable<OrganizationUser> users, Organization organization, bool initOrganization) =>
|
||||||
|
(Users, Organization, InitOrganization) = (users.ToArray(), organization, initOrganization);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Organization Users to send emails to.
|
||||||
|
/// </summary>
|
||||||
|
public OrganizationUser[] Users { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The organization to invite the users to.
|
||||||
|
/// </summary>
|
||||||
|
public Organization Organization { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This is for when the organization is being created and this is the owners initial invite
|
||||||
|
/// </summary>
|
||||||
|
public bool InitOrganization { get; init; }
|
||||||
|
}
|
@ -0,0 +1,80 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.Auth.Models.Business;
|
||||||
|
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||||
|
using Bit.Core.Auth.Repositories;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Models.Mail;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Tokens;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
|
||||||
|
public class SendOrganizationInvitesCommand(
|
||||||
|
IUserRepository userRepository,
|
||||||
|
ISsoConfigRepository ssoConfigurationRepository,
|
||||||
|
IPolicyRepository policyRepository,
|
||||||
|
IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory,
|
||||||
|
IDataProtectorTokenFactory<OrgUserInviteTokenable> dataProtectorTokenFactory,
|
||||||
|
IMailService mailService) : ISendOrganizationInvitesCommand
|
||||||
|
{
|
||||||
|
public async Task SendInvitesAsync(SendInvitesRequest request)
|
||||||
|
{
|
||||||
|
var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(request.Users, request.Organization, request.InitOrganization);
|
||||||
|
|
||||||
|
await mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<OrganizationInvitesInfo> BuildOrganizationInvitesInfoAsync(IEnumerable<OrganizationUser> orgUsers,
|
||||||
|
Organization organization, bool initOrganization = false)
|
||||||
|
{
|
||||||
|
// Materialize the sequence into a list to avoid multiple enumeration warnings
|
||||||
|
var orgUsersList = orgUsers.ToList();
|
||||||
|
|
||||||
|
// Email links must include information about the org and user for us to make routing decisions client side
|
||||||
|
// Given an org user, determine if existing BW user exists
|
||||||
|
var orgUserEmails = orgUsersList.Select(ou => ou.Email).ToList();
|
||||||
|
var existingUsers = await userRepository.GetManyByEmailsAsync(orgUserEmails);
|
||||||
|
|
||||||
|
// hash existing users emails list for O(1) lookups
|
||||||
|
var existingUserEmailsHashSet = new HashSet<string>(existingUsers.Select(u => u.Email));
|
||||||
|
|
||||||
|
// Create a dictionary of org user guids and bools for whether or not they have an existing BW user
|
||||||
|
var orgUserHasExistingUserDict = orgUsersList.ToDictionary(
|
||||||
|
ou => ou.Id,
|
||||||
|
ou => existingUserEmailsHashSet.Contains(ou.Email)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determine if org has SSO enabled and if user is required to login with SSO
|
||||||
|
// Note: we only want to call the DB after checking if the org can use SSO per plan and if they have any policies enabled.
|
||||||
|
var orgSsoEnabled = organization.UseSso && (await ssoConfigurationRepository.GetByOrganizationIdAsync(organization.Id))?.Enabled == true;
|
||||||
|
// Even though the require SSO policy can be turned on regardless of SSO being enabled, for this logic, we only
|
||||||
|
// need to check the policy if the org has SSO enabled.
|
||||||
|
var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled &&
|
||||||
|
organization.UsePolicies &&
|
||||||
|
(await policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.RequireSso))?.Enabled == true;
|
||||||
|
|
||||||
|
// Generate the list of org users and expiring tokens
|
||||||
|
// create helper function to create expiring tokens
|
||||||
|
(OrganizationUser, ExpiringToken) MakeOrgUserExpiringTokenPair(OrganizationUser orgUser)
|
||||||
|
{
|
||||||
|
var orgUserInviteTokenable = orgUserInviteTokenableFactory.CreateToken(orgUser);
|
||||||
|
var protectedToken = dataProtectorTokenFactory.Protect(orgUserInviteTokenable);
|
||||||
|
return (orgUser, new ExpiringToken(protectedToken, orgUserInviteTokenable.ExpirationDate));
|
||||||
|
}
|
||||||
|
|
||||||
|
var orgUsersWithExpTokens = orgUsers.Select(MakeOrgUserExpiringTokenPair);
|
||||||
|
|
||||||
|
return new OrganizationInvitesInfo(
|
||||||
|
organization,
|
||||||
|
orgSsoEnabled,
|
||||||
|
orgSsoLoginRequiredPolicyEnabled,
|
||||||
|
orgUsersWithExpTokens,
|
||||||
|
orgUserHasExistingUserDict,
|
||||||
|
initOrganization
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
using Bit.Core.Models.Data;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||||
|
|
||||||
|
public static class CollectionAccessSelectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// This validates the permissions on the given assigned collection
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsValidCollectionAccessConfiguration(this CollectionAccessSelection collectionAccessSelection) =>
|
||||||
|
collectionAccessSelection.Manage && (collectionAccessSelection.ReadOnly || collectionAccessSelection.HidePasswords);
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
|
||||||
|
|
||||||
|
public record CannotAutoScaleOnSelfHostError(EnvironmentRequest Invalid) : Error<EnvironmentRequest>(Code, Invalid)
|
||||||
|
{
|
||||||
|
public const string Code = "Cannot auto scale self-host.";
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
|
||||||
|
using Bit.Core.Settings;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
|
||||||
|
|
||||||
|
public class EnvironmentRequest
|
||||||
|
{
|
||||||
|
public bool IsSelfHosted { get; init; }
|
||||||
|
public PasswordManagerSubscriptionUpdate PasswordManagerSubscriptionUpdate { get; init; }
|
||||||
|
|
||||||
|
public EnvironmentRequest(IGlobalSettings globalSettings, PasswordManagerSubscriptionUpdate passwordManagerSubscriptionUpdate)
|
||||||
|
{
|
||||||
|
IsSelfHosted = globalSettings.SelfHosted;
|
||||||
|
PasswordManagerSubscriptionUpdate = passwordManagerSubscriptionUpdate;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
|
||||||
|
|
||||||
|
public interface IInviteUsersEnvironmentValidator : IValidator<EnvironmentRequest>;
|
||||||
|
|
||||||
|
public class InviteUsersEnvironmentValidator : IInviteUsersEnvironmentValidator
|
||||||
|
{
|
||||||
|
public Task<ValidationResult<EnvironmentRequest>> ValidateAsync(EnvironmentRequest value) =>
|
||||||
|
Task.FromResult<ValidationResult<EnvironmentRequest>>(
|
||||||
|
value.IsSelfHosted && value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd > 0 ?
|
||||||
|
new Invalid<EnvironmentRequest>(new CannotAutoScaleOnSelfHostError(value)) :
|
||||||
|
new Valid<EnvironmentRequest>(value));
|
||||||
|
}
|
@ -0,0 +1,108 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
|
||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
using Bit.Core.Models.Business;
|
||||||
|
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||||
|
|
||||||
|
public interface IInviteUsersValidator : IValidator<InviteOrganizationUsersValidationRequest>;
|
||||||
|
|
||||||
|
public class InviteOrganizationUsersValidator(
|
||||||
|
IOrganizationRepository organizationRepository,
|
||||||
|
IInviteUsersPasswordManagerValidator inviteUsersPasswordManagerValidator,
|
||||||
|
IUpdateSecretsManagerSubscriptionCommand secretsManagerSubscriptionCommand,
|
||||||
|
IPaymentService paymentService) : IInviteUsersValidator
|
||||||
|
{
|
||||||
|
public async Task<ValidationResult<InviteOrganizationUsersValidationRequest>> ValidateAsync(
|
||||||
|
InviteOrganizationUsersValidationRequest request)
|
||||||
|
{
|
||||||
|
var subscriptionUpdate = new PasswordManagerSubscriptionUpdate(request);
|
||||||
|
|
||||||
|
var passwordManagerValidationResult =
|
||||||
|
await inviteUsersPasswordManagerValidator.ValidateAsync(subscriptionUpdate);
|
||||||
|
|
||||||
|
if (passwordManagerValidationResult is Invalid<PasswordManagerSubscriptionUpdate> invalidSubscriptionUpdate)
|
||||||
|
{
|
||||||
|
return invalidSubscriptionUpdate.Map(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the organization has the Secrets Manager Standalone Discount, all users are added to secrets manager.
|
||||||
|
// This is an expensive call, so we're doing it now to delay the check as long as possible.
|
||||||
|
if (await paymentService.HasSecretsManagerStandalone(request.InviteOrganization))
|
||||||
|
{
|
||||||
|
request = new InviteOrganizationUsersValidationRequest(request)
|
||||||
|
{
|
||||||
|
Invites = request.Invites
|
||||||
|
.Select(x => new OrganizationUserInvite(x, accessSecretsManager: true))
|
||||||
|
.ToArray()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.InviteOrganization.UseSecretsManager && request.Invites.Any(x => x.AccessSecretsManager))
|
||||||
|
{
|
||||||
|
return await ValidateSecretsManagerSubscriptionUpdateAsync(request, subscriptionUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Valid<InviteOrganizationUsersValidationRequest>(new InviteOrganizationUsersValidationRequest(
|
||||||
|
request,
|
||||||
|
subscriptionUpdate,
|
||||||
|
null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ValidationResult<InviteOrganizationUsersValidationRequest>> ValidateSecretsManagerSubscriptionUpdateAsync(
|
||||||
|
InviteOrganizationUsersValidationRequest request,
|
||||||
|
PasswordManagerSubscriptionUpdate subscriptionUpdate)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
|
||||||
|
var smSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(
|
||||||
|
organization: await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId),
|
||||||
|
plan: request.InviteOrganization.Plan,
|
||||||
|
autoscaling: true);
|
||||||
|
|
||||||
|
var seatsToAdd = GetSecretManagerSeatAdjustment(request);
|
||||||
|
|
||||||
|
if (seatsToAdd > 0)
|
||||||
|
{
|
||||||
|
smSubscriptionUpdate.AdjustSeats(seatsToAdd);
|
||||||
|
|
||||||
|
await secretsManagerSubscriptionCommand.ValidateUpdateAsync(smSubscriptionUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Valid<InviteOrganizationUsersValidationRequest>(new InviteOrganizationUsersValidationRequest(
|
||||||
|
request,
|
||||||
|
subscriptionUpdate,
|
||||||
|
smSubscriptionUpdate));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new Invalid<InviteOrganizationUsersValidationRequest>(
|
||||||
|
new Error<InviteOrganizationUsersValidationRequest>(ex.Message, request));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This calculates the number of SM seats to add to the organization seat total.
|
||||||
|
///
|
||||||
|
/// If they have a current seat limit (it can be null), we want to figure out how many are available (seats -
|
||||||
|
/// occupied seats). Then, we'll subtract the available seats from the number of users we're trying to invite.
|
||||||
|
///
|
||||||
|
/// If it's negative, we have available seats and do not need to increase, so we go with 0.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
private static int GetSecretManagerSeatAdjustment(InviteOrganizationUsersValidationRequest request) =>
|
||||||
|
request.InviteOrganization.SmSeats.HasValue
|
||||||
|
? Math.Max(
|
||||||
|
request.Invites.Count(x => x.AccessSecretsManager) -
|
||||||
|
(request.InviteOrganization.SmSeats.Value -
|
||||||
|
request.OccupiedSmSeats),
|
||||||
|
0)
|
||||||
|
: 0;
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization;
|
||||||
|
|
||||||
|
public record OrganizationNoPaymentMethodFoundError(InviteOrganization InvalidRequest)
|
||||||
|
: Error<InviteOrganization>(Code, InvalidRequest)
|
||||||
|
{
|
||||||
|
public const string Code = "No payment method found.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public record OrganizationNoSubscriptionFoundError(InviteOrganization InvalidRequest)
|
||||||
|
: Error<InviteOrganization>(Code, InvalidRequest)
|
||||||
|
{
|
||||||
|
public const string Code = "No subscription found.";
|
||||||
|
}
|
@ -0,0 +1,32 @@
|
|||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization;
|
||||||
|
|
||||||
|
public interface IInviteUsersOrganizationValidator : IValidator<InviteOrganization>;
|
||||||
|
|
||||||
|
public class InviteUsersOrganizationValidator : IInviteUsersOrganizationValidator
|
||||||
|
{
|
||||||
|
public Task<ValidationResult<InviteOrganization>> ValidateAsync(InviteOrganization inviteOrganization)
|
||||||
|
{
|
||||||
|
if (inviteOrganization.Seats is null)
|
||||||
|
{
|
||||||
|
return Task.FromResult<ValidationResult<InviteOrganization>>(
|
||||||
|
new Valid<InviteOrganization>(inviteOrganization));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(inviteOrganization.GatewayCustomerId))
|
||||||
|
{
|
||||||
|
return Task.FromResult<ValidationResult<InviteOrganization>>(
|
||||||
|
new Invalid<InviteOrganization>(new OrganizationNoPaymentMethodFoundError(inviteOrganization)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(inviteOrganization.GatewaySubscriptionId))
|
||||||
|
{
|
||||||
|
return Task.FromResult<ValidationResult<InviteOrganization>>(
|
||||||
|
new Invalid<InviteOrganization>(new OrganizationNoSubscriptionFoundError(inviteOrganization)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult<ValidationResult<InviteOrganization>>(new Valid<InviteOrganization>(inviteOrganization));
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,30 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
|
||||||
|
|
||||||
|
public record PasswordManagerSeatLimitHasBeenReachedError(PasswordManagerSubscriptionUpdate InvalidRequest)
|
||||||
|
: Error<PasswordManagerSubscriptionUpdate>(Code, InvalidRequest)
|
||||||
|
{
|
||||||
|
public const string Code = "Seat limit has been reached.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PasswordManagerPlanDoesNotAllowAdditionalSeatsError(PasswordManagerSubscriptionUpdate InvalidRequest)
|
||||||
|
: Error<PasswordManagerSubscriptionUpdate>(Code, InvalidRequest)
|
||||||
|
{
|
||||||
|
public const string Code = "Plan does not allow additional seats.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PasswordManagerPlanOnlyAllowsMaxAdditionalSeatsError(PasswordManagerSubscriptionUpdate InvalidRequest)
|
||||||
|
: Error<PasswordManagerSubscriptionUpdate>(GetErrorMessage(InvalidRequest), InvalidRequest)
|
||||||
|
{
|
||||||
|
private static string GetErrorMessage(PasswordManagerSubscriptionUpdate invalidRequest) =>
|
||||||
|
string.Format(Code, invalidRequest.PasswordManagerPlan.MaxAdditionalSeats);
|
||||||
|
|
||||||
|
public const string Code = "Organization plan allows a maximum of {0} additional seats.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PasswordManagerMustHaveSeatsError(PasswordManagerSubscriptionUpdate InvalidRequest)
|
||||||
|
: Error<PasswordManagerSubscriptionUpdate>(Code, InvalidRequest)
|
||||||
|
{
|
||||||
|
public const string Code = "You do not have any Password Manager seats!";
|
||||||
|
}
|
@ -0,0 +1,117 @@
|
|||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Settings;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
|
||||||
|
|
||||||
|
public interface IInviteUsersPasswordManagerValidator : IValidator<PasswordManagerSubscriptionUpdate>;
|
||||||
|
|
||||||
|
public class InviteUsersPasswordManagerValidator(
|
||||||
|
IGlobalSettings globalSettings,
|
||||||
|
IInviteUsersEnvironmentValidator inviteUsersEnvironmentValidator,
|
||||||
|
IInviteUsersOrganizationValidator inviteUsersOrganizationValidator,
|
||||||
|
IProviderRepository providerRepository,
|
||||||
|
IPaymentService paymentService,
|
||||||
|
IOrganizationRepository organizationRepository
|
||||||
|
) : IInviteUsersPasswordManagerValidator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// This is for validating if the organization can add additional users.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="subscriptionUpdate"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public static ValidationResult<PasswordManagerSubscriptionUpdate> ValidatePasswordManager(PasswordManagerSubscriptionUpdate subscriptionUpdate)
|
||||||
|
{
|
||||||
|
if (subscriptionUpdate.Seats is null)
|
||||||
|
{
|
||||||
|
return new Valid<PasswordManagerSubscriptionUpdate>(subscriptionUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscriptionUpdate.SeatsRequiredToAdd == 0)
|
||||||
|
{
|
||||||
|
return new Valid<PasswordManagerSubscriptionUpdate>(subscriptionUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscriptionUpdate.PasswordManagerPlan.BaseSeats + subscriptionUpdate.SeatsRequiredToAdd <= 0)
|
||||||
|
{
|
||||||
|
return new Invalid<PasswordManagerSubscriptionUpdate>(new PasswordManagerMustHaveSeatsError(subscriptionUpdate));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscriptionUpdate.MaxSeatsReached)
|
||||||
|
{
|
||||||
|
return new Invalid<PasswordManagerSubscriptionUpdate>(
|
||||||
|
new PasswordManagerSeatLimitHasBeenReachedError(subscriptionUpdate));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscriptionUpdate.PasswordManagerPlan.HasAdditionalSeatsOption is false)
|
||||||
|
{
|
||||||
|
return new Invalid<PasswordManagerSubscriptionUpdate>(
|
||||||
|
new PasswordManagerPlanDoesNotAllowAdditionalSeatsError(subscriptionUpdate));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apparently MaxAdditionalSeats is never set. Can probably be removed.
|
||||||
|
if (subscriptionUpdate.UpdatedSeatTotal - subscriptionUpdate.PasswordManagerPlan.BaseSeats > subscriptionUpdate.PasswordManagerPlan.MaxAdditionalSeats)
|
||||||
|
{
|
||||||
|
return new Invalid<PasswordManagerSubscriptionUpdate>(
|
||||||
|
new PasswordManagerPlanOnlyAllowsMaxAdditionalSeatsError(subscriptionUpdate));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Valid<PasswordManagerSubscriptionUpdate>(subscriptionUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ValidationResult<PasswordManagerSubscriptionUpdate>> ValidateAsync(PasswordManagerSubscriptionUpdate request)
|
||||||
|
{
|
||||||
|
switch (ValidatePasswordManager(request))
|
||||||
|
{
|
||||||
|
case Valid<PasswordManagerSubscriptionUpdate> valid
|
||||||
|
when valid.Value.SeatsRequiredToAdd is 0:
|
||||||
|
return new Valid<PasswordManagerSubscriptionUpdate>(request);
|
||||||
|
|
||||||
|
case Invalid<PasswordManagerSubscriptionUpdate> invalid:
|
||||||
|
return invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await inviteUsersEnvironmentValidator.ValidateAsync(new EnvironmentRequest(globalSettings, request)) is Invalid<EnvironmentRequest> invalidEnvironment)
|
||||||
|
{
|
||||||
|
return invalidEnvironment.Map(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
var organizationValidationResult = await inviteUsersOrganizationValidator.ValidateAsync(request.InviteOrganization);
|
||||||
|
|
||||||
|
if (organizationValidationResult is Invalid<InviteOrganization> organizationValidation)
|
||||||
|
{
|
||||||
|
return organizationValidation.Map(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
var provider = await providerRepository.GetByOrganizationIdAsync(request.InviteOrganization.OrganizationId);
|
||||||
|
if (provider is not null)
|
||||||
|
{
|
||||||
|
var providerValidationResult = InvitingUserOrganizationProviderValidator.Validate(new InviteOrganizationProvider(provider));
|
||||||
|
|
||||||
|
if (providerValidationResult is Invalid<InviteOrganizationProvider> invalidProviderValidation)
|
||||||
|
{
|
||||||
|
return invalidProviderValidation.Map(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var paymentSubscription = await paymentService.GetSubscriptionAsync(
|
||||||
|
await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId));
|
||||||
|
|
||||||
|
var paymentValidationResult = InviteUserPaymentValidation.Validate(
|
||||||
|
new PaymentsSubscription(paymentSubscription, request.InviteOrganization));
|
||||||
|
|
||||||
|
if (paymentValidationResult is Invalid<PaymentsSubscription> invalidPaymentValidation)
|
||||||
|
{
|
||||||
|
return invalidPaymentValidation.Map(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Valid<PasswordManagerSubscriptionUpdate>(request);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,89 @@
|
|||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
using Bit.Core.Models.StaticStore;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
|
||||||
|
|
||||||
|
public class PasswordManagerSubscriptionUpdate
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Seats the organization has
|
||||||
|
/// </summary>
|
||||||
|
public int? Seats { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Max number of seats that the organization can have
|
||||||
|
/// </summary>
|
||||||
|
public int? MaxAutoScaleSeats { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seats currently occupied by current users
|
||||||
|
/// </summary>
|
||||||
|
public int OccupiedSeats { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Users to add to the organization seats
|
||||||
|
/// </summary>
|
||||||
|
public int NewUsersToAdd { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of seats available for users
|
||||||
|
/// </summary>
|
||||||
|
public int? AvailableSeats => Seats - OccupiedSeats;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of seats to scale the organization by.
|
||||||
|
///
|
||||||
|
/// If Organization has no seat limit (Seats is null), then there are no new seats to add.
|
||||||
|
/// </summary>
|
||||||
|
public int SeatsRequiredToAdd => AvailableSeats.HasValue ? Math.Max(NewUsersToAdd - AvailableSeats.Value, 0) : 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// New total of seats for the organization
|
||||||
|
/// </summary>
|
||||||
|
public int? UpdatedSeatTotal => Seats + SeatsRequiredToAdd;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// If the new seat total is equal to the organization's auto-scale seat count
|
||||||
|
/// </summary>
|
||||||
|
public bool MaxSeatsReached => UpdatedSeatTotal.HasValue && MaxAutoScaleSeats.HasValue && UpdatedSeatTotal.Value >= MaxAutoScaleSeats.Value;
|
||||||
|
|
||||||
|
public Plan.PasswordManagerPlanFeatures PasswordManagerPlan { get; }
|
||||||
|
|
||||||
|
public InviteOrganization InviteOrganization { get; }
|
||||||
|
|
||||||
|
private PasswordManagerSubscriptionUpdate(int? organizationSeats,
|
||||||
|
int? organizationAutoScaleSeatLimit,
|
||||||
|
int currentSeats,
|
||||||
|
int newUsersToAdd,
|
||||||
|
Plan.PasswordManagerPlanFeatures plan,
|
||||||
|
InviteOrganization inviteOrganization)
|
||||||
|
{
|
||||||
|
Seats = organizationSeats;
|
||||||
|
MaxAutoScaleSeats = organizationAutoScaleSeatLimit;
|
||||||
|
OccupiedSeats = currentSeats;
|
||||||
|
NewUsersToAdd = newUsersToAdd;
|
||||||
|
PasswordManagerPlan = plan;
|
||||||
|
InviteOrganization = inviteOrganization;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PasswordManagerSubscriptionUpdate(InviteOrganization inviteOrganization, int occupiedSeats, int newUsersToAdd) :
|
||||||
|
this(
|
||||||
|
organizationSeats: inviteOrganization.Seats,
|
||||||
|
organizationAutoScaleSeatLimit: inviteOrganization.MaxAutoScaleSeats,
|
||||||
|
currentSeats: occupiedSeats,
|
||||||
|
newUsersToAdd: newUsersToAdd,
|
||||||
|
plan: inviteOrganization.Plan.PasswordManager,
|
||||||
|
inviteOrganization: inviteOrganization)
|
||||||
|
{ }
|
||||||
|
|
||||||
|
public PasswordManagerSubscriptionUpdate(InviteOrganizationUsersValidationRequest usersValidationRequest) :
|
||||||
|
this(
|
||||||
|
organizationSeats: usersValidationRequest.InviteOrganization.Seats,
|
||||||
|
organizationAutoScaleSeatLimit: usersValidationRequest.InviteOrganization.MaxAutoScaleSeats,
|
||||||
|
currentSeats: usersValidationRequest.OccupiedPmSeats,
|
||||||
|
newUsersToAdd: usersValidationRequest.Invites.Length,
|
||||||
|
plan: usersValidationRequest.InviteOrganization.Plan.PasswordManager,
|
||||||
|
inviteOrganization: usersValidationRequest.InviteOrganization)
|
||||||
|
{ }
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments;
|
||||||
|
|
||||||
|
public record PaymentCancelledSubscriptionError(PaymentsSubscription InvalidRequest)
|
||||||
|
: Error<PaymentsSubscription>(Code, InvalidRequest)
|
||||||
|
{
|
||||||
|
public const string Code = "You do not have an active subscription. Reinstate your subscription to make changes.";
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments;
|
||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
using Bit.Core.Billing.Constants;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||||
|
|
||||||
|
public static class InviteUserPaymentValidation
|
||||||
|
{
|
||||||
|
public static ValidationResult<PaymentsSubscription> Validate(PaymentsSubscription subscription)
|
||||||
|
{
|
||||||
|
if (subscription.ProductTierType is ProductTierType.Free)
|
||||||
|
{
|
||||||
|
return new Valid<PaymentsSubscription>(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.SubscriptionStatus == StripeConstants.SubscriptionStatus.Canceled)
|
||||||
|
{
|
||||||
|
return new Invalid<PaymentsSubscription>(new PaymentCancelledSubscriptionError(subscription));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Valid<PaymentsSubscription>(subscription);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,19 @@
|
|||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Bit.Core.Models.Business;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
|
||||||
|
|
||||||
|
public class PaymentsSubscription
|
||||||
|
{
|
||||||
|
public ProductTierType ProductTierType { get; init; }
|
||||||
|
public string SubscriptionStatus { get; init; }
|
||||||
|
|
||||||
|
public PaymentsSubscription() { }
|
||||||
|
|
||||||
|
public PaymentsSubscription(SubscriptionInfo subscriptionInfo, InviteOrganization inviteOrganization)
|
||||||
|
{
|
||||||
|
SubscriptionStatus = subscriptionInfo?.Subscription?.Status ?? string.Empty;
|
||||||
|
ProductTierType = inviteOrganization.Plan.ProductTier;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
|
||||||
|
|
||||||
|
public record ProviderBillableSeatLimitError(InviteOrganizationProvider InvalidRequest) : Error<InviteOrganizationProvider>(Code, InvalidRequest)
|
||||||
|
{
|
||||||
|
public const string Code = "Seat limit has been reached. Please contact your provider to add more seats.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ProviderResellerSeatLimitError(InviteOrganizationProvider InvalidRequest) : Error<InviteOrganizationProvider>(Code, InvalidRequest)
|
||||||
|
{
|
||||||
|
public const string Code = "Seat limit has been reached. Contact your provider to purchase additional seats.";
|
||||||
|
}
|
@ -0,0 +1,19 @@
|
|||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
|
||||||
|
|
||||||
|
public class InviteOrganizationProvider
|
||||||
|
{
|
||||||
|
public Guid ProviderId { get; init; }
|
||||||
|
public ProviderType Type { get; init; }
|
||||||
|
public ProviderStatusType Status { get; init; }
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
|
||||||
|
public InviteOrganizationProvider(Entities.Provider.Provider provider)
|
||||||
|
{
|
||||||
|
ProviderId = provider.Id;
|
||||||
|
Type = provider.Type;
|
||||||
|
Status = provider.Status;
|
||||||
|
Enabled = provider.Enabled;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
using Bit.Core.Billing.Extensions;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
|
||||||
|
|
||||||
|
public static class InvitingUserOrganizationProviderValidator
|
||||||
|
{
|
||||||
|
public static ValidationResult<InviteOrganizationProvider> Validate(InviteOrganizationProvider inviteOrganizationProvider)
|
||||||
|
{
|
||||||
|
if (inviteOrganizationProvider is not { Enabled: true })
|
||||||
|
{
|
||||||
|
return new Valid<InviteOrganizationProvider>(inviteOrganizationProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inviteOrganizationProvider.IsBillable())
|
||||||
|
{
|
||||||
|
return new Invalid<InviteOrganizationProvider>(new ProviderBillableSeatLimitError(inviteOrganizationProvider));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inviteOrganizationProvider.Type == ProviderType.Reseller)
|
||||||
|
{
|
||||||
|
return new Invalid<InviteOrganizationProvider>(new ProviderResellerSeatLimitError(inviteOrganizationProvider));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Valid<InviteOrganizationProvider>(inviteOrganizationProvider);
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.KeyManagement.UserKey;
|
using Bit.Core.KeyManagement.UserKey;
|
||||||
@ -68,4 +69,6 @@ public interface IOrganizationUserRepository : IRepository<OrganizationUser, Gui
|
|||||||
/// <param name="role">The role to search for</param>
|
/// <param name="role">The role to search for</param>
|
||||||
/// <returns>A list of OrganizationUsersUserDetails with the specified role</returns>
|
/// <returns>A list of OrganizationUsersUserDetails with the specified role</returns>
|
||||||
Task<IEnumerable<OrganizationUserUserDetails>> GetManyDetailsByRoleAsync(Guid organizationId, OrganizationUserType role);
|
Task<IEnumerable<OrganizationUserUserDetails>> GetManyDetailsByRoleAsync(Guid organizationId, OrganizationUserType role);
|
||||||
|
|
||||||
|
Task CreateManyAsync(IEnumerable<CreateOrganizationUser> organizationUserCollection);
|
||||||
}
|
}
|
||||||
|
@ -6,13 +6,13 @@ using Bit.Core.AdminConsole.Enums.Provider;
|
|||||||
using Bit.Core.AdminConsole.Models.Business;
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.AdminConsole.Services;
|
using Bit.Core.AdminConsole.Services;
|
||||||
using Bit.Core.Auth.Enums;
|
using Bit.Core.Auth.Enums;
|
||||||
using Bit.Core.Auth.Models.Business;
|
|
||||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
|
||||||
using Bit.Core.Auth.Repositories;
|
using Bit.Core.Auth.Repositories;
|
||||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
@ -26,18 +26,17 @@ using Bit.Core.Exceptions;
|
|||||||
using Bit.Core.Models.Business;
|
using Bit.Core.Models.Business;
|
||||||
using Bit.Core.Models.Data;
|
using Bit.Core.Models.Data;
|
||||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||||
using Bit.Core.Models.Mail;
|
|
||||||
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
||||||
using Bit.Core.Platform.Push;
|
using Bit.Core.Platform.Push;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Tokens;
|
|
||||||
using Bit.Core.Tools.Enums;
|
using Bit.Core.Tools.Enums;
|
||||||
using Bit.Core.Tools.Models.Business;
|
using Bit.Core.Tools.Models.Business;
|
||||||
using Bit.Core.Tools.Services;
|
using Bit.Core.Tools.Services;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
|
using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite;
|
||||||
|
|
||||||
namespace Bit.Core.Services;
|
namespace Bit.Core.Services;
|
||||||
|
|
||||||
@ -58,7 +57,6 @@ public class OrganizationService : IOrganizationService
|
|||||||
private readonly IPaymentService _paymentService;
|
private readonly IPaymentService _paymentService;
|
||||||
private readonly IPolicyRepository _policyRepository;
|
private readonly IPolicyRepository _policyRepository;
|
||||||
private readonly IPolicyService _policyService;
|
private readonly IPolicyService _policyService;
|
||||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
|
||||||
private readonly ISsoUserRepository _ssoUserRepository;
|
private readonly ISsoUserRepository _ssoUserRepository;
|
||||||
private readonly IReferenceEventService _referenceEventService;
|
private readonly IReferenceEventService _referenceEventService;
|
||||||
private readonly IGlobalSettings _globalSettings;
|
private readonly IGlobalSettings _globalSettings;
|
||||||
@ -70,13 +68,12 @@ public class OrganizationService : IOrganizationService
|
|||||||
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
|
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
|
||||||
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
|
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
|
||||||
private readonly IProviderRepository _providerRepository;
|
private readonly IProviderRepository _providerRepository;
|
||||||
private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory;
|
|
||||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
|
||||||
private readonly IFeatureService _featureService;
|
private readonly IFeatureService _featureService;
|
||||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||||
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
|
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
|
||||||
private readonly IPricingClient _pricingClient;
|
private readonly IPricingClient _pricingClient;
|
||||||
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
||||||
|
private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand;
|
||||||
|
|
||||||
public OrganizationService(
|
public OrganizationService(
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
@ -94,7 +91,6 @@ public class OrganizationService : IOrganizationService
|
|||||||
IPaymentService paymentService,
|
IPaymentService paymentService,
|
||||||
IPolicyRepository policyRepository,
|
IPolicyRepository policyRepository,
|
||||||
IPolicyService policyService,
|
IPolicyService policyService,
|
||||||
ISsoConfigRepository ssoConfigRepository,
|
|
||||||
ISsoUserRepository ssoUserRepository,
|
ISsoUserRepository ssoUserRepository,
|
||||||
IReferenceEventService referenceEventService,
|
IReferenceEventService referenceEventService,
|
||||||
IGlobalSettings globalSettings,
|
IGlobalSettings globalSettings,
|
||||||
@ -104,15 +100,14 @@ public class OrganizationService : IOrganizationService
|
|||||||
IProviderOrganizationRepository providerOrganizationRepository,
|
IProviderOrganizationRepository providerOrganizationRepository,
|
||||||
IProviderUserRepository providerUserRepository,
|
IProviderUserRepository providerUserRepository,
|
||||||
ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery,
|
ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery,
|
||||||
IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory,
|
|
||||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
|
||||||
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
|
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
|
||||||
IProviderRepository providerRepository,
|
IProviderRepository providerRepository,
|
||||||
IFeatureService featureService,
|
IFeatureService featureService,
|
||||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||||
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
|
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
|
||||||
IPricingClient pricingClient,
|
IPricingClient pricingClient,
|
||||||
IPolicyRequirementQuery policyRequirementQuery)
|
IPolicyRequirementQuery policyRequirementQuery,
|
||||||
|
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand)
|
||||||
{
|
{
|
||||||
_organizationRepository = organizationRepository;
|
_organizationRepository = organizationRepository;
|
||||||
_organizationUserRepository = organizationUserRepository;
|
_organizationUserRepository = organizationUserRepository;
|
||||||
@ -129,7 +124,6 @@ public class OrganizationService : IOrganizationService
|
|||||||
_paymentService = paymentService;
|
_paymentService = paymentService;
|
||||||
_policyRepository = policyRepository;
|
_policyRepository = policyRepository;
|
||||||
_policyService = policyService;
|
_policyService = policyService;
|
||||||
_ssoConfigRepository = ssoConfigRepository;
|
|
||||||
_ssoUserRepository = ssoUserRepository;
|
_ssoUserRepository = ssoUserRepository;
|
||||||
_referenceEventService = referenceEventService;
|
_referenceEventService = referenceEventService;
|
||||||
_globalSettings = globalSettings;
|
_globalSettings = globalSettings;
|
||||||
@ -141,13 +135,12 @@ public class OrganizationService : IOrganizationService
|
|||||||
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
|
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
|
||||||
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
|
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
|
||||||
_providerRepository = providerRepository;
|
_providerRepository = providerRepository;
|
||||||
_orgUserInviteTokenableFactory = orgUserInviteTokenableFactory;
|
|
||||||
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
|
||||||
_featureService = featureService;
|
_featureService = featureService;
|
||||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||||
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
|
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
|
||||||
_pricingClient = pricingClient;
|
_pricingClient = pricingClient;
|
||||||
_policyRequirementQuery = policyRequirementQuery;
|
_policyRequirementQuery = policyRequirementQuery;
|
||||||
|
_sendOrganizationInvitesCommand = sendOrganizationInvitesCommand;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken,
|
public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken,
|
||||||
@ -1055,74 +1048,14 @@ public class OrganizationService : IOrganizationService
|
|||||||
await SendInviteAsync(orgUser, org, initOrganization);
|
await SendInviteAsync(orgUser, org, initOrganization);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SendInvitesAsync(IEnumerable<OrganizationUser> orgUsers, Organization organization)
|
private async Task SendInvitesAsync(IEnumerable<OrganizationUser> orgUsers, Organization organization) =>
|
||||||
{
|
await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(orgUsers, organization));
|
||||||
var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(orgUsers, organization);
|
|
||||||
|
|
||||||
await _mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo);
|
private async Task SendInviteAsync(OrganizationUser orgUser, Organization organization, bool initOrganization) =>
|
||||||
}
|
await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(
|
||||||
|
users: [orgUser],
|
||||||
private async Task SendInviteAsync(OrganizationUser orgUser, Organization organization, bool initOrganization)
|
organization: organization,
|
||||||
{
|
initOrganization: initOrganization));
|
||||||
// convert single org user into array of 1 org user
|
|
||||||
var orgUsers = new[] { orgUser };
|
|
||||||
|
|
||||||
var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(orgUsers, organization, initOrganization);
|
|
||||||
|
|
||||||
await _mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<OrganizationInvitesInfo> BuildOrganizationInvitesInfoAsync(
|
|
||||||
IEnumerable<OrganizationUser> orgUsers,
|
|
||||||
Organization organization,
|
|
||||||
bool initOrganization = false)
|
|
||||||
{
|
|
||||||
// Materialize the sequence into a list to avoid multiple enumeration warnings
|
|
||||||
var orgUsersList = orgUsers.ToList();
|
|
||||||
|
|
||||||
// Email links must include information about the org and user for us to make routing decisions client side
|
|
||||||
// Given an org user, determine if existing BW user exists
|
|
||||||
var orgUserEmails = orgUsersList.Select(ou => ou.Email).ToList();
|
|
||||||
var existingUsers = await _userRepository.GetManyByEmailsAsync(orgUserEmails);
|
|
||||||
|
|
||||||
// hash existing users emails list for O(1) lookups
|
|
||||||
var existingUserEmailsHashSet = new HashSet<string>(existingUsers.Select(u => u.Email));
|
|
||||||
|
|
||||||
// Create a dictionary of org user guids and bools for whether or not they have an existing BW user
|
|
||||||
var orgUserHasExistingUserDict = orgUsersList.ToDictionary(
|
|
||||||
ou => ou.Id,
|
|
||||||
ou => existingUserEmailsHashSet.Contains(ou.Email)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Determine if org has SSO enabled and if user is required to login with SSO
|
|
||||||
// Note: we only want to call the DB after checking if the org can use SSO per plan and if they have any policies enabled.
|
|
||||||
var orgSsoEnabled = organization.UseSso && (await _ssoConfigRepository.GetByOrganizationIdAsync(organization.Id))?.Enabled == true;
|
|
||||||
// Even though the require SSO policy can be turned on regardless of SSO being enabled, for this logic, we only
|
|
||||||
// need to check the policy if the org has SSO enabled.
|
|
||||||
var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled &&
|
|
||||||
organization.UsePolicies &&
|
|
||||||
(await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.RequireSso))?.Enabled == true;
|
|
||||||
|
|
||||||
// Generate the list of org users and expiring tokens
|
|
||||||
// create helper function to create expiring tokens
|
|
||||||
(OrganizationUser, ExpiringToken) MakeOrgUserExpiringTokenPair(OrganizationUser orgUser)
|
|
||||||
{
|
|
||||||
var orgUserInviteTokenable = _orgUserInviteTokenableFactory.CreateToken(orgUser);
|
|
||||||
var protectedToken = _orgUserInviteTokenDataFactory.Protect(orgUserInviteTokenable);
|
|
||||||
return (orgUser, new ExpiringToken(protectedToken, orgUserInviteTokenable.ExpirationDate));
|
|
||||||
}
|
|
||||||
|
|
||||||
var orgUsersWithExpTokens = orgUsers.Select(MakeOrgUserExpiringTokenPair);
|
|
||||||
|
|
||||||
return new OrganizationInvitesInfo(
|
|
||||||
organization,
|
|
||||||
orgSsoEnabled,
|
|
||||||
orgSsoLoginRequiredPolicyEnabled,
|
|
||||||
orgUsersWithExpTokens,
|
|
||||||
orgUserHasExistingUserDict,
|
|
||||||
initOrganization
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal async Task<(bool canScale, string failureReason)> CanScaleAsync(
|
internal async Task<(bool canScale, string failureReason)> CanScaleAsync(
|
||||||
Organization organization,
|
Organization organization,
|
||||||
|
@ -6,10 +6,39 @@ public abstract record ValidationResult<T>;
|
|||||||
|
|
||||||
public record Valid<T> : ValidationResult<T>
|
public record Valid<T> : ValidationResult<T>
|
||||||
{
|
{
|
||||||
|
public Valid() { }
|
||||||
|
|
||||||
|
public Valid(T Value)
|
||||||
|
{
|
||||||
|
this.Value = Value;
|
||||||
|
}
|
||||||
|
|
||||||
public T Value { get; init; }
|
public T Value { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public record Invalid<T> : ValidationResult<T>
|
public record Invalid<T> : ValidationResult<T>
|
||||||
{
|
{
|
||||||
public IEnumerable<Error<T>> Errors { get; init; }
|
public IEnumerable<Error<T>> Errors { get; init; } = [];
|
||||||
|
|
||||||
|
public string ErrorMessageString => string.Join(" ", Errors.Select(e => e.Message));
|
||||||
|
|
||||||
|
public Invalid() { }
|
||||||
|
|
||||||
|
public Invalid(Error<T> error) : this([error]) { }
|
||||||
|
|
||||||
|
public Invalid(IEnumerable<Error<T>> errors)
|
||||||
|
{
|
||||||
|
Errors = errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ValidationResultMappers
|
||||||
|
{
|
||||||
|
public static ValidationResult<B> Map<A, B>(this ValidationResult<A> validationResult, B invalidValue) =>
|
||||||
|
validationResult switch
|
||||||
|
{
|
||||||
|
Valid<A> => new Valid<B>(invalidValue),
|
||||||
|
Invalid<A> invalid => new Invalid<B>(invalid.Errors.Select(x => x.ToError(invalidValue))),
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(validationResult), "Unhandled validation result type")
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.AdminConsole.Enums.Provider;
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
|
||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
@ -28,6 +29,13 @@ public static class BillingExtensions
|
|||||||
Status: ProviderStatusType.Billable
|
Status: ProviderStatusType.Billable
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public static bool IsBillable(this InviteOrganizationProvider inviteOrganizationProvider) =>
|
||||||
|
inviteOrganizationProvider is
|
||||||
|
{
|
||||||
|
Type: ProviderType.Msp or ProviderType.MultiOrganizationEnterprise,
|
||||||
|
Status: ProviderStatusType.Billable
|
||||||
|
};
|
||||||
|
|
||||||
public static bool SupportsConsolidatedBilling(this ProviderType providerType)
|
public static bool SupportsConsolidatedBilling(this ProviderType providerType)
|
||||||
=> providerType is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise;
|
=> providerType is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise;
|
||||||
|
|
||||||
|
@ -109,6 +109,7 @@ public static class FeatureFlagKeys
|
|||||||
public const string PushSyncOrgKeysOnRevokeRestore = "pm-17168-push-sync-org-keys-on-revoke-restore";
|
public const string PushSyncOrgKeysOnRevokeRestore = "pm-17168-push-sync-org-keys-on-revoke-restore";
|
||||||
public const string PolicyRequirements = "pm-14439-policy-requirements";
|
public const string PolicyRequirements = "pm-14439-policy-requirements";
|
||||||
public const string SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility";
|
public const string SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility";
|
||||||
|
public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast";
|
||||||
|
|
||||||
/* Auth Team */
|
/* Auth Team */
|
||||||
public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence";
|
public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence";
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
||||||
using Bit.Core.AdminConsole.Errors;
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
|
||||||
namespace Bit.Core.Models.Commands;
|
namespace Bit.Core.Models.Commands;
|
||||||
|
|
||||||
@ -40,10 +41,23 @@ public class Success<T>(T value) : CommandResult<T>
|
|||||||
public class Failure<T>(IEnumerable<string> errorMessages) : CommandResult<T>
|
public class Failure<T>(IEnumerable<string> errorMessages) : CommandResult<T>
|
||||||
{
|
{
|
||||||
public List<string> ErrorMessages { get; } = errorMessages.ToList();
|
public List<string> ErrorMessages { get; } = errorMessages.ToList();
|
||||||
|
public Error<T>[] Errors { get; set; } = [];
|
||||||
|
|
||||||
public string ErrorMessage => string.Join(" ", ErrorMessages);
|
public string ErrorMessage => string.Join(" ", ErrorMessages);
|
||||||
|
|
||||||
public Failure(string error) : this([error]) { }
|
public Failure(string error) : this([error])
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public Failure(IEnumerable<Error<T>> errors) : this(errors.Select(e => e.Message))
|
||||||
|
{
|
||||||
|
Errors = errors.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Failure(Error<T> error) : this([error.Message])
|
||||||
|
{
|
||||||
|
Errors = [error];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class Partial<T> : CommandResult<T>
|
public class Partial<T> : CommandResult<T>
|
||||||
@ -57,3 +71,18 @@ public class Partial<T> : CommandResult<T>
|
|||||||
Failures = failedItems.ToArray();
|
Failures = failedItems.ToArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class CommandResultExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// This is to help map between the InvalidT ValidationResult and the FailureT CommandResult types.
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="invalidResult">This is the invalid type from validating the object.</param>
|
||||||
|
/// <param name="mappingFunction">This function will map between the two types for the inner ErrorT</param>
|
||||||
|
/// <typeparam name="A">Invalid object's type</typeparam>
|
||||||
|
/// <typeparam name="B">Failure object's type</typeparam>
|
||||||
|
/// <returns></returns>
|
||||||
|
public static CommandResult<B> MapToFailure<A, B>(this Invalid<A> invalidResult, Func<A, B> mappingFunction) =>
|
||||||
|
new Failure<B>(invalidResult.Errors.Select(errorA => errorA.ToError(mappingFunction(errorA.ErroredValue))));
|
||||||
|
}
|
||||||
|
@ -13,6 +13,11 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
|
|||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
|
||||||
using Bit.Core.Models.Business.Tokenables;
|
using Bit.Core.Models.Business.Tokenables;
|
||||||
using Bit.Core.OrganizationFeatures.OrganizationCollections;
|
using Bit.Core.OrganizationFeatures.OrganizationCollections;
|
||||||
@ -174,6 +179,14 @@ public static class OrganizationServiceCollectionExtensions
|
|||||||
services.AddScoped<IAuthorizationHandler, OrganizationUserUserMiniDetailsAuthorizationHandler>();
|
services.AddScoped<IAuthorizationHandler, OrganizationUserUserMiniDetailsAuthorizationHandler>();
|
||||||
services.AddScoped<IAuthorizationHandler, OrganizationUserUserDetailsAuthorizationHandler>();
|
services.AddScoped<IAuthorizationHandler, OrganizationUserUserDetailsAuthorizationHandler>();
|
||||||
services.AddScoped<IHasConfirmedOwnersExceptQuery, HasConfirmedOwnersExceptQuery>();
|
services.AddScoped<IHasConfirmedOwnersExceptQuery, HasConfirmedOwnersExceptQuery>();
|
||||||
|
|
||||||
|
services.AddScoped<IInviteOrganizationUsersCommand, InviteOrganizationUsersCommand>();
|
||||||
|
services.AddScoped<ISendOrganizationInvitesCommand, SendOrganizationInvitesCommand>();
|
||||||
|
|
||||||
|
services.AddScoped<IInviteUsersValidator, InviteOrganizationUsersValidator>();
|
||||||
|
services.AddScoped<IInviteUsersOrganizationValidator, InviteUsersOrganizationValidator>();
|
||||||
|
services.AddScoped<IInviteUsersPasswordManagerValidator, InviteUsersPasswordManagerValidator>();
|
||||||
|
services.AddScoped<IInviteUsersEnvironmentValidator, InviteUsersEnvironmentValidator>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: move to OrganizationSubscriptionServiceCollectionExtensions when OrganizationUser methods are moved out of
|
// TODO: move to OrganizationSubscriptionServiceCollectionExtensions when OrganizationUser methods are moved out of
|
||||||
|
@ -5,4 +5,5 @@ namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
|||||||
public interface IUpdateSecretsManagerSubscriptionCommand
|
public interface IUpdateSecretsManagerSubscriptionCommand
|
||||||
{
|
{
|
||||||
Task UpdateSubscriptionAsync(SecretsManagerSubscriptionUpdate update);
|
Task UpdateSubscriptionAsync(SecretsManagerSubscriptionUpdate update);
|
||||||
|
Task ValidateUpdateAsync(SecretsManagerSubscriptionUpdate update);
|
||||||
}
|
}
|
||||||
|
@ -124,7 +124,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ValidateUpdateAsync(SecretsManagerSubscriptionUpdate update)
|
public async Task ValidateUpdateAsync(SecretsManagerSubscriptionUpdate update)
|
||||||
{
|
{
|
||||||
if (_globalSettings.SelfHosted)
|
if (_globalSettings.SelfHosted)
|
||||||
{
|
{
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
using Bit.Core.Billing.Models;
|
using Bit.Core.Billing.Models;
|
||||||
using Bit.Core.Billing.Models.Api.Requests.Accounts;
|
using Bit.Core.Billing.Models.Api.Requests.Accounts;
|
||||||
using Bit.Core.Billing.Models.Api.Requests.Organizations;
|
using Bit.Core.Billing.Models.Api.Requests.Organizations;
|
||||||
@ -38,9 +39,28 @@ public interface IPaymentService
|
|||||||
Task<SubscriptionInfo> GetSubscriptionAsync(ISubscriber subscriber);
|
Task<SubscriptionInfo> GetSubscriptionAsync(ISubscriber subscriber);
|
||||||
Task<TaxInfo> GetTaxInfoAsync(ISubscriber subscriber);
|
Task<TaxInfo> GetTaxInfoAsync(ISubscriber subscriber);
|
||||||
Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo);
|
Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo);
|
||||||
Task<string> AddSecretsManagerToSubscription(Organization org, Plan plan, int additionalSmSeats,
|
Task<string> AddSecretsManagerToSubscription(Organization org, Plan plan, int additionalSmSeats, int additionalServiceAccount);
|
||||||
int additionalServiceAccount);
|
/// <summary>
|
||||||
|
/// Secrets Manager Standalone is a discount in Stripe that is used to give an organization access to Secrets Manager.
|
||||||
|
/// Usually, this also implies that when they invite a user to their organization, they are doing so for both Password
|
||||||
|
/// Manager and Secrets Manger.
|
||||||
|
///
|
||||||
|
/// This will not call out to Stripe if they don't have a GatewayId or if they don't have Secrets Manager.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="organization">Organization Entity</param>
|
||||||
|
/// <returns>If the organization has Secrets Manager and has the Standalone Stripe Discount</returns>
|
||||||
Task<bool> HasSecretsManagerStandalone(Organization organization);
|
Task<bool> HasSecretsManagerStandalone(Organization organization);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Secrets Manager Standalone is a discount in Stripe that is used to give an organization access to Secrets Manager.
|
||||||
|
/// Usually, this also implies that when they invite a user to their organization, they are doing so for both Password
|
||||||
|
/// Manager and Secrets Manger.
|
||||||
|
///
|
||||||
|
/// This will not call out to Stripe if they don't have a GatewayId or if they don't have Secrets Manager.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="organization">Organization Representation used for Inviting Organization Users</param>
|
||||||
|
/// <returns>If the organization has Secrets Manager and has the Standalone Stripe Discount</returns>
|
||||||
|
Task<bool> HasSecretsManagerStandalone(InviteOrganization organization);
|
||||||
Task<PreviewInvoiceResponseModel> PreviewInvoiceAsync(PreviewIndividualInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId);
|
Task<PreviewInvoiceResponseModel> PreviewInvoiceAsync(PreviewIndividualInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId);
|
||||||
Task<PreviewInvoiceResponseModel> PreviewInvoiceAsync(PreviewOrganizationInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId);
|
Task<PreviewInvoiceResponseModel> PreviewInvoiceAsync(PreviewOrganizationInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId);
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
using Bit.Core.Billing.Extensions;
|
using Bit.Core.Billing.Extensions;
|
||||||
using Bit.Core.Billing.Models;
|
using Bit.Core.Billing.Models;
|
||||||
@ -1110,14 +1111,27 @@ public class StripePaymentService : IPaymentService
|
|||||||
new SecretsManagerSubscribeUpdate(org, plan, additionalSmSeats, additionalServiceAccount),
|
new SecretsManagerSubscribeUpdate(org, plan, additionalSmSeats, additionalServiceAccount),
|
||||||
true);
|
true);
|
||||||
|
|
||||||
public async Task<bool> HasSecretsManagerStandalone(Organization organization)
|
public async Task<bool> HasSecretsManagerStandalone(Organization organization) =>
|
||||||
|
await HasSecretsManagerStandaloneAsync(gatewayCustomerId: organization.GatewayCustomerId,
|
||||||
|
organizationHasSecretsManager: organization.UseSecretsManager);
|
||||||
|
|
||||||
|
public async Task<bool> HasSecretsManagerStandalone(InviteOrganization organization) =>
|
||||||
|
await HasSecretsManagerStandaloneAsync(gatewayCustomerId: organization.GatewayCustomerId,
|
||||||
|
organizationHasSecretsManager: organization.UseSecretsManager);
|
||||||
|
|
||||||
|
private async Task<bool> HasSecretsManagerStandaloneAsync(string gatewayCustomerId, bool organizationHasSecretsManager)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(organization.GatewayCustomerId))
|
if (string.IsNullOrEmpty(gatewayCustomerId))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var customer = await _stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId);
|
if (organizationHasSecretsManager is false)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var customer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId);
|
||||||
|
|
||||||
return customer?.Discount?.Coupon?.Id == SecretsManagerStandaloneDiscountId;
|
return customer?.Discount?.Coupon?.Id == SecretsManagerStandaloneDiscountId;
|
||||||
}
|
}
|
||||||
|
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.ComponentModel.DataAnnotations;
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using MimeKit;
|
|
||||||
|
|
||||||
namespace Bit.Core.Utilities;
|
namespace Bit.Core.Utilities;
|
||||||
|
|
||||||
@ -12,39 +10,8 @@ public class StrictEmailAddressAttribute : ValidationAttribute
|
|||||||
|
|
||||||
public override bool IsValid(object value)
|
public override bool IsValid(object value)
|
||||||
{
|
{
|
||||||
var emailAddress = value?.ToString();
|
var emailAddress = value?.ToString() ?? string.Empty;
|
||||||
if (emailAddress == null)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
return emailAddress.IsValidEmail() && new EmailAddressAttribute().IsValid(emailAddress);
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.KeyManagement.UserKey;
|
using Bit.Core.KeyManagement.UserKey;
|
||||||
@ -580,4 +581,32 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
|
|||||||
return results.ToList();
|
return results.ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task CreateManyAsync(IEnumerable<CreateOrganizationUser> organizationUserCollection)
|
||||||
|
{
|
||||||
|
await using var connection = new SqlConnection(_marsConnectionString);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
$"[{Schema}].[OrganizationUser_CreateManyWithCollectionsAndGroups]",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
OrganizationUserData = JsonSerializer.Serialize(organizationUserCollection.Select(x => x.OrganizationUser)),
|
||||||
|
CollectionData = JsonSerializer.Serialize(organizationUserCollection
|
||||||
|
.SelectMany(x => x.Collections, (user, collection) => new CollectionUser
|
||||||
|
{
|
||||||
|
CollectionId = collection.Id,
|
||||||
|
OrganizationUserId = user.OrganizationUser.Id,
|
||||||
|
ReadOnly = collection.ReadOnly,
|
||||||
|
HidePasswords = collection.HidePasswords,
|
||||||
|
Manage = collection.Manage
|
||||||
|
})),
|
||||||
|
GroupData = JsonSerializer.Serialize(organizationUserCollection
|
||||||
|
.SelectMany(x => x.Groups, (user, group) => new GroupUser
|
||||||
|
{
|
||||||
|
GroupId = group,
|
||||||
|
OrganizationUserId = user.OrganizationUser.Id
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.KeyManagement.UserKey;
|
using Bit.Core.KeyManagement.UserKey;
|
||||||
using Bit.Core.Models.Data;
|
using Bit.Core.Models.Data;
|
||||||
@ -757,4 +758,28 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
|
|||||||
return await query.ToListAsync();
|
return await query.ToListAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task CreateManyAsync(IEnumerable<CreateOrganizationUser> organizationUserCollection)
|
||||||
|
{
|
||||||
|
using var scope = ServiceScopeFactory.CreateScope();
|
||||||
|
|
||||||
|
await using var dbContext = GetDatabaseContext(scope);
|
||||||
|
|
||||||
|
dbContext.OrganizationUsers.AddRange(Mapper.Map<List<OrganizationUser>>(organizationUserCollection.Select(x => x.OrganizationUser)));
|
||||||
|
dbContext.CollectionUsers.AddRange(organizationUserCollection.SelectMany(x => x.Collections, (user, collection) => new CollectionUser
|
||||||
|
{
|
||||||
|
CollectionId = collection.Id,
|
||||||
|
HidePasswords = collection.HidePasswords,
|
||||||
|
OrganizationUserId = user.OrganizationUser.Id,
|
||||||
|
Manage = collection.Manage,
|
||||||
|
ReadOnly = collection.ReadOnly
|
||||||
|
}));
|
||||||
|
dbContext.GroupUsers.AddRange(organizationUserCollection.SelectMany(x => x.Groups, (user, group) => new GroupUser
|
||||||
|
{
|
||||||
|
GroupId = group,
|
||||||
|
OrganizationUserId = user.OrganizationUser.Id
|
||||||
|
}));
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,97 @@
|
|||||||
|
CREATE PROCEDURE [dbo].[OrganizationUser_CreateManyWithCollectionsAndGroups]
|
||||||
|
@organizationUserData NVARCHAR(MAX),
|
||||||
|
@collectionData NVARCHAR(MAX),
|
||||||
|
@groupData NVARCHAR(MAX)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON
|
||||||
|
|
||||||
|
INSERT INTO [dbo].[OrganizationUser]
|
||||||
|
(
|
||||||
|
[Id],
|
||||||
|
[OrganizationId],
|
||||||
|
[UserId],
|
||||||
|
[Email],
|
||||||
|
[Key],
|
||||||
|
[Status],
|
||||||
|
[Type],
|
||||||
|
[ExternalId],
|
||||||
|
[CreationDate],
|
||||||
|
[RevisionDate],
|
||||||
|
[Permissions],
|
||||||
|
[ResetPasswordKey],
|
||||||
|
[AccessSecretsManager]
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
OUI.[Id],
|
||||||
|
OUI.[OrganizationId],
|
||||||
|
OUI.[UserId],
|
||||||
|
OUI.[Email],
|
||||||
|
OUI.[Key],
|
||||||
|
OUI.[Status],
|
||||||
|
OUI.[Type],
|
||||||
|
OUI.[ExternalId],
|
||||||
|
OUI.[CreationDate],
|
||||||
|
OUI.[RevisionDate],
|
||||||
|
OUI.[Permissions],
|
||||||
|
OUI.[ResetPasswordKey],
|
||||||
|
OUI.[AccessSecretsManager]
|
||||||
|
FROM
|
||||||
|
OPENJSON(@organizationUserData)
|
||||||
|
WITH (
|
||||||
|
[Id] UNIQUEIDENTIFIER '$.Id',
|
||||||
|
[OrganizationId] UNIQUEIDENTIFIER '$.OrganizationId',
|
||||||
|
[UserId] UNIQUEIDENTIFIER '$.UserId',
|
||||||
|
[Email] NVARCHAR(256) '$.Email',
|
||||||
|
[Key] VARCHAR(MAX) '$.Key',
|
||||||
|
[Status] SMALLINT '$.Status',
|
||||||
|
[Type] TINYINT '$.Type',
|
||||||
|
[ExternalId] NVARCHAR(300) '$.ExternalId',
|
||||||
|
[CreationDate] DATETIME2(7) '$.CreationDate',
|
||||||
|
[RevisionDate] DATETIME2(7) '$.RevisionDate',
|
||||||
|
[Permissions] NVARCHAR (MAX) '$.Permissions',
|
||||||
|
[ResetPasswordKey] VARCHAR (MAX) '$.ResetPasswordKey',
|
||||||
|
[AccessSecretsManager] BIT '$.AccessSecretsManager'
|
||||||
|
) OUI
|
||||||
|
|
||||||
|
INSERT INTO [dbo].[GroupUser]
|
||||||
|
(
|
||||||
|
[OrganizationUserId],
|
||||||
|
[GroupId]
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
OUG.OrganizationUserId,
|
||||||
|
OUG.GroupId
|
||||||
|
FROM
|
||||||
|
OPENJSON(@groupData)
|
||||||
|
WITH(
|
||||||
|
[OrganizationUserId] UNIQUEIDENTIFIER '$.OrganizationUserId',
|
||||||
|
[GroupId] UNIQUEIDENTIFIER '$.GroupId'
|
||||||
|
) OUG
|
||||||
|
|
||||||
|
INSERT INTO [dbo].[CollectionUser]
|
||||||
|
(
|
||||||
|
[CollectionId],
|
||||||
|
[OrganizationUserId],
|
||||||
|
[ReadOnly],
|
||||||
|
[HidePasswords],
|
||||||
|
[Manage]
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
OUC.[CollectionId],
|
||||||
|
OUC.[OrganizationUserId],
|
||||||
|
OUC.[ReadOnly],
|
||||||
|
OUC.[HidePasswords],
|
||||||
|
OUC.[Manage]
|
||||||
|
FROM
|
||||||
|
OPENJSON(@collectionData)
|
||||||
|
WITH(
|
||||||
|
[CollectionId] UNIQUEIDENTIFIER '$.CollectionId',
|
||||||
|
[OrganizationUserId] UNIQUEIDENTIFIER '$.OrganizationUserId',
|
||||||
|
[ReadOnly] BIT '$.ReadOnly',
|
||||||
|
[HidePasswords] BIT '$.HidePasswords',
|
||||||
|
[Manage] BIT '$.Manage'
|
||||||
|
) OUC
|
||||||
|
END
|
||||||
|
go
|
||||||
|
|
@ -0,0 +1,67 @@
|
|||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Models.Data;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.InviteOrganizationUserErrorMessages;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.AdminConsole.Models;
|
||||||
|
|
||||||
|
public class InviteOrganizationUsersRequestTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void Constructor_WhenPassedInvalidEmail_ThrowsException(string email, OrganizationUserType type, Permissions permissions, string externalId)
|
||||||
|
{
|
||||||
|
var exception = Assert.Throws<BadRequestException>(() =>
|
||||||
|
new OrganizationUserInvite(email, [], [], type, permissions, externalId, false));
|
||||||
|
|
||||||
|
Assert.Contains(InvalidEmailErrorMessage, exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_WhenPassedInvalidCollectionAccessConfiguration_ThrowsException()
|
||||||
|
{
|
||||||
|
const string validEmail = "test@email.com";
|
||||||
|
|
||||||
|
var invalidCollectionConfiguration = new CollectionAccessSelection
|
||||||
|
{
|
||||||
|
Manage = true,
|
||||||
|
HidePasswords = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var exception = Assert.Throws<BadRequestException>(() =>
|
||||||
|
new OrganizationUserInvite(
|
||||||
|
email: validEmail,
|
||||||
|
assignedCollections: [invalidCollectionConfiguration],
|
||||||
|
groups: [],
|
||||||
|
type: default,
|
||||||
|
permissions: new Permissions(),
|
||||||
|
externalId: string.Empty,
|
||||||
|
accessSecretsManager: false));
|
||||||
|
|
||||||
|
Assert.Equal(InvalidCollectionConfigurationErrorMessage, exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_WhenPassedValidArguments_ReturnsInvite()
|
||||||
|
{
|
||||||
|
const string validEmail = "test@email.com";
|
||||||
|
var validCollectionConfiguration = new CollectionAccessSelection { Id = Guid.NewGuid(), Manage = true };
|
||||||
|
|
||||||
|
var invite = new OrganizationUserInvite(
|
||||||
|
email: validEmail,
|
||||||
|
assignedCollections: [validCollectionConfiguration],
|
||||||
|
groups: [],
|
||||||
|
type: default,
|
||||||
|
permissions: null,
|
||||||
|
externalId: null,
|
||||||
|
accessSecretsManager: false);
|
||||||
|
|
||||||
|
Assert.NotNull(invite);
|
||||||
|
Assert.Contains(validEmail, invite.Email);
|
||||||
|
Assert.Contains(validCollectionConfiguration, invite.AssignedCollections);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,51 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
|
||||||
|
using Bit.Core.Models.Business;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Helpers;
|
||||||
|
|
||||||
|
public static class InviteUserOrganizationValidationRequestHelpers
|
||||||
|
{
|
||||||
|
public static InviteOrganizationUsersValidationRequest GetInviteValidationRequestMock(InviteOrganizationUsersRequest request,
|
||||||
|
InviteOrganization inviteOrganization, Organization organization) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Invites = request.Invites,
|
||||||
|
InviteOrganization = inviteOrganization,
|
||||||
|
PerformedBy = Guid.Empty,
|
||||||
|
PerformedAt = request.PerformedAt,
|
||||||
|
OccupiedPmSeats = 0,
|
||||||
|
OccupiedSmSeats = 0,
|
||||||
|
PasswordManagerSubscriptionUpdate = new PasswordManagerSubscriptionUpdate(inviteOrganization, 0, 0),
|
||||||
|
SecretsManagerSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(organization, inviteOrganization.Plan, true)
|
||||||
|
.AdjustSeats(request.Invites.Count(x => x.AccessSecretsManager))
|
||||||
|
};
|
||||||
|
|
||||||
|
public static InviteOrganizationUsersValidationRequest WithPasswordManagerUpdate(this InviteOrganizationUsersValidationRequest request, PasswordManagerSubscriptionUpdate passwordManagerSubscriptionUpdate) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Invites = request.Invites,
|
||||||
|
InviteOrganization = request.InviteOrganization,
|
||||||
|
PerformedBy = request.PerformedBy,
|
||||||
|
PerformedAt = request.PerformedAt,
|
||||||
|
OccupiedPmSeats = request.OccupiedPmSeats,
|
||||||
|
OccupiedSmSeats = request.OccupiedSmSeats,
|
||||||
|
PasswordManagerSubscriptionUpdate = passwordManagerSubscriptionUpdate,
|
||||||
|
SecretsManagerSubscriptionUpdate = request.SecretsManagerSubscriptionUpdate
|
||||||
|
};
|
||||||
|
|
||||||
|
public static InviteOrganizationUsersValidationRequest WithSecretsManagerUpdate(this InviteOrganizationUsersValidationRequest request, SecretsManagerSubscriptionUpdate secretsManagerSubscriptionUpdate) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Invites = request.Invites,
|
||||||
|
InviteOrganization = request.InviteOrganization,
|
||||||
|
PerformedBy = request.PerformedBy,
|
||||||
|
PerformedAt = request.PerformedAt,
|
||||||
|
OccupiedPmSeats = request.OccupiedPmSeats,
|
||||||
|
OccupiedSmSeats = request.OccupiedSmSeats,
|
||||||
|
PasswordManagerSubscriptionUpdate = request.PasswordManagerSubscriptionUpdate,
|
||||||
|
SecretsManagerSubscriptionUpdate = secretsManagerSubscriptionUpdate
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,613 @@
|
|||||||
|
using System.Net.Mail;
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Errors;
|
||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.Models.Data.Provider;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
using Bit.Core.Billing.Models.StaticStore.Plans;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Models.Business;
|
||||||
|
using Bit.Core.Models.Commands;
|
||||||
|
using Bit.Core.Models.Data;
|
||||||
|
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||||
|
using Bit.Core.Models.StaticStore;
|
||||||
|
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Test.Common.AutoFixture;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using Microsoft.Extensions.Time.Testing;
|
||||||
|
using NSubstitute;
|
||||||
|
using NSubstitute.ExceptionExtensions;
|
||||||
|
using Xunit;
|
||||||
|
using static Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Helpers.InviteUserOrganizationValidationRequestHelpers;
|
||||||
|
using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
|
||||||
|
[SutProviderCustomize]
|
||||||
|
public class InviteOrganizationUserCommandTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task InviteScimOrganizationUserAsync_WhenEmailAlreadyExists_ThenNoInviteIsSentAndNoSeatsAreAdjusted(
|
||||||
|
MailAddress address,
|
||||||
|
Organization organization,
|
||||||
|
OrganizationUser user,
|
||||||
|
FakeTimeProvider timeProvider,
|
||||||
|
string externalId,
|
||||||
|
SutProvider<InviteOrganizationUsersCommand> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
user.Email = address.Address;
|
||||||
|
|
||||||
|
var inviteOrganization = new InviteOrganization(organization, new FreePlan());
|
||||||
|
|
||||||
|
var request = new InviteOrganizationUsersRequest(
|
||||||
|
invites: [
|
||||||
|
new OrganizationUserInvite(
|
||||||
|
email: user.Email,
|
||||||
|
assignedCollections: [],
|
||||||
|
groups: [],
|
||||||
|
type: OrganizationUserType.User,
|
||||||
|
permissions: new Permissions(),
|
||||||
|
externalId: externalId,
|
||||||
|
accessSecretsManager: true)
|
||||||
|
],
|
||||||
|
inviteOrganization: inviteOrganization,
|
||||||
|
performedBy: Guid.Empty,
|
||||||
|
timeProvider.GetUtcNow());
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
|
.SelectKnownEmailsAsync(organization.Id, Arg.Any<IEnumerable<string>>(), false)
|
||||||
|
.Returns([user.Email]);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IInviteUsersValidator>()
|
||||||
|
.ValidateAsync(Arg.Any<InviteOrganizationUsersValidationRequest>())
|
||||||
|
.Returns(new Valid<InviteOrganizationUsersValidationRequest>(GetInviteValidationRequestMock(request, inviteOrganization, organization)));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.IsType<Failure<ScimInviteOrganizationUsersResponse>>(result);
|
||||||
|
Assert.Equal(NoUsersToInviteError.Code, (result as Failure<ScimInviteOrganizationUsersResponse>).ErrorMessage);
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IPaymentService>()
|
||||||
|
.DidNotReceiveWithAnyArgs()
|
||||||
|
.AdjustSeatsAsync(Arg.Any<Organization>(), Arg.Any<Plan>(), Arg.Any<int>());
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>()
|
||||||
|
.DidNotReceiveWithAnyArgs()
|
||||||
|
.SendInvitesAsync(Arg.Any<SendInvitesRequest>());
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IUpdateSecretsManagerSubscriptionCommand>()
|
||||||
|
.DidNotReceiveWithAnyArgs()
|
||||||
|
.UpdateSubscriptionAsync(Arg.Any<Core.Models.Business.SecretsManagerSubscriptionUpdate>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task InviteScimOrganizationUserAsync_WhenEmailDoesNotExistAndRequestIsValid_ThenUserIsSavedAndInviteIsSent(
|
||||||
|
MailAddress address,
|
||||||
|
Organization organization,
|
||||||
|
OrganizationUser orgUser,
|
||||||
|
FakeTimeProvider timeProvider,
|
||||||
|
string externalId,
|
||||||
|
SutProvider<InviteOrganizationUsersCommand> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
orgUser.Email = address.Address;
|
||||||
|
|
||||||
|
var inviteOrganization = new InviteOrganization(organization, new FreePlan());
|
||||||
|
|
||||||
|
var request = new InviteOrganizationUsersRequest(
|
||||||
|
invites: [
|
||||||
|
new OrganizationUserInvite(
|
||||||
|
email: orgUser.Email,
|
||||||
|
assignedCollections: [],
|
||||||
|
groups: [],
|
||||||
|
type: OrganizationUserType.User,
|
||||||
|
permissions: new Permissions(),
|
||||||
|
externalId: externalId,
|
||||||
|
accessSecretsManager: true)
|
||||||
|
],
|
||||||
|
inviteOrganization: inviteOrganization,
|
||||||
|
performedBy: Guid.Empty,
|
||||||
|
timeProvider.GetUtcNow());
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
|
.SelectKnownEmailsAsync(organization.Id, Arg.Any<IEnumerable<string>>(), false)
|
||||||
|
.Returns([]);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>()
|
||||||
|
.GetByIdAsync(organization.Id)
|
||||||
|
.Returns(organization);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IInviteUsersValidator>()
|
||||||
|
.ValidateAsync(Arg.Any<InviteOrganizationUsersValidationRequest>())
|
||||||
|
.Returns(new Valid<InviteOrganizationUsersValidationRequest>(GetInviteValidationRequestMock(request, inviteOrganization, organization)));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.IsType<Success<ScimInviteOrganizationUsersResponse>>(result);
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
|
.Received(1)
|
||||||
|
.CreateManyAsync(Arg.Is<IEnumerable<CreateOrganizationUser>>(users =>
|
||||||
|
users.Any(user => user.OrganizationUser.Email == request.Invites.First().Email)));
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>()
|
||||||
|
.Received(1)
|
||||||
|
.SendInvitesAsync(Arg.Is<SendInvitesRequest>(invite =>
|
||||||
|
invite.Organization == organization &&
|
||||||
|
invite.Users.Count(x => x.Email == orgUser.Email) == 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task InviteScimOrganizationUserAsync_WhenEmailIsNewAndRequestIsInvalid_ThenFailureIsReturnedWithValidationFailureReason(
|
||||||
|
MailAddress address,
|
||||||
|
Organization organization,
|
||||||
|
OrganizationUser user,
|
||||||
|
FakeTimeProvider timeProvider,
|
||||||
|
string externalId,
|
||||||
|
SutProvider<InviteOrganizationUsersCommand> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string errorMessage = "Org cannot add user for some given reason";
|
||||||
|
|
||||||
|
user.Email = address.Address;
|
||||||
|
|
||||||
|
var inviteOrganization = new InviteOrganization(organization, new FreePlan());
|
||||||
|
|
||||||
|
var request = new InviteOrganizationUsersRequest(
|
||||||
|
invites: [
|
||||||
|
new OrganizationUserInvite(
|
||||||
|
email: user.Email,
|
||||||
|
assignedCollections: [],
|
||||||
|
groups: [],
|
||||||
|
type: OrganizationUserType.User,
|
||||||
|
permissions: new Permissions(),
|
||||||
|
externalId: externalId,
|
||||||
|
accessSecretsManager: true)
|
||||||
|
],
|
||||||
|
inviteOrganization: inviteOrganization,
|
||||||
|
performedBy: Guid.Empty,
|
||||||
|
timeProvider.GetUtcNow());
|
||||||
|
|
||||||
|
var validationRequest = GetInviteValidationRequestMock(request, inviteOrganization, organization);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
|
.SelectKnownEmailsAsync(organization.Id, Arg.Any<IEnumerable<string>>(), false)
|
||||||
|
.Returns([]);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>()
|
||||||
|
.GetByIdAsync(organization.Id)
|
||||||
|
.Returns(organization);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IInviteUsersValidator>()
|
||||||
|
.ValidateAsync(Arg.Any<InviteOrganizationUsersValidationRequest>())
|
||||||
|
.Returns(new Invalid<InviteOrganizationUsersValidationRequest>(
|
||||||
|
new Error<InviteOrganizationUsersValidationRequest>(errorMessage, validationRequest)));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.IsType<Failure<ScimInviteOrganizationUsersResponse>>(result);
|
||||||
|
var failure = result as Failure<ScimInviteOrganizationUsersResponse>;
|
||||||
|
|
||||||
|
Assert.Equal(errorMessage, failure!.ErrorMessage);
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
|
.DidNotReceive()
|
||||||
|
.CreateManyAsync(Arg.Any<IEnumerable<CreateOrganizationUser>>());
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>()
|
||||||
|
.DidNotReceive()
|
||||||
|
.SendInvitesAsync(Arg.Any<SendInvitesRequest>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task InviteScimOrganizationUserAsync_WhenValidInviteCausesOrganizationToReachMaxSeats_ThenOrganizationOwnersShouldBeNotified(
|
||||||
|
MailAddress address,
|
||||||
|
Organization organization,
|
||||||
|
OrganizationUser user,
|
||||||
|
FakeTimeProvider timeProvider,
|
||||||
|
string externalId,
|
||||||
|
OrganizationUserUserDetails ownerDetails,
|
||||||
|
SutProvider<InviteOrganizationUsersCommand> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
user.Email = address.Address;
|
||||||
|
organization.Seats = 1;
|
||||||
|
organization.MaxAutoscaleSeats = 2;
|
||||||
|
ownerDetails.Type = OrganizationUserType.Owner;
|
||||||
|
|
||||||
|
var inviteOrganization = new InviteOrganization(organization, new FreePlan());
|
||||||
|
|
||||||
|
var request = new InviteOrganizationUsersRequest(
|
||||||
|
invites: [
|
||||||
|
new OrganizationUserInvite(
|
||||||
|
email: user.Email,
|
||||||
|
assignedCollections: [],
|
||||||
|
groups: [],
|
||||||
|
type: OrganizationUserType.User,
|
||||||
|
permissions: new Permissions(),
|
||||||
|
externalId: externalId,
|
||||||
|
accessSecretsManager: true)
|
||||||
|
],
|
||||||
|
inviteOrganization: inviteOrganization,
|
||||||
|
performedBy: Guid.Empty,
|
||||||
|
timeProvider.GetUtcNow());
|
||||||
|
|
||||||
|
var orgUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
|
||||||
|
|
||||||
|
orgUserRepository
|
||||||
|
.SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any<IEnumerable<string>>(), false)
|
||||||
|
.Returns([]);
|
||||||
|
orgUserRepository
|
||||||
|
.GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner)
|
||||||
|
.Returns([ownerDetails]);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>()
|
||||||
|
.GetByIdAsync(organization.Id)
|
||||||
|
.Returns(organization);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IInviteUsersValidator>()
|
||||||
|
.ValidateAsync(Arg.Any<InviteOrganizationUsersValidationRequest>())
|
||||||
|
.Returns(new Valid<InviteOrganizationUsersValidationRequest>(GetInviteValidationRequestMock(request, inviteOrganization, organization)
|
||||||
|
.WithPasswordManagerUpdate(new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1))));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.IsType<Success<ScimInviteOrganizationUsersResponse>>(result);
|
||||||
|
|
||||||
|
Assert.NotNull(inviteOrganization.MaxAutoScaleSeats);
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IMailService>()
|
||||||
|
.Received(1)
|
||||||
|
.SendOrganizationMaxSeatLimitReachedEmailAsync(organization,
|
||||||
|
inviteOrganization.MaxAutoScaleSeats.Value,
|
||||||
|
Arg.Is<IEnumerable<string>>(emails => emails.Any(email => email == ownerDetails.Email)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task InviteScimOrganizationUserAsync_WhenValidInviteIncreasesSeats_ThenSeatTotalShouldBeUpdated(
|
||||||
|
MailAddress address,
|
||||||
|
Organization organization,
|
||||||
|
OrganizationUser user,
|
||||||
|
FakeTimeProvider timeProvider,
|
||||||
|
string externalId,
|
||||||
|
OrganizationUserUserDetails ownerDetails,
|
||||||
|
SutProvider<InviteOrganizationUsersCommand> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
user.Email = address.Address;
|
||||||
|
organization.Seats = 1;
|
||||||
|
organization.MaxAutoscaleSeats = 2;
|
||||||
|
ownerDetails.Type = OrganizationUserType.Owner;
|
||||||
|
|
||||||
|
var inviteOrganization = new InviteOrganization(organization, new FreePlan());
|
||||||
|
|
||||||
|
var request = new InviteOrganizationUsersRequest(
|
||||||
|
invites: [
|
||||||
|
new OrganizationUserInvite(
|
||||||
|
email: user.Email,
|
||||||
|
assignedCollections: [],
|
||||||
|
groups: [],
|
||||||
|
type: OrganizationUserType.User,
|
||||||
|
permissions: new Permissions(),
|
||||||
|
externalId: externalId,
|
||||||
|
accessSecretsManager: true)
|
||||||
|
],
|
||||||
|
inviteOrganization: inviteOrganization,
|
||||||
|
performedBy: Guid.Empty,
|
||||||
|
timeProvider.GetUtcNow());
|
||||||
|
|
||||||
|
var passwordManagerUpdate = new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1);
|
||||||
|
|
||||||
|
var orgUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
|
||||||
|
|
||||||
|
orgUserRepository
|
||||||
|
.SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any<IEnumerable<string>>(), false)
|
||||||
|
.Returns([]);
|
||||||
|
orgUserRepository
|
||||||
|
.GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner)
|
||||||
|
.Returns([ownerDetails]);
|
||||||
|
|
||||||
|
var orgRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||||
|
|
||||||
|
orgRepository.GetByIdAsync(organization.Id)
|
||||||
|
.Returns(organization);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IInviteUsersValidator>()
|
||||||
|
.ValidateAsync(Arg.Any<InviteOrganizationUsersValidationRequest>())
|
||||||
|
.Returns(new Valid<InviteOrganizationUsersValidationRequest>(GetInviteValidationRequestMock(request, inviteOrganization, organization)
|
||||||
|
.WithPasswordManagerUpdate(passwordManagerUpdate)));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.IsType<Success<ScimInviteOrganizationUsersResponse>>(result);
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IPaymentService>()
|
||||||
|
.AdjustSeatsAsync(organization, inviteOrganization.Plan, passwordManagerUpdate.SeatsRequiredToAdd);
|
||||||
|
|
||||||
|
await orgRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(x => x.Seats == passwordManagerUpdate.UpdatedSeatTotal));
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IApplicationCacheService>()
|
||||||
|
.Received(1)
|
||||||
|
.UpsertOrganizationAbilityAsync(Arg.Is<Organization>(x => x.Seats == passwordManagerUpdate.UpdatedSeatTotal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task InviteScimOrganizationUserAsync_WhenValidInviteIncreasesSecretsManagerSeats_ThenSecretsManagerShouldBeUpdated(
|
||||||
|
MailAddress address,
|
||||||
|
Organization organization,
|
||||||
|
OrganizationUser user,
|
||||||
|
FakeTimeProvider timeProvider,
|
||||||
|
string externalId,
|
||||||
|
OrganizationUserUserDetails ownerDetails,
|
||||||
|
SutProvider<InviteOrganizationUsersCommand> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
user.Email = address.Address;
|
||||||
|
organization.Seats = 1;
|
||||||
|
organization.SmSeats = 1;
|
||||||
|
organization.MaxAutoscaleSeats = 2;
|
||||||
|
organization.MaxAutoscaleSmSeats = 2;
|
||||||
|
ownerDetails.Type = OrganizationUserType.Owner;
|
||||||
|
|
||||||
|
var inviteOrganization = new InviteOrganization(organization, new FreePlan());
|
||||||
|
|
||||||
|
var request = new InviteOrganizationUsersRequest(
|
||||||
|
invites: [
|
||||||
|
new OrganizationUserInvite(
|
||||||
|
email: user.Email,
|
||||||
|
assignedCollections: [],
|
||||||
|
groups: [],
|
||||||
|
type: OrganizationUserType.User,
|
||||||
|
permissions: new Permissions(),
|
||||||
|
externalId: externalId,
|
||||||
|
accessSecretsManager: true)
|
||||||
|
],
|
||||||
|
inviteOrganization: inviteOrganization,
|
||||||
|
performedBy: Guid.Empty,
|
||||||
|
timeProvider.GetUtcNow());
|
||||||
|
|
||||||
|
var secretsManagerSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(organization, inviteOrganization.Plan, true)
|
||||||
|
.AdjustSeats(request.Invites.Count(x => x.AccessSecretsManager));
|
||||||
|
|
||||||
|
var orgUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
|
||||||
|
|
||||||
|
orgUserRepository
|
||||||
|
.SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any<IEnumerable<string>>(), false)
|
||||||
|
.Returns([]);
|
||||||
|
orgUserRepository
|
||||||
|
.GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner)
|
||||||
|
.Returns([ownerDetails]);
|
||||||
|
orgUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(1);
|
||||||
|
orgUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id).Returns(1);
|
||||||
|
|
||||||
|
var orgRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||||
|
|
||||||
|
orgRepository.GetByIdAsync(organization.Id)
|
||||||
|
.Returns(organization);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IInviteUsersValidator>()
|
||||||
|
.ValidateAsync(Arg.Any<InviteOrganizationUsersValidationRequest>())
|
||||||
|
.Returns(new Valid<InviteOrganizationUsersValidationRequest>(GetInviteValidationRequestMock(request, inviteOrganization, organization)
|
||||||
|
.WithSecretsManagerUpdate(secretsManagerSubscriptionUpdate)));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
|
||||||
|
|
||||||
|
// Assert;
|
||||||
|
Assert.IsType<Success<ScimInviteOrganizationUsersResponse>>(result);
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IUpdateSecretsManagerSubscriptionCommand>()
|
||||||
|
.Received(1)
|
||||||
|
.UpdateSubscriptionAsync(secretsManagerSubscriptionUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task InviteScimOrganizationUserAsync_WhenAnErrorOccursWhileInvitingUsers_ThenAnySeatChangesShouldBeReverted(
|
||||||
|
MailAddress address,
|
||||||
|
Organization organization,
|
||||||
|
OrganizationUser user,
|
||||||
|
FakeTimeProvider timeProvider,
|
||||||
|
string externalId,
|
||||||
|
OrganizationUserUserDetails ownerDetails,
|
||||||
|
SutProvider<InviteOrganizationUsersCommand> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
user.Email = address.Address;
|
||||||
|
organization.Seats = 1;
|
||||||
|
organization.SmSeats = 1;
|
||||||
|
organization.MaxAutoscaleSeats = 2;
|
||||||
|
organization.MaxAutoscaleSmSeats = 2;
|
||||||
|
ownerDetails.Type = OrganizationUserType.Owner;
|
||||||
|
|
||||||
|
var inviteOrganization = new InviteOrganization(organization, new FreePlan());
|
||||||
|
|
||||||
|
var request = new InviteOrganizationUsersRequest(
|
||||||
|
invites: [
|
||||||
|
new OrganizationUserInvite(
|
||||||
|
email: user.Email,
|
||||||
|
assignedCollections: [],
|
||||||
|
groups: [],
|
||||||
|
type: OrganizationUserType.User,
|
||||||
|
permissions: new Permissions(),
|
||||||
|
externalId: externalId,
|
||||||
|
accessSecretsManager: true)
|
||||||
|
],
|
||||||
|
inviteOrganization: inviteOrganization,
|
||||||
|
performedBy: Guid.Empty,
|
||||||
|
timeProvider.GetUtcNow());
|
||||||
|
|
||||||
|
var secretsManagerSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(organization, inviteOrganization.Plan, true)
|
||||||
|
.AdjustSeats(request.Invites.Count(x => x.AccessSecretsManager));
|
||||||
|
|
||||||
|
var passwordManagerSubscriptionUpdate =
|
||||||
|
new PasswordManagerSubscriptionUpdate(inviteOrganization, 1, request.Invites.Length);
|
||||||
|
|
||||||
|
var orgUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
|
||||||
|
|
||||||
|
orgUserRepository
|
||||||
|
.SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any<IEnumerable<string>>(), false)
|
||||||
|
.Returns([]);
|
||||||
|
orgUserRepository
|
||||||
|
.GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner)
|
||||||
|
.Returns([ownerDetails]);
|
||||||
|
|
||||||
|
var orgRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||||
|
|
||||||
|
orgRepository.GetByIdAsync(organization.Id)
|
||||||
|
.Returns(organization);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IInviteUsersValidator>()
|
||||||
|
.ValidateAsync(Arg.Any<InviteOrganizationUsersValidationRequest>())
|
||||||
|
.Returns(new Valid<InviteOrganizationUsersValidationRequest>(GetInviteValidationRequestMock(request, inviteOrganization, organization)
|
||||||
|
.WithPasswordManagerUpdate(passwordManagerSubscriptionUpdate)
|
||||||
|
.WithSecretsManagerUpdate(secretsManagerSubscriptionUpdate)));
|
||||||
|
|
||||||
|
sutProvider.GetDependency<ISendOrganizationInvitesCommand>()
|
||||||
|
.SendInvitesAsync(Arg.Any<SendInvitesRequest>())
|
||||||
|
.Throws(new Exception("Something went wrong"));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.IsType<Failure<ScimInviteOrganizationUsersResponse>>(result);
|
||||||
|
Assert.Equal(FailedToInviteUsersError.Code, (result as Failure<ScimInviteOrganizationUsersResponse>)!.ErrorMessage);
|
||||||
|
|
||||||
|
// org user revert
|
||||||
|
await orgUserRepository.Received(1).DeleteManyAsync(Arg.Is<IEnumerable<Guid>>(x => x.Count() == 1));
|
||||||
|
|
||||||
|
// SM revert
|
||||||
|
await sutProvider.GetDependency<IUpdateSecretsManagerSubscriptionCommand>()
|
||||||
|
.Received(2)
|
||||||
|
.UpdateSubscriptionAsync(Arg.Any<SecretsManagerSubscriptionUpdate>());
|
||||||
|
|
||||||
|
// PM revert
|
||||||
|
await sutProvider.GetDependency<IPaymentService>()
|
||||||
|
.Received(2)
|
||||||
|
.AdjustSeatsAsync(Arg.Any<Organization>(), Arg.Any<Plan>(), Arg.Any<int>());
|
||||||
|
|
||||||
|
await orgRepository.Received(2).ReplaceAsync(Arg.Any<Organization>());
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IApplicationCacheService>().Received(2)
|
||||||
|
.UpsertOrganizationAbilityAsync(Arg.Any<Organization>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task InviteScimOrganizationUserAsync_WhenAnOrganizationIsManagedByAProvider_ThenAnEmailShouldBeSentToTheProvider(
|
||||||
|
MailAddress address,
|
||||||
|
Organization organization,
|
||||||
|
OrganizationUser user,
|
||||||
|
FakeTimeProvider timeProvider,
|
||||||
|
string externalId,
|
||||||
|
OrganizationUserUserDetails ownerDetails,
|
||||||
|
ProviderOrganization providerOrganization,
|
||||||
|
SutProvider<InviteOrganizationUsersCommand> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
user.Email = address.Address;
|
||||||
|
organization.Seats = 1;
|
||||||
|
organization.SmSeats = 1;
|
||||||
|
organization.MaxAutoscaleSeats = 2;
|
||||||
|
organization.MaxAutoscaleSmSeats = 2;
|
||||||
|
ownerDetails.Type = OrganizationUserType.Owner;
|
||||||
|
|
||||||
|
providerOrganization.OrganizationId = organization.Id;
|
||||||
|
|
||||||
|
var inviteOrganization = new InviteOrganization(organization, new FreePlan());
|
||||||
|
|
||||||
|
var request = new InviteOrganizationUsersRequest(
|
||||||
|
invites: [
|
||||||
|
new OrganizationUserInvite(
|
||||||
|
email: user.Email,
|
||||||
|
assignedCollections: [],
|
||||||
|
groups: [],
|
||||||
|
type: OrganizationUserType.User,
|
||||||
|
permissions: new Permissions(),
|
||||||
|
externalId: externalId,
|
||||||
|
accessSecretsManager: true)
|
||||||
|
],
|
||||||
|
inviteOrganization: inviteOrganization,
|
||||||
|
performedBy: Guid.Empty,
|
||||||
|
timeProvider.GetUtcNow());
|
||||||
|
|
||||||
|
var secretsManagerSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(organization, inviteOrganization.Plan, true)
|
||||||
|
.AdjustSeats(request.Invites.Count(x => x.AccessSecretsManager));
|
||||||
|
|
||||||
|
var passwordManagerSubscriptionUpdate =
|
||||||
|
new PasswordManagerSubscriptionUpdate(inviteOrganization, 1, request.Invites.Length);
|
||||||
|
|
||||||
|
var orgUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
|
||||||
|
|
||||||
|
orgUserRepository
|
||||||
|
.SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any<IEnumerable<string>>(), false)
|
||||||
|
.Returns([]);
|
||||||
|
orgUserRepository
|
||||||
|
.GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner)
|
||||||
|
.Returns([ownerDetails]);
|
||||||
|
|
||||||
|
var orgRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||||
|
|
||||||
|
orgRepository.GetByIdAsync(organization.Id)
|
||||||
|
.Returns(organization);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IInviteUsersValidator>()
|
||||||
|
.ValidateAsync(Arg.Any<InviteOrganizationUsersValidationRequest>())
|
||||||
|
.Returns(new Valid<InviteOrganizationUsersValidationRequest>(GetInviteValidationRequestMock(request, inviteOrganization, organization)
|
||||||
|
.WithPasswordManagerUpdate(passwordManagerSubscriptionUpdate)
|
||||||
|
.WithSecretsManagerUpdate(secretsManagerSubscriptionUpdate)));
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderOrganizationRepository>()
|
||||||
|
.GetByOrganizationId(organization.Id)
|
||||||
|
.Returns(providerOrganization);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IProviderUserRepository>()
|
||||||
|
.GetManyDetailsByProviderAsync(providerOrganization.ProviderId, ProviderUserStatusType.Confirmed)
|
||||||
|
.Returns(new List<ProviderUserUserDetails>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Email = "provider@email.com"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.IsType<Success<ScimInviteOrganizationUsersResponse>>(result);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IMailService>().Received(1)
|
||||||
|
.SendOrganizationMaxSeatLimitReachedEmailAsync(organization, 2,
|
||||||
|
Arg.Is<IEnumerable<string>>(emails => emails.Any(email => email == "provider@email.com")));
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,108 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.Auth.Entities;
|
||||||
|
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||||
|
using Bit.Core.Auth.Repositories;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Models.Mail;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Test.AutoFixture.OrganizationFixtures;
|
||||||
|
using Bit.Core.Tokens;
|
||||||
|
using Bit.Test.Common.AutoFixture;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using Bit.Test.Common.Fakes;
|
||||||
|
using NSubstitute;
|
||||||
|
using NSubstitute.ReturnsExtensions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
|
||||||
|
[SutProviderCustomize]
|
||||||
|
public class SendOrganizationInvitesCommandTests
|
||||||
|
{
|
||||||
|
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory = new FakeDataProtectorTokenFactory<OrgUserInviteTokenable>();
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[OrganizationInviteCustomize, OrganizationCustomize, BitAutoData]
|
||||||
|
public async Task SendInvitesAsync_SsoOrgWithNeverEnabledRequireSsoPolicy_SendsEmailWithoutRequiringSso(
|
||||||
|
Organization organization,
|
||||||
|
SsoConfig ssoConfig,
|
||||||
|
OrganizationUser invite,
|
||||||
|
SutProvider<SendOrganizationInvitesCommand> sutProvider)
|
||||||
|
{
|
||||||
|
// Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks
|
||||||
|
sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory");
|
||||||
|
sutProvider.Create();
|
||||||
|
|
||||||
|
// Org must be able to use SSO and policies to trigger this test case
|
||||||
|
organization.UseSso = true;
|
||||||
|
organization.UsePolicies = true;
|
||||||
|
|
||||||
|
ssoConfig.Enabled = true;
|
||||||
|
sutProvider.GetDependency<ISsoConfigRepository>().GetByOrganizationIdAsync(organization.Id).Returns(ssoConfig);
|
||||||
|
|
||||||
|
// Return null policy to mimic new org that's never turned on the require sso policy
|
||||||
|
sutProvider.GetDependency<IPolicyRepository>().GetManyByOrganizationIdAsync(organization.Id).ReturnsNull();
|
||||||
|
|
||||||
|
// Mock tokenable factory to return a token that expires in 5 days
|
||||||
|
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
|
||||||
|
.CreateToken(Arg.Any<OrganizationUser>())
|
||||||
|
.Returns(
|
||||||
|
info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
|
||||||
|
{
|
||||||
|
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await sutProvider.Sut.SendInvitesAsync(new SendInvitesRequest([invite], organization));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await sutProvider.GetDependency<IMailService>().Received(1)
|
||||||
|
.SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
|
||||||
|
info.OrgUserTokenPairs.Count() == 1 &&
|
||||||
|
info.OrgUserTokenPairs.FirstOrDefault(x => x.OrgUser.Email == invite.Email).OrgUser == invite &&
|
||||||
|
info.IsFreeOrg == (organization.PlanType == PlanType.Free) &&
|
||||||
|
info.OrganizationName == organization.Name &&
|
||||||
|
info.OrgSsoLoginRequiredPolicyEnabled == false));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[OrganizationInviteCustomize, OrganizationCustomize, BitAutoData]
|
||||||
|
public async Task InviteUsers_SsoOrgWithNullSsoConfig_SendsInvite(
|
||||||
|
Organization organization,
|
||||||
|
OrganizationUser invite,
|
||||||
|
SutProvider<SendOrganizationInvitesCommand> sutProvider)
|
||||||
|
{
|
||||||
|
// Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks
|
||||||
|
sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory");
|
||||||
|
sutProvider.Create();
|
||||||
|
|
||||||
|
// Org must be able to use SSO to trigger this proper test case as we currently only call to retrieve
|
||||||
|
// an org's SSO config if the org can use SSO
|
||||||
|
organization.UseSso = true;
|
||||||
|
|
||||||
|
// Return null for sso config
|
||||||
|
sutProvider.GetDependency<ISsoConfigRepository>().GetByOrganizationIdAsync(organization.Id).ReturnsNull();
|
||||||
|
|
||||||
|
// Mock tokenable factory to return a token that expires in 5 days
|
||||||
|
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
|
||||||
|
.CreateToken(Arg.Any<OrganizationUser>())
|
||||||
|
.Returns(
|
||||||
|
info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
|
||||||
|
{
|
||||||
|
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
||||||
|
});
|
||||||
|
|
||||||
|
await sutProvider.Sut.SendInvitesAsync(new SendInvitesRequest([invite], organization));
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IMailService>().Received(1)
|
||||||
|
.SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
|
||||||
|
info.OrgUserTokenPairs.Count() == 1 &&
|
||||||
|
info.OrgUserTokenPairs.FirstOrDefault(x => x.OrgUser.Email == invite.Email).OrgUser == invite &&
|
||||||
|
info.IsFreeOrg == (organization.PlanType == PlanType.Free) &&
|
||||||
|
info.OrganizationName == organization.Name));
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,161 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
using Bit.Core.Billing.Models.StaticStore.Plans;
|
||||||
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Models.Business;
|
||||||
|
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Test.Common.AutoFixture;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using NSubstitute;
|
||||||
|
using NSubstitute.ExceptionExtensions;
|
||||||
|
using Xunit;
|
||||||
|
using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||||
|
|
||||||
|
[SutProviderCustomize]
|
||||||
|
public class InviteOrganizationUsersValidatorTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task ValidateAsync_WhenOrganizationHasSecretsManagerInvitesAndDoesNotHaveEnoughSeatsAvailable_ThenShouldCorrectlyCalculateSeatsToAdd(
|
||||||
|
Organization organization,
|
||||||
|
SutProvider<InviteOrganizationUsersValidator> sutProvider
|
||||||
|
)
|
||||||
|
{
|
||||||
|
organization.Seats = null;
|
||||||
|
organization.SmSeats = 10;
|
||||||
|
organization.UseSecretsManager = true;
|
||||||
|
|
||||||
|
var request = new InviteOrganizationUsersValidationRequest
|
||||||
|
{
|
||||||
|
Invites =
|
||||||
|
[
|
||||||
|
new OrganizationUserInvite(
|
||||||
|
email: "test@email.com",
|
||||||
|
externalId: "test-external-id"),
|
||||||
|
new OrganizationUserInvite(
|
||||||
|
email: "test2@email.com",
|
||||||
|
externalId: "test-external-id2"),
|
||||||
|
new OrganizationUserInvite(
|
||||||
|
email: "test3@email.com",
|
||||||
|
externalId: "test-external-id3")
|
||||||
|
],
|
||||||
|
InviteOrganization = new InviteOrganization(organization, new Enterprise2023Plan(true)),
|
||||||
|
OccupiedPmSeats = 0,
|
||||||
|
OccupiedSmSeats = 9
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IPaymentService>()
|
||||||
|
.HasSecretsManagerStandalone(request.InviteOrganization)
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>()
|
||||||
|
.GetByIdAsync(organization.Id)
|
||||||
|
.Returns(organization);
|
||||||
|
|
||||||
|
_ = await sutProvider.Sut.ValidateAsync(request);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IUpdateSecretsManagerSubscriptionCommand>()
|
||||||
|
.Received(1)
|
||||||
|
.ValidateUpdateAsync(Arg.Is<SecretsManagerSubscriptionUpdate>(x =>
|
||||||
|
x.SmSeatsChanged == true && x.SmSeats == 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task ValidateAsync_WhenOrganizationHasSecretsManagerInvitesAndHasSeatsAvailable_ThenShouldReturnValid(
|
||||||
|
Organization organization,
|
||||||
|
SutProvider<InviteOrganizationUsersValidator> sutProvider
|
||||||
|
)
|
||||||
|
{
|
||||||
|
organization.Seats = null;
|
||||||
|
organization.SmSeats = 12;
|
||||||
|
organization.UseSecretsManager = true;
|
||||||
|
|
||||||
|
var request = new InviteOrganizationUsersValidationRequest
|
||||||
|
{
|
||||||
|
Invites =
|
||||||
|
[
|
||||||
|
new OrganizationUserInvite(
|
||||||
|
email: "test@email.com",
|
||||||
|
externalId: "test-external-id"),
|
||||||
|
new OrganizationUserInvite(
|
||||||
|
email: "test2@email.com",
|
||||||
|
externalId: "test-external-id2"),
|
||||||
|
new OrganizationUserInvite(
|
||||||
|
email: "test3@email.com",
|
||||||
|
externalId: "test-external-id3")
|
||||||
|
],
|
||||||
|
InviteOrganization = new InviteOrganization(organization, new Enterprise2023Plan(true)),
|
||||||
|
OccupiedPmSeats = 0,
|
||||||
|
OccupiedSmSeats = 9
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IPaymentService>()
|
||||||
|
.HasSecretsManagerStandalone(request.InviteOrganization)
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>()
|
||||||
|
.GetByIdAsync(organization.Id)
|
||||||
|
.Returns(organization);
|
||||||
|
|
||||||
|
var result = await sutProvider.Sut.ValidateAsync(request);
|
||||||
|
|
||||||
|
Assert.IsType<Valid<InviteOrganizationUsersValidationRequest>>(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task ValidateAsync_WhenOrganizationHasSecretsManagerInvitesAndSmSeatUpdateFailsValidation_ThenShouldReturnInvalid(
|
||||||
|
Organization organization,
|
||||||
|
SutProvider<InviteOrganizationUsersValidator> sutProvider
|
||||||
|
)
|
||||||
|
{
|
||||||
|
organization.Seats = null;
|
||||||
|
organization.SmSeats = 5;
|
||||||
|
organization.MaxAutoscaleSmSeats = 5;
|
||||||
|
organization.UseSecretsManager = true;
|
||||||
|
|
||||||
|
var request = new InviteOrganizationUsersValidationRequest
|
||||||
|
{
|
||||||
|
Invites =
|
||||||
|
[
|
||||||
|
new OrganizationUserInvite(
|
||||||
|
email: "test@email.com",
|
||||||
|
externalId: "test-external-id"),
|
||||||
|
new OrganizationUserInvite(
|
||||||
|
email: "test2@email.com",
|
||||||
|
externalId: "test-external-id2"),
|
||||||
|
new OrganizationUserInvite(
|
||||||
|
email: "test3@email.com",
|
||||||
|
externalId: "test-external-id3")
|
||||||
|
],
|
||||||
|
InviteOrganization = new InviteOrganization(organization, new Enterprise2023Plan(true)),
|
||||||
|
OccupiedPmSeats = 0,
|
||||||
|
OccupiedSmSeats = 4
|
||||||
|
};
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IPaymentService>()
|
||||||
|
.HasSecretsManagerStandalone(request.InviteOrganization)
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>()
|
||||||
|
.GetByIdAsync(organization.Id)
|
||||||
|
.Returns(organization);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IUpdateSecretsManagerSubscriptionCommand>()
|
||||||
|
.ValidateUpdateAsync(Arg.Any<SecretsManagerSubscriptionUpdate>())
|
||||||
|
.Throws(new BadRequestException("Some Secrets Manager Failure"));
|
||||||
|
|
||||||
|
var result = await sutProvider.Sut.ValidateAsync(request);
|
||||||
|
|
||||||
|
Assert.IsType<Invalid<InviteOrganizationUsersValidationRequest>>(result);
|
||||||
|
Assert.Equal("Some Secrets Manager Failure", (result as Invalid<InviteOrganizationUsersValidationRequest>)!.ErrorMessageString);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,58 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization;
|
||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
using Bit.Core.Billing.Models.StaticStore.Plans;
|
||||||
|
using Bit.Test.Common.AutoFixture;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||||
|
|
||||||
|
[SutProviderCustomize]
|
||||||
|
public class InviteUserOrganizationValidationTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task Validate_WhenOrganizationIsFreeTier_ShouldReturnValidResponse(Organization organization, SutProvider<InviteUsersOrganizationValidator> sutProvider)
|
||||||
|
{
|
||||||
|
var inviteOrganization = new InviteOrganization(organization, new FreePlan());
|
||||||
|
|
||||||
|
var result = await sutProvider.Sut.ValidateAsync(inviteOrganization);
|
||||||
|
|
||||||
|
Assert.IsType<Valid<InviteOrganization>>(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task Validate_WhenOrganizationDoesNotHavePaymentMethod_ShouldReturnInvalidResponseWithPaymentMethodMessage(
|
||||||
|
Organization organization, SutProvider<InviteUsersOrganizationValidator> sutProvider)
|
||||||
|
{
|
||||||
|
organization.GatewayCustomerId = string.Empty;
|
||||||
|
organization.Seats = 3;
|
||||||
|
|
||||||
|
var inviteOrganization = new InviteOrganization(organization, new FreePlan());
|
||||||
|
|
||||||
|
var result = await sutProvider.Sut.ValidateAsync(inviteOrganization);
|
||||||
|
|
||||||
|
Assert.IsType<Invalid<InviteOrganization>>(result);
|
||||||
|
Assert.Equal(OrganizationNoPaymentMethodFoundError.Code, (result as Invalid<InviteOrganization>)!.ErrorMessageString);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task Validate_WhenOrganizationDoesNotHaveSubscription_ShouldReturnInvalidResponseWithSubscriptionMessage(
|
||||||
|
Organization organization, SutProvider<InviteUsersOrganizationValidator> sutProvider)
|
||||||
|
{
|
||||||
|
organization.GatewaySubscriptionId = string.Empty;
|
||||||
|
organization.Seats = 3;
|
||||||
|
organization.MaxAutoscaleSeats = 4;
|
||||||
|
|
||||||
|
var inviteOrganization = new InviteOrganization(organization, new FreePlan());
|
||||||
|
|
||||||
|
var result = await sutProvider.Sut.ValidateAsync(inviteOrganization);
|
||||||
|
|
||||||
|
Assert.IsType<Invalid<InviteOrganization>>(result);
|
||||||
|
Assert.Equal(OrganizationNoSubscriptionFoundError.Code, (result as Invalid<InviteOrganization>)!.ErrorMessageString);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,56 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments;
|
||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
using Bit.Core.Billing.Constants;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Bit.Core.Billing.Models.StaticStore.Plans;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||||
|
|
||||||
|
public class InviteUserPaymentValidationTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public void Validate_WhenPlanIsFree_ReturnsValidResponse(Organization organization)
|
||||||
|
{
|
||||||
|
organization.PlanType = PlanType.Free;
|
||||||
|
|
||||||
|
var result = InviteUserPaymentValidation.Validate(new PaymentsSubscription
|
||||||
|
{
|
||||||
|
SubscriptionStatus = StripeConstants.SubscriptionStatus.Active,
|
||||||
|
ProductTierType = new InviteOrganization(organization, new FreePlan()).Plan.ProductTier
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.IsType<Valid<PaymentsSubscription>>(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_WhenSubscriptionIsCanceled_ReturnsInvalidResponse()
|
||||||
|
{
|
||||||
|
var result = InviteUserPaymentValidation.Validate(new PaymentsSubscription
|
||||||
|
{
|
||||||
|
SubscriptionStatus = StripeConstants.SubscriptionStatus.Canceled,
|
||||||
|
ProductTierType = ProductTierType.Enterprise
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.IsType<Invalid<PaymentsSubscription>>(result);
|
||||||
|
Assert.Equal(PaymentCancelledSubscriptionError.Code, (result as Invalid<PaymentsSubscription>)!.ErrorMessageString);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_WhenSubscriptionIsActive_ReturnsValidResponse()
|
||||||
|
{
|
||||||
|
var result = InviteUserPaymentValidation.Validate(new PaymentsSubscription
|
||||||
|
{
|
||||||
|
SubscriptionStatus = StripeConstants.SubscriptionStatus.Active,
|
||||||
|
ProductTierType = ProductTierType.Enterprise
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.IsType<Valid<PaymentsSubscription>>(result);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,93 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
|
||||||
|
using Bit.Core.AdminConsole.Shared.Validation;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Bit.Core.Billing.Models.StaticStore.Plans;
|
||||||
|
using Bit.Test.Common.AutoFixture;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||||
|
|
||||||
|
|
||||||
|
[SutProviderCustomize]
|
||||||
|
public class InviteUsersPasswordManagerValidatorTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task Validate_OrganizationDoesNotHaveSeatsLimit_ShouldReturnValidResult(Organization organization,
|
||||||
|
SutProvider<InviteUsersPasswordManagerValidator> sutProvider)
|
||||||
|
{
|
||||||
|
organization.Seats = null;
|
||||||
|
|
||||||
|
var organizationDto = new InviteOrganization(organization, new FreePlan());
|
||||||
|
|
||||||
|
var subscriptionUpdate = new PasswordManagerSubscriptionUpdate(organizationDto, 0, 0);
|
||||||
|
|
||||||
|
var result = await sutProvider.Sut.ValidateAsync(subscriptionUpdate);
|
||||||
|
|
||||||
|
Assert.IsType<Valid<PasswordManagerSubscriptionUpdate>>(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task Validate_NumberOfSeatsToAddMatchesSeatsAvailable_ShouldReturnValidResult(Organization organization,
|
||||||
|
SutProvider<InviteUsersPasswordManagerValidator> sutProvider)
|
||||||
|
{
|
||||||
|
organization.Seats = 8;
|
||||||
|
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||||
|
var seatsOccupiedByUsers = 4;
|
||||||
|
var additionalSeats = 4;
|
||||||
|
|
||||||
|
var organizationDto = new InviteOrganization(organization, new Enterprise2023Plan(isAnnual: true));
|
||||||
|
|
||||||
|
var subscriptionUpdate = new PasswordManagerSubscriptionUpdate(organizationDto, seatsOccupiedByUsers, additionalSeats);
|
||||||
|
|
||||||
|
var result = await sutProvider.Sut.ValidateAsync(subscriptionUpdate);
|
||||||
|
|
||||||
|
Assert.IsType<Valid<PasswordManagerSubscriptionUpdate>>(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task Validate_NumberOfSeatsToAddIsGreaterThanMaxSeatsAllowed_ShouldBeInvalidWithSeatLimitMessage(Organization organization,
|
||||||
|
SutProvider<InviteUsersPasswordManagerValidator> sutProvider)
|
||||||
|
{
|
||||||
|
organization.Seats = 4;
|
||||||
|
organization.MaxAutoscaleSeats = 4;
|
||||||
|
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||||
|
var seatsOccupiedByUsers = 4;
|
||||||
|
var additionalSeats = 1;
|
||||||
|
|
||||||
|
var organizationDto = new InviteOrganization(organization, new Enterprise2023Plan(isAnnual: true));
|
||||||
|
|
||||||
|
var subscriptionUpdate = new PasswordManagerSubscriptionUpdate(organizationDto, seatsOccupiedByUsers, additionalSeats);
|
||||||
|
|
||||||
|
var result = await sutProvider.Sut.ValidateAsync(subscriptionUpdate);
|
||||||
|
|
||||||
|
Assert.IsType<Invalid<PasswordManagerSubscriptionUpdate>>(result);
|
||||||
|
Assert.Equal(PasswordManagerSeatLimitHasBeenReachedError.Code, (result as Invalid<PasswordManagerSubscriptionUpdate>)!.ErrorMessageString);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData]
|
||||||
|
public async Task Validate_GivenThePlanDoesNotAllowAdditionalSeats_ShouldBeInvalidMessageOfPlanNotAllowingSeats(Organization organization,
|
||||||
|
SutProvider<InviteUsersPasswordManagerValidator> sutProvider)
|
||||||
|
{
|
||||||
|
organization.Seats = 4;
|
||||||
|
organization.MaxAutoscaleSeats = 9;
|
||||||
|
var seatsOccupiedByUsers = 4;
|
||||||
|
var additionalSeats = 4;
|
||||||
|
organization.PlanType = PlanType.Free;
|
||||||
|
|
||||||
|
var organizationDto = new InviteOrganization(organization, new FreePlan());
|
||||||
|
|
||||||
|
var subscriptionUpdate = new PasswordManagerSubscriptionUpdate(organizationDto, seatsOccupiedByUsers, additionalSeats);
|
||||||
|
|
||||||
|
var result = await sutProvider.Sut.ValidateAsync(subscriptionUpdate);
|
||||||
|
|
||||||
|
Assert.IsType<Invalid<PasswordManagerSubscriptionUpdate>>(result);
|
||||||
|
Assert.Equal(PasswordManagerPlanDoesNotAllowAdditionalSeatsError.Code, (result as Invalid<PasswordManagerSubscriptionUpdate>)!.ErrorMessageString);
|
||||||
|
}
|
||||||
|
}
|
@ -2,10 +2,10 @@
|
|||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.AdminConsole.Enums.Provider;
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Auth.Entities;
|
|
||||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||||
using Bit.Core.Auth.Repositories;
|
|
||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
using Bit.Core.Billing.Pricing;
|
using Bit.Core.Billing.Pricing;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
@ -15,7 +15,6 @@ using Bit.Core.Exceptions;
|
|||||||
using Bit.Core.Models.Business;
|
using Bit.Core.Models.Business;
|
||||||
using Bit.Core.Models.Data;
|
using Bit.Core.Models.Data;
|
||||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||||
using Bit.Core.Models.Mail;
|
|
||||||
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
||||||
using Bit.Core.Platform.Push;
|
using Bit.Core.Platform.Push;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
@ -37,6 +36,7 @@ using NSubstitute.ReturnsExtensions;
|
|||||||
using Xunit;
|
using Xunit;
|
||||||
using Organization = Bit.Core.AdminConsole.Entities.Organization;
|
using Organization = Bit.Core.AdminConsole.Entities.Organization;
|
||||||
using OrganizationUser = Bit.Core.Entities.OrganizationUser;
|
using OrganizationUser = Bit.Core.Entities.OrganizationUser;
|
||||||
|
using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite;
|
||||||
|
|
||||||
namespace Bit.Core.Test.Services;
|
namespace Bit.Core.Test.Services;
|
||||||
|
|
||||||
@ -77,15 +77,6 @@ public class OrganizationServiceTests
|
|||||||
.Returns(true);
|
.Returns(true);
|
||||||
sutProvider.GetDependency<ICurrentContext>().ManageUsers(org.Id).Returns(true);
|
sutProvider.GetDependency<ICurrentContext>().ManageUsers(org.Id).Returns(true);
|
||||||
|
|
||||||
// Mock tokenable factory to return a token that expires in 5 days
|
|
||||||
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
|
|
||||||
.CreateToken(Arg.Any<OrganizationUser>())
|
|
||||||
.Returns(
|
|
||||||
info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
|
|
||||||
{
|
|
||||||
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
await sutProvider.Sut.ImportAsync(org.Id, null, newUsers, null, false, EventSystemUser.PublicApi);
|
await sutProvider.Sut.ImportAsync(org.Id, null, newUsers, null, false, EventSystemUser.PublicApi);
|
||||||
|
|
||||||
@ -100,9 +91,11 @@ public class OrganizationServiceTests
|
|||||||
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1)
|
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1)
|
||||||
.CreateManyAsync(Arg.Is<IEnumerable<OrganizationUser>>(users => users.Count() == expectedNewUsersCount));
|
.CreateManyAsync(Arg.Is<IEnumerable<OrganizationUser>>(users => users.Count() == expectedNewUsersCount));
|
||||||
|
|
||||||
await sutProvider.GetDependency<IMailService>().Received(1)
|
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>().Received(1)
|
||||||
.SendOrganizationInviteEmailsAsync(
|
.SendInvitesAsync(
|
||||||
Arg.Is<OrganizationInvitesInfo>(info => info.OrgUserTokenPairs.Count() == expectedNewUsersCount && info.IsFreeOrg == (org.PlanType == PlanType.Free) && info.OrganizationName == org.Name));
|
Arg.Is<SendInvitesRequest>(
|
||||||
|
info => info.Users.Length == expectedNewUsersCount &&
|
||||||
|
info.Organization == org));
|
||||||
|
|
||||||
// Send events
|
// Send events
|
||||||
await sutProvider.GetDependency<IEventService>().Received(1)
|
await sutProvider.GetDependency<IEventService>().Received(1)
|
||||||
@ -152,16 +145,6 @@ public class OrganizationServiceTests
|
|||||||
var currentContext = sutProvider.GetDependency<ICurrentContext>();
|
var currentContext = sutProvider.GetDependency<ICurrentContext>();
|
||||||
currentContext.ManageUsers(org.Id).Returns(true);
|
currentContext.ManageUsers(org.Id).Returns(true);
|
||||||
|
|
||||||
// Mock tokenable factory to return a token that expires in 5 days
|
|
||||||
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
|
|
||||||
.CreateToken(Arg.Any<OrganizationUser>())
|
|
||||||
.Returns(
|
|
||||||
info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
|
|
||||||
{
|
|
||||||
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
await sutProvider.Sut.ImportAsync(org.Id, null, newUsers, null, false, EventSystemUser.PublicApi);
|
await sutProvider.Sut.ImportAsync(org.Id, null, newUsers, null, false, EventSystemUser.PublicApi);
|
||||||
|
|
||||||
await sutProvider.GetDependency<IOrganizationUserRepository>().DidNotReceiveWithAnyArgs()
|
await sutProvider.GetDependency<IOrganizationUserRepository>().DidNotReceiveWithAnyArgs()
|
||||||
@ -179,14 +162,15 @@ public class OrganizationServiceTests
|
|||||||
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1)
|
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1)
|
||||||
.CreateManyAsync(Arg.Is<IEnumerable<OrganizationUser>>(users => users.Count() == expectedNewUsersCount));
|
.CreateManyAsync(Arg.Is<IEnumerable<OrganizationUser>>(users => users.Count() == expectedNewUsersCount));
|
||||||
|
|
||||||
await sutProvider.GetDependency<IMailService>().Received(1)
|
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>().Received(1)
|
||||||
.SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
|
.SendInvitesAsync(Arg.Is<SendInvitesRequest>(request =>
|
||||||
info.OrgUserTokenPairs.Count() == expectedNewUsersCount && info.IsFreeOrg == (org.PlanType == PlanType.Free) && info.OrganizationName == org.Name));
|
request.Users.Length == expectedNewUsersCount &&
|
||||||
|
request.Organization == org));
|
||||||
|
|
||||||
// Sent events
|
// Sent events
|
||||||
await sutProvider.GetDependency<IEventService>().Received(1)
|
await sutProvider.GetDependency<IEventService>().Received(1)
|
||||||
.LogOrganizationUserEventsAsync(Arg.Is<IEnumerable<(OrganizationUser, EventType, EventSystemUser, DateTime?)>>(events =>
|
.LogOrganizationUserEventsAsync(Arg.Is<IEnumerable<(OrganizationUser, EventType, EventSystemUser, DateTime?)>>(events =>
|
||||||
events.Where(e => e.Item2 == EventType.OrganizationUser_Invited).Count() == expectedNewUsersCount));
|
events.Count(e => e.Item2 == EventType.OrganizationUser_Invited) == expectedNewUsersCount));
|
||||||
await sutProvider.GetDependency<IReferenceEventService>().Received(1)
|
await sutProvider.GetDependency<IReferenceEventService>().Received(1)
|
||||||
.RaiseEventAsync(Arg.Is<ReferenceEvent>(referenceEvent =>
|
.RaiseEventAsync(Arg.Is<ReferenceEvent>(referenceEvent =>
|
||||||
referenceEvent.Type == ReferenceEventType.InvitedUsers && referenceEvent.Id == org.Id &&
|
referenceEvent.Type == ReferenceEventType.InvitedUsers && referenceEvent.Id == org.Id &&
|
||||||
@ -270,125 +254,15 @@ public class OrganizationServiceTests
|
|||||||
// Must set guids in order for dictionary of guids to not throw aggregate exceptions
|
// Must set guids in order for dictionary of guids to not throw aggregate exceptions
|
||||||
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
|
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
|
||||||
|
|
||||||
// Mock tokenable factory to return a token that expires in 5 days
|
|
||||||
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
|
|
||||||
.CreateToken(Arg.Any<OrganizationUser>())
|
|
||||||
.Returns(
|
|
||||||
info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
|
|
||||||
{
|
|
||||||
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) });
|
await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) });
|
||||||
|
|
||||||
await sutProvider.GetDependency<IMailService>().Received(1)
|
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>().Received(1)
|
||||||
.SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
|
.SendInvitesAsync(Arg.Is<SendInvitesRequest>(request =>
|
||||||
info.OrgUserTokenPairs.Count() == invite.Emails.Distinct().Count() &&
|
request.Users.DistinctBy(x => x.Email).Count() == invite.Emails.Distinct().Count() &&
|
||||||
info.IsFreeOrg == (organization.PlanType == PlanType.Free) &&
|
request.Organization == organization));
|
||||||
info.OrganizationName == organization.Name));
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[OrganizationInviteCustomize, OrganizationCustomize, BitAutoData]
|
|
||||||
public async Task InviteUsers_SsoOrgWithNullSsoConfig_Passes(Organization organization, OrganizationUser invitor,
|
|
||||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
|
|
||||||
OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
|
|
||||||
{
|
|
||||||
// Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks
|
|
||||||
sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory");
|
|
||||||
sutProvider.Create();
|
|
||||||
|
|
||||||
// Org must be able to use SSO to trigger this proper test case as we currently only call to retrieve
|
|
||||||
// an org's SSO config if the org can use SSO
|
|
||||||
organization.UseSso = true;
|
|
||||||
|
|
||||||
// Return null for sso config
|
|
||||||
sutProvider.GetDependency<ISsoConfigRepository>().GetByOrganizationIdAsync(organization.Id).ReturnsNull();
|
|
||||||
|
|
||||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
|
||||||
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organization.Id).Returns(true);
|
|
||||||
sutProvider.GetDependency<ICurrentContext>().ManageUsers(organization.Id).Returns(true);
|
|
||||||
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
|
|
||||||
organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner)
|
|
||||||
.Returns(new[] { owner });
|
|
||||||
|
|
||||||
// Must set guids in order for dictionary of guids to not throw aggregate exceptions
|
|
||||||
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
|
|
||||||
|
|
||||||
// Mock tokenable factory to return a token that expires in 5 days
|
|
||||||
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
|
|
||||||
.CreateToken(Arg.Any<OrganizationUser>())
|
|
||||||
.Returns(
|
|
||||||
info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
|
|
||||||
{
|
|
||||||
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) });
|
|
||||||
|
|
||||||
await sutProvider.GetDependency<IMailService>().Received(1)
|
|
||||||
.SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
|
|
||||||
info.OrgUserTokenPairs.Count() == invite.Emails.Distinct().Count() &&
|
|
||||||
info.IsFreeOrg == (organization.PlanType == PlanType.Free) &&
|
|
||||||
info.OrganizationName == organization.Name));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[OrganizationInviteCustomize, OrganizationCustomize, BitAutoData]
|
|
||||||
public async Task InviteUsers_SsoOrgWithNeverEnabledRequireSsoPolicy_Passes(Organization organization, SsoConfig ssoConfig, OrganizationUser invitor,
|
|
||||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
|
|
||||||
OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
|
|
||||||
{
|
|
||||||
// Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks
|
|
||||||
sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory");
|
|
||||||
sutProvider.Create();
|
|
||||||
|
|
||||||
// Org must be able to use SSO and policies to trigger this test case
|
|
||||||
organization.UseSso = true;
|
|
||||||
organization.UsePolicies = true;
|
|
||||||
|
|
||||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
|
||||||
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organization.Id).Returns(true);
|
|
||||||
sutProvider.GetDependency<ICurrentContext>().ManageUsers(organization.Id).Returns(true);
|
|
||||||
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
|
|
||||||
organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner)
|
|
||||||
.Returns(new[] { owner });
|
|
||||||
|
|
||||||
ssoConfig.Enabled = true;
|
|
||||||
sutProvider.GetDependency<ISsoConfigRepository>().GetByOrganizationIdAsync(organization.Id).Returns(ssoConfig);
|
|
||||||
|
|
||||||
|
|
||||||
// Return null policy to mimic new org that's never turned on the require sso policy
|
|
||||||
sutProvider.GetDependency<IPolicyRepository>().GetManyByOrganizationIdAsync(organization.Id).ReturnsNull();
|
|
||||||
|
|
||||||
// Must set guids in order for dictionary of guids to not throw aggregate exceptions
|
|
||||||
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
|
|
||||||
|
|
||||||
// Mock tokenable factory to return a token that expires in 5 days
|
|
||||||
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
|
|
||||||
.CreateToken(Arg.Any<OrganizationUser>())
|
|
||||||
.Returns(
|
|
||||||
info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
|
|
||||||
{
|
|
||||||
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) });
|
|
||||||
|
|
||||||
await sutProvider.GetDependency<IMailService>().Received(1)
|
|
||||||
.SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
|
|
||||||
info.OrgUserTokenPairs.Count() == invite.Emails.Distinct().Count() &&
|
|
||||||
info.IsFreeOrg == (organization.PlanType == PlanType.Free) &&
|
|
||||||
info.OrganizationName == organization.Name));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[OrganizationInviteCustomize(
|
[OrganizationInviteCustomize(
|
||||||
InviteeUserType = OrganizationUserType.Admin,
|
InviteeUserType = OrganizationUserType.Admin,
|
||||||
@ -637,14 +511,14 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
|
|||||||
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
|
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
|
||||||
|
|
||||||
// Mock tokenable factory to return a token that expires in 5 days
|
// Mock tokenable factory to return a token that expires in 5 days
|
||||||
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
|
// sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
|
||||||
.CreateToken(Arg.Any<OrganizationUser>())
|
// .CreateToken(Arg.Any<OrganizationUser>())
|
||||||
.Returns(
|
// .Returns(
|
||||||
info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
|
// info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
|
||||||
{
|
// {
|
||||||
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
// ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
||||||
}
|
// }
|
||||||
);
|
// );
|
||||||
|
|
||||||
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
|
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
|
||||||
.HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any<IEnumerable<Guid>>())
|
.HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any<IEnumerable<Guid>>())
|
||||||
@ -655,11 +529,10 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
|
|||||||
|
|
||||||
await sutProvider.Sut.InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId);
|
await sutProvider.Sut.InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId);
|
||||||
|
|
||||||
await sutProvider.GetDependency<IMailService>().Received(1)
|
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>().Received(1)
|
||||||
.SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
|
.SendInvitesAsync(Arg.Is<SendInvitesRequest>(request =>
|
||||||
info.OrgUserTokenPairs.Count() == 1 &&
|
request.Users.Length == 1 &&
|
||||||
info.IsFreeOrg == (organization.PlanType == PlanType.Free) &&
|
request.Organization == organization));
|
||||||
info.OrganizationName == organization.Name));
|
|
||||||
|
|
||||||
await sutProvider.GetDependency<IEventService>().Received(1).LogOrganizationUserEventsAsync(Arg.Any<IEnumerable<(OrganizationUser, EventType, DateTime?)>>());
|
await sutProvider.GetDependency<IEventService>().Received(1).LogOrganizationUserEventsAsync(Arg.Any<IEnumerable<(OrganizationUser, EventType, DateTime?)>>());
|
||||||
}
|
}
|
||||||
@ -712,16 +585,6 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
|
|||||||
|
|
||||||
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
|
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
|
||||||
|
|
||||||
// Mock tokenable factory to return a token that expires in 5 days
|
|
||||||
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
|
|
||||||
.CreateToken(Arg.Any<OrganizationUser>())
|
|
||||||
.Returns(
|
|
||||||
info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
|
|
||||||
{
|
|
||||||
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
|
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
|
||||||
.HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any<IEnumerable<Guid>>())
|
.HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any<IEnumerable<Guid>>())
|
||||||
.Returns(true);
|
.Returns(true);
|
||||||
@ -733,12 +596,11 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
|
|||||||
.InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId));
|
.InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId));
|
||||||
Assert.Contains("This user has already been invited", exception.Message);
|
Assert.Contains("This user has already been invited", exception.Message);
|
||||||
|
|
||||||
// MailService and EventService are still called, but with no OrgUsers
|
// SendOrganizationInvitesCommand and EventService are still called, but with no OrgUsers
|
||||||
await sutProvider.GetDependency<IMailService>().Received(1)
|
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>().Received(1)
|
||||||
.SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
|
.SendInvitesAsync(Arg.Is<SendInvitesRequest>(info =>
|
||||||
!info.OrgUserTokenPairs.Any() &&
|
info.Organization == organization &&
|
||||||
info.IsFreeOrg == (organization.PlanType == PlanType.Free) &&
|
info.Users.Length == 0));
|
||||||
info.OrganizationName == organization.Name));
|
|
||||||
await sutProvider.GetDependency<IEventService>().Received(1)
|
await sutProvider.GetDependency<IEventService>().Received(1)
|
||||||
.LogOrganizationUserEventsAsync(Arg.Is<IEnumerable<(OrganizationUser, EventType, DateTime?)>>(events => !events.Any()));
|
.LogOrganizationUserEventsAsync(Arg.Is<IEnumerable<(OrganizationUser, EventType, DateTime?)>>(events => !events.Any()));
|
||||||
}
|
}
|
||||||
@ -787,16 +649,6 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
|
|||||||
|
|
||||||
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
|
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
|
||||||
|
|
||||||
// Mock tokenable factory to return a token that expires in 5 days
|
|
||||||
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
|
|
||||||
.CreateToken(Arg.Any<OrganizationUser>())
|
|
||||||
.Returns(
|
|
||||||
info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
|
|
||||||
{
|
|
||||||
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
|
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
|
||||||
.HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any<IEnumerable<Guid>>())
|
.HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any<IEnumerable<Guid>>())
|
||||||
.Returns(true);
|
.Returns(true);
|
||||||
@ -806,11 +658,10 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
|
|||||||
|
|
||||||
await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, invites);
|
await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, invites);
|
||||||
|
|
||||||
await sutProvider.GetDependency<IMailService>().Received(1)
|
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>().Received(1)
|
||||||
.SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
|
.SendInvitesAsync(Arg.Is<SendInvitesRequest>(info =>
|
||||||
info.OrgUserTokenPairs.Count() == invites.SelectMany(i => i.invite.Emails).Count() &&
|
info.Organization == organization &&
|
||||||
info.IsFreeOrg == (organization.PlanType == PlanType.Free) &&
|
info.Users.Length == invites.SelectMany(x => x.invite.Emails).Distinct().Count()));
|
||||||
info.OrganizationName == organization.Name));
|
|
||||||
|
|
||||||
await sutProvider.GetDependency<IEventService>().Received(1).LogOrganizationUserEventsAsync(Arg.Any<IEnumerable<(OrganizationUser, EventType, DateTime?)>>());
|
await sutProvider.GetDependency<IEventService>().Received(1).LogOrganizationUserEventsAsync(Arg.Any<IEnumerable<(OrganizationUser, EventType, DateTime?)>>());
|
||||||
}
|
}
|
||||||
@ -848,23 +699,12 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
|
|||||||
|
|
||||||
currentContext.ManageUsers(organization.Id).Returns(true);
|
currentContext.ManageUsers(organization.Id).Returns(true);
|
||||||
|
|
||||||
// Mock tokenable factory to return a token that expires in 5 days
|
|
||||||
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()
|
|
||||||
.CreateToken(Arg.Any<OrganizationUser>())
|
|
||||||
.Returns(
|
|
||||||
info => new OrgUserInviteTokenable(info.Arg<OrganizationUser>())
|
|
||||||
{
|
|
||||||
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
await sutProvider.Sut.InviteUsersAsync(organization.Id, invitingUserId: null, eventSystemUser, invites);
|
await sutProvider.Sut.InviteUsersAsync(organization.Id, invitingUserId: null, eventSystemUser, invites);
|
||||||
|
|
||||||
await sutProvider.GetDependency<IMailService>().Received(1)
|
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>().Received(1)
|
||||||
.SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
|
.SendInvitesAsync(Arg.Is<SendInvitesRequest>(info =>
|
||||||
info.OrgUserTokenPairs.Count() == invites.SelectMany(i => i.invite.Emails).Count() &&
|
info.Users.Length == invites.SelectMany(i => i.invite.Emails).Count() &&
|
||||||
info.IsFreeOrg == (organization.PlanType == PlanType.Free) &&
|
info.Organization == organization));
|
||||||
info.OrganizationName == organization.Name));
|
|
||||||
|
|
||||||
await sutProvider.GetDependency<IEventService>().Received(1).LogOrganizationUserEventsAsync(Arg.Any<IEnumerable<(OrganizationUser, EventType, EventSystemUser, DateTime?)>>());
|
await sutProvider.GetDependency<IEventService>().Received(1).LogOrganizationUserEventsAsync(Arg.Any<IEnumerable<(OrganizationUser, EventType, EventSystemUser, DateTime?)>>());
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Models.Data;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@ -424,4 +427,173 @@ public class OrganizationUserRepositoryTests
|
|||||||
|
|
||||||
Assert.Equal(createdOrgUserIds.ToHashSet(), readOrgUserIds.ToHashSet());
|
Assert.Equal(createdOrgUserIds.ToHashSet(), readOrgUserIds.ToHashSet());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[DatabaseTheory, DatabaseData]
|
||||||
|
public async Task CreateManyAsync_WithCollectionAndGroup_SaveSuccessfully(
|
||||||
|
IOrganizationUserRepository organizationUserRepository,
|
||||||
|
IOrganizationRepository organizationRepository,
|
||||||
|
ICollectionRepository collectionRepository,
|
||||||
|
IGroupRepository groupRepository)
|
||||||
|
{
|
||||||
|
var requestTime = DateTime.UtcNow;
|
||||||
|
|
||||||
|
var organization = await organizationRepository.CreateAsync(new Organization
|
||||||
|
{
|
||||||
|
Name = "Test Org",
|
||||||
|
BillingEmail = "billing@test.com", // TODO: EF does not enfore this being NOT NULL
|
||||||
|
Plan = "Test", // TODO: EF does not enforce this being NOT NULl,
|
||||||
|
CreationDate = requestTime
|
||||||
|
});
|
||||||
|
|
||||||
|
var collection1 = await collectionRepository.CreateAsync(new Collection
|
||||||
|
{
|
||||||
|
Id = CoreHelpers.GenerateComb(),
|
||||||
|
OrganizationId = organization.Id,
|
||||||
|
Name = "Test Collection",
|
||||||
|
ExternalId = "external-collection-1",
|
||||||
|
CreationDate = requestTime,
|
||||||
|
RevisionDate = requestTime
|
||||||
|
});
|
||||||
|
var collection2 = await collectionRepository.CreateAsync(new Collection
|
||||||
|
{
|
||||||
|
Id = CoreHelpers.GenerateComb(),
|
||||||
|
OrganizationId = organization.Id,
|
||||||
|
Name = "Test Collection",
|
||||||
|
ExternalId = "external-collection-1",
|
||||||
|
CreationDate = requestTime,
|
||||||
|
RevisionDate = requestTime
|
||||||
|
});
|
||||||
|
var collection3 = await collectionRepository.CreateAsync(new Collection
|
||||||
|
{
|
||||||
|
Id = CoreHelpers.GenerateComb(),
|
||||||
|
OrganizationId = organization.Id,
|
||||||
|
Name = "Test Collection",
|
||||||
|
ExternalId = "external-collection-1",
|
||||||
|
CreationDate = requestTime,
|
||||||
|
RevisionDate = requestTime
|
||||||
|
});
|
||||||
|
|
||||||
|
var group1 = await groupRepository.CreateAsync(new Group
|
||||||
|
{
|
||||||
|
Id = CoreHelpers.GenerateComb(),
|
||||||
|
OrganizationId = organization.Id,
|
||||||
|
Name = "Test Group",
|
||||||
|
ExternalId = "external-group-1"
|
||||||
|
});
|
||||||
|
var group2 = await groupRepository.CreateAsync(new Group
|
||||||
|
{
|
||||||
|
Id = CoreHelpers.GenerateComb(),
|
||||||
|
OrganizationId = organization.Id,
|
||||||
|
Name = "Test Group",
|
||||||
|
ExternalId = "external-group-1"
|
||||||
|
});
|
||||||
|
var group3 = await groupRepository.CreateAsync(new Group
|
||||||
|
{
|
||||||
|
Id = CoreHelpers.GenerateComb(),
|
||||||
|
OrganizationId = organization.Id,
|
||||||
|
Name = "Test Group",
|
||||||
|
ExternalId = "external-group-1"
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
var orgUserCollection = new List<CreateOrganizationUser>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
OrganizationUser = new OrganizationUser
|
||||||
|
{
|
||||||
|
Id = CoreHelpers.GenerateComb(),
|
||||||
|
OrganizationId = organization.Id,
|
||||||
|
Email = "test-user@test.com",
|
||||||
|
Status = OrganizationUserStatusType.Invited,
|
||||||
|
Type = OrganizationUserType.Owner,
|
||||||
|
ExternalId = "externalid-1",
|
||||||
|
Permissions = CoreHelpers.ClassToJsonData(new Permissions()),
|
||||||
|
AccessSecretsManager = false
|
||||||
|
},
|
||||||
|
Collections =
|
||||||
|
[
|
||||||
|
new CollectionAccessSelection
|
||||||
|
{
|
||||||
|
Id = collection1.Id,
|
||||||
|
ReadOnly = true,
|
||||||
|
HidePasswords = false,
|
||||||
|
Manage = false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
Groups = [group1.Id]
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
OrganizationUser = new OrganizationUser
|
||||||
|
{
|
||||||
|
Id = CoreHelpers.GenerateComb(),
|
||||||
|
OrganizationId = organization.Id,
|
||||||
|
Email = "test-user@test.com",
|
||||||
|
Status = OrganizationUserStatusType.Invited,
|
||||||
|
Type = OrganizationUserType.Owner,
|
||||||
|
ExternalId = "externalid-1",
|
||||||
|
Permissions = CoreHelpers.ClassToJsonData(new Permissions()),
|
||||||
|
AccessSecretsManager = false
|
||||||
|
},
|
||||||
|
Collections =
|
||||||
|
[
|
||||||
|
new CollectionAccessSelection
|
||||||
|
{
|
||||||
|
Id = collection2.Id,
|
||||||
|
ReadOnly = true,
|
||||||
|
HidePasswords = false,
|
||||||
|
Manage = false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
Groups = [group2.Id]
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
OrganizationUser = new OrganizationUser
|
||||||
|
{
|
||||||
|
Id = CoreHelpers.GenerateComb(),
|
||||||
|
OrganizationId = organization.Id,
|
||||||
|
Email = "test-user@test.com",
|
||||||
|
Status = OrganizationUserStatusType.Invited,
|
||||||
|
Type = OrganizationUserType.Owner,
|
||||||
|
ExternalId = "externalid-1",
|
||||||
|
Permissions = CoreHelpers.ClassToJsonData(new Permissions()),
|
||||||
|
AccessSecretsManager = false
|
||||||
|
},
|
||||||
|
Collections =
|
||||||
|
[
|
||||||
|
new CollectionAccessSelection
|
||||||
|
{
|
||||||
|
Id = collection3.Id,
|
||||||
|
ReadOnly = true,
|
||||||
|
HidePasswords = false,
|
||||||
|
Manage = false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
Groups = [group3.Id]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await organizationUserRepository.CreateManyAsync(orgUserCollection);
|
||||||
|
|
||||||
|
var orgUser1 = await organizationUserRepository.GetDetailsByIdWithCollectionsAsync(orgUserCollection[0].OrganizationUser.Id);
|
||||||
|
var group1Database = await groupRepository.GetManyIdsByUserIdAsync(orgUserCollection[0].OrganizationUser.Id);
|
||||||
|
Assert.Equal(orgUserCollection[0].OrganizationUser.Id, orgUser1.OrganizationUser.Id);
|
||||||
|
Assert.Equal(collection1.Id, orgUser1.Collections.First().Id);
|
||||||
|
Assert.Equal(group1.Id, group1Database.First());
|
||||||
|
|
||||||
|
|
||||||
|
var orgUser2 = await organizationUserRepository.GetDetailsByIdWithCollectionsAsync(orgUserCollection[1].OrganizationUser.Id);
|
||||||
|
var group2Database = await groupRepository.GetManyIdsByUserIdAsync(orgUserCollection[1].OrganizationUser.Id);
|
||||||
|
Assert.Equal(orgUserCollection[1].OrganizationUser.Id, orgUser2.OrganizationUser.Id);
|
||||||
|
Assert.Equal(collection2.Id, orgUser2.Collections.First().Id);
|
||||||
|
Assert.Equal(group2.Id, group2Database.First());
|
||||||
|
|
||||||
|
var orgUser3 = await organizationUserRepository.GetDetailsByIdWithCollectionsAsync(orgUserCollection[2].OrganizationUser.Id);
|
||||||
|
var group3Database = await groupRepository.GetManyIdsByUserIdAsync(orgUserCollection[2].OrganizationUser.Id);
|
||||||
|
Assert.Equal(orgUserCollection[2].OrganizationUser.Id, orgUser3.OrganizationUser.Id);
|
||||||
|
Assert.Equal(collection3.Id, orgUser3.Collections.First().Id);
|
||||||
|
Assert.Equal(group3.Id, group3Database.First());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,97 @@
|
|||||||
|
CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_CreateManyWithCollectionsAndGroups]
|
||||||
|
@organizationUserData NVARCHAR(MAX),
|
||||||
|
@collectionData NVARCHAR(MAX),
|
||||||
|
@groupData NVARCHAR(MAX)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON
|
||||||
|
|
||||||
|
INSERT INTO [dbo].[OrganizationUser]
|
||||||
|
(
|
||||||
|
[Id],
|
||||||
|
[OrganizationId],
|
||||||
|
[UserId],
|
||||||
|
[Email],
|
||||||
|
[Key],
|
||||||
|
[Status],
|
||||||
|
[Type],
|
||||||
|
[ExternalId],
|
||||||
|
[CreationDate],
|
||||||
|
[RevisionDate],
|
||||||
|
[Permissions],
|
||||||
|
[ResetPasswordKey],
|
||||||
|
[AccessSecretsManager]
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
OUI.[Id],
|
||||||
|
OUI.[OrganizationId],
|
||||||
|
OUI.[UserId],
|
||||||
|
OUI.[Email],
|
||||||
|
OUI.[Key],
|
||||||
|
OUI.[Status],
|
||||||
|
OUI.[Type],
|
||||||
|
OUI.[ExternalId],
|
||||||
|
OUI.[CreationDate],
|
||||||
|
OUI.[RevisionDate],
|
||||||
|
OUI.[Permissions],
|
||||||
|
OUI.[ResetPasswordKey],
|
||||||
|
OUI.[AccessSecretsManager]
|
||||||
|
FROM
|
||||||
|
OPENJSON(@organizationUserData)
|
||||||
|
WITH (
|
||||||
|
[Id] UNIQUEIDENTIFIER '$.Id',
|
||||||
|
[OrganizationId] UNIQUEIDENTIFIER '$.OrganizationId',
|
||||||
|
[UserId] UNIQUEIDENTIFIER '$.UserId',
|
||||||
|
[Email] NVARCHAR(256) '$.Email',
|
||||||
|
[Key] VARCHAR(MAX) '$.Key',
|
||||||
|
[Status] SMALLINT '$.Status',
|
||||||
|
[Type] TINYINT '$.Type',
|
||||||
|
[ExternalId] NVARCHAR(300) '$.ExternalId',
|
||||||
|
[CreationDate] DATETIME2(7) '$.CreationDate',
|
||||||
|
[RevisionDate] DATETIME2(7) '$.RevisionDate',
|
||||||
|
[Permissions] NVARCHAR (MAX) '$.Permissions',
|
||||||
|
[ResetPasswordKey] VARCHAR (MAX) '$.ResetPasswordKey',
|
||||||
|
[AccessSecretsManager] BIT '$.AccessSecretsManager'
|
||||||
|
) OUI
|
||||||
|
|
||||||
|
INSERT INTO [dbo].[GroupUser]
|
||||||
|
(
|
||||||
|
[OrganizationUserId],
|
||||||
|
[GroupId]
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
OUG.OrganizationUserId,
|
||||||
|
OUG.GroupId
|
||||||
|
FROM
|
||||||
|
OPENJSON(@groupData)
|
||||||
|
WITH(
|
||||||
|
[OrganizationUserId] UNIQUEIDENTIFIER '$.OrganizationUserId',
|
||||||
|
[GroupId] UNIQUEIDENTIFIER '$.GroupId'
|
||||||
|
) OUG
|
||||||
|
|
||||||
|
INSERT INTO [dbo].[CollectionUser]
|
||||||
|
(
|
||||||
|
[CollectionId],
|
||||||
|
[OrganizationUserId],
|
||||||
|
[ReadOnly],
|
||||||
|
[HidePasswords],
|
||||||
|
[Manage]
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
OUC.[CollectionId],
|
||||||
|
OUC.[OrganizationUserId],
|
||||||
|
OUC.[ReadOnly],
|
||||||
|
OUC.[HidePasswords],
|
||||||
|
OUC.[Manage]
|
||||||
|
FROM
|
||||||
|
OPENJSON(@collectionData)
|
||||||
|
WITH(
|
||||||
|
[CollectionId] UNIQUEIDENTIFIER '$.CollectionId',
|
||||||
|
[OrganizationUserId] UNIQUEIDENTIFIER '$.OrganizationUserId',
|
||||||
|
[ReadOnly] BIT '$.ReadOnly',
|
||||||
|
[HidePasswords] BIT '$.HidePasswords',
|
||||||
|
[Manage] BIT '$.Manage'
|
||||||
|
) OUC
|
||||||
|
END
|
||||||
|
go
|
||||||
|
|
Loading…
x
Reference in New Issue
Block a user