mirror of
https://github.com/bitwarden/server.git
synced 2024-11-21 12:05:42 +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:
parent
d206c03ad1
commit
d2808b2615
@ -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<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)
|
||||
{
|
||||
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
|
||||
);
|
||||
}
|
||||
|
41
src/Core/Models/Mail/OrganizationInvitesInfo.cs
Normal file
41
src/Core/Models/Mail/OrganizationInvitesInfo.cs
Normal 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; }
|
||||
|
||||
}
|
@ -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<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)}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ namespace Bit.Core.Repositories;
|
||||
public interface IUserRepository : IRepository<User, Guid>
|
||||
{
|
||||
Task<User> GetByEmailAsync(string email);
|
||||
Task<IEnumerable<User>> GetManyByEmailsAsync(IEnumerable<string> emails);
|
||||
Task<User> GetBySsoUserAsync(string externalId, Guid? organizationId);
|
||||
Task<UserKdfInformation> GetKdfInformationByEmailAsync(string email);
|
||||
Task<ICollection<User>> SearchAsync(string email, int skip, int take);
|
||||
|
@ -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);
|
||||
|
||||
/// <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 SendOrganizationAutoscaledEmailAsync(Organization organization, int initialSeatCount, IEnumerable<string> ownerEmails);
|
||||
Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable<string> adminEmails);
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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)
|
||||
{
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
|
@ -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)
|
||||
{
|
||||
using (var scope = ServiceScopeFactory.CreateScope())
|
||||
|
19
src/Sql/dbo/Stored Procedures/User_ReadByEmails.sql
Normal file
19
src/Sql/dbo/Stored Procedures/User_ReadByEmails.sql
Normal 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
|
@ -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<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);
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>().GetCountByOrganizationIdAsync(org.Id)
|
||||
organizationUserRepository.GetCountByOrganizationIdAsync(org.Id)
|
||||
.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());
|
||||
sutProvider.GetDependency<ICurrentContext>().ManageUsers(org.Id).Returns(true);
|
||||
|
||||
@ -98,9 +102,10 @@ public class OrganizationServiceTests
|
||||
// Create new users
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1)
|
||||
.CreateManyAsync(Arg.Is<IEnumerable<OrganizationUser>>(users => users.Count() == expectedNewUsersCount));
|
||||
|
||||
await sutProvider.GetDependency<IMailService>().Received(1)
|
||||
.BulkSendOrganizationInviteEmailAsync(org.Name,
|
||||
Arg.Is<IEnumerable<(OrganizationUser, ExpiringToken)>>(messages => messages.Count() == expectedNewUsersCount), org.PlanType == PlanType.Free);
|
||||
.SendOrganizationInviteEmailsAsync(
|
||||
Arg.Is<OrganizationInvitesInfo>(info => info.OrgUserTokenPairs.Count() == expectedNewUsersCount && info.IsFreeOrg == (org.PlanType == PlanType.Free) && info.OrganizationName == org.Name));
|
||||
|
||||
// Send events
|
||||
await sutProvider.GetDependency<IEventService>().Received(1)
|
||||
@ -139,8 +144,14 @@ public class OrganizationServiceTests
|
||||
.Returns(existingUsers.Count);
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(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());
|
||||
|
||||
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
|
||||
|
||||
var currentContext = sutProvider.GetDependency<ICurrentContext>();
|
||||
currentContext.ManageUsers(org.Id).Returns(true);
|
||||
|
||||
@ -170,9 +181,10 @@ public class OrganizationServiceTests
|
||||
// Created and invited new users
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1)
|
||||
.CreateManyAsync(Arg.Is<IEnumerable<OrganizationUser>>(users => users.Count() == expectedNewUsersCount));
|
||||
|
||||
await sutProvider.GetDependency<IMailService>().Received(1)
|
||||
.BulkSendOrganizationInviteEmailAsync(org.Name,
|
||||
Arg.Is<IEnumerable<(OrganizationUser, ExpiringToken)>>(messages => messages.Count() == expectedNewUsersCount), org.PlanType == PlanType.Free);
|
||||
.SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
|
||||
info.OrgUserTokenPairs.Count() == expectedNewUsersCount && info.IsFreeOrg == (org.PlanType == PlanType.Free) && info.OrganizationName == org.Name));
|
||||
|
||||
// Sent events
|
||||
await sutProvider.GetDependency<IEventService>().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<IOrgUserInviteTokenableFactory>()
|
||||
.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.GetDependency<IMailService>().Received(1)
|
||||
.BulkSendOrganizationInviteEmailAsync(organization.Name,
|
||||
Arg.Is<IEnumerable<(OrganizationUser, ExpiringToken)>>(v => v.Count() == invite.Emails.Distinct().Count()), organization.PlanType == PlanType.Free);
|
||||
.SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(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<ICurrentContext>();
|
||||
|
||||
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
|
||||
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
|
||||
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<IMailService>().Received(1)
|
||||
.BulkSendOrganizationInviteEmailAsync(organization.Name,
|
||||
Arg.Is<IEnumerable<(OrganizationUser, ExpiringToken)>>(v => v.Count() == invites.SelectMany(i => i.invite.Emails).Count()), organization.PlanType == PlanType.Free);
|
||||
.SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
|
||||
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?)>>());
|
||||
}
|
||||
@ -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<IMailService>().Received(1)
|
||||
.BulkSendOrganizationInviteEmailAsync(organization.Name,
|
||||
Arg.Is<IEnumerable<(OrganizationUser, ExpiringToken)>>(v => v.Count() == invites.SelectMany(i => i.invite.Emails).Count()), organization.PlanType == PlanType.Free);
|
||||
.SendOrganizationInviteEmailsAsync(Arg.Is<OrganizationInvitesInfo>(info =>
|
||||
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?)>>());
|
||||
}
|
||||
@ -760,6 +801,10 @@ public class OrganizationServiceTests
|
||||
sutProvider.GetDependency<ICountNewSmSeatsRequiredQuery>()
|
||||
.CountNewSmSeatsRequiredAsync(organization.Id, invitedSmUsers).Returns(invitedSmUsers);
|
||||
|
||||
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
|
||||
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
|
||||
SetupOrgUserRepositoryCreateAsyncMock(organizationUserRepository);
|
||||
|
||||
await sutProvider.Sut.InviteUsersAsync(organization.Id, savingUser.Id, invites);
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
24
util/Migrator/DbScripts/2023-10-21_00_User_ReadByEmails.sql
Normal file
24
util/Migrator/DbScripts/2023-10-21_00_User_ReadByEmails.sql
Normal 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
|
Loading…
Reference in New Issue
Block a user