diff --git a/src/Admin/HostedServices/AzureQueueMailHostedService.cs b/src/Admin/HostedServices/AzureQueueMailHostedService.cs new file mode 100644 index 000000000..790643f3c --- /dev/null +++ b/src/Admin/HostedServices/AzureQueueMailHostedService.cs @@ -0,0 +1,107 @@ +using System; +using Microsoft.Extensions.Hosting; +using Azure.Storage.Queues; +using Microsoft.Extensions.Logging; +using Bit.Core.Settings; +using System.Threading.Tasks; +using System.Threading; +using Bit.Core.Services; +using Newtonsoft.Json; +using Bit.Core.Models.Mail; +using Azure.Storage.Queues.Models; +using System.Linq; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; + +namespace Bit.Admin.HostedServices +{ + public class AzureQueueMailHostedService : IHostedService + { + private readonly ILogger _logger; + private readonly GlobalSettings _globalSettings; + private readonly IMailService _mailService; + private CancellationTokenSource _cts; + private Task _executingTask; + + private QueueClient _mailQueueClient; + + public AzureQueueMailHostedService( + ILogger logger, + IMailService mailService, + GlobalSettings globalSettings) + { + _logger = logger; + _mailService = mailService; + _globalSettings = globalSettings; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _executingTask = ExecuteAsync(_cts.Token); + return _executingTask.IsCompleted ? _executingTask : Task.CompletedTask; + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + if (_executingTask == null) + { + return; + } + _cts.Cancel(); + await Task.WhenAny(_executingTask, Task.Delay(-1, cancellationToken)); + cancellationToken.ThrowIfCancellationRequested(); + } + + private async Task ExecuteAsync(CancellationToken cancellationToken) + { + _mailQueueClient = new QueueClient(_globalSettings.Mail.ConnectionString, "mail"); + + QueueMessage[] mailMessages; + while (!cancellationToken.IsCancellationRequested) + { + if (!(mailMessages = await RetrieveMessagesAsync()).Any()) + { + await Task.Delay(TimeSpan.FromSeconds(15)); + } + + foreach (var message in mailMessages) + { + try + { + var token = JToken.Parse(message.MessageText); + if (token is JArray) + { + foreach (var mailQueueMessage in token.ToObject>()) + { + await _mailService.SendEnqueuedMailMessageAsync(mailQueueMessage); + } + } + else if (token is JObject) + { + var mailQueueMessage = token.ToObject(); + await _mailService.SendEnqueuedMailMessageAsync(mailQueueMessage); + } + } + catch (Exception e) + { + _logger.LogError(e, "Failed to send email"); + // TODO: retries? + } + + await _mailQueueClient.DeleteMessageAsync(message.MessageId, message.PopReceipt); + + if (cancellationToken.IsCancellationRequested) + { + break; + } + } + } + } + + private async Task RetrieveMessagesAsync() + { + return (await _mailQueueClient.ReceiveMessagesAsync(maxMessages: 32))?.Value ?? new QueueMessage[] { }; + } + } +} diff --git a/src/Admin/Jobs/DeleteSendsJob.cs b/src/Admin/Jobs/DeleteSendsJob.cs index 523ed09fa..6f45abb51 100644 --- a/src/Admin/Jobs/DeleteSendsJob.cs +++ b/src/Admin/Jobs/DeleteSendsJob.cs @@ -6,7 +6,6 @@ using Bit.Core.Context; using Bit.Core.Jobs; using Bit.Core.Repositories; using Bit.Core.Services; -using Microsoft.EntityFrameworkCore.Internal; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Quartz; diff --git a/src/Admin/Models/OrganizationEditModel.cs b/src/Admin/Models/OrganizationEditModel.cs index 5fe5e80fc..36167e260 100644 --- a/src/Admin/Models/OrganizationEditModel.cs +++ b/src/Admin/Models/OrganizationEditModel.cs @@ -67,7 +67,7 @@ namespace Bit.Admin.Models [Display(Name = "Plan Name")] public string Plan { get; set; } [Display(Name = "Seats")] - public short? Seats { get; set; } + public int? Seats { get; set; } [Display(Name = "Max. Collections")] public short? MaxCollections { get; set; } [Display(Name = "Policies")] diff --git a/src/Admin/Startup.cs b/src/Admin/Startup.cs index 380274586..2409989b8 100644 --- a/src/Admin/Startup.cs +++ b/src/Admin/Startup.cs @@ -90,6 +90,10 @@ namespace Bit.Admin { services.AddHostedService(); } + if (CoreHelpers.SettingHasValue(globalSettings.Mail.ConnectionString)) + { + services.AddHostedService(); + } } } diff --git a/src/Api/Controllers/OrganizationsController.cs b/src/Api/Controllers/OrganizationsController.cs index 70c356fc1..613e9532d 100644 --- a/src/Api/Controllers/OrganizationsController.cs +++ b/src/Api/Controllers/OrganizationsController.cs @@ -418,7 +418,7 @@ namespace Bit.Api.Controllers [HttpPost("{id}/import")] public async Task Import(string id, [FromBody]ImportOrganizationUsersRequestModel model) { - if (!_globalSettings.SelfHosted && + if (!_globalSettings.SelfHosted && !model.LargeImport && (model.Groups.Count() > 2000 || model.Users.Count(u => !u.Deleted) > 2000)) { throw new BadRequestException("You cannot import this much data at once."); diff --git a/src/Api/Public/Controllers/OrganizationController.cs b/src/Api/Public/Controllers/OrganizationController.cs index 03d3f002a..9399ca4d4 100644 --- a/src/Api/Public/Controllers/OrganizationController.cs +++ b/src/Api/Public/Controllers/OrganizationController.cs @@ -41,7 +41,7 @@ namespace Bit.Api.Public.Controllers [ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)] public async Task Import([FromBody]OrganizationImportRequestModel model) { - if (!_globalSettings.SelfHosted && + if (!_globalSettings.SelfHosted && !model.LargeImport && (model.Groups.Count() > 2000 || model.Members.Count(u => !u.Deleted) > 2000)) { throw new BadRequestException("You cannot import this much data at once."); diff --git a/src/Core/Models/Api/Public/Request/OrganizationImportRequestModel.cs b/src/Core/Models/Api/Public/Request/OrganizationImportRequestModel.cs index 058b7be62..2c948c1a0 100644 --- a/src/Core/Models/Api/Public/Request/OrganizationImportRequestModel.cs +++ b/src/Core/Models/Api/Public/Request/OrganizationImportRequestModel.cs @@ -20,6 +20,10 @@ namespace Bit.Core.Models.Api.Public /// [Required] public bool? OverwriteExisting { get; set; } + /// + /// Indicates an import of over 2000 users and/or groups is expected + /// + public bool LargeImport { get; set; } = false; public class OrganizationImportGroupRequestModel { diff --git a/src/Core/Models/Api/Request/Organizations/ImportOrganizationUsersRequestModel.cs b/src/Core/Models/Api/Request/Organizations/ImportOrganizationUsersRequestModel.cs index 7ac1db129..b10c479bb 100644 --- a/src/Core/Models/Api/Request/Organizations/ImportOrganizationUsersRequestModel.cs +++ b/src/Core/Models/Api/Request/Organizations/ImportOrganizationUsersRequestModel.cs @@ -11,6 +11,7 @@ namespace Bit.Core.Models.Api public Group[] Groups { get; set; } public User[] Users { get; set; } public bool OverwriteExisting { get; set; } + public bool LargeImport { get; set; } public class Group { diff --git a/src/Core/Models/Api/Request/Organizations/OrganizationCreateRequestModel.cs b/src/Core/Models/Api/Request/Organizations/OrganizationCreateRequestModel.cs index 592572b67..3d9617f1f 100644 --- a/src/Core/Models/Api/Request/Organizations/OrganizationCreateRequestModel.cs +++ b/src/Core/Models/Api/Request/Organizations/OrganizationCreateRequestModel.cs @@ -24,8 +24,8 @@ namespace Bit.Core.Models.Api public OrganizationKeysRequestModel Keys { get; set; } public PaymentMethodType? PaymentMethodType { get; set; } public string PaymentToken { get; set; } - [Range(0, double.MaxValue)] - public short AdditionalSeats { get; set; } + [Range(0, int.MaxValue)] + public int AdditionalSeats { get; set; } [Range(0, 99)] public short? AdditionalStorageGb { get; set; } public bool PremiumAccessAddon { get; set; } diff --git a/src/Core/Models/Api/Request/Organizations/OrganizationUpgradeRequestModel.cs b/src/Core/Models/Api/Request/Organizations/OrganizationUpgradeRequestModel.cs index 2283ed18d..d40df5537 100644 --- a/src/Core/Models/Api/Request/Organizations/OrganizationUpgradeRequestModel.cs +++ b/src/Core/Models/Api/Request/Organizations/OrganizationUpgradeRequestModel.cs @@ -9,8 +9,8 @@ namespace Bit.Core.Models.Api [StringLength(50)] public string BusinessName { get; set; } public PlanType PlanType { get; set; } - [Range(0, double.MaxValue)] - public short AdditionalSeats { get; set; } + [Range(0, int.MaxValue)] + public int AdditionalSeats { get; set; } [Range(0, 99)] public short? AdditionalStorageGb { get; set; } public bool PremiumAccessAddon { get; set; } diff --git a/src/Core/Models/Api/Response/OrganizationResponseModel.cs b/src/Core/Models/Api/Response/OrganizationResponseModel.cs index fadaae9aa..2332075ed 100644 --- a/src/Core/Models/Api/Response/OrganizationResponseModel.cs +++ b/src/Core/Models/Api/Response/OrganizationResponseModel.cs @@ -58,7 +58,7 @@ namespace Bit.Core.Models.Api public string BillingEmail { get; set; } public PlanResponseModel Plan { get; set; } public PlanType PlanType { get; set; } - public short? Seats { get; set; } + public int? Seats { get; set; } public short? MaxCollections { get; set; } public short? MaxStorageGb { get; set; } public bool UsePolicies { get; set; } diff --git a/src/Core/Models/Api/Response/PlanResponseModel.cs b/src/Core/Models/Api/Response/PlanResponseModel.cs index 0277a7ea9..050246a9d 100644 --- a/src/Core/Models/Api/Response/PlanResponseModel.cs +++ b/src/Core/Models/Api/Response/PlanResponseModel.cs @@ -67,7 +67,7 @@ namespace Bit.Core.Models.Api public short? MaxUsers { get; set; } public bool HasAdditionalSeatsOption { get; set; } - public short? MaxAdditionalSeats { get; set; } + public int? MaxAdditionalSeats { get; set; } public bool HasAdditionalStorageOption { get; set; } public short? MaxAdditionalStorage { get; set; } public bool HasPremiumAccessOption { get; set; } diff --git a/src/Core/Models/Business/OrganizationLicense.cs b/src/Core/Models/Business/OrganizationLicense.cs index 8e9dace41..29381098b 100644 --- a/src/Core/Models/Business/OrganizationLicense.cs +++ b/src/Core/Models/Business/OrganizationLicense.cs @@ -100,7 +100,7 @@ namespace Bit.Core.Models.Business public bool Enabled { get; set; } public string Plan { get; set; } public PlanType PlanType { get; set; } - public short? Seats { get; set; } + public int? Seats { get; set; } public short? MaxCollections { get; set; } public bool UsePolicies { get; set; } public bool UseSso { get; set; } diff --git a/src/Core/Models/Business/OrganizationUpgrade.cs b/src/Core/Models/Business/OrganizationUpgrade.cs index 06153c50b..f6d8aa415 100644 --- a/src/Core/Models/Business/OrganizationUpgrade.cs +++ b/src/Core/Models/Business/OrganizationUpgrade.cs @@ -6,7 +6,7 @@ namespace Bit.Core.Models.Business { public string BusinessName { get; set; } public PlanType Plan { get; set; } - public short AdditionalSeats { get; set; } + public int AdditionalSeats { get; set; } public short AdditionalStorageGb { get; set; } public bool PremiumAccessAddon { get; set; } public TaxInfo TaxInfo { get; set; } diff --git a/src/Core/Models/Business/ReferenceEvent.cs b/src/Core/Models/Business/ReferenceEvent.cs index b074519e9..569b6ee7b 100644 --- a/src/Core/Models/Business/ReferenceEvent.cs +++ b/src/Core/Models/Business/ReferenceEvent.cs @@ -42,7 +42,7 @@ namespace Bit.Core.Models.Business public PlanType? PlanType { get; set; } - public short? Seats { get; set; } + public int? Seats { get; set; } public short? Storage { get; set; } diff --git a/src/Core/Models/Mail/IMailQueueMessage.cs b/src/Core/Models/Mail/IMailQueueMessage.cs new file mode 100644 index 000000000..c9d70bc94 --- /dev/null +++ b/src/Core/Models/Mail/IMailQueueMessage.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace Bit.Core.Models.Mail +{ + public interface IMailQueueMessage + { + string Subject { get; set; } + IEnumerable ToEmails { get; set; } + IEnumerable BccEmails { get; set; } + string Category { get; set; } + string TemplateName { get; set; } + object Model { get; set; } + } +} diff --git a/src/Core/Models/Mail/MailQueueMessage.cs b/src/Core/Models/Mail/MailQueueMessage.cs new file mode 100644 index 000000000..2606853eb --- /dev/null +++ b/src/Core/Models/Mail/MailQueueMessage.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; + +namespace Bit.Core.Models.Mail +{ + public class MailQueueMessage : IMailQueueMessage + { + public string Subject { get; set; } + public IEnumerable ToEmails { get; set; } + public IEnumerable BccEmails { get; set; } + public string Category { get; set; } + public string TemplateName { get; set; } + public object Model { get; set; } + + public MailQueueMessage() { } + + public MailQueueMessage(MailMessage message, string templateName, object model) + { + Subject = message.Subject; + ToEmails = message.ToEmails; + BccEmails = message.BccEmails; + Category = string.IsNullOrEmpty(message.Category) ? templateName : message.Category; + TemplateName = templateName; + Model = model; + } + } +} diff --git a/src/Core/Models/StaticStore/Plan.cs b/src/Core/Models/StaticStore/Plan.cs index 2cb38e753..768f596dc 100644 --- a/src/Core/Models/StaticStore/Plan.cs +++ b/src/Core/Models/StaticStore/Plan.cs @@ -17,7 +17,7 @@ namespace Bit.Core.Models.StaticStore public short? MaxUsers { get; set; } public bool HasAdditionalSeatsOption { get; set; } - public short? MaxAdditionalSeats { get; set; } + public int? MaxAdditionalSeats { get; set; } public bool HasAdditionalStorageOption { get; set; } public short? MaxAdditionalStorage { get; set; } public bool HasPremiumAccessOption { get; set; } diff --git a/src/Core/Models/Table/Organization.cs b/src/Core/Models/Table/Organization.cs index 6acdb06db..6af3e4665 100644 --- a/src/Core/Models/Table/Organization.cs +++ b/src/Core/Models/Table/Organization.cs @@ -23,7 +23,7 @@ namespace Bit.Core.Models.Table public string BillingEmail { get; set; } public string Plan { get; set; } public PlanType PlanType { get; set; } - public short? Seats { get; set; } + public int? Seats { get; set; } public short? MaxCollections { get; set; } public bool UsePolicies { get; set; } public bool UseSso { get; set; } diff --git a/src/Core/Repositories/IEventRepository.cs b/src/Core/Repositories/IEventRepository.cs index 7f222beff..47a5e4146 100644 --- a/src/Core/Repositories/IEventRepository.cs +++ b/src/Core/Repositories/IEventRepository.cs @@ -17,6 +17,6 @@ namespace Bit.Core.Repositories Task> GetManyByCipherAsync(Cipher cipher, DateTime startDate, DateTime endDate, PageOptions pageOptions); Task CreateAsync(IEvent e); - Task CreateManyAsync(IList e); + Task CreateManyAsync(IEnumerable e); } } diff --git a/src/Core/Repositories/IOrganizationUserRepository.cs b/src/Core/Repositories/IOrganizationUserRepository.cs index c80ec6175..357833d7a 100644 --- a/src/Core/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/Repositories/IOrganizationUserRepository.cs @@ -15,6 +15,7 @@ namespace Bit.Core.Repositories Task> GetManyByUserAsync(Guid userId); Task> GetManyByOrganizationAsync(Guid organizationId, OrganizationUserType? type); Task GetCountByOrganizationAsync(Guid organizationId, string email, bool onlyRegisteredUsers); + Task> SelectKnownEmailsAsync(Guid organizationId, IEnumerable emails, bool onlyRegisteredUsers); Task GetByOrganizationAsync(Guid organizationId, Guid userId); Task>> GetByIdWithCollectionsAsync(Guid id); Task GetDetailsByIdAsync(Guid id); @@ -26,10 +27,14 @@ namespace Bit.Core.Repositories Task GetDetailsByUserAsync(Guid userId, Guid organizationId, OrganizationUserStatusType? status = null); Task UpdateGroupsAsync(Guid orgUserId, IEnumerable groupIds); + Task UpsertManyAsync(IEnumerable organizationUsers); Task CreateAsync(OrganizationUser obj, IEnumerable collections); + Task CreateManyAsync(IEnumerable organizationIdUsers); Task ReplaceAsync(OrganizationUser obj, IEnumerable collections); + Task ReplaceManyAsync(IEnumerable organizationUsers); Task> GetManyByManyUsersAsync(IEnumerable userIds); Task> GetManyAsync(IEnumerable Ids); + Task DeleteManyAsync(IEnumerable userIds); Task GetByOrganizationEmailAsync(Guid organizationId, string email); } } diff --git a/src/Core/Repositories/SqlServer/EventRepository.cs b/src/Core/Repositories/SqlServer/EventRepository.cs index 24ffd6d92..b2f114288 100644 --- a/src/Core/Repositories/SqlServer/EventRepository.cs +++ b/src/Core/Repositories/SqlServer/EventRepository.cs @@ -74,14 +74,14 @@ namespace Bit.Core.Repositories.SqlServer await base.CreateAsync(ev); } - public async Task CreateManyAsync(IList entities) + public async Task CreateManyAsync(IEnumerable entities) { if (!entities?.Any() ?? true) { return; } - if (entities.Count == 1) + if (!entities.Skip(1).Any()) { await CreateAsync(entities.First()); return; diff --git a/src/Core/Repositories/SqlServer/OrganizationUserRepository.cs b/src/Core/Repositories/SqlServer/OrganizationUserRepository.cs index 09b3a8247..95be480e5 100644 --- a/src/Core/Repositories/SqlServer/OrganizationUserRepository.cs +++ b/src/Core/Repositories/SqlServer/OrganizationUserRepository.cs @@ -76,6 +76,20 @@ namespace Bit.Core.Repositories.SqlServer } } + public async Task> SelectKnownEmailsAsync(Guid organizationId, IEnumerable emails, + bool onlyRegisteredUsers) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var result = await connection.QueryAsync( + "[dbo].[OrganizationUser_SelectKnownEmails]", + new { OrganizationId = organizationId, Emails = emails.ToArrayTVP("Email"), OnlyUsers = onlyRegisteredUsers }, + commandType: CommandType.StoredProcedure); + + return result; + } + } + public async Task GetByOrganizationAsync(Guid organizationId, Guid userId) { using (var connection = new SqlConnection(ConnectionString)) @@ -285,5 +299,71 @@ namespace Bit.Core.Repositories.SqlServer return results.SingleOrDefault(); } } + + public async Task DeleteManyAsync(IEnumerable organizationUserIds) + { + using (var connection = new SqlConnection(ConnectionString)) + { + await connection.ExecuteAsync("[dbo].[OrganizationUser_DeleteByIds]", + new { Ids = organizationUserIds.ToGuidIdArrayTVP() }, commandType: CommandType.StoredProcedure); + } + } + + public async Task UpsertManyAsync(IEnumerable organizationUsers) + { + var createUsers = new List(); + var replaceUsers = new List(); + foreach (var organizationUser in organizationUsers) + { + if (organizationUser.Id.Equals(default)) + { + createUsers.Add(organizationUser); + } + else + { + replaceUsers.Add(organizationUser); + } + } + + await CreateManyAsync(createUsers); + await ReplaceManyAsync(replaceUsers); + } + + public async Task CreateManyAsync(IEnumerable organizationUsers) + { + if (!organizationUsers.Any()) + { + return; + } + + foreach(var organizationUser in organizationUsers) + { + organizationUser.SetNewId(); + } + + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.ExecuteAsync( + $"[{Schema}].[{Table}_CreateMany]", + new { OrganizationUsersInput = organizationUsers.ToTvp() }, + commandType: CommandType.StoredProcedure); + } + } + + public async Task ReplaceManyAsync(IEnumerable organizationUsers) + { + if (!organizationUsers.Any()) + { + return; + } + + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.ExecuteAsync( + $"[{Schema}].[{Table}_UpdateMany]", + new { OrganizationUsersInput = organizationUsers.ToTvp() }, + commandType: CommandType.StoredProcedure); + } + } } } diff --git a/src/Core/Repositories/TableStorage/EventRepository.cs b/src/Core/Repositories/TableStorage/EventRepository.cs index f7c168a3a..62a77c8bb 100644 --- a/src/Core/Repositories/TableStorage/EventRepository.cs +++ b/src/Core/Repositories/TableStorage/EventRepository.cs @@ -62,14 +62,14 @@ namespace Bit.Core.Repositories.TableStorage await CreateEntityAsync(entity); } - public async Task CreateManyAsync(IList e) + public async Task CreateManyAsync(IEnumerable e) { if (!e?.Any() ?? true) { return; } - if (e.Count == 1) + if (!e.Skip(1).Any()) { await CreateAsync(e.First()); return; diff --git a/src/Core/Services/IEventService.cs b/src/Core/Services/IEventService.cs index 0acc34461..d250fc3b0 100644 --- a/src/Core/Services/IEventService.cs +++ b/src/Core/Services/IEventService.cs @@ -15,6 +15,7 @@ namespace Bit.Core.Services Task LogGroupEventAsync(Group group, EventType type, DateTime? date = null); Task LogPolicyEventAsync(Policy policy, EventType type, DateTime? date = null); Task LogOrganizationUserEventAsync(OrganizationUser organizationUser, EventType type, DateTime? date = null); + Task LogOrganizationUserEventsAsync(IEnumerable<(OrganizationUser, EventType, DateTime?)> events); Task LogOrganizationEventAsync(Organization organization, EventType type, DateTime? date = null); } } diff --git a/src/Core/Services/IEventWriteService.cs b/src/Core/Services/IEventWriteService.cs index fda65d62d..975bb3f8c 100644 --- a/src/Core/Services/IEventWriteService.cs +++ b/src/Core/Services/IEventWriteService.cs @@ -7,6 +7,6 @@ namespace Bit.Core.Services public interface IEventWriteService { Task CreateAsync(IEvent e); - Task CreateManyAsync(IList e); + Task CreateManyAsync(IEnumerable e); } } diff --git a/src/Core/Services/IMailEnqueuingService.cs b/src/Core/Services/IMailEnqueuingService.cs new file mode 100644 index 000000000..3f763e0f4 --- /dev/null +++ b/src/Core/Services/IMailEnqueuingService.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Bit.Core.Models.Mail; + +namespace Bit.Core.Services +{ + public interface IMailEnqueuingService + { + Task EnqueueAsync(IMailQueueMessage message, Func fallback); + Task EnqueueManyAsync(IEnumerable messages, Func fallback); + } +} diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index d1d8dace6..c09eb132f 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -2,6 +2,7 @@ using Bit.Core.Models.Table; using System.Collections.Generic; using System; +using Bit.Core.Models.Mail; namespace Bit.Core.Services { @@ -16,6 +17,7 @@ namespace Bit.Core.Services Task SendNoMasterPasswordHintEmailAsync(string email); Task SendMasterPasswordHintEmailAsync(string email, string hint); Task SendOrganizationInviteEmailAsync(string organizationName, OrganizationUser orgUser, string token); + Task BulkSendOrganizationInviteEmailAsync(string organizationName, IEnumerable<(OrganizationUser orgUser, string token)> invites); Task SendOrganizationAcceptedEmailAsync(string organizationName, string userEmail, IEnumerable adminEmails); Task SendOrganizationConfirmedEmailAsync(string organizationName, string email); @@ -37,5 +39,6 @@ namespace Bit.Core.Services Task SendEmergencyAccessRecoveryRejected(EmergencyAccess emergencyAccess, string rejectingName, string email); Task SendEmergencyAccessRecoveryReminder(EmergencyAccess emergencyAccess, string initiatingName, string email); Task SendEmergencyAccessRecoveryTimedOut(EmergencyAccess ea, string initiatingName, string email); + Task SendEnqueuedMailMessageAsync(IMailQueueMessage queueMessage); } } diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index 70d7a8d90..78772bb18 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -9,10 +9,10 @@ namespace Bit.Core.Services { Task CancelAndRecoverChargesAsync(ISubscriber subscriber); Task PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType, - string paymentToken, Models.StaticStore.Plan plan, short additionalStorageGb, short additionalSeats, + string paymentToken, Models.StaticStore.Plan plan, short additionalStorageGb, int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo); Task UpgradeFreeOrganizationAsync(Organization org, Models.StaticStore.Plan plan, - short additionalStorageGb, short additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo); + short additionalStorageGb, int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo); Task PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, string paymentToken, short additionalStorageGb, TaxInfo taxInfo); Task AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId); diff --git a/src/Core/Services/Implementations/AzureQueueEventWriteService.cs b/src/Core/Services/Implementations/AzureQueueEventWriteService.cs index fee106c1a..107df5ecd 100644 --- a/src/Core/Services/Implementations/AzureQueueEventWriteService.cs +++ b/src/Core/Services/Implementations/AzureQueueEventWriteService.cs @@ -4,34 +4,16 @@ using Azure.Storage.Queues; using Newtonsoft.Json; using Bit.Core.Models.Data; using Bit.Core.Settings; +using System.Linq; +using System.Text; namespace Bit.Core.Services { - public class AzureQueueEventWriteService : IEventWriteService + public class AzureQueueEventWriteService : AzureQueueService, IEventWriteService { - private readonly QueueClient _queueClient; - - private JsonSerializerSettings _jsonSettings = new JsonSerializerSettings - { - NullValueHandling = NullValueHandling.Ignore - }; - - public AzureQueueEventWriteService( - GlobalSettings globalSettings) - { - _queueClient = new QueueClient(globalSettings.Events.ConnectionString, "event"); - } - - public async Task CreateAsync(IEvent e) - { - var json = JsonConvert.SerializeObject(e, _jsonSettings); - await _queueClient.SendMessageAsync(json); - } - - public async Task CreateManyAsync(IList e) - { - var json = JsonConvert.SerializeObject(e, _jsonSettings); - await _queueClient.SendMessageAsync(json); - } + public AzureQueueEventWriteService(GlobalSettings globalSettings) : base( + new QueueClient(globalSettings.Events.ConnectionString, "event"), + new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }) + { } } } diff --git a/src/Core/Services/Implementations/AzureQueueMailService.cs b/src/Core/Services/Implementations/AzureQueueMailService.cs new file mode 100644 index 000000000..c7fc28afc --- /dev/null +++ b/src/Core/Services/Implementations/AzureQueueMailService.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Azure.Storage.Queues; +using Bit.Core.Models.Mail; +using Bit.Core.Settings; +using Newtonsoft.Json; + +namespace Bit.Core.Services +{ + public class AzureQueueMailService : AzureQueueService, IMailEnqueuingService + { + public AzureQueueMailService(GlobalSettings globalSettings) : base( + new QueueClient(globalSettings.Mail.ConnectionString, "mail"), + new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }) + { } + + public Task EnqueueAsync(IMailQueueMessage message, Func fallback) => + CreateAsync(message); + + public Task EnqueueManyAsync(IEnumerable messages, Func fallback) => + CreateManyAsync(messages); + } +} diff --git a/src/Core/Services/Implementations/AzureQueueService.cs b/src/Core/Services/Implementations/AzureQueueService.cs new file mode 100644 index 000000000..faccc123a --- /dev/null +++ b/src/Core/Services/Implementations/AzureQueueService.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using System.Threading.Tasks; +using Azure.Storage.Queues; +using IdentityServer4.Extensions; +using Microsoft.EntityFrameworkCore.Internal; +using Newtonsoft.Json; + +namespace Bit.Core.Services +{ + public abstract class AzureQueueService + { + protected QueueClient _queueClient; + protected JsonSerializerSettings _jsonSettings; + + protected AzureQueueService(QueueClient queueClient, JsonSerializerSettings jsonSettings) + { + _queueClient = queueClient; + _jsonSettings = jsonSettings; + } + + public async Task CreateAsync(T message) + { + var json = JsonConvert.SerializeObject(message, _jsonSettings); + await _queueClient.SendMessageAsync(json); + } + + public async Task CreateManyAsync(IEnumerable messages) + { + if (messages?.Any() != true) + { + return; + } + + if (!messages.Skip(1).Any()) + { + await CreateAsync(messages.First()); + return; + } + + foreach (var json in SerializeMany(messages, _jsonSettings)) + { + await _queueClient.SendMessageAsync(json); + } + } + + + protected IEnumerable SerializeMany(IEnumerable messages, JsonSerializerSettings jsonSettings) + { + var messagesLists = new List> { new List() }; + var strings = new List(); + var ListMessageLength = 2; // to account for json array brackets "[]" + foreach (var (message, jsonEvent) in messages.Select(e => (e, JsonConvert.SerializeObject(e, jsonSettings)))) + { + + var messageLength = jsonEvent.Length + 1; // To account for json array comma + if (ListMessageLength + messageLength > _queueClient.MessageMaxBytes) + { + messagesLists.Add(new List { message }); + ListMessageLength = 2 + messageLength; + } + else + { + messagesLists.Last().Add(message); + ListMessageLength += messageLength; + } + } + return messagesLists.Select(l => JsonConvert.SerializeObject(l, jsonSettings)); + } + } +} diff --git a/src/Core/Services/Implementations/BlockingMailQueueService.cs b/src/Core/Services/Implementations/BlockingMailQueueService.cs new file mode 100644 index 000000000..58e03df6e --- /dev/null +++ b/src/Core/Services/Implementations/BlockingMailQueueService.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Threading.Tasks; +using Bit.Core.Models.Mail; + +namespace Bit.Core.Services +{ + public class BlockingMailEnqueuingService : IMailEnqueuingService + { + public async Task EnqueueAsync(IMailQueueMessage message, Func fallback) + { + await fallback(message); + } + + public async Task EnqueueManyAsync(IEnumerable messages, Func fallback) + { + foreach(var message in messages) + { + await fallback(message); + } + } + } +} diff --git a/src/Core/Services/Implementations/EventService.cs b/src/Core/Services/Implementations/EventService.cs index 2c9be7ca1..32280b084 100644 --- a/src/Core/Services/Implementations/EventService.cs +++ b/src/Core/Services/Implementations/EventService.cs @@ -178,24 +178,31 @@ namespace Bit.Core.Services } public async Task LogOrganizationUserEventAsync(OrganizationUser organizationUser, EventType type, - DateTime? date = null) + DateTime? date = null) => + await LogOrganizationUserEventsAsync(new[] { (organizationUser, type, date) }); + + public async Task LogOrganizationUserEventsAsync(IEnumerable<(OrganizationUser, EventType, DateTime?)> events) { var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); - if (!CanUseEvents(orgAbilities, organizationUser.OrganizationId)) + var eventMessages = new List(); + foreach (var (organizationUser, type, date) in events) { - return; + if (!CanUseEvents(orgAbilities, organizationUser.OrganizationId)) + { + continue; + } + eventMessages.Add(new EventMessage + { + OrganizationId = organizationUser.OrganizationId, + UserId = organizationUser.UserId, + OrganizationUserId = organizationUser.Id, + Type = type, + ActingUserId = _currentContext?.UserId, + Date = date.GetValueOrDefault(DateTime.UtcNow) + }); } - var e = new EventMessage(_currentContext) - { - OrganizationId = organizationUser.OrganizationId, - UserId = organizationUser.UserId, - OrganizationUserId = organizationUser.Id, - Type = type, - ActingUserId = _currentContext?.UserId, - Date = date.GetValueOrDefault(DateTime.UtcNow) - }; - await _eventWriteService.CreateAsync(e); + await _eventWriteService.CreateManyAsync(eventMessages); } public async Task LogOrganizationEventAsync(Organization organization, EventType type, DateTime? date = null) diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 72ed4dba7..17c2ee7a4 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -19,6 +19,7 @@ namespace Bit.Core.Services private readonly GlobalSettings _globalSettings; private readonly IMailDeliveryService _mailDeliveryService; + private readonly IMailEnqueuingService _mailEnqueuingService; private readonly Dictionary> _templateCache = new Dictionary>(); @@ -26,10 +27,12 @@ namespace Bit.Core.Services public HandlebarsMailService( GlobalSettings globalSettings, - IMailDeliveryService mailDeliveryService) + IMailDeliveryService mailDeliveryService, + IMailEnqueuingService mailEnqueuingService) { _globalSettings = globalSettings; _mailDeliveryService = mailDeliveryService; + _mailEnqueuingService = mailEnqueuingService; } public async Task SendVerifyEmailEmailAsync(string email, Guid userId, string token) @@ -168,23 +171,32 @@ namespace Bit.Core.Services await _mailDeliveryService.SendEmailAsync(message); } - public async Task SendOrganizationInviteEmailAsync(string organizationName, OrganizationUser orgUser, string token) + public Task SendOrganizationInviteEmailAsync(string organizationName, OrganizationUser orgUser, string token) => + BulkSendOrganizationInviteEmailAsync(organizationName, new[] { (orgUser, token) }); + + public async Task BulkSendOrganizationInviteEmailAsync(string organizationName, IEnumerable<(OrganizationUser orgUser, string token)> invites) { - var message = CreateDefaultMessage($"Join {organizationName}", orgUser.Email); - var model = new OrganizationUserInvitedViewModel + MailQueueMessage CreateMessage(string email, object model) { - OrganizationName = CoreHelpers.SanitizeForEmail(organizationName), - Email = WebUtility.UrlEncode(orgUser.Email), - OrganizationId = orgUser.OrganizationId.ToString(), - OrganizationUserId = orgUser.Id.ToString(), - Token = WebUtility.UrlEncode(token), - OrganizationNameUrlEncoded = WebUtility.UrlEncode(organizationName), - WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, - SiteName = _globalSettings.SiteName - }; - await AddMessageContentAsync(message, "OrganizationUserInvited", model); - message.Category = "OrganizationUserInvited"; - await _mailDeliveryService.SendEmailAsync(message); + var message = CreateDefaultMessage($"Join {organizationName}", email); + return new MailQueueMessage(message, "OrganizationUserInvited", model); + } + + var messageModels = invites.Select(invite => CreateMessage(invite.orgUser.Email, + new OrganizationUserInvitedViewModel + { + OrganizationName = CoreHelpers.SanitizeForEmail(organizationName), + Email = WebUtility.UrlEncode(invite.orgUser.Email), + OrganizationId = invite.orgUser.OrganizationId.ToString(), + OrganizationUserId = invite.orgUser.Id.ToString(), + Token = WebUtility.UrlEncode(invite.token), + OrganizationNameUrlEncoded = WebUtility.UrlEncode(organizationName), + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName, + } + )); + + await EnqueueMailAsync(messageModels); } public async Task SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(string organizationName, string email) @@ -341,6 +353,21 @@ namespace Bit.Core.Services await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendEnqueuedMailMessageAsync(IMailQueueMessage queueMessage) + { + var message = CreateDefaultMessage(queueMessage.Subject, queueMessage.ToEmails); + message.BccEmails = queueMessage.BccEmails; + message.Category = queueMessage.Category; + await AddMessageContentAsync(message, queueMessage.TemplateName, queueMessage.Model); + await _mailDeliveryService.SendEmailAsync(message); + } + + private Task EnqueueMailAsync(IMailQueueMessage queueMessage) => + _mailEnqueuingService.EnqueueAsync(queueMessage, SendEnqueuedMailMessageAsync); + + private Task EnqueueMailAsync(IEnumerable queueMessages) => + _mailEnqueuingService.EnqueueManyAsync(queueMessages, SendEnqueuedMailMessageAsync); + private MailMessage CreateDefaultMessage(string subject, string toEmail) { return CreateDefaultMessage(subject, new List { toEmail }); diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index 86b55843f..588f2235f 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -443,9 +443,9 @@ namespace Bit.Core.Services var taxRate = taxRates.FirstOrDefault(); if (taxRate != null && !sub.DefaultTaxRates.Any(x => x.Equals(taxRate.Id))) { - subUpdateOptions.DefaultTaxRates = new List(1) - { - taxRate.Id + subUpdateOptions.DefaultTaxRates = new List(1) + { + taxRate.Id }; } } @@ -1011,6 +1011,117 @@ namespace Bit.Core.Services await UpdateAsync(organization); } + private async Task> InviteUsersAsync(Guid organizationId, Guid? invitingUserId, + IEnumerable<(OrganizationUserInvite invite, string externalId)> invites) + { + var organization = await GetOrgById(organizationId); + if (organization == null || invites.Any(i => i.invite.Emails == null || i.externalId == null)) + { + throw new NotFoundException(); + } + + var inviteTypes = new HashSet(invites.Where(i => i.invite.Type.HasValue) + .Select(i => i.invite.Type.Value)); + if (invitingUserId.HasValue && inviteTypes.Count > 0) + { + foreach (var type in inviteTypes) + { + await ValidateOrganizationUserUpdatePermissionsAsync(invitingUserId.Value, organizationId, type, null); + } + } + + if (organization.Seats.HasValue) + { + var userCount = await _organizationUserRepository.GetCountByOrganizationIdAsync(organizationId); + var availableSeats = organization.Seats.Value - userCount; + if (availableSeats < invites.Select(i => i.invite.Emails.Count()).Sum()) + { + throw new BadRequestException("You have reached the maximum number of users " + + $"({organization.Seats.Value}) for this organization."); + } + } + + var orgUsers = new List(); + var orgUserInvitedCount = 0; + var exceptions = new List(); + var events = new List<(OrganizationUser, EventType, DateTime?)>(); + var existingEmails = new HashSet(await _organizationUserRepository.SelectKnownEmailsAsync( + organizationId, invites.SelectMany(i => i.invite.Emails), false), StringComparer.InvariantCultureIgnoreCase); + foreach (var (invite, externalId) in invites) + { + foreach (var email in invite.Emails) + { + try + { + // Make sure user is not already invited + if (existingEmails.Contains(email)) + { + continue; + } + + var orgUser = new OrganizationUser + { + OrganizationId = organizationId, + UserId = null, + Email = email.ToLowerInvariant(), + Key = null, + Type = invite.Type.Value, + Status = OrganizationUserStatusType.Invited, + AccessAll = invite.AccessAll, + ExternalId = externalId, + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow, + }; + + if (invite.Permissions != null) + { + orgUser.Permissions = System.Text.Json.JsonSerializer.Serialize(invite.Permissions, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }); + } + + if (!orgUser.AccessAll && invite.Collections.Any()) + { + throw new Exception("Bulk invite does not support limited collection invites"); + } + + events.Add((orgUser, EventType.OrganizationUser_Invited, DateTime.UtcNow)); + orgUsers.Add(orgUser); + orgUserInvitedCount++; + } + catch (Exception e) + { + exceptions.Add(e); + } + } + } + + try + { + await _organizationUserRepository.CreateManyAsync(orgUsers); + await SendInvitesAsync(orgUsers, organization); + await _eventService.LogOrganizationUserEventsAsync(events); + + await _referenceEventService.RaiseEventAsync( + new ReferenceEvent(ReferenceEventType.InvitedUsers, organization) + { + Users = orgUserInvitedCount + }); + } + catch (Exception e) + { + exceptions.Add(e); + } + + if (exceptions.Any()) + { + throw new AggregateException("One or more errors occurred while inviting users.", exceptions); + } + + return orgUsers; + } + public async Task> InviteUserAsync(Guid organizationId, Guid? invitingUserId, string externalId, OrganizationUserInvite invite) { @@ -1022,7 +1133,7 @@ namespace Bit.Core.Services if (invitingUserId.HasValue && invite.Type.HasValue) { - await ValidateOrganizationUserUpdatePermissions(invitingUserId.Value, organizationId, invite.Type.Value, null); + await ValidateOrganizationUserUpdatePermissionsAsync(invitingUserId.Value, organizationId, invite.Type.Value, null); } if (organization.Seats.HasValue) @@ -1125,6 +1236,14 @@ namespace Bit.Core.Services await SendInviteAsync(orgUser, org); } + private async Task SendInvitesAsync(IEnumerable orgUsers, Organization organization) + { + string MakeToken(OrganizationUser orgUser) => + _dataProtector.Protect($"OrganizationUserInvite {orgUser.Id} {orgUser.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); + await _mailService.BulkSendOrganizationInviteEmailAsync(organization.Name, + orgUsers.Select(o => (o, MakeToken(o)))); + } + private async Task SendInviteAsync(OrganizationUser orgUser, Organization organization) { var nowMillis = CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow); @@ -1185,7 +1304,7 @@ namespace Bit.Core.Services return await AcceptUserAsync(orgUser, user, userService); } - private async Task AcceptUserAsync(OrganizationUser orgUser, User user, + private async Task AcceptUserAsync(OrganizationUser orgUser, User user, IUserService userService) { if (orgUser.Status != OrganizationUserStatusType.Invited) @@ -1322,13 +1441,14 @@ namespace Bit.Core.Services } var originalUser = await _organizationUserRepository.GetByIdAsync(user.Id); - if (user.Equals(originalUser)) { + if (user.Equals(originalUser)) + { throw new BadRequestException("Please make changes before saving."); } if (savingUserId.HasValue) { - await ValidateOrganizationUserUpdatePermissions(savingUserId.Value, user.OrganizationId, user.Type, originalUser.Type); + await ValidateOrganizationUserUpdatePermissionsAsync(savingUserId.Value, user.OrganizationId, user.Type, originalUser.Type); } if (user.Type != OrganizationUserType.Owner && @@ -1459,13 +1579,13 @@ namespace Bit.Core.Services { if (loggedInUserId.HasValue) { - await ValidateOrganizationUserUpdatePermissions(loggedInUserId.Value, organizationUser.OrganizationId, organizationUser.Type, null); + await ValidateOrganizationUserUpdatePermissionsAsync(loggedInUserId.Value, organizationUser.OrganizationId, organizationUser.Type, null); } await _organizationUserRepository.UpdateGroupsAsync(organizationUser.Id, groupIds); await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_UpdatedGroups); } - + public async Task UpdateUserResetPasswordEnrollmentAsync(Guid organizationId, Guid organizationUserId, string resetPasswordKey, Guid? callingUserId) { var orgUser = await _organizationUserRepository.GetByOrganizationAsync(organizationId, organizationUserId); @@ -1480,7 +1600,7 @@ namespace Bit.Core.Services orgUser.ResetPasswordKey = resetPasswordKey; await _organizationUserRepository.ReplaceAsync(orgUser); - await _eventService.LogOrganizationUserEventAsync(orgUser, resetPasswordKey != null ? + await _eventService.LogOrganizationUserEventAsync(orgUser, resetPasswordKey != null ? EventType.OrganizationUser_ResetPassword_Enroll : EventType.OrganizationUser_ResetPassword_Withdraw); } @@ -1558,32 +1678,23 @@ namespace Bit.Core.Services var removeUsersSet = new HashSet(removeUserExternalIds); var existingUsersDict = existingExternalUsers.ToDictionary(u => u.ExternalId); - var usersToRemove = removeUsersSet + await _organizationUserRepository.DeleteManyAsync(removeUsersSet .Except(newUsersSet) - .Where(ru => existingUsersDict.ContainsKey(ru)) - .Select(ru => existingUsersDict[ru]); - - foreach (var user in usersToRemove) - { - if (user.Type != OrganizationUserType.Owner) - { - await _organizationUserRepository.DeleteAsync(new OrganizationUser { Id = user.Id }); - existingExternalUsersIdDict.Remove(user.ExternalId); - } - } + .Where(u => existingUsersDict.ContainsKey(u) && existingUsersDict[u].Type != OrganizationUserType.Owner) + .Select(u => existingUsersDict[u].Id)); } if (overwriteExisting) { // Remove existing external users that are not in new user set - foreach (var user in existingExternalUsers) + var usersToDelete = existingExternalUsers.Where(u => + u.Type != OrganizationUserType.Owner && + !newUsersSet.Contains(u.ExternalId) && + existingExternalUsersIdDict.ContainsKey(u.ExternalId)); + await _organizationUserRepository.DeleteManyAsync(usersToDelete.Select(u => u.Id)); + foreach (var deletedUser in usersToDelete) { - if (user.Type != OrganizationUserType.Owner && !newUsersSet.Contains(user.ExternalId) && - existingExternalUsersIdDict.ContainsKey(user.ExternalId)) - { - await _organizationUserRepository.DeleteAsync(new OrganizationUser { Id = user.Id }); - existingExternalUsersIdDict.Remove(user.ExternalId); - } + existingExternalUsersIdDict.Remove(deletedUser.ExternalId); } } @@ -1595,6 +1706,7 @@ namespace Bit.Core.Services .ToDictionary(u => u.Email); var newUsersEmailsDict = newUsers.ToDictionary(u => u.Email); var usersToAttach = existingUsersEmailsDict.Keys.Intersect(newUsersEmailsDict.Keys).ToList(); + var usersToUpsert = new List(); foreach (var user in usersToAttach) { var orgUserDetails = existingUsersEmailsDict[user]; @@ -1602,10 +1714,11 @@ namespace Bit.Core.Services if (orgUser != null) { orgUser.ExternalId = newUsersEmailsDict[user].ExternalId; - await _organizationUserRepository.UpsertAsync(orgUser); + usersToUpsert.Add(orgUser); existingExternalUsersIdDict.Add(orgUser.ExternalId, orgUser.Id); } } + await _organizationUserRepository.UpsertManyAsync(usersToUpsert); // Add new users var existingUsersSet = new HashSet(existingExternalUsersIdDict.Keys); @@ -1620,11 +1733,12 @@ namespace Bit.Core.Services enoughSeatsAvailable = seatsAvailable >= usersToAdd.Count; } - if (!enoughSeatsAvailable) + if (!enoughSeatsAvailable) { throw new BadRequestException($"Organization does not have enough seats available. Need {usersToAdd.Count} but {seatsAvailable} available."); } + var userInvites = new List<(OrganizationUserInvite, string)>(); foreach (var user in newUsers) { if (!usersToAdd.Contains(user.ExternalId) || string.IsNullOrWhiteSpace(user.Email)) @@ -1641,9 +1755,7 @@ namespace Bit.Core.Services AccessAll = false, Collections = new List(), }; - var newUser = await InviteUserAsync(organizationId, importingUserId, user.Email, - OrganizationUserType.User, false, user.ExternalId, new List()); - existingExternalUsersIdDict.Add(newUser.ExternalId, newUser.Id); + userInvites.Add((invite, user.ExternalId)); } catch (BadRequestException) { @@ -1651,10 +1763,16 @@ namespace Bit.Core.Services continue; } } + + var invitedUsers = await InviteUsersAsync(organizationId, importingUserId, userInvites); + foreach (var invitedUser in invitedUsers) + { + existingExternalUsersIdDict.Add(invitedUser.ExternalId, invitedUser.Id); + } } - // Groups + // Groups if (groups?.Any() ?? false) { if (!organization.UseGroups) @@ -1822,7 +1940,8 @@ namespace Bit.Core.Services } } - private async Task ValidateOrganizationUserUpdatePermissions(Guid loggedInUserId, Guid organizationId, OrganizationUserType newType, OrganizationUserType? oldType) + private async Task ValidateOrganizationUserUpdatePermissionsAsync(Guid loggedInUserId, Guid organizationId, + OrganizationUserType newType, OrganizationUserType? oldType) { var loggedInUserOrgs = await _organizationUserRepository.GetManyByUserAsync(loggedInUserId); var loggedInAsOrgOwner = loggedInUserOrgs diff --git a/src/Core/Services/Implementations/RepositoryEventWriteService.cs b/src/Core/Services/Implementations/RepositoryEventWriteService.cs index e9d0a4621..9551f89e8 100644 --- a/src/Core/Services/Implementations/RepositoryEventWriteService.cs +++ b/src/Core/Services/Implementations/RepositoryEventWriteService.cs @@ -20,7 +20,7 @@ namespace Bit.Core.Services await _eventRepository.CreateAsync(e); } - public async Task CreateManyAsync(IList e) + public async Task CreateManyAsync(IEnumerable e) { await _eventRepository.CreateManyAsync(e); } diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 00044e606..88006eb21 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -55,7 +55,7 @@ namespace Bit.Core.Services public async Task PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType, string paymentToken, Models.StaticStore.Plan plan, short additionalStorageGb, - short additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo) + int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo) { var customerService = new CustomerService(); @@ -202,7 +202,7 @@ namespace Bit.Core.Services } public async Task UpgradeFreeOrganizationAsync(Organization org, Models.StaticStore.Plan plan, - short additionalStorageGb, short additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo) + short additionalStorageGb, int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo) { if (!string.IsNullOrWhiteSpace(org.GatewaySubscriptionId)) { diff --git a/src/Core/Services/NoopImplementations/NoopEventService.cs b/src/Core/Services/NoopImplementations/NoopEventService.cs index 1a3fa27ef..60fd1fa5b 100644 --- a/src/Core/Services/NoopImplementations/NoopEventService.cs +++ b/src/Core/Services/NoopImplementations/NoopEventService.cs @@ -44,6 +44,11 @@ namespace Bit.Core.Services return Task.FromResult(0); } + public Task LogOrganizationUserEventsAsync(IEnumerable<(OrganizationUser, EventType, DateTime?)> events) + { + return Task.FromResult(0); + } + public Task LogUserEventAsync(Guid userId, EventType type, DateTime? date = null) { return Task.FromResult(0); diff --git a/src/Core/Services/NoopImplementations/NoopEventWriteService.cs b/src/Core/Services/NoopImplementations/NoopEventWriteService.cs index 782994d6b..00f2f027b 100644 --- a/src/Core/Services/NoopImplementations/NoopEventWriteService.cs +++ b/src/Core/Services/NoopImplementations/NoopEventWriteService.cs @@ -11,7 +11,7 @@ namespace Bit.Core.Services return Task.FromResult(0); } - public Task CreateManyAsync(IList e) + public Task CreateManyAsync(IEnumerable e) { return Task.FromResult(0); } diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index e65b2358e..29bef4892 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Bit.Core.Models.Mail; using Bit.Core.Models.Table; namespace Bit.Core.Services @@ -47,6 +48,11 @@ namespace Bit.Core.Services return Task.FromResult(0); } + public Task BulkSendOrganizationInviteEmailAsync(string organizationName, IEnumerable<(OrganizationUser orgUser, string token)> invites) + { + return Task.FromResult(0); + } + public Task SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(string organizationName, string email) { return Task.FromResult(0); @@ -147,5 +153,10 @@ namespace Bit.Core.Services { return Task.FromResult(0); } + + public Task SendEnqueuedMailMessageAsync(IMailQueueMessage queueMessage) + { + return Task.FromResult(0); + } } } diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 6e2ff2cce..12b95e149 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -272,6 +272,19 @@ namespace Bit.Core.Settings public class MailSettings { + private ConnectionStringSettings _connectionStringSettings; + public string ConnectionString + { + get => _connectionStringSettings?.ConnectionString; + set + { + if (_connectionStringSettings == null) + { + _connectionStringSettings = new ConnectionStringSettings(); + } + _connectionStringSettings.ConnectionString = value; + } + } public string ReplyToEmail { get; set; } public string AmazonConfigSetName { get; set; } public SmtpSettings Smtp { get; set; } = new SmtpSettings(); diff --git a/src/Core/Utilities/CoreHelpers.cs b/src/Core/Utilities/CoreHelpers.cs index d5843f517..d3a101a8c 100644 --- a/src/Core/Utilities/CoreHelpers.cs +++ b/src/Core/Utilities/CoreHelpers.cs @@ -148,6 +148,55 @@ namespace Bit.Core.Utilities return table; } + public static DataTable ToTvp(this IEnumerable orgUsers) + { + var table = new DataTable(); + table.SetTypeName("[dbo].[OrganizationUserType]"); + + var columnData = new List<(string name, Type type, Func getter)> + { + (nameof(OrganizationUser.Id), typeof(Guid), ou => ou.Id), + (nameof(OrganizationUser.OrganizationId), typeof(Guid), ou => ou.OrganizationId), + (nameof(OrganizationUser.UserId), typeof(Guid), ou => ou.UserId), + (nameof(OrganizationUser.Email), typeof(string), ou => ou.Email), + (nameof(OrganizationUser.Key), typeof(string), ou => ou.Key), + (nameof(OrganizationUser.Status), typeof(byte), ou => ou.Status), + (nameof(OrganizationUser.Type), typeof(byte), ou => ou.Type), + (nameof(OrganizationUser.AccessAll), typeof(bool), ou => ou.AccessAll), + (nameof(OrganizationUser.ExternalId), typeof(string), ou => ou.ExternalId), + (nameof(OrganizationUser.CreationDate), typeof(DateTime), ou => ou.CreationDate), + (nameof(OrganizationUser.RevisionDate), typeof(DateTime), ou => ou.RevisionDate), + (nameof(OrganizationUser.Permissions), typeof(string), ou => ou.Permissions), + (nameof(OrganizationUser.ResetPasswordKey), typeof(Guid), ou => ou.UserId), + }; + + foreach (var (name, type, getter) in columnData) + { + var column = new DataColumn(name, type); + table.Columns.Add(column); + } + + foreach (var orgUser in orgUsers ?? new OrganizationUser[] { }) + { + var row = table.NewRow(); + foreach (var (name, type, getter) in columnData) + { + var val = getter(orgUser); + if (val == null) + { + row[name] = DBNull.Value; + } + else + { + row[name] = val; + } + } + table.Rows.Add(row); + } + + return table; + } + public static string CleanCertificateThumbprint(string thumbprint) { // Clean possible garbage characters from thumbprint copy/paste diff --git a/src/Core/Utilities/ServiceCollectionExtensions.cs b/src/Core/Utilities/ServiceCollectionExtensions.cs index 33d465256..be4fee35d 100644 --- a/src/Core/Utilities/ServiceCollectionExtensions.cs +++ b/src/Core/Utilities/ServiceCollectionExtensions.cs @@ -192,6 +192,15 @@ namespace Bit.Core.Utilities services.AddSingleton(); } + if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Mail.ConnectionString)) + { + services.AddSingleton(); + } + else + { + services.AddSingleton(); + } + if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString)) { services.AddSingleton(); diff --git a/src/Sql/Sql.sqlproj b/src/Sql/Sql.sqlproj index 0fba467d2..4e0e1fbbe 100644 --- a/src/Sql/Sql.sqlproj +++ b/src/Sql/Sql.sqlproj @@ -108,8 +108,10 @@ + + @@ -123,12 +125,16 @@ + + + + @@ -202,7 +208,10 @@ + + + @@ -272,6 +281,7 @@ + diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateMany.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateMany.sql new file mode 100644 index 000000000..917553c34 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateMany.sql @@ -0,0 +1,40 @@ +CREATE PROCEDURE [dbo].[OrganizationUser_CreateMany] + @OrganizationUsersInput [dbo].[OrganizationUserType] READONLY +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[OrganizationUser] + ( + [Id], + [OrganizationId], + [UserId], + [Email], + [Key], + [Status], + [Type], + [AccessAll], + [ExternalId], + [CreationDate], + [RevisionDate], + [Permissions], + [ResetPasswordKey] + ) + SELECT + OU.[Id], + OU.[OrganizationId], + OU.[UserId], + OU.[Email], + OU.[Key], + OU.[Status], + OU.[Type], + OU.[AccessAll], + OU.[ExternalId], + OU.[CreationDate], + OU.[RevisionDate], + OU.[Permissions], + OU.[ResetPasswordKey] + FROM + @OrganizationUsersInput OU +END +GO diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_DeleteByIds.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_DeleteByIds.sql new file mode 100644 index 000000000..df8bfc315 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_DeleteByIds.sql @@ -0,0 +1,83 @@ +CREATE PROCEDURE [dbo].[OrganizationUser_DeleteByIds] + @Ids [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationUserIds] @Ids + + DECLARE @UserAndOrganizationIds [dbo].[TwoGuidIdArray] + + INSERT INTO @UserAndOrganizationIds + (Id1, Id2) + SELECT + UserId, + OrganizationId + FROM + [dbo].[OrganizationUser] OU + INNER JOIN + @Ids OUIds ON OUIds.Id = OU.Id + WHERE + UserId IS NOT NULL AND + OrganizationId IS NOT NULL + + BEGIN + EXEC [dbo].[SsoUser_DeleteMany] @UserAndOrganizationIds + END + + DECLARE @BatchSize INT = 100 + + -- Delete CollectionUsers + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION CollectionUser_DeleteMany_CollectionUsers + + DELETE TOP(@BatchSize) CU + FROM + [dbo].[CollectionUser] CU + INNER JOIN + @Ids I ON I.Id = CU.OrganizationUserId + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION CollectionUser_DeleteMany_CollectionUsers + END + + SET @BatchSize = 100; + + -- Delete GroupUsers + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION GroupUser_DeleteMany_GroupUsers + + DELETE TOP(@BatchSize) GU + FROM + [dbo].[GroupUser] GU + INNER JOIN + @Ids I ON I.Id = GU.OrganizationUserId + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION GoupUser_DeleteMany_GroupUsers + END + + + SET @BatchSize = 100; + + -- Delete OrganizationUsers + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION OrganizationUser_DeleteMany_OrganizationUsers + + DELETE TOP(@BatchSize) OU + FROM + [dbo].[OrganizationUser] OU + INNER JOIN + @Ids I ON I.Id = OU.Id + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION OrganizationUser_DeleteMany_OrganizationUsers + END +END +GO diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_SelectKnownEmails.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_SelectKnownEmails.sql new file mode 100644 index 000000000..48dcc9dc7 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_SelectKnownEmails.sql @@ -0,0 +1,30 @@ +CREATE PROCEDURE [dbo].[OrganizationUser_SelectKnownEmails] + @OrganizationId UNIQUEIDENTIFIER, + @Emails [dbo].[EmailArray] READONLY, + @OnlyUsers BIT +AS +BEGIN + SET NOCOUNT ON + + SELECT + E.Email + FROM + @Emails E + INNER JOIN + ( + SELECT + U.[Email] as 'UEmail', + OU.[Email] as 'OUEmail', + OU.OrganizationId + FROM + [dbo].[User] U + RIGHT JOIN + [dbo].[OrganizationUser] OU ON OU.[UserId] = U.[Id] + WHERE + OU.OrganizationId = @OrganizationId + ) OUU ON OUU.[UEmail] = E.[Email] OR OUU.[OUEmail] = E.[Email] + WHERE + (@OnlyUsers = 0 AND (OUU.UEmail IS NOT NULL OR OUU.OUEmail IS NOT NULL)) OR + (@OnlyUsers = 1 AND (OUU.UEmail IS NOT NULL)) + +END diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateMany.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateMany.sql new file mode 100644 index 000000000..aad6ddedc --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateMany.sql @@ -0,0 +1,33 @@ +CREATE PROCEDURE [dbo].[OrganizationUser_UpdateMany] + @OrganizationUsersInput [dbo].[OrganizationUserType] READONLY +AS +BEGIN + SET NOCOUNT ON + + UPDATE + OU + SET + [OrganizationId] = OUI.[OrganizationId], + [UserId] = OUI.[UserId], + [Email] = OUI.[Email], + [Key] = OUI.[Key], + [Status] = OUI.[Status], + [Type] = OUI.[Type], + [AccessAll] = OUI.[AccessAll], + [ExternalId] = OUI.[ExternalId], + [CreationDate] = OUI.[CreationDate], + [RevisionDate] = OUI.[RevisionDate], + [Permissions] = OUI.[Permissions], + [ResetPasswordKey] = OUI.[ResetPasswordKey] + FROM + [dbo].[OrganizationUser] OU + INNER JOIN + @OrganizationUsersInput OUI ON OU.Id = OUI.Id + + EXEC [dbo].[User_BumpManyAccountRevisionDates] + ( + SELECT UserId + FROM @OrganizationUsersInput + ) +END +GO diff --git a/src/Sql/dbo/Stored Procedures/Organization_Create.sql b/src/Sql/dbo/Stored Procedures/Organization_Create.sql index 95cd0438c..cea32a9ce 100644 --- a/src/Sql/dbo/Stored Procedures/Organization_Create.sql +++ b/src/Sql/dbo/Stored Procedures/Organization_Create.sql @@ -11,7 +11,7 @@ @BillingEmail NVARCHAR(256), @Plan NVARCHAR(50), @PlanType TINYINT, - @Seats SMALLINT, + @Seats INT, @MaxCollections SMALLINT, @UsePolicies BIT, @UseSso BIT, diff --git a/src/Sql/dbo/Stored Procedures/Organization_Update.sql b/src/Sql/dbo/Stored Procedures/Organization_Update.sql index 45ae43f38..186645ded 100644 --- a/src/Sql/dbo/Stored Procedures/Organization_Update.sql +++ b/src/Sql/dbo/Stored Procedures/Organization_Update.sql @@ -11,7 +11,7 @@ @BillingEmail NVARCHAR(256), @Plan NVARCHAR(50), @PlanType TINYINT, - @Seats SMALLINT, + @Seats INT, @MaxCollections SMALLINT, @UsePolicies BIT, @UseSso BIT, diff --git a/src/Sql/dbo/Stored Procedures/SsoUser_DeleteMany.sql b/src/Sql/dbo/Stored Procedures/SsoUser_DeleteMany.sql new file mode 100644 index 000000000..e69b1ba9a --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/SsoUser_DeleteMany.sql @@ -0,0 +1,34 @@ +CREATE PROCEDURE [dbo].[SsoUser_DeleteMany] + @UserAndOrganizationIds [dbo].[TwoGuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + SELECT + Id + INTO + #SSOIds + FROM + [dbo].[SsoUser] SU + INNER JOIN + @UserAndOrganizationIds UOI ON UOI.Id1 = SU.UserId AND UOI.Id2 = SU.OrganizationId + + DECLARE @BatchSize INT = 100 + + -- Delete SSO Users + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION SsoUser_DeleteMany_SsoUsers + + DELETE TOP(@BatchSize) SU + FROM + [dbo].[SsoUser] SU + INNER JOIN + #SSOIds ON #SSOIds.Id = SU.Id + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION SsoUser_DeleteMany_SsoUsers + END +END +GO diff --git a/src/Sql/dbo/Stored Procedures/User_BumpAccountRevisionDateByOrganizationUserIds.sql b/src/Sql/dbo/Stored Procedures/User_BumpAccountRevisionDateByOrganizationUserIds.sql new file mode 100644 index 000000000..9daa82fcc --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/User_BumpAccountRevisionDateByOrganizationUserIds.sql @@ -0,0 +1,18 @@ +CREATE PROCEDURE [dbo].[User_BumpAccountRevisionDateByOrganizationUserIds] + @OrganizationUserIds [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + UPDATE + U + SET + U.[AccountRevisionDate] = GETUTCDATE() + FROM + @OrganizationUserIds OUIDs + INNER JOIN + [dbo].[OrganizationUser] OU ON OUIDs.Id = OU.Id AND OU.[Status] = 2 -- Confirmed + INNER JOIN + [dbo].[User] U ON OU.UserId = U.Id +END +GO diff --git a/src/Sql/dbo/Stored Procedures/User_BumpManyAccountRevisionDates.sql b/src/Sql/dbo/Stored Procedures/User_BumpManyAccountRevisionDates.sql new file mode 100644 index 000000000..1d35d3c8f --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/User_BumpManyAccountRevisionDates.sql @@ -0,0 +1,16 @@ +CREATE PROCEDURE [dbo].[User_BumpManyAccountRevisionDates] + @Ids [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + UPDATE + U + SET + [AccountRevisionDate] = GETUTCDATE() + FROM + [dbo].[User] U + INNER JOIN + @Ids IDs ON IDs.Id = U.Id +END +GO diff --git a/src/Sql/dbo/Tables/Organization.sql b/src/Sql/dbo/Tables/Organization.sql index 1b795aebb..5ee2c1510 100644 --- a/src/Sql/dbo/Tables/Organization.sql +++ b/src/Sql/dbo/Tables/Organization.sql @@ -11,7 +11,7 @@ [BillingEmail] NVARCHAR (256) NOT NULL, [Plan] NVARCHAR (50) NOT NULL, [PlanType] TINYINT NOT NULL, - [Seats] SMALLINT NULL, + [Seats] INT NULL, [MaxCollections] SMALLINT NULL, [UsePolicies] BIT NOT NULL, [UseSso] BIT NOT NULL, diff --git a/src/Sql/dbo/User Defined Types/EmailArray.sql b/src/Sql/dbo/User Defined Types/EmailArray.sql new file mode 100644 index 000000000..642589995 --- /dev/null +++ b/src/Sql/dbo/User Defined Types/EmailArray.sql @@ -0,0 +1,3 @@ +CREATE TYPE [dbo].[EmailArray] AS TABLE ( + [Email] NVARCHAR(256) NOT NULL); +GO diff --git a/src/Sql/dbo/User Defined Types/OrganizationUserType.sql b/src/Sql/dbo/User Defined Types/OrganizationUserType.sql new file mode 100644 index 000000000..cfb4871c6 --- /dev/null +++ b/src/Sql/dbo/User Defined Types/OrganizationUserType.sql @@ -0,0 +1,15 @@ +CREATE TYPE [dbo].[OrganizationUserType] AS TABLE( + [Id] UNIQUEIDENTIFIER, + [OrganizationId] UNIQUEIDENTIFIER, + [UserId] UNIQUEIDENTIFIER, + [Email] NVARCHAR(256), + [Key] VARCHAR(MAX), + [Status] TINYINT, + [Type] TINYINT, + [AccessAll] BIT, + [ExternalId] NVARCHAR(300), + [CreationDate] DATETIME2(7), + [RevisionDate] DATETIME2(7), + [Permissions] NVARCHAR(MAX), + [ResetPasswordKey] VARCHAR(MAX) +) diff --git a/src/Sql/dbo/User Defined Types/TwoGuidIdArray.sql b/src/Sql/dbo/User Defined Types/TwoGuidIdArray.sql new file mode 100644 index 000000000..12c0b476c --- /dev/null +++ b/src/Sql/dbo/User Defined Types/TwoGuidIdArray.sql @@ -0,0 +1,4 @@ +CREATE TYPE [dbo].[TwoGuidIdArray] AS TABLE ( + [Id1] UNIQUEIDENTIFIER NOT NULL, + [Id2] UNIQUEIDENTIFIER NOT NULL); +GO diff --git a/test/Core.Test/Services/HandlebarsMailServiceTests.cs b/test/Core.Test/Services/HandlebarsMailServiceTests.cs index 161ceecfb..1204a0613 100644 --- a/test/Core.Test/Services/HandlebarsMailServiceTests.cs +++ b/test/Core.Test/Services/HandlebarsMailServiceTests.cs @@ -12,15 +12,18 @@ namespace Bit.Core.Test.Services private readonly GlobalSettings _globalSettings; private readonly IMailDeliveryService _mailDeliveryService; + private readonly IMailEnqueuingService _mailEnqueuingService; public HandlebarsMailServiceTests() { _globalSettings = new GlobalSettings(); _mailDeliveryService = Substitute.For(); + _mailEnqueuingService = Substitute.For(); _sut = new HandlebarsMailService( _globalSettings, - _mailDeliveryService + _mailDeliveryService, + _mailEnqueuingService ); } diff --git a/test/Core.Test/Services/OrganizationServiceTests.cs b/test/Core.Test/Services/OrganizationServiceTests.cs index 47978055d..ef6c32921 100644 --- a/test/Core.Test/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/Services/OrganizationServiceTests.cs @@ -7,7 +7,6 @@ using Bit.Core.Models.Table; using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.Services; -using Microsoft.AspNetCore.DataProtection; using NSubstitute; using Xunit; using Bit.Core.Test.AutoFixture; @@ -17,135 +16,108 @@ using Bit.Core.Test.AutoFixture.Attributes; using Bit.Core.Test.AutoFixture.OrganizationFixtures; using System.Text.Json; using Organization = Bit.Core.Models.Table.Organization; +using System.Linq; namespace Bit.Core.Test.Services { public class OrganizationServiceTests { - [Fact] - public async Task OrgImportCreateNewUsers() + // [Fact] + [Theory, PaidOrganizationAutoData] + public async Task OrgImportCreateNewUsers(SutProvider sutProvider, Guid userId, + Organization org, List existingUsers, List newUsers) { - var orgRepo = Substitute.For(); - var orgUserRepo = Substitute.For(); - var collectionRepo = Substitute.For(); - var userRepo = Substitute.For(); - var groupRepo = Substitute.For(); - var dataProtector = Substitute.For(); - var mailService = Substitute.For(); - var pushNotService = Substitute.For(); - var pushRegService = Substitute.For(); - var deviceRepo = Substitute.For(); - var licenseService = Substitute.For(); - var eventService = Substitute.For(); - var installationRepo = Substitute.For(); - var appCacheService = Substitute.For(); - var paymentService = Substitute.For(); - var policyRepo = Substitute.For(); - var ssoConfigRepo = Substitute.For(); - var ssoUserRepo = Substitute.For(); - var referenceEventService = Substitute.For(); - var globalSettings = Substitute.For(); - var taxRateRepository = Substitute.For(); - - var orgService = new OrganizationService(orgRepo, orgUserRepo, collectionRepo, userRepo, - groupRepo, dataProtector, mailService, pushNotService, pushRegService, deviceRepo, - licenseService, eventService, installationRepo, appCacheService, paymentService, policyRepo, - ssoConfigRepo, ssoUserRepo, referenceEventService, globalSettings, taxRateRepository); - - var id = Guid.NewGuid(); - var userId = Guid.NewGuid(); - var org = new Organization + org.UseDirectory = true; + newUsers.Add(new ImportedOrganizationUser { - Id = id, - Name = "Test Org", - UseDirectory = true, - UseGroups = true, - Seats = 3 - }; - orgRepo.GetByIdAsync(id).Returns(org); - - var existingUsers = new List(); - existingUsers.Add(new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - ExternalId = "a", - Email = "a@test.com" + Email = existingUsers.First().Email, + ExternalId = existingUsers.First().ExternalId }); - orgUserRepo.GetManyDetailsByOrganizationAsync(id).Returns(existingUsers); - orgUserRepo.GetCountByOrganizationIdAsync(id).Returns(1); + var expectedNewUsersCount = newUsers.Count - 1; - var newUsers = new List(); - newUsers.Add(new ImportedOrganizationUser { Email = "a@test.com", ExternalId = "a" }); - newUsers.Add(new ImportedOrganizationUser { Email = "b@test.com", ExternalId = "b" }); - newUsers.Add(new ImportedOrganizationUser { Email = "c@test.com", ExternalId = "c" }); - await orgService.ImportAsync(id, userId, null, newUsers, null, false); + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + sutProvider.GetDependency().GetManyDetailsByOrganizationAsync(org.Id) + .Returns(existingUsers); + sutProvider.GetDependency().GetCountByOrganizationIdAsync(org.Id) + .Returns(existingUsers.Count); - await orgUserRepo.DidNotReceive().UpsertAsync(Arg.Any()); - await orgUserRepo.Received(2).CreateAsync(Arg.Any()); + await sutProvider.Sut.ImportAsync(org.Id, userId, null, newUsers, null, false); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .UpsertAsync(default); + await sutProvider.GetDependency().Received(1) + .UpsertManyAsync(Arg.Is>(users => users.Count() == 0)); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAsync(default); + + // 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)); + + // Send events + await sutProvider.GetDependency().Received(1) + .LogOrganizationUserEventsAsync(Arg.Is>(events => + events.Count() == expectedNewUsersCount)); + await sutProvider.GetDependency().Received(1) + .RaiseEventAsync(Arg.Is(referenceEvent => + referenceEvent.Type == ReferenceEventType.InvitedUsers && referenceEvent.Id == org.Id && + referenceEvent.Users == expectedNewUsersCount)); } - [Fact] - public async Task OrgImportCreateNewUsersAndMarryExistingUser() + [Theory, PaidOrganizationAutoData] + public async Task OrgImportCreateNewUsersAndMarryExistingUser(SutProvider sutProvider, + Guid userId, Organization org, List existingUsers, + List newUsers) { - var orgRepo = Substitute.For(); - var orgUserRepo = Substitute.For(); - var collectionRepo = Substitute.For(); - var userRepo = Substitute.For(); - var groupRepo = Substitute.For(); - var dataProtector = Substitute.For(); - var mailService = Substitute.For(); - var pushNotService = Substitute.For(); - var pushRegService = Substitute.For(); - var deviceRepo = Substitute.For(); - var licenseService = Substitute.For(); - var eventService = Substitute.For(); - var installationRepo = Substitute.For(); - var appCacheService = Substitute.For(); - var paymentService = Substitute.For(); - var policyRepo = Substitute.For(); - var ssoConfigRepo = Substitute.For(); - var ssoUserRepo = Substitute.For(); - var referenceEventService = Substitute.For(); - var globalSettings = Substitute.For(); - var taxRateRepo = Substitute.For(); - - var orgService = new OrganizationService(orgRepo, orgUserRepo, collectionRepo, userRepo, - groupRepo, dataProtector, mailService, pushNotService, pushRegService, deviceRepo, - licenseService, eventService, installationRepo, appCacheService, paymentService, policyRepo, - ssoConfigRepo, ssoUserRepo, referenceEventService, globalSettings, taxRateRepo); - - var id = Guid.NewGuid(); - var userId = Guid.NewGuid(); - var org = new Organization + org.UseDirectory = true; + var reInvitedUser = existingUsers.First(); + reInvitedUser.ExternalId = null; + newUsers.Add(new ImportedOrganizationUser { - Id = id, - Name = "Test Org", - UseDirectory = true, - UseGroups = true, - Seats = 3 - }; - orgRepo.GetByIdAsync(id).Returns(org); - - var existingUserAId = Guid.NewGuid(); - var existingUsers = new List(); - existingUsers.Add(new OrganizationUserUserDetails - { - Id = existingUserAId, - // No external id here - Email = "a@test.com" + Email = reInvitedUser.Email, + ExternalId = reInvitedUser.Email, }); - orgUserRepo.GetManyDetailsByOrganizationAsync(id).Returns(existingUsers); - orgUserRepo.GetCountByOrganizationIdAsync(id).Returns(1); - orgUserRepo.GetByIdAsync(existingUserAId).Returns(new OrganizationUser { Id = existingUserAId }); + var expectedNewUsersCount = newUsers.Count - 1; - var newUsers = new List(); - newUsers.Add(new ImportedOrganizationUser { Email = "a@test.com", ExternalId = "a" }); - newUsers.Add(new ImportedOrganizationUser { Email = "b@test.com", ExternalId = "b" }); - newUsers.Add(new ImportedOrganizationUser { Email = "c@test.com", ExternalId = "c" }); - await orgService.ImportAsync(id, userId, null, newUsers, null, false); + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + sutProvider.GetDependency().GetManyDetailsByOrganizationAsync(org.Id) + .Returns(existingUsers); + sutProvider.GetDependency().GetCountByOrganizationIdAsync(org.Id) + .Returns(existingUsers.Count); + sutProvider.GetDependency().GetByIdAsync(reInvitedUser.Id) + .Returns(new OrganizationUser { Id = reInvitedUser.Id }); - await orgUserRepo.Received(1).UpsertAsync(Arg.Any()); - await orgUserRepo.Received(2).CreateAsync(Arg.Any()); + await sutProvider.Sut.ImportAsync(org.Id, userId, null, newUsers, null, false); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .UpsertAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAsync(default, default); + + // Upserted existing user + await sutProvider.GetDependency().Received(1) + .UpsertManyAsync(Arg.Is>(users => users.Count() == 1)); + + // 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)); + + // Sent events + await sutProvider.GetDependency().Received(1) + .LogOrganizationUserEventsAsync(Arg.Is>(events => + events.Where(e => e.Item2 == EventType.OrganizationUser_Invited).Count() == expectedNewUsersCount)); + await sutProvider.GetDependency().Received(1) + .RaiseEventAsync(Arg.Is(referenceEvent => + referenceEvent.Type == ReferenceEventType.InvitedUsers && referenceEvent.Id == org.Id && + referenceEvent.Users == expectedNewUsersCount)); } [Theory, CustomAutoData(typeof(SutProviderCustomization))] diff --git a/util/Migrator/DbScripts/2021-04-07_00_IncreaseOrgSeatSize.sql b/util/Migrator/DbScripts/2021-04-07_00_IncreaseOrgSeatSize.sql new file mode 100644 index 000000000..61f208960 --- /dev/null +++ b/util/Migrator/DbScripts/2021-04-07_00_IncreaseOrgSeatSize.sql @@ -0,0 +1,235 @@ +IF EXISTS ( + SELECT * + FROM INFORMATION_SCHEMA.COLUMNS + WHERE COLUMN_NAME = 'Seats' AND + DATA_TYPE = 'smallint' AND + TABLE_NAME = 'Organization') +BEGIN + ALTER TABLE [dbo].[Organization] + ALTER COLUMN [Seats] INT NULL +END +GO + +IF OBJECT_ID('[dbo].[Organization_Create]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[Organization_Create] +END +GO + +CREATE PROCEDURE [dbo].[Organization_Create] + @Id UNIQUEIDENTIFIER, + @Identifier NVARCHAR(50), + @Name NVARCHAR(50), + @BusinessName NVARCHAR(50), + @BusinessAddress1 NVARCHAR(50), + @BusinessAddress2 NVARCHAR(50), + @BusinessAddress3 NVARCHAR(50), + @BusinessCountry VARCHAR(2), + @BusinessTaxNumber NVARCHAR(30), + @BillingEmail NVARCHAR(256), + @Plan NVARCHAR(50), + @PlanType TINYINT, + @Seats INT, + @MaxCollections SMALLINT, + @UsePolicies BIT, + @UseSso BIT, + @UseGroups BIT, + @UseDirectory BIT, + @UseEvents BIT, + @UseTotp BIT, + @Use2fa BIT, + @UseApi BIT, + @SelfHost BIT, + @UsersGetPremium BIT, + @Storage BIGINT, + @MaxStorageGb SMALLINT, + @Gateway TINYINT, + @GatewayCustomerId VARCHAR(50), + @GatewaySubscriptionId VARCHAR(50), + @ReferenceData VARCHAR(MAX), + @Enabled BIT, + @LicenseKey VARCHAR(100), + @ApiKey VARCHAR(30), + @TwoFactorProviders NVARCHAR(MAX), + @ExpirationDate DATETIME2(7), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[Organization] + ( + [Id], + [Identifier], + [Name], + [BusinessName], + [BusinessAddress1], + [BusinessAddress2], + [BusinessAddress3], + [BusinessCountry], + [BusinessTaxNumber], + [BillingEmail], + [Plan], + [PlanType], + [Seats], + [MaxCollections], + [UsePolicies], + [UseSso], + [UseGroups], + [UseDirectory], + [UseEvents], + [UseTotp], + [Use2fa], + [UseApi], + [SelfHost], + [UsersGetPremium], + [Storage], + [MaxStorageGb], + [Gateway], + [GatewayCustomerId], + [GatewaySubscriptionId], + [ReferenceData], + [Enabled], + [LicenseKey], + [ApiKey], + [TwoFactorProviders], + [ExpirationDate], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @Id, + @Identifier, + @Name, + @BusinessName, + @BusinessAddress1, + @BusinessAddress2, + @BusinessAddress3, + @BusinessCountry, + @BusinessTaxNumber, + @BillingEmail, + @Plan, + @PlanType, + @Seats, + @MaxCollections, + @UsePolicies, + @UseSso, + @UseGroups, + @UseDirectory, + @UseEvents, + @UseTotp, + @Use2fa, + @UseApi, + @SelfHost, + @UsersGetPremium, + @Storage, + @MaxStorageGb, + @Gateway, + @GatewayCustomerId, + @GatewaySubscriptionId, + @ReferenceData, + @Enabled, + @LicenseKey, + @ApiKey, + @TwoFactorProviders, + @ExpirationDate, + @CreationDate, + @RevisionDate + ) +END +GO + +-- Recreate procedure Organization_Update +IF OBJECT_ID('[dbo].[Organization_Update]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[Organization_Update] +END +GO + +CREATE PROCEDURE [dbo].[Organization_Update] + @Id UNIQUEIDENTIFIER, + @Identifier NVARCHAR(50), + @Name NVARCHAR(50), + @BusinessName NVARCHAR(50), + @BusinessAddress1 NVARCHAR(50), + @BusinessAddress2 NVARCHAR(50), + @BusinessAddress3 NVARCHAR(50), + @BusinessCountry VARCHAR(2), + @BusinessTaxNumber NVARCHAR(30), + @BillingEmail NVARCHAR(256), + @Plan NVARCHAR(50), + @PlanType TINYINT, + @Seats INT, + @MaxCollections SMALLINT, + @UsePolicies BIT, + @UseSso BIT, + @UseGroups BIT, + @UseDirectory BIT, + @UseEvents BIT, + @UseTotp BIT, + @Use2fa BIT, + @UseApi BIT, + @SelfHost BIT, + @UsersGetPremium BIT, + @Storage BIGINT, + @MaxStorageGb SMALLINT, + @Gateway TINYINT, + @GatewayCustomerId VARCHAR(50), + @GatewaySubscriptionId VARCHAR(50), + @ReferenceData VARCHAR(MAX), + @Enabled BIT, + @LicenseKey VARCHAR(100), + @ApiKey VARCHAR(30), + @TwoFactorProviders NVARCHAR(MAX), + @ExpirationDate DATETIME2(7), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[Organization] + SET + [Identifier] = @Identifier, + [Name] = @Name, + [BusinessName] = @BusinessName, + [BusinessAddress1] = @BusinessAddress1, + [BusinessAddress2] = @BusinessAddress2, + [BusinessAddress3] = @BusinessAddress3, + [BusinessCountry] = @BusinessCountry, + [BusinessTaxNumber] = @BusinessTaxNumber, + [BillingEmail] = @BillingEmail, + [Plan] = @Plan, + [PlanType] = @PlanType, + [Seats] = @Seats, + [MaxCollections] = @MaxCollections, + [UsePolicies] = @UsePolicies, + [UseSso] = @UseSso, + [UseGroups] = @UseGroups, + [UseDirectory] = @UseDirectory, + [UseEvents] = @UseEvents, + [UseTotp] = @UseTotp, + [Use2fa] = @Use2fa, + [UseApi] = @UseApi, + [SelfHost] = @SelfHost, + [UsersGetPremium] = @UsersGetPremium, + [Storage] = @Storage, + [MaxStorageGb] = @MaxStorageGb, + [Gateway] = @Gateway, + [GatewayCustomerId] = @GatewayCustomerId, + [GatewaySubscriptionId] = @GatewaySubscriptionId, + [ReferenceData] = @ReferenceData, + [Enabled] = @Enabled, + [LicenseKey] = @LicenseKey, + [ApiKey] = @ApiKey, + [TwoFactorProviders] = @TwoFactorProviders, + [ExpirationDate] = @ExpirationDate, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id +END +GO diff --git a/util/Migrator/DbScripts/2021-04-16_00_OrganizationUser_DeleteMany.sql b/util/Migrator/DbScripts/2021-04-16_00_OrganizationUser_DeleteMany.sql new file mode 100644 index 000000000..e6468670c --- /dev/null +++ b/util/Migrator/DbScripts/2021-04-16_00_OrganizationUser_DeleteMany.sql @@ -0,0 +1,183 @@ +-- Create sproc to bump the revision date of a batch of users +IF OBJECT_ID('[dbo].[User_BumpAccountRevisionDateByOrganizationUserIds]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[User_BumpAccountRevisionDateByOrganizationUserIds] +END +GO + +CREATE PROCEDURE [dbo].[User_BumpAccountRevisionDateByOrganizationUserIds] + @OrganizationUserIds [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + SELECT + OU.UserId + INTO + #UserIds + FROM + [dbo].[OrganizationUser] OU + INNER JOIN + @OrganizationUserIds OUIds ON OUIds.Id = OU.Id + WHERE + OU.[Status] = 2 -- Confirmed + + UPDATE + U + SET + U.[AccountRevisionDate] = GETUTCDATE() + FROM + [dbo].[User] U + INNER JOIN + #UserIds ON U.[Id] = #UserIds.[UserId] +END +GO + +-- Create TwoGuidIdArray Type +IF NOT EXISTS ( + SELECT + * + FROM + sys.types + WHERE + [Name] = 'TwoGuidIdArray' AND + is_user_defined = 1 +) +CREATE TYPE [dbo].[TwoGuidIdArray] AS TABLE ( + [Id1] UNIQUEIDENTIFIER NOT NULL, + [Id2] UNIQUEIDENTIFIER NOT NULL); +GO + +-- Create sproc to delete batch of users +-- Parameter Ids are UserId, OrganizationId +IF OBJECT_ID('[dbo].[SsoUser_DeleteMany]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[SsoUser_DeleteMany] +END +GO + +CREATE PROCEDURE [dbo].[SsoUser_DeleteMany] + @UserAndOrganizationIds [dbo].[TwoGuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + SELECT + Id + INTO + #SSOIds + FROM + [dbo].[SsoUser] SU + INNER JOIN + @UserAndOrganizationIds UOI ON UOI.Id1 = SU.UserId AND UOI.Id2 = SU.OrganizationId + + DECLARE @BatchSize INT = 100 + + -- Delete SSO Users + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION SsoUser_DeleteMany_SsoUsers + + DELETE TOP(@BatchSize) SU + FROM + [dbo].[SsoUser] SU + INNER JOIN + #SSOIDs ON #SSOIds.Id = SU.Id + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION SsoUser_DeleteMany_SsoUsers + END +END +GO + +-- Create OrganizationUser Delete many by Id procedure +IF OBJECT_ID('[dbo].[OrganizationUser_DeleteByIds]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[OrganizationUser_DeleteByIds] +END +GO + +CREATE PROCEDURE [dbo].[OrganizationUser_DeleteByIds] + @Ids [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationUserIds] @Ids + + DECLARE @UserAndOrganizationIds [dbo].[TwoGuidIdArray] + + INSERT INTO @UserAndOrganizationIds + (Id1, Id2) + SELECT + UserId, + OrganizationId + FROM + [dbo].[OrganizationUser] OU + INNER JOIN + @Ids OUIds ON OUIds.Id = OU.Id + WHERE + UserId IS NOT NULL AND + OrganizationId IS NOT NULL + + BEGIN + EXEC [dbo].[SsoUser_DeleteMany] @UserAndOrganizationIds + END + + DECLARE @BatchSize INT = 100 + + -- Delete CollectionUsers + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION CollectionUser_DeleteMany_CUs + + DELETE TOP(@BatchSize) CU + FROM + [dbo].[CollectionUser] CU + INNER JOIN + @Ids I ON I.Id = CU.OrganizationUserId + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION CollectionUser_DeleteMany_CUs + END + + SET @BatchSize = 100; + + -- Delete GroupUsers + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION GroupUser_DeleteMany_GroupUsers + + DELETE TOP(@BatchSize) GU + FROM + [dbo].[GroupUser] GU + INNER JOIN + @Ids I ON I.Id = GU.OrganizationUserId + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION GoupUser_DeleteMany_GroupUsers + END + + + SET @BatchSize = 100; + + -- Delete OrganizationUsers + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION OrganizationUser_DeleteMany_OUs + + DELETE TOP(@BatchSize) OU + FROM + [dbo].[OrganizationUser] OU + INNER JOIN + @Ids I ON I.Id = OU.Id + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION OrganizationUser_DeleteMany_OUs + END +END +GO diff --git a/util/Migrator/DbScripts/2021-04-27_00_OrganizationUser_UpsertMany.sql b/util/Migrator/DbScripts/2021-04-27_00_OrganizationUser_UpsertMany.sql new file mode 100644 index 000000000..7a90d767f --- /dev/null +++ b/util/Migrator/DbScripts/2021-04-27_00_OrganizationUser_UpsertMany.sql @@ -0,0 +1,142 @@ +-- Create OrganizationUser Type +IF NOT EXISTS ( + SELECT + * + FROM + sys.types + WHERE + [Name] = 'OrganizationUserType' AND + is_user_defined = 1 +) +BEGIN +CREATE TYPE [dbo].[OrganizationUserType] AS TABLE( + [Id] UNIQUEIDENTIFIER, + [OrganizationId] UNIQUEIDENTIFIER, + [UserId] UNIQUEIDENTIFIER, + [Email] NVARCHAR(256), + [Key] VARCHAR(MAX), + [Status] TINYINT, + [Type] TINYINT, + [AccessAll] BIT, + [ExternalId] NVARCHAR(300), + [CreationDate] DATETIME2(7), + [RevisionDate] DATETIME2(7), + [Permissions] NVARCHAR(MAX), + [ResetPasswordKey] VARCHAR(MAX) +) +END +GO + +-- Create many sproc +IF OBJECT_ID('[dbo].[OrganizationUser_CreateMany]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[OrganizationUser_CreateMany] +END +GO + +CREATE PROCEDURE [dbo].[OrganizationUser_CreateMany] + @OrganizationUsersInput [dbo].[OrganizationUserType] READONLY +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[OrganizationUser] + ( + [Id], + [OrganizationId], + [UserId], + [Email], + [Key], + [Status], + [Type], + [AccessAll], + [ExternalId], + [CreationDate], + [RevisionDate], + [Permissions], + [ResetPasswordKey] + ) + SELECT + OU.[Id], + OU.[OrganizationId], + OU.[UserId], + OU.[Email], + OU.[Key], + OU.[Status], + OU.[Type], + OU.[AccessAll], + OU.[ExternalId], + OU.[CreationDate], + OU.[RevisionDate], + OU.[Permissions], + OU.[ResetPasswordKey] + FROM + @OrganizationUsersInput OU +END +GO + +-- Bump many user account revision dates +IF OBJECT_ID('[dbo].[User_BumpManyAccountRevisionDates]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[User_BumpManyAccountRevisionDates] +END +GO + +CREATE PROCEDURE [dbo].[User_BumpManyAccountRevisionDates] + @Ids [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + UPDATE + U + SET + [AccountRevisionDate] = GETUTCDATE() + FROM + [dbo].[User] U + INNER JOIN + @Ids IDs ON IDs.Id = U.Id +END +GO + +-- Update many OrganizationUsers +IF OBJECT_ID('[dbo].[OrganizationUser_UpdateMany]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[OrganizationUser_UpdateMany] +END +GO + +CREATE PROCEDURE [dbo].[OrganizationUser_UpdateMany] + @OrganizationUsersInput [dbo].[OrganizationUserType] READONLY +AS +BEGIN + SET NOCOUNT ON + + UPDATE + OU + SET + [OrganizationId] = OUI.[OrganizationId], + [UserId] = OUI.[UserId], + [Email] = OUI.[Email], + [Key] = OUI.[Key], + [Status] = OUI.[Status], + [Type] = OUI.[Type], + [AccessAll] = OUI.[AccessAll], + [ExternalId] = OUI.[ExternalId], + [CreationDate] = OUI.[CreationDate], + [RevisionDate] = OUI.[RevisionDate], + [Permissions] = OUI.[Permissions], + [ResetPasswordKey] = OUI.[ResetPasswordKey] + FROM + [dbo].[OrganizationUser] OU + INNER JOIN + @OrganizationUsersInput OUI ON OU.Id = OUI.Id + + + EXEC [dbo].[User_BumpManyAccountRevisionDates] + ( + SELECT UserId + FROM @OrganizationUsersInput + ) +END +GO diff --git a/util/Migrator/DbScripts/2021-04-30_00_Select_Known_OrganizationUsers_Emails.sql b/util/Migrator/DbScripts/2021-04-30_00_Select_Known_OrganizationUsers_Emails.sql new file mode 100644 index 000000000..2486a7876 --- /dev/null +++ b/util/Migrator/DbScripts/2021-04-30_00_Select_Known_OrganizationUsers_Emails.sql @@ -0,0 +1,49 @@ +-- Create EmailArray type +IF NOT EXISTS ( + SELECT * +FROM sys.types +WHERE [Name] = 'EmailArray' + AND is_user_defined = 1 +) +CREATE TYPE [dbo].[EmailArray] AS TABLE ( + [Email] NVARCHAR(256) NOT NULL); +GO + +IF OBJECT_ID('[dbo].[OrganizationUser_SelectKnownEmails]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[OrganizationUser_SelectKnownEmails] +END +GO + +-- Create sproc to return existing users +CREATE PROCEDURE [dbo].[OrganizationUser_SelectKnownEmails] + @OrganizationId UNIQUEIDENTIFIER, + @Emails [dbo].[EmailArray] READONLY, + @OnlyUsers BIT +AS +BEGIN + SET NOCOUNT ON + + SELECT + E.Email + FROM + @Emails E + INNER JOIN + ( + SELECT + U.[Email] as 'UEmail', + OU.[Email] as 'OUEmail', + OU.OrganizationId + FROM + [dbo].[User] U + RIGHT JOIN + [dbo].[OrganizationUser] OU ON OU.[UserId] = U.[Id] + WHERE + OU.OrganizationId = @OrganizationId + ) OUU ON OUU.[UEmail] = E.[Email] OR OUU.[OUEmail] = E.[Email] + WHERE + (@OnlyUsers = 0 AND (OUU.UEmail IS NOT NULL OR OUU.OUEmail IS NOT NULL)) OR + (@OnlyUsers = 1 AND (OUU.UEmail IS NOT NULL)) + +END +GO