1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-25 12:45:18 +01:00

Auth/PM-1658 - Dynamic Org Invite Link to accelerate users through org invite accept process (#3378)

* PM-1658 - Create User_ReadByEmails stored proc

* PM-1658 - Update UserRepository.cs with dapper and EF implementations of GetManyByEmailsAsync using new stored proc

* PM-1658 - OrganizationService.cs - Proved out that the new GetManyByEmailsAsync along with a hash set will allow me to generate a a dict mapping org user ids to a bool representing if they have an org user account or not.

* PM-1658 - OrganizationService.cs - re-implement all send invites logic as part of rebase

* PM-1658 - Add new User_ReadByEmails stored proc to SQL project

* PM-1658 - HandlebarsMailService.cs - (1) Remove unnecessary SendOrganizationInviteEmailAsync method as we can simply use the bulk method for one or more emails (2) Refactor BulkSendOrganizationInviteEmailAsync parameters into new OrganizationInvitesInfo class

* PM-1658 - OrganizationService.cs - rebase commit 2

* PM-1658 - rebase commit 3 - org service + IMailService conflicts resolved

* PM-1658 - Update HandlebarsMailService.cs and OrganizationUserInvitedViewModel.cs to include new query params required client side for accelerating the user through the org invite accept process.

* dotnet format

* PM-1658 - rebase commit 4 -  Fix broken OrganizationServiceTests.cs

* PM-1658 TODO cleanup

* PM-1658 - Remove noop for deleted method.

* rebase commit 5 - fix NoopMailService merge conflicts

* PM-1658 - Fix SQL formatting with proper indentations

* PM-1658 - Rename BulkSendOrganizationInviteEmailAsync to SendOrganizationInviteEmailsAsync per PR feedback

* PM-1658 - Per PR Feedback, refactor OrganizationUserInvitedViewModel creation to use new static factory function for better encapsulation of the creation process.

* PM-1658 - Rename OrganizationInvitesInfo.Invites to OrgUserTokenPairs b/c that just makes sense.

* PM-1658 - Per PR feedback, simplify query params sent down to client. Always include whether the user exists but only include the org sso identifier if it is meant to be used (b/c sso is enabled and sso required policy is on)

* dotnet format

* PM-1658 - OrganizationServiceTests.cs - Fix mysteriously failing tests - several tests were falling into logic which created n org users using the organizationUserRepository.CreateAsync instead of the organizationUserRepository.CreateManyAsync method.  This meant that I had to add a new mock helper to ensure that those created org users had valid and distinct guids to avoid aggregate exceptions due to my added dict in the latter parts of the invite process.

* PM-1658 - Resolve errors from mistakes made during rebase merge conflict resolutions

* PM-1658 - OrganizationServiceTests.cs - fix new test with mock to make guids unique.

* dotnet format

---------

Co-authored-by: Matt Bishop <mbishop@bitwarden.com>
This commit is contained in:
Jared Snider 2023-12-18 11:16:17 -05:00 committed by GitHub
parent d206c03ad1
commit d2808b2615
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 349 additions and 75 deletions

View File

@ -18,6 +18,7 @@ using Bit.Core.Enums;
using Bit.Core.Exceptions; 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.Mail;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Settings; using Bit.Core.Settings;
@ -1078,6 +1079,54 @@ public class OrganizationService : IOrganizationService
private async Task SendInvitesAsync(IEnumerable<OrganizationUser> orgUsers, Organization organization) private async Task SendInvitesAsync(IEnumerable<OrganizationUser> orgUsers, Organization organization)
{ {
var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(orgUsers, organization);
await _mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo);
}
private async Task SendInviteAsync(OrganizationUser orgUser, Organization organization, bool initOrganization)
{
// convert single org user into array of 1 org user
var orgUsers = new[] { orgUser };
var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(orgUsers, organization, initOrganization);
await _mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo);
}
private async Task<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;
// 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;
// Generate the list of org users and expiring tokens
// create helper function to create expiring tokens
(OrganizationUser, ExpiringToken) MakeOrgUserExpiringTokenPair(OrganizationUser orgUser) (OrganizationUser, ExpiringToken) MakeOrgUserExpiringTokenPair(OrganizationUser orgUser)
{ {
var orgUserInviteTokenable = _orgUserInviteTokenableFactory.CreateToken(orgUser); var orgUserInviteTokenable = _orgUserInviteTokenableFactory.CreateToken(orgUser);
@ -1087,22 +1136,12 @@ public class OrganizationService : IOrganizationService
var orgUsersWithExpTokens = orgUsers.Select(MakeOrgUserExpiringTokenPair); var orgUsersWithExpTokens = orgUsers.Select(MakeOrgUserExpiringTokenPair);
await _mailService.BulkSendOrganizationInviteEmailAsync( return new OrganizationInvitesInfo(
organization.Name, organization,
orgSsoEnabled,
orgSsoLoginRequiredPolicyEnabled,
orgUsersWithExpTokens, orgUsersWithExpTokens,
organization.PlanType == PlanType.Free orgUserHasExistingUserDict,
);
}
private async Task SendInviteAsync(OrganizationUser orgUser, Organization organization, bool initOrganization)
{
var orgUserInviteTokenable = _orgUserInviteTokenableFactory.CreateToken(orgUser);
var protectedToken = _orgUserInviteTokenDataFactory.Protect(orgUserInviteTokenable);
await _mailService.SendOrganizationInviteEmailAsync(
organization.Name,
orgUser,
new ExpiringToken(protectedToken, orgUserInviteTokenable.ExpirationDate),
organization.PlanType == PlanType.Free,
initOrganization initOrganization
); );
} }

View File

@ -0,0 +1,41 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Models.Business;
using Bit.Core.Entities;
using Bit.Core.Enums;
namespace Bit.Core.Models.Mail;
public class OrganizationInvitesInfo
{
public OrganizationInvitesInfo(
Organization org,
bool orgSsoEnabled,
bool orgSsoLoginRequiredPolicyEnabled,
IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> orgUserTokenPairs,
Dictionary<Guid, bool> orgUserHasExistingUserDict,
bool initOrganization = false
)
{
OrganizationName = org.Name;
OrgSsoIdentifier = org.Identifier;
IsFreeOrg = org.PlanType == PlanType.Free;
InitOrganization = initOrganization;
OrgSsoEnabled = orgSsoEnabled;
OrgSsoLoginRequiredPolicyEnabled = orgSsoLoginRequiredPolicyEnabled;
OrgUserTokenPairs = orgUserTokenPairs;
OrgUserHasExistingUserDict = orgUserHasExistingUserDict;
}
public string OrganizationName { get; }
public bool IsFreeOrg { get; }
public bool InitOrganization { get; } = false;
public bool OrgSsoEnabled { get; }
public string OrgSsoIdentifier { get; }
public bool OrgSsoLoginRequiredPolicyEnabled { get; }
public IEnumerable<(OrganizationUser OrgUser, ExpiringToken Token)> OrgUserTokenPairs { get; }
public Dictionary<Guid, bool> OrgUserHasExistingUserDict { get; }
}

View File

@ -1,7 +1,46 @@
namespace Bit.Core.Models.Mail; using System.Net;
using Bit.Core.Auth.Models.Business;
using Bit.Core.Entities;
using Bit.Core.Settings;
using Bit.Core.Utilities;
namespace Bit.Core.Models.Mail;
public class OrganizationUserInvitedViewModel : BaseTitleContactUsMailModel public class OrganizationUserInvitedViewModel : BaseTitleContactUsMailModel
{ {
// Private constructor to enforce usage of the factory method.
private OrganizationUserInvitedViewModel() { }
public static OrganizationUserInvitedViewModel CreateFromInviteInfo(
OrganizationInvitesInfo orgInvitesInfo,
OrganizationUser orgUser,
ExpiringToken expiringToken,
GlobalSettings globalSettings)
{
var freeOrgTitle = "A Bitwarden member invited you to an organization. Join now to start securing your passwords!";
return new OrganizationUserInvitedViewModel
{
TitleFirst = orgInvitesInfo.IsFreeOrg ? freeOrgTitle : "Join ",
TitleSecondBold = orgInvitesInfo.IsFreeOrg ? string.Empty : CoreHelpers.SanitizeForEmail(orgInvitesInfo.OrganizationName, false),
TitleThird = orgInvitesInfo.IsFreeOrg ? string.Empty : " on Bitwarden and start securing your passwords!",
OrganizationName = CoreHelpers.SanitizeForEmail(orgInvitesInfo.OrganizationName, false) + orgUser.Status,
Email = WebUtility.UrlEncode(orgUser.Email),
OrganizationId = orgUser.OrganizationId.ToString(),
OrganizationUserId = orgUser.Id.ToString(),
Token = WebUtility.UrlEncode(expiringToken.Token),
ExpirationDate = $"{expiringToken.ExpirationDate.ToLongDateString()} {expiringToken.ExpirationDate.ToShortTimeString()} UTC",
OrganizationNameUrlEncoded = WebUtility.UrlEncode(orgInvitesInfo.OrganizationName),
WebVaultUrl = globalSettings.BaseServiceUri.VaultWithHash,
SiteName = globalSettings.SiteName,
InitOrganization = orgInvitesInfo.InitOrganization,
OrgSsoIdentifier = orgInvitesInfo.OrgSsoIdentifier,
OrgSsoEnabled = orgInvitesInfo.OrgSsoEnabled,
OrgSsoLoginRequiredPolicyEnabled = orgInvitesInfo.OrgSsoLoginRequiredPolicyEnabled,
OrgUserHasExistingUser = orgInvitesInfo.OrgUserHasExistingUserDict[orgUser.Id]
};
}
public string OrganizationName { get; set; } public string OrganizationName { get; set; }
public string OrganizationId { get; set; } public string OrganizationId { get; set; }
public string OrganizationUserId { get; set; } public string OrganizationUserId { get; set; }
@ -10,13 +49,34 @@ public class OrganizationUserInvitedViewModel : BaseTitleContactUsMailModel
public string Token { get; set; } public string Token { get; set; }
public string ExpirationDate { get; set; } public string ExpirationDate { get; set; }
public bool InitOrganization { get; set; } public bool InitOrganization { get; set; }
public string Url => string.Format("{0}/accept-organization?organizationId={1}&" + public string OrgSsoIdentifier { get; set; }
"organizationUserId={2}&email={3}&organizationName={4}&token={5}&initOrganization={6}", public bool OrgSsoEnabled { get; set; }
WebVaultUrl, public bool OrgSsoLoginRequiredPolicyEnabled { get; set; }
OrganizationId, public bool OrgUserHasExistingUser { get; set; }
OrganizationUserId,
Email, public string Url
OrganizationNameUrlEncoded, {
Token, get
InitOrganization); {
var baseUrl = $"{WebVaultUrl}/accept-organization";
var queryParams = new List<string>
{
$"organizationId={OrganizationId}",
$"organizationUserId={OrganizationUserId}",
$"email={Email}",
$"organizationName={OrganizationNameUrlEncoded}",
$"token={Token}",
$"initOrganization={InitOrganization}",
$"orgUserHasExistingUser={OrgUserHasExistingUser}"
};
if (OrgSsoEnabled && OrgSsoLoginRequiredPolicyEnabled)
{
// Only send down the orgSsoIdentifier if we are going to accelerate the user to the SSO login page.
queryParams.Add($"orgSsoIdentifier={OrgSsoIdentifier}");
}
return $"{baseUrl}?{string.Join("&", queryParams)}";
}
}
} }

View File

@ -7,6 +7,7 @@ namespace Bit.Core.Repositories;
public interface IUserRepository : IRepository<User, Guid> public interface IUserRepository : IRepository<User, Guid>
{ {
Task<User> GetByEmailAsync(string email); Task<User> GetByEmailAsync(string email);
Task<IEnumerable<User>> GetManyByEmailsAsync(IEnumerable<string> emails);
Task<User> GetBySsoUserAsync(string externalId, Guid? organizationId); Task<User> GetBySsoUserAsync(string externalId, Guid? organizationId);
Task<UserKdfInformation> GetKdfInformationByEmailAsync(string email); Task<UserKdfInformation> GetKdfInformationByEmailAsync(string email);
Task<ICollection<User>> SearchAsync(string email, int skip, int take); Task<ICollection<User>> SearchAsync(string email, int skip, int take);

View File

@ -1,7 +1,6 @@
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.Auth.Entities; using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Business;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Models.Mail; using Bit.Core.Models.Mail;
@ -17,8 +16,12 @@ public interface IMailService
Task SendTwoFactorEmailAsync(string email, string token); Task SendTwoFactorEmailAsync(string email, string token);
Task SendNoMasterPasswordHintEmailAsync(string email); Task SendNoMasterPasswordHintEmailAsync(string email);
Task SendMasterPasswordHintEmailAsync(string email, string hint); Task SendMasterPasswordHintEmailAsync(string email, string hint);
Task SendOrganizationInviteEmailAsync(string organizationName, OrganizationUser orgUser, ExpiringToken token, bool isFreeOrg, bool initOrganization = false);
Task BulkSendOrganizationInviteEmailAsync(string organizationName, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> invites, bool isFreeOrg, bool initOrganization = false); /// <summary>
/// Sends one or many organization invite emails.
/// </summary>
/// <param name="orgInvitesInfo">The information required to send the organization invites.</param>
Task SendOrganizationInviteEmailsAsync(OrganizationInvitesInfo orgInvitesInfo);
Task SendOrganizationMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable<string> ownerEmails); Task SendOrganizationMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable<string> ownerEmails);
Task SendOrganizationAutoscaledEmailAsync(Organization organization, int initialSeatCount, IEnumerable<string> ownerEmails); Task SendOrganizationAutoscaledEmailAsync(Organization organization, int initialSeatCount, IEnumerable<string> ownerEmails);
Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable<string> adminEmails); Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable<string> adminEmails);

View File

@ -3,7 +3,6 @@ using System.Reflection;
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.Auth.Entities; using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Business;
using Bit.Core.Auth.Models.Mail; using Bit.Core.Auth.Models.Mail;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Models.Mail; using Bit.Core.Models.Mail;
@ -207,35 +206,20 @@ public class HandlebarsMailService : IMailService
await _mailDeliveryService.SendEmailAsync(message); await _mailDeliveryService.SendEmailAsync(message);
} }
public Task SendOrganizationInviteEmailAsync(string organizationName, OrganizationUser orgUser, ExpiringToken token, bool isFreeOrg, bool initOrganization = false) => public async Task SendOrganizationInviteEmailsAsync(OrganizationInvitesInfo orgInvitesInfo)
BulkSendOrganizationInviteEmailAsync(organizationName, new[] { (orgUser, token) }, isFreeOrg, initOrganization);
public async Task BulkSendOrganizationInviteEmailAsync(string organizationName, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> invites, bool isFreeOrg, bool initOrganization = false)
{ {
MailQueueMessage CreateMessage(string email, object model) MailQueueMessage CreateMessage(string email, object model)
{ {
var message = CreateDefaultMessage($"Join {organizationName}", email); var message = CreateDefaultMessage($"Join {orgInvitesInfo.OrganizationName}", email);
return new MailQueueMessage(message, "OrganizationUserInvited", model); return new MailQueueMessage(message, "OrganizationUserInvited", model);
} }
var freeOrgTitle = "A Bitwarden member invited you to an organization. Join now to start securing your passwords!";
var messageModels = invites.Select(invite => CreateMessage(invite.orgUser.Email, var messageModels = orgInvitesInfo.OrgUserTokenPairs.Select(orgUserTokenPair =>
new OrganizationUserInvitedViewModel {
{ var orgUserInviteViewModel = OrganizationUserInvitedViewModel.CreateFromInviteInfo(
TitleFirst = isFreeOrg ? freeOrgTitle : "Join ", orgInvitesInfo, orgUserTokenPair.OrgUser, orgUserTokenPair.Token, _globalSettings);
TitleSecondBold = isFreeOrg ? string.Empty : CoreHelpers.SanitizeForEmail(organizationName, false), return CreateMessage(orgUserTokenPair.OrgUser.Email, orgUserInviteViewModel);
TitleThird = isFreeOrg ? string.Empty : " on Bitwarden and start securing your passwords!", });
OrganizationName = CoreHelpers.SanitizeForEmail(organizationName, false) + invite.orgUser.Status,
Email = WebUtility.UrlEncode(invite.orgUser.Email),
OrganizationId = invite.orgUser.OrganizationId.ToString(),
OrganizationUserId = invite.orgUser.Id.ToString(),
Token = WebUtility.UrlEncode(invite.token.Token),
ExpirationDate = $"{invite.token.ExpirationDate.ToLongDateString()} {invite.token.ExpirationDate.ToShortTimeString()} UTC",
OrganizationNameUrlEncoded = WebUtility.UrlEncode(organizationName),
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
SiteName = _globalSettings.SiteName,
InitOrganization = initOrganization
}
));
await EnqueueMailAsync(messageModels); await EnqueueMailAsync(messageModels);
} }

View File

@ -1,7 +1,6 @@
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.Auth.Entities; using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Business;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Models.Mail; using Bit.Core.Models.Mail;
@ -54,12 +53,7 @@ public class NoopMailService : IMailService
return Task.FromResult(0); return Task.FromResult(0);
} }
public Task SendOrganizationInviteEmailAsync(string organizationName, OrganizationUser orgUser, ExpiringToken token, bool isFreeOrg, bool initOrganization = false) public Task SendOrganizationInviteEmailsAsync(OrganizationInvitesInfo orgInvitesInfo)
{
return Task.FromResult(0);
}
public Task BulkSendOrganizationInviteEmailAsync(string organizationName, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> invites, bool isFreeOrg, bool initOrganization = false)
{ {
return Task.FromResult(0); return Task.FromResult(0);
} }

View File

@ -48,6 +48,27 @@ public class UserRepository : Repository<User, Guid>, IUserRepository
} }
} }
public async Task<IEnumerable<User>> GetManyByEmailsAsync(IEnumerable<string> emails)
{
var emailTable = new DataTable();
emailTable.Columns.Add("Email", typeof(string));
foreach (var email in emails)
{
emailTable.Rows.Add(email);
}
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<User>(
$"[{Schema}].[{Table}_ReadByEmails]",
new { Emails = emailTable.AsTableValuedParameter("dbo.EmailArray") },
commandType: CommandType.StoredProcedure);
UnprotectData(results);
return results.ToList();
}
}
public async Task<User> GetBySsoUserAsync(string externalId, Guid? organizationId) public async Task<User> GetBySsoUserAsync(string externalId, Guid? organizationId)
{ {
using (var connection = new SqlConnection(ConnectionString)) using (var connection = new SqlConnection(ConnectionString))

View File

@ -24,6 +24,18 @@ public class UserRepository : Repository<Core.Entities.User, User, Guid>, IUserR
} }
} }
public async Task<IEnumerable<Core.Entities.User>> GetManyByEmailsAsync(IEnumerable<string> emails)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var users = await GetDbSet(dbContext)
.Where(u => emails.Contains(u.Email))
.ToListAsync();
return Mapper.Map<List<Core.Entities.User>>(users);
}
}
public async Task<DataModel.UserKdfInformation> GetKdfInformationByEmailAsync(string email) public async Task<DataModel.UserKdfInformation> GetKdfInformationByEmailAsync(string email)
{ {
using (var scope = ServiceScopeFactory.CreateScope()) using (var scope = ServiceScopeFactory.CreateScope())

View File

@ -0,0 +1,19 @@
CREATE PROCEDURE [dbo].[User_ReadByEmails]
@Emails AS [dbo].[EmailArray] READONLY
AS
BEGIN
SET NOCOUNT ON;
IF (SELECT COUNT(1) FROM @Emails) < 1
BEGIN
RETURN(-1)
END
SELECT
*
FROM
[dbo].[UserView]
WHERE
[Email] IN (SELECT [Email] FROM @Emails)
END
GO

View File

@ -6,7 +6,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Entities; using Bit.Core.Auth.Entities;
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.Models.Business.Tokenables;
using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
@ -17,6 +16,7 @@ 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.Models.StaticStore; using Bit.Core.Models.StaticStore;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -68,11 +68,15 @@ public class OrganizationServiceTests
existingUsers.First().Type = OrganizationUserType.Owner; existingUsers.First().Type = OrganizationUserType.Owner;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(org.Id).Returns(org); sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(org.Id).Returns(org);
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyDetailsByOrganizationAsync(org.Id)
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
organizationUserRepository.GetManyDetailsByOrganizationAsync(org.Id)
.Returns(existingUsers); .Returns(existingUsers);
sutProvider.GetDependency<IOrganizationUserRepository>().GetCountByOrganizationIdAsync(org.Id) organizationUserRepository.GetCountByOrganizationIdAsync(org.Id)
.Returns(existingUsers.Count); .Returns(existingUsers.Count);
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyByOrganizationAsync(org.Id, OrganizationUserType.Owner) organizationUserRepository.GetManyByOrganizationAsync(org.Id, OrganizationUserType.Owner)
.Returns(existingUsers.Select(u => new OrganizationUser { Status = OrganizationUserStatusType.Confirmed, Type = OrganizationUserType.Owner, Id = u.Id }).ToList()); .Returns(existingUsers.Select(u => new OrganizationUser { Status = OrganizationUserStatusType.Confirmed, Type = OrganizationUserType.Owner, Id = u.Id }).ToList());
sutProvider.GetDependency<ICurrentContext>().ManageUsers(org.Id).Returns(true); sutProvider.GetDependency<ICurrentContext>().ManageUsers(org.Id).Returns(true);
@ -98,9 +102,10 @@ public class OrganizationServiceTests
// Create new users // Create new users
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<IMailService>().Received(1)
.BulkSendOrganizationInviteEmailAsync(org.Name, .SendOrganizationInviteEmailsAsync(
Arg.Is<IEnumerable<(OrganizationUser, ExpiringToken)>>(messages => messages.Count() == expectedNewUsersCount), org.PlanType == PlanType.Free); Arg.Is<OrganizationInvitesInfo>(info => info.OrgUserTokenPairs.Count() == expectedNewUsersCount && info.IsFreeOrg == (org.PlanType == PlanType.Free) && info.OrganizationName == org.Name));
// Send events // Send events
await sutProvider.GetDependency<IEventService>().Received(1) await sutProvider.GetDependency<IEventService>().Received(1)
@ -139,8 +144,14 @@ public class OrganizationServiceTests
.Returns(existingUsers.Count); .Returns(existingUsers.Count);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(reInvitedUser.Id) sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(reInvitedUser.Id)
.Returns(new OrganizationUser { Id = reInvitedUser.Id }); .Returns(new OrganizationUser { Id = reInvitedUser.Id });
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyByOrganizationAsync(org.Id, OrganizationUserType.Owner)
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
organizationUserRepository.GetManyByOrganizationAsync(org.Id, OrganizationUserType.Owner)
.Returns(existingUsers.Select(u => new OrganizationUser { Status = OrganizationUserStatusType.Confirmed, Type = OrganizationUserType.Owner, Id = u.Id }).ToList()); .Returns(existingUsers.Select(u => new OrganizationUser { Status = OrganizationUserStatusType.Confirmed, Type = OrganizationUserType.Owner, Id = u.Id }).ToList());
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
var currentContext = sutProvider.GetDependency<ICurrentContext>(); var currentContext = sutProvider.GetDependency<ICurrentContext>();
currentContext.ManageUsers(org.Id).Returns(true); currentContext.ManageUsers(org.Id).Returns(true);
@ -170,9 +181,10 @@ public class OrganizationServiceTests
// Created and invited new users // Created and invited new users
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<IMailService>().Received(1)
.BulkSendOrganizationInviteEmailAsync(org.Name, .SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
Arg.Is<IEnumerable<(OrganizationUser, ExpiringToken)>>(messages => messages.Count() == expectedNewUsersCount), org.PlanType == PlanType.Free); info.OrgUserTokenPairs.Count() == expectedNewUsersCount && info.IsFreeOrg == (org.PlanType == PlanType.Free) && info.OrganizationName == org.Name));
// Sent events // Sent events
await sutProvider.GetDependency<IEventService>().Received(1) await sutProvider.GetDependency<IEventService>().Received(1)
@ -396,6 +408,9 @@ public class OrganizationServiceTests
organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner) organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner)
.Returns(new[] { 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 // 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>())
@ -406,11 +421,15 @@ public class OrganizationServiceTests
} }
); );
await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) }); await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) });
await sutProvider.GetDependency<IMailService>().Received(1) await sutProvider.GetDependency<IMailService>().Received(1)
.BulkSendOrganizationInviteEmailAsync(organization.Name, .SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
Arg.Is<IEnumerable<(OrganizationUser, ExpiringToken)>>(v => v.Count() == invite.Emails.Distinct().Count()), organization.PlanType == PlanType.Free); info.OrgUserTokenPairs.Count() == invite.Emails.Distinct().Count() &&
info.IsFreeOrg == (organization.PlanType == PlanType.Free) &&
info.OrganizationName == organization.Name));
} }
[Theory] [Theory]
@ -516,6 +535,9 @@ public class OrganizationServiceTests
organizationRepository.GetByIdAsync(organization.Id).Returns(organization); organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner) organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner)
.Returns(new[] { invitor }); .Returns(new[] { invitor });
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
currentContext.OrganizationOwner(organization.Id).Returns(true); currentContext.OrganizationOwner(organization.Id).Returns(true);
currentContext.ManageUsers(organization.Id).Returns(true); currentContext.ManageUsers(organization.Id).Returns(true);
@ -543,6 +565,9 @@ public class OrganizationServiceTests
organizationRepository.GetByIdAsync(organization.Id).Returns(organization); organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner) organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner)
.Returns(new[] { invitor }); .Returns(new[] { invitor });
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
currentContext.OrganizationOwner(organization.Id).Returns(true); currentContext.OrganizationOwner(organization.Id).Returns(true);
currentContext.ManageUsers(organization.Id).Returns(true); currentContext.ManageUsers(organization.Id).Returns(true);
@ -567,6 +592,8 @@ public class OrganizationServiceTests
var currentContext = sutProvider.GetDependency<ICurrentContext>(); var currentContext = sutProvider.GetDependency<ICurrentContext>();
organizationRepository.GetByIdAsync(organization.Id).Returns(organization); organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
currentContext.OrganizationCustom(organization.Id).Returns(true); currentContext.OrganizationCustom(organization.Id).Returns(true);
currentContext.ManageUsers(organization.Id).Returns(false); currentContext.ManageUsers(organization.Id).Returns(false);
@ -618,6 +645,9 @@ public class OrganizationServiceTests
organizationRepository.GetByIdAsync(organization.Id).Returns(organization); organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner) organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner)
.Returns(new[] { invitor }); .Returns(new[] { invitor });
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
currentContext.OrganizationOwner(organization.Id).Returns(true); currentContext.OrganizationOwner(organization.Id).Returns(true);
currentContext.ManageUsers(organization.Id).Returns(true); currentContext.ManageUsers(organization.Id).Returns(true);
@ -683,11 +713,16 @@ public class OrganizationServiceTests
} }
); );
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
SetupOrgUserRepositoryCreateAsyncMock(organizationUserRepository);
await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, invites); await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, invites);
await sutProvider.GetDependency<IMailService>().Received(1) await sutProvider.GetDependency<IMailService>().Received(1)
.BulkSendOrganizationInviteEmailAsync(organization.Name, .SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
Arg.Is<IEnumerable<(OrganizationUser, ExpiringToken)>>(v => v.Count() == invites.SelectMany(i => i.invite.Emails).Count()), organization.PlanType == PlanType.Free); info.OrgUserTokenPairs.Count() == invites.SelectMany(i => i.invite.Emails).Count() &&
info.IsFreeOrg == (organization.PlanType == PlanType.Free) &&
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?)>>());
} }
@ -719,6 +754,10 @@ public class OrganizationServiceTests
organizationRepository.GetByIdAsync(organization.Id).Returns(organization); organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner) organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner)
.Returns(new[] { owner }); .Returns(new[] { owner });
SetupOrgUserRepositoryCreateAsyncMock(organizationUserRepository);
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
currentContext.ManageUsers(organization.Id).Returns(true); currentContext.ManageUsers(organization.Id).Returns(true);
// Mock tokenable factory to return a token that expires in 5 days // Mock tokenable factory to return a token that expires in 5 days
@ -734,8 +773,10 @@ public class OrganizationServiceTests
await sutProvider.Sut.InviteUsersAsync(organization.Id, eventSystemUser, invites); await sutProvider.Sut.InviteUsersAsync(organization.Id, eventSystemUser, invites);
await sutProvider.GetDependency<IMailService>().Received(1) await sutProvider.GetDependency<IMailService>().Received(1)
.BulkSendOrganizationInviteEmailAsync(organization.Name, .SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
Arg.Is<IEnumerable<(OrganizationUser, ExpiringToken)>>(v => v.Count() == invites.SelectMany(i => i.invite.Emails).Count()), organization.PlanType == PlanType.Free); info.OrgUserTokenPairs.Count() == invites.SelectMany(i => i.invite.Emails).Count() &&
info.IsFreeOrg == (organization.PlanType == PlanType.Free) &&
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?)>>());
} }
@ -760,6 +801,10 @@ public class OrganizationServiceTests
sutProvider.GetDependency<ICountNewSmSeatsRequiredQuery>() sutProvider.GetDependency<ICountNewSmSeatsRequiredQuery>()
.CountNewSmSeatsRequiredAsync(organization.Id, invitedSmUsers).Returns(invitedSmUsers); .CountNewSmSeatsRequiredAsync(organization.Id, invitedSmUsers).Returns(invitedSmUsers);
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
SetupOrgUserRepositoryCreateAsyncMock(organizationUserRepository);
await sutProvider.Sut.InviteUsersAsync(organization.Id, savingUser.Id, invites); await sutProvider.Sut.InviteUsersAsync(organization.Id, savingUser.Id, invites);
sutProvider.GetDependency<IUpdateSecretsManagerSubscriptionCommand>().Received(1) sutProvider.GetDependency<IUpdateSecretsManagerSubscriptionCommand>().Received(1)
@ -2074,4 +2119,35 @@ public class OrganizationServiceTests
Assert.Contains("custom users can only grant the same custom permissions that they have.", exception.Message.ToLowerInvariant()); Assert.Contains("custom users can only grant the same custom permissions that they have.", exception.Message.ToLowerInvariant());
} }
// Must set real guids in order for dictionary of guids to not throw aggregate exceptions
private void SetupOrgUserRepositoryCreateManyAsyncMock(IOrganizationUserRepository organizationUserRepository)
{
organizationUserRepository.CreateManyAsync(Arg.Any<IEnumerable<OrganizationUser>>()).Returns(
info =>
{
var orgUsers = info.Arg<IEnumerable<OrganizationUser>>();
foreach (var orgUser in orgUsers)
{
orgUser.Id = Guid.NewGuid();
}
return Task.FromResult<ICollection<Guid>>(orgUsers.Select(u => u.Id).ToList());
}
);
}
// Must set real guids in order for dictionary of guids to not throw aggregate exceptions
private void SetupOrgUserRepositoryCreateAsyncMock(IOrganizationUserRepository organizationUserRepository)
{
organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>(),
Arg.Any<IEnumerable<CollectionAccessSelection>>()).Returns(
info =>
{
var orgUser = info.Arg<OrganizationUser>();
orgUser.Id = Guid.NewGuid();
return Task.FromResult<Guid>(orgUser.Id);
}
);
}
} }

View File

@ -0,0 +1,24 @@
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE OR ALTER PROCEDURE [dbo].[User_ReadByEmails]
@Emails AS [dbo].[EmailArray] READONLY
AS
BEGIN
SET NOCOUNT ON;
IF (SELECT COUNT(1) FROM @Emails) < 1
BEGIN
RETURN(-1)
END
SELECT
*
FROM
[dbo].[UserView]
WHERE
[Email] IN (SELECT [Email] FROM @Emails)
END
GO