From d2808b2615af4e7682fc3a0c2a6ed42c5eb7e725 Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Mon, 18 Dec 2023 11:16:17 -0500 Subject: [PATCH] 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 --- .../Implementations/OrganizationService.cs | 69 +++++++++--- .../Models/Mail/OrganizationInvitesInfo.cs | 41 +++++++ .../Mail/OrganizationUserInvitedViewModel.cs | 80 +++++++++++-- src/Core/Repositories/IUserRepository.cs | 1 + src/Core/Services/IMailService.cs | 9 +- .../Implementations/HandlebarsMailService.cs | 34 ++---- .../NoopImplementations/NoopMailService.cs | 8 +- .../Repositories/UserRepository.cs | 21 ++++ .../Repositories/UserRepository.cs | 12 ++ .../Stored Procedures/User_ReadByEmails.sql | 19 ++++ .../Services/OrganizationServiceTests.cs | 106 +++++++++++++++--- .../2023-10-21_00_User_ReadByEmails.sql | 24 ++++ 12 files changed, 349 insertions(+), 75 deletions(-) create mode 100644 src/Core/Models/Mail/OrganizationInvitesInfo.cs create mode 100644 src/Sql/dbo/Stored Procedures/User_ReadByEmails.sql create mode 100644 util/Migrator/DbScripts/2023-10-21_00_User_ReadByEmails.sql diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 43890919e..4a46c4200 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -18,6 +18,7 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Models.Data; +using Bit.Core.Models.Mail; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Settings; @@ -1078,6 +1079,54 @@ public class OrganizationService : IOrganizationService private async Task SendInvitesAsync(IEnumerable 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 BuildOrganizationInvitesInfoAsync( + IEnumerable 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(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) { var orgUserInviteTokenable = _orgUserInviteTokenableFactory.CreateToken(orgUser); @@ -1087,22 +1136,12 @@ public class OrganizationService : IOrganizationService var orgUsersWithExpTokens = orgUsers.Select(MakeOrgUserExpiringTokenPair); - await _mailService.BulkSendOrganizationInviteEmailAsync( - organization.Name, + return new OrganizationInvitesInfo( + organization, + orgSsoEnabled, + orgSsoLoginRequiredPolicyEnabled, orgUsersWithExpTokens, - organization.PlanType == PlanType.Free - ); - } - - 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, + orgUserHasExistingUserDict, initOrganization ); } diff --git a/src/Core/Models/Mail/OrganizationInvitesInfo.cs b/src/Core/Models/Mail/OrganizationInvitesInfo.cs new file mode 100644 index 000000000..10602bac6 --- /dev/null +++ b/src/Core/Models/Mail/OrganizationInvitesInfo.cs @@ -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 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 OrgUserHasExistingUserDict { get; } + +} diff --git a/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs b/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs index 99156b551..0fc65d91e 100644 --- a/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs +++ b/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs @@ -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 { + + // 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 OrganizationId { get; set; } public string OrganizationUserId { get; set; } @@ -10,13 +49,34 @@ public class OrganizationUserInvitedViewModel : BaseTitleContactUsMailModel public string Token { get; set; } public string ExpirationDate { get; set; } public bool InitOrganization { get; set; } - public string Url => string.Format("{0}/accept-organization?organizationId={1}&" + - "organizationUserId={2}&email={3}&organizationName={4}&token={5}&initOrganization={6}", - WebVaultUrl, - OrganizationId, - OrganizationUserId, - Email, - OrganizationNameUrlEncoded, - Token, - InitOrganization); + public string OrgSsoIdentifier { get; set; } + public bool OrgSsoEnabled { get; set; } + public bool OrgSsoLoginRequiredPolicyEnabled { get; set; } + public bool OrgUserHasExistingUser { get; set; } + + public string Url + { + get + { + var baseUrl = $"{WebVaultUrl}/accept-organization"; + var queryParams = new List + { + $"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)}"; + } + } } diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs index f5b00e774..4bbbe1b65 100644 --- a/src/Core/Repositories/IUserRepository.cs +++ b/src/Core/Repositories/IUserRepository.cs @@ -7,6 +7,7 @@ namespace Bit.Core.Repositories; public interface IUserRepository : IRepository { Task GetByEmailAsync(string email); + Task> GetManyByEmailsAsync(IEnumerable emails); Task GetBySsoUserAsync(string externalId, Guid? organizationId); Task GetKdfInformationByEmailAsync(string email); Task> SearchAsync(string email, int skip, int take); diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 2920175d0..c2d81d6ed 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -1,7 +1,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Auth.Entities; -using Bit.Core.Auth.Models.Business; using Bit.Core.Entities; using Bit.Core.Models.Mail; @@ -17,8 +16,12 @@ public interface IMailService Task SendTwoFactorEmailAsync(string email, string token); Task SendNoMasterPasswordHintEmailAsync(string email); 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); + + /// + /// Sends one or many organization invite emails. + /// + /// The information required to send the organization invites. + Task SendOrganizationInviteEmailsAsync(OrganizationInvitesInfo orgInvitesInfo); Task SendOrganizationMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable ownerEmails); Task SendOrganizationAutoscaledEmailAsync(Organization organization, int initialSeatCount, IEnumerable ownerEmails); Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable adminEmails); diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 601fc292b..8805e3af5 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -3,7 +3,6 @@ using System.Reflection; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Auth.Entities; -using Bit.Core.Auth.Models.Business; using Bit.Core.Auth.Models.Mail; using Bit.Core.Entities; using Bit.Core.Models.Mail; @@ -207,35 +206,20 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } - public Task SendOrganizationInviteEmailAsync(string organizationName, OrganizationUser orgUser, ExpiringToken token, bool isFreeOrg, bool initOrganization = false) => - BulkSendOrganizationInviteEmailAsync(organizationName, new[] { (orgUser, token) }, isFreeOrg, initOrganization); - - public async Task BulkSendOrganizationInviteEmailAsync(string organizationName, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> invites, bool isFreeOrg, bool initOrganization = false) + public async Task SendOrganizationInviteEmailsAsync(OrganizationInvitesInfo orgInvitesInfo) { 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); } - 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, - new OrganizationUserInvitedViewModel - { - TitleFirst = isFreeOrg ? freeOrgTitle : "Join ", - TitleSecondBold = isFreeOrg ? string.Empty : CoreHelpers.SanitizeForEmail(organizationName, false), - 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 - } - )); + + var messageModels = orgInvitesInfo.OrgUserTokenPairs.Select(orgUserTokenPair => + { + var orgUserInviteViewModel = OrganizationUserInvitedViewModel.CreateFromInviteInfo( + orgInvitesInfo, orgUserTokenPair.OrgUser, orgUserTokenPair.Token, _globalSettings); + return CreateMessage(orgUserTokenPair.OrgUser.Email, orgUserInviteViewModel); + }); await EnqueueMailAsync(messageModels); } diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index e40434666..92e548e0d 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -1,7 +1,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Auth.Entities; -using Bit.Core.Auth.Models.Business; using Bit.Core.Entities; using Bit.Core.Models.Mail; @@ -54,12 +53,7 @@ public class NoopMailService : IMailService return Task.FromResult(0); } - public Task SendOrganizationInviteEmailAsync(string organizationName, OrganizationUser orgUser, ExpiringToken token, bool isFreeOrg, bool initOrganization = false) - { - return Task.FromResult(0); - } - - public Task BulkSendOrganizationInviteEmailAsync(string organizationName, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> invites, bool isFreeOrg, bool initOrganization = false) + public Task SendOrganizationInviteEmailsAsync(OrganizationInvitesInfo orgInvitesInfo) { return Task.FromResult(0); } diff --git a/src/Infrastructure.Dapper/Repositories/UserRepository.cs b/src/Infrastructure.Dapper/Repositories/UserRepository.cs index e827d261f..09d14f1b9 100644 --- a/src/Infrastructure.Dapper/Repositories/UserRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/UserRepository.cs @@ -48,6 +48,27 @@ public class UserRepository : Repository, IUserRepository } } + public async Task> GetManyByEmailsAsync(IEnumerable 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( + $"[{Schema}].[{Table}_ReadByEmails]", + new { Emails = emailTable.AsTableValuedParameter("dbo.EmailArray") }, + commandType: CommandType.StoredProcedure); + + UnprotectData(results); + return results.ToList(); + } + } + public async Task GetBySsoUserAsync(string externalId, Guid? organizationId) { using (var connection = new SqlConnection(ConnectionString)) diff --git a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs index 0e2ecd145..b25698598 100644 --- a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs @@ -24,6 +24,18 @@ public class UserRepository : Repository, IUserR } } + public async Task> GetManyByEmailsAsync(IEnumerable 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>(users); + } + } + public async Task GetKdfInformationByEmailAsync(string email) { using (var scope = ServiceScopeFactory.CreateScope()) diff --git a/src/Sql/dbo/Stored Procedures/User_ReadByEmails.sql b/src/Sql/dbo/Stored Procedures/User_ReadByEmails.sql new file mode 100644 index 000000000..6884262fb --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/User_ReadByEmails.sql @@ -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 diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index 00a94efed..dd78133d2 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -6,7 +6,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; -using Bit.Core.Auth.Models.Business; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; @@ -17,6 +16,7 @@ using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Models.Mail; using Bit.Core.Models.StaticStore; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; @@ -68,11 +68,15 @@ public class OrganizationServiceTests existingUsers.First().Type = OrganizationUserType.Owner; sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); - sutProvider.GetDependency().GetManyDetailsByOrganizationAsync(org.Id) + + var organizationUserRepository = sutProvider.GetDependency(); + SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); + + organizationUserRepository.GetManyDetailsByOrganizationAsync(org.Id) .Returns(existingUsers); - sutProvider.GetDependency().GetCountByOrganizationIdAsync(org.Id) + organizationUserRepository.GetCountByOrganizationIdAsync(org.Id) .Returns(existingUsers.Count); - sutProvider.GetDependency().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()); sutProvider.GetDependency().ManageUsers(org.Id).Returns(true); @@ -98,9 +102,10 @@ public class OrganizationServiceTests // Create new users await sutProvider.GetDependency().Received(1) .CreateManyAsync(Arg.Is>(users => users.Count() == expectedNewUsersCount)); + await sutProvider.GetDependency().Received(1) - .BulkSendOrganizationInviteEmailAsync(org.Name, - Arg.Is>(messages => messages.Count() == expectedNewUsersCount), org.PlanType == PlanType.Free); + .SendOrganizationInviteEmailsAsync( + Arg.Is(info => info.OrgUserTokenPairs.Count() == expectedNewUsersCount && info.IsFreeOrg == (org.PlanType == PlanType.Free) && info.OrganizationName == org.Name)); // Send events await sutProvider.GetDependency().Received(1) @@ -139,8 +144,14 @@ public class OrganizationServiceTests .Returns(existingUsers.Count); sutProvider.GetDependency().GetByIdAsync(reInvitedUser.Id) .Returns(new OrganizationUser { Id = reInvitedUser.Id }); - sutProvider.GetDependency().GetManyByOrganizationAsync(org.Id, OrganizationUserType.Owner) + + var organizationUserRepository = sutProvider.GetDependency(); + + organizationUserRepository.GetManyByOrganizationAsync(org.Id, OrganizationUserType.Owner) .Returns(existingUsers.Select(u => new OrganizationUser { Status = OrganizationUserStatusType.Confirmed, Type = OrganizationUserType.Owner, Id = u.Id }).ToList()); + + SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); + var currentContext = sutProvider.GetDependency(); currentContext.ManageUsers(org.Id).Returns(true); @@ -170,9 +181,10 @@ public class OrganizationServiceTests // Created and invited new users await sutProvider.GetDependency().Received(1) .CreateManyAsync(Arg.Is>(users => users.Count() == expectedNewUsersCount)); + await sutProvider.GetDependency().Received(1) - .BulkSendOrganizationInviteEmailAsync(org.Name, - Arg.Is>(messages => messages.Count() == expectedNewUsersCount), org.PlanType == PlanType.Free); + .SendOrganizationInviteEmailsAsync(Arg.Is(info => + info.OrgUserTokenPairs.Count() == expectedNewUsersCount && info.IsFreeOrg == (org.PlanType == PlanType.Free) && info.OrganizationName == org.Name)); // Sent events await sutProvider.GetDependency().Received(1) @@ -396,6 +408,9 @@ public class OrganizationServiceTests 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() .CreateToken(Arg.Any()) @@ -406,11 +421,15 @@ public class OrganizationServiceTests } ); + await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) }); await sutProvider.GetDependency().Received(1) - .BulkSendOrganizationInviteEmailAsync(organization.Name, - Arg.Is>(v => v.Count() == invite.Emails.Distinct().Count()), organization.PlanType == PlanType.Free); + .SendOrganizationInviteEmailsAsync(Arg.Is(info => + info.OrgUserTokenPairs.Count() == invite.Emails.Distinct().Count() && + info.IsFreeOrg == (organization.PlanType == PlanType.Free) && + info.OrganizationName == organization.Name)); + } [Theory] @@ -516,6 +535,9 @@ public class OrganizationServiceTests organizationRepository.GetByIdAsync(organization.Id).Returns(organization); organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner) .Returns(new[] { invitor }); + + SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); + currentContext.OrganizationOwner(organization.Id).Returns(true); currentContext.ManageUsers(organization.Id).Returns(true); @@ -543,6 +565,9 @@ public class OrganizationServiceTests organizationRepository.GetByIdAsync(organization.Id).Returns(organization); organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner) .Returns(new[] { invitor }); + + SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); + currentContext.OrganizationOwner(organization.Id).Returns(true); currentContext.ManageUsers(organization.Id).Returns(true); @@ -567,6 +592,8 @@ public class OrganizationServiceTests var currentContext = sutProvider.GetDependency(); organizationRepository.GetByIdAsync(organization.Id).Returns(organization); + var organizationUserRepository = sutProvider.GetDependency(); + SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); currentContext.OrganizationCustom(organization.Id).Returns(true); currentContext.ManageUsers(organization.Id).Returns(false); @@ -618,6 +645,9 @@ public class OrganizationServiceTests organizationRepository.GetByIdAsync(organization.Id).Returns(organization); organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner) .Returns(new[] { invitor }); + + SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); + currentContext.OrganizationOwner(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.GetDependency().Received(1) - .BulkSendOrganizationInviteEmailAsync(organization.Name, - Arg.Is>(v => v.Count() == invites.SelectMany(i => i.invite.Emails).Count()), organization.PlanType == PlanType.Free); + .SendOrganizationInviteEmailsAsync(Arg.Is(info => + info.OrgUserTokenPairs.Count() == invites.SelectMany(i => i.invite.Emails).Count() && + info.IsFreeOrg == (organization.PlanType == PlanType.Free) && + info.OrganizationName == organization.Name)); await sutProvider.GetDependency().Received(1).LogOrganizationUserEventsAsync(Arg.Any>()); } @@ -719,6 +754,10 @@ public class OrganizationServiceTests organizationRepository.GetByIdAsync(organization.Id).Returns(organization); organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner) .Returns(new[] { owner }); + + SetupOrgUserRepositoryCreateAsyncMock(organizationUserRepository); + SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); + currentContext.ManageUsers(organization.Id).Returns(true); // 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.GetDependency().Received(1) - .BulkSendOrganizationInviteEmailAsync(organization.Name, - Arg.Is>(v => v.Count() == invites.SelectMany(i => i.invite.Emails).Count()), organization.PlanType == PlanType.Free); + .SendOrganizationInviteEmailsAsync(Arg.Is(info => + info.OrgUserTokenPairs.Count() == invites.SelectMany(i => i.invite.Emails).Count() && + info.IsFreeOrg == (organization.PlanType == PlanType.Free) && + info.OrganizationName == organization.Name)); await sutProvider.GetDependency().Received(1).LogOrganizationUserEventsAsync(Arg.Any>()); } @@ -760,6 +801,10 @@ public class OrganizationServiceTests sutProvider.GetDependency() .CountNewSmSeatsRequiredAsync(organization.Id, invitedSmUsers).Returns(invitedSmUsers); + var organizationUserRepository = sutProvider.GetDependency(); + SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); + SetupOrgUserRepositoryCreateAsyncMock(organizationUserRepository); + await sutProvider.Sut.InviteUsersAsync(organization.Id, savingUser.Id, invites); sutProvider.GetDependency().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()); } + + // Must set real guids in order for dictionary of guids to not throw aggregate exceptions + private void SetupOrgUserRepositoryCreateManyAsyncMock(IOrganizationUserRepository organizationUserRepository) + { + organizationUserRepository.CreateManyAsync(Arg.Any>()).Returns( + info => + { + var orgUsers = info.Arg>(); + foreach (var orgUser in orgUsers) + { + orgUser.Id = Guid.NewGuid(); + } + + return Task.FromResult>(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(), + Arg.Any>()).Returns( + info => + { + var orgUser = info.Arg(); + orgUser.Id = Guid.NewGuid(); + return Task.FromResult(orgUser.Id); + } + ); + } } diff --git a/util/Migrator/DbScripts/2023-10-21_00_User_ReadByEmails.sql b/util/Migrator/DbScripts/2023-10-21_00_User_ReadByEmails.sql new file mode 100644 index 000000000..a6cd468c7 --- /dev/null +++ b/util/Migrator/DbScripts/2023-10-21_00_User_ReadByEmails.sql @@ -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