From 1267332b5b589104058de2338818ac9b68db0df9 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Thu, 27 Feb 2025 08:34:42 -0600 Subject: [PATCH] [PM-14406] Security Task Notifications (#5344) * initial commit of `CipherOrganizationPermission_GetManyByUserId` * create queries to get all of the security tasks that are actionable by a user - A task is "actionable" when the user has manage permissions for that cipher * rename query * return the user's email from the query as well * Add email notification for at-risk passwords - Added email layouts for security tasks * add push notification for security tasks * update entity framework to match stored procedure plus testing * update date of migration and remove orderby * add push service to security task controller * rename `SyncSecurityTasksCreated` to `SyncNotification` * remove duplicate return * remove unused directive * remove unneeded new notification type * use `createNotificationCommand` to alert all platforms * return the cipher id that is associated with the security task and store the security task id on the notification entry * Add `TaskId` to the output model of `GetUserSecurityTasksByCipherIdsAsync` * move notification logic to command * use TaskId from `_getSecurityTasksNotificationDetailsQuery` * add service * only push last notification for each user * formatting * refactor `CreateNotificationCommand` parameter to `sendPush` * flip boolean in test * update interface to match usage * do not push any of the security related notifications to the user * add `PendingSecurityTasks` push type * add push notification for pending security tasks --- .../Controllers/SecurityTaskController.cs | 8 +- src/Core/Enums/PushType.cs | 4 +- .../Handlebars/Layouts/SecurityTasks.html.hbs | 61 ++++++ .../Handlebars/Layouts/SecurityTasks.text.hbs | 12 ++ .../SecurityTasksNotification.html.hbs | 28 +++ .../SecurityTasksNotification.text.hbs | 8 + .../Mail/SecurityTaskNotificationViewModel.cs | 12 ++ .../Commands/CreateNotificationCommand.cs | 7 +- .../Interfaces/ICreateNotificationCommand.cs | 2 +- .../NotificationHubPushNotificationService.cs | 5 + .../AzureQueuePushNotificationService.cs | 5 + .../Push/Services/IPushNotificationService.cs | 1 + .../MultiServicePushNotificationService.cs | 6 + .../Services/NoopPushNotificationService.cs | 5 + ...NotificationsApiPushNotificationService.cs | 5 + .../Services/RelayPushNotificationService.cs | 5 + src/Core/Services/IMailService.cs | 3 +- .../Implementations/HandlebarsMailService.cs | 24 ++- .../NoopImplementations/NoopMailService.cs | 7 +- .../CreateManyTaskNotificationsCommand.cs | 82 ++++++++ .../ICreateManyTaskNotificationsCommand.cs | 13 ++ .../Vault/Models/Data/UserCipherForTask.cs | 23 +++ .../Models/Data/UserSecurityTaskCipher.cs | 27 +++ .../Models/Data/UserSecurityTasksCount.cs | 22 +++ ...etSecurityTasksNotificationDetailsQuery.cs | 33 ++++ ...etSecurityTasksNotificationDetailsQuery.cs | 16 ++ .../Vault/Repositories/ICipherRepository.cs | 8 + .../Vault/VaultServiceCollectionExtensions.cs | 2 + .../Vault/Repositories/CipherRepository.cs | 22 +++ .../Vault/Repositories/CipherRepository.cs | 45 +++++ .../UserSecurityTasksByCipherIdsQuery.cs | 71 +++++++ .../UserSecurityTasks_GetManyByCipherIds.sql | 67 +++++++ .../Commands/CreateNotificationCommandTest.cs | 15 ++ .../Repositories/CipherRepositoryTests.cs | 179 ++++++++++++++++++ ...0_UserSecurityTasks_GetManyByCipherIds.sql | 68 +++++++ 35 files changed, 893 insertions(+), 8 deletions(-) create mode 100644 src/Core/MailTemplates/Handlebars/Layouts/SecurityTasks.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/Layouts/SecurityTasks.text.hbs create mode 100644 src/Core/MailTemplates/Handlebars/SecurityTasksNotification.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/SecurityTasksNotification.text.hbs create mode 100644 src/Core/Models/Mail/SecurityTaskNotificationViewModel.cs create mode 100644 src/Core/Vault/Commands/CreateManyTaskNotificationsCommand.cs create mode 100644 src/Core/Vault/Commands/Interfaces/ICreateManyTaskNotificationsCommand.cs create mode 100644 src/Core/Vault/Models/Data/UserCipherForTask.cs create mode 100644 src/Core/Vault/Models/Data/UserSecurityTaskCipher.cs create mode 100644 src/Core/Vault/Models/Data/UserSecurityTasksCount.cs create mode 100644 src/Core/Vault/Queries/GetSecurityTasksNotificationDetailsQuery.cs create mode 100644 src/Core/Vault/Queries/IGetSecurityTasksNotificationDetailsQuery.cs create mode 100644 src/Infrastructure.EntityFramework/Vault/Repositories/Queries/UserSecurityTasksByCipherIdsQuery.cs create mode 100644 src/Sql/Vault/dbo/Stored Procedures/Cipher/UserSecurityTasks_GetManyByCipherIds.sql create mode 100644 util/Migrator/DbScripts/2025-02-11_00_UserSecurityTasks_GetManyByCipherIds.sql diff --git a/src/Api/Vault/Controllers/SecurityTaskController.cs b/src/Api/Vault/Controllers/SecurityTaskController.cs index 88b7aed9c6..2693d60825 100644 --- a/src/Api/Vault/Controllers/SecurityTaskController.cs +++ b/src/Api/Vault/Controllers/SecurityTaskController.cs @@ -22,19 +22,22 @@ public class SecurityTaskController : Controller private readonly IMarkTaskAsCompleteCommand _markTaskAsCompleteCommand; private readonly IGetTasksForOrganizationQuery _getTasksForOrganizationQuery; private readonly ICreateManyTasksCommand _createManyTasksCommand; + private readonly ICreateManyTaskNotificationsCommand _createManyTaskNotificationsCommand; public SecurityTaskController( IUserService userService, IGetTaskDetailsForUserQuery getTaskDetailsForUserQuery, IMarkTaskAsCompleteCommand markTaskAsCompleteCommand, IGetTasksForOrganizationQuery getTasksForOrganizationQuery, - ICreateManyTasksCommand createManyTasksCommand) + ICreateManyTasksCommand createManyTasksCommand, + ICreateManyTaskNotificationsCommand createManyTaskNotificationsCommand) { _userService = userService; _getTaskDetailsForUserQuery = getTaskDetailsForUserQuery; _markTaskAsCompleteCommand = markTaskAsCompleteCommand; _getTasksForOrganizationQuery = getTasksForOrganizationQuery; _createManyTasksCommand = createManyTasksCommand; + _createManyTaskNotificationsCommand = createManyTaskNotificationsCommand; } /// @@ -87,6 +90,9 @@ public class SecurityTaskController : Controller [FromBody] BulkCreateSecurityTasksRequestModel model) { var securityTasks = await _createManyTasksCommand.CreateAsync(orgId, model.Tasks); + + await _createManyTaskNotificationsCommand.CreateAsync(orgId, securityTasks); + var response = securityTasks.Select(x => new SecurityTasksResponseModel(x)).ToList(); return new ListResponseModel(response); } diff --git a/src/Core/Enums/PushType.cs b/src/Core/Enums/PushType.cs index 6d0dd9393c..96a1192478 100644 --- a/src/Core/Enums/PushType.cs +++ b/src/Core/Enums/PushType.cs @@ -29,5 +29,7 @@ public enum PushType : byte SyncOrganizationCollectionSettingChanged = 19, Notification = 20, - NotificationStatus = 21 + NotificationStatus = 21, + + PendingSecurityTasks = 22 } diff --git a/src/Core/MailTemplates/Handlebars/Layouts/SecurityTasks.html.hbs b/src/Core/MailTemplates/Handlebars/Layouts/SecurityTasks.html.hbs new file mode 100644 index 0000000000..930d39eeee --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Layouts/SecurityTasks.html.hbs @@ -0,0 +1,61 @@ +{{#>FullUpdatedHtmlLayout}} + + + + + +
+ + + + +
+ {{OrgName}} has identified {{TaskCount}} critical login{{#if TaskCountPlural}}s{{/if}} that require{{#unless + TaskCountPlural}}s{{/unless}} a + password change +
+
+ +
+ +{{>@partial-block}} + + + + + + +
+ + + + + +
+{{/FullUpdatedHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/Layouts/SecurityTasks.text.hbs b/src/Core/MailTemplates/Handlebars/Layouts/SecurityTasks.text.hbs new file mode 100644 index 0000000000..f9befac46c --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Layouts/SecurityTasks.text.hbs @@ -0,0 +1,12 @@ +{{#>FullTextLayout}} +{{OrgName}} has identified {{TaskCount}} critical login{{#if TaskCountPlural}}s{{/if}} that require{{#unless +TaskCountPlural}}s{{/unless}} a +password change + +{{>@partial-block}} + +We’re here for you! +If you have any questions, search the Bitwarden Help site or contact us. +- https://bitwarden.com/help/ +- https://bitwarden.com/contact/ +{{/FullTextLayout}} diff --git a/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.html.hbs b/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.html.hbs new file mode 100644 index 0000000000..039806f44b --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.html.hbs @@ -0,0 +1,28 @@ +{{#>SecurityTasksHtmlLayout}} + + + + + + + +
+ Keep you and your organization's data safe by changing passwords that are weak, reused, or have been exposed in a + data breach. +
+ Launch the Bitwarden extension to review your at-risk passwords. +
+ + + + +
+ + Review at-risk passwords + +
+{{/SecurityTasksHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.text.hbs b/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.text.hbs new file mode 100644 index 0000000000..ba8650ad10 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.text.hbs @@ -0,0 +1,8 @@ +{{#>SecurityTasksHtmlLayout}} +Keep you and your organization's data safe by changing passwords that are weak, reused, or have been exposed in a data +breach. + +Launch the Bitwarden extension to review your at-risk passwords. + +Review at-risk passwords ({{{ReviewPasswordsUrl}}}) +{{/SecurityTasksHtmlLayout}} diff --git a/src/Core/Models/Mail/SecurityTaskNotificationViewModel.cs b/src/Core/Models/Mail/SecurityTaskNotificationViewModel.cs new file mode 100644 index 0000000000..7f93ac2439 --- /dev/null +++ b/src/Core/Models/Mail/SecurityTaskNotificationViewModel.cs @@ -0,0 +1,12 @@ +namespace Bit.Core.Models.Mail; + +public class SecurityTaskNotificationViewModel : BaseMailModel +{ + public string OrgName { get; set; } + + public int TaskCount { get; set; } + + public bool TaskCountPlural => TaskCount != 1; + + public string ReviewPasswordsUrl => $"{WebVaultUrl}/browser-extension-prompt"; +} diff --git a/src/Core/NotificationCenter/Commands/CreateNotificationCommand.cs b/src/Core/NotificationCenter/Commands/CreateNotificationCommand.cs index 3fddafcdc7..e6eec3f4a8 100644 --- a/src/Core/NotificationCenter/Commands/CreateNotificationCommand.cs +++ b/src/Core/NotificationCenter/Commands/CreateNotificationCommand.cs @@ -28,7 +28,7 @@ public class CreateNotificationCommand : ICreateNotificationCommand _pushNotificationService = pushNotificationService; } - public async Task CreateAsync(Notification notification) + public async Task CreateAsync(Notification notification, bool sendPush = true) { notification.CreationDate = notification.RevisionDate = DateTime.UtcNow; @@ -37,7 +37,10 @@ public class CreateNotificationCommand : ICreateNotificationCommand var newNotification = await _notificationRepository.CreateAsync(notification); - await _pushNotificationService.PushNotificationAsync(newNotification); + if (sendPush) + { + await _pushNotificationService.PushNotificationAsync(newNotification); + } return newNotification; } diff --git a/src/Core/NotificationCenter/Commands/Interfaces/ICreateNotificationCommand.cs b/src/Core/NotificationCenter/Commands/Interfaces/ICreateNotificationCommand.cs index a3b4d894e6..cacd69c8ad 100644 --- a/src/Core/NotificationCenter/Commands/Interfaces/ICreateNotificationCommand.cs +++ b/src/Core/NotificationCenter/Commands/Interfaces/ICreateNotificationCommand.cs @@ -5,5 +5,5 @@ namespace Bit.Core.NotificationCenter.Commands.Interfaces; public interface ICreateNotificationCommand { - Task CreateAsync(Notification notification); + Task CreateAsync(Notification notification, bool sendPush = true); } diff --git a/src/Core/NotificationHub/NotificationHubPushNotificationService.cs b/src/Core/NotificationHub/NotificationHubPushNotificationService.cs index 6bc5b0db6b..a28b21f465 100644 --- a/src/Core/NotificationHub/NotificationHubPushNotificationService.cs +++ b/src/Core/NotificationHub/NotificationHubPushNotificationService.cs @@ -329,6 +329,11 @@ public class NotificationHubPushNotificationService : IPushNotificationService GetContextIdentifier(excludeCurrentContext), clientType: clientType); } + public async Task PushPendingSecurityTasksAsync(Guid userId) + { + await PushUserAsync(userId, PushType.PendingSecurityTasks); + } + public async Task SendPayloadToInstallationAsync(string installationId, PushType type, object payload, string? identifier, string? deviceId = null, ClientType? clientType = null) { diff --git a/src/Core/Platform/Push/Services/AzureQueuePushNotificationService.cs b/src/Core/Platform/Push/Services/AzureQueuePushNotificationService.cs index f88c0641c5..e61dd15f0d 100644 --- a/src/Core/Platform/Push/Services/AzureQueuePushNotificationService.cs +++ b/src/Core/Platform/Push/Services/AzureQueuePushNotificationService.cs @@ -219,6 +219,11 @@ public class AzureQueuePushNotificationService : IPushNotificationService await SendMessageAsync(PushType.NotificationStatus, message, true); } + public async Task PushPendingSecurityTasksAsync(Guid userId) + { + await PushUserAsync(userId, PushType.PendingSecurityTasks); + } + private async Task PushSendAsync(Send send, PushType type) { if (send.UserId.HasValue) diff --git a/src/Core/Platform/Push/Services/IPushNotificationService.cs b/src/Core/Platform/Push/Services/IPushNotificationService.cs index d0f18cd8ac..60f3c35089 100644 --- a/src/Core/Platform/Push/Services/IPushNotificationService.cs +++ b/src/Core/Platform/Push/Services/IPushNotificationService.cs @@ -38,4 +38,5 @@ public interface IPushNotificationService string? deviceId = null, ClientType? clientType = null); Task SendPayloadToOrganizationAsync(string orgId, PushType type, object payload, string? identifier, string? deviceId = null, ClientType? clientType = null); + Task PushPendingSecurityTasksAsync(Guid userId); } diff --git a/src/Core/Platform/Push/Services/MultiServicePushNotificationService.cs b/src/Core/Platform/Push/Services/MultiServicePushNotificationService.cs index 1f88f5dcc6..490b690a3b 100644 --- a/src/Core/Platform/Push/Services/MultiServicePushNotificationService.cs +++ b/src/Core/Platform/Push/Services/MultiServicePushNotificationService.cs @@ -179,6 +179,12 @@ public class MultiServicePushNotificationService : IPushNotificationService return Task.FromResult(0); } + public Task PushPendingSecurityTasksAsync(Guid userId) + { + PushToServices((s) => s.PushPendingSecurityTasksAsync(userId)); + return Task.CompletedTask; + } + private void PushToServices(Func pushFunc) { if (!_services.Any()) diff --git a/src/Core/Platform/Push/Services/NoopPushNotificationService.cs b/src/Core/Platform/Push/Services/NoopPushNotificationService.cs index e005f9d7af..6e7278cf94 100644 --- a/src/Core/Platform/Push/Services/NoopPushNotificationService.cs +++ b/src/Core/Platform/Push/Services/NoopPushNotificationService.cs @@ -121,4 +121,9 @@ public class NoopPushNotificationService : IPushNotificationService { return Task.FromResult(0); } + + public Task PushPendingSecurityTasksAsync(Guid userId) + { + return Task.FromResult(0); + } } diff --git a/src/Core/Platform/Push/Services/NotificationsApiPushNotificationService.cs b/src/Core/Platform/Push/Services/NotificationsApiPushNotificationService.cs index 2833c43985..53a0de9a27 100644 --- a/src/Core/Platform/Push/Services/NotificationsApiPushNotificationService.cs +++ b/src/Core/Platform/Push/Services/NotificationsApiPushNotificationService.cs @@ -232,6 +232,11 @@ public class NotificationsApiPushNotificationService : BaseIdentityClientService await SendMessageAsync(PushType.NotificationStatus, message, true); } + public async Task PushPendingSecurityTasksAsync(Guid userId) + { + await PushUserAsync(userId, PushType.PendingSecurityTasks); + } + private async Task PushSendAsync(Send send, PushType type) { if (send.UserId.HasValue) diff --git a/src/Core/Platform/Push/Services/RelayPushNotificationService.cs b/src/Core/Platform/Push/Services/RelayPushNotificationService.cs index d111efa2a8..53f5835322 100644 --- a/src/Core/Platform/Push/Services/RelayPushNotificationService.cs +++ b/src/Core/Platform/Push/Services/RelayPushNotificationService.cs @@ -300,6 +300,11 @@ public class RelayPushNotificationService : BaseIdentityClientService, IPushNoti false ); + public async Task PushPendingSecurityTasksAsync(Guid userId) + { + await PushUserAsync(userId, PushType.PendingSecurityTasks); + } + private async Task SendPayloadToInstallationAsync(PushType type, object payload, bool excludeCurrentContext, ClientType? clientType = null) { diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 3492ada838..b0b884eb3e 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -5,6 +5,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Mail; +using Bit.Core.Vault.Models.Data; namespace Bit.Core.Services; @@ -98,5 +99,5 @@ public interface IMailService string organizationName); Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList); Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable adminEmails, Guid organizationId, string email, string userName); + Task SendBulkSecurityTaskNotificationsAsync(string orgName, IEnumerable securityTaskNotificaitons); } - diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 44be3bfdf4..c598a9d432 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -15,6 +15,7 @@ using Bit.Core.Models.Mail.Provider; using Bit.Core.SecretsManager.Models.Mail; using Bit.Core.Settings; using Bit.Core.Utilities; +using Bit.Core.Vault.Models.Data; using HandlebarsDotNet; namespace Bit.Core.Services; @@ -654,6 +655,10 @@ public class HandlebarsMailService : IMailService Handlebars.RegisterTemplate("TitleContactUsHtmlLayout", titleContactUsHtmlLayoutSource); var titleContactUsTextLayoutSource = await ReadSourceAsync("Layouts.TitleContactUs.text"); Handlebars.RegisterTemplate("TitleContactUsTextLayout", titleContactUsTextLayoutSource); + var securityTasksHtmlLayoutSource = await ReadSourceAsync("Layouts.SecurityTasks.html"); + Handlebars.RegisterTemplate("SecurityTasksHtmlLayout", securityTasksHtmlLayoutSource); + var securityTasksTextLayoutSource = await ReadSourceAsync("Layouts.SecurityTasks.text"); + Handlebars.RegisterTemplate("SecurityTasksTextLayout", securityTasksTextLayoutSource); Handlebars.RegisterHelper("date", (writer, context, parameters) => { @@ -1196,9 +1201,26 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendBulkSecurityTaskNotificationsAsync(string orgName, IEnumerable securityTaskNotificaitons) + { + MailQueueMessage CreateMessage(UserSecurityTasksCount notification) + { + var message = CreateDefaultMessage($"{orgName} has identified {notification.TaskCount} at-risk password{(notification.TaskCount.Equals(1) ? "" : "s")}", notification.Email); + var model = new SecurityTaskNotificationViewModel + { + OrgName = orgName, + TaskCount = notification.TaskCount, + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + }; + message.Category = "SecurityTasksNotification"; + return new MailQueueMessage(message, "SecurityTasksNotification", model); + } + var messageModels = securityTaskNotificaitons.Select(CreateMessage); + await EnqueueMailAsync(messageModels.ToList()); + } + private static string GetUserIdentifier(string email, string userName) { return string.IsNullOrEmpty(userName) ? email : CoreHelpers.SanitizeForEmail(userName, false); } } - diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index 9984f8ee90..5fba545903 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -5,6 +5,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Mail; +using Bit.Core.Vault.Models.Data; namespace Bit.Core.Services; @@ -322,5 +323,9 @@ public class NoopMailService : IMailService { return Task.FromResult(0); } -} + public Task SendBulkSecurityTaskNotificationsAsync(string orgName, IEnumerable securityTaskNotificaitons) + { + return Task.FromResult(0); + } +} diff --git a/src/Core/Vault/Commands/CreateManyTaskNotificationsCommand.cs b/src/Core/Vault/Commands/CreateManyTaskNotificationsCommand.cs new file mode 100644 index 0000000000..58b5f65e0f --- /dev/null +++ b/src/Core/Vault/Commands/CreateManyTaskNotificationsCommand.cs @@ -0,0 +1,82 @@ +using Bit.Core.Enums; +using Bit.Core.NotificationCenter.Commands.Interfaces; +using Bit.Core.NotificationCenter.Entities; +using Bit.Core.NotificationCenter.Enums; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Vault.Commands.Interfaces; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Queries; + +public class CreateManyTaskNotificationsCommand : ICreateManyTaskNotificationsCommand +{ + private readonly IGetSecurityTasksNotificationDetailsQuery _getSecurityTasksNotificationDetailsQuery; + private readonly IOrganizationRepository _organizationRepository; + private readonly IMailService _mailService; + private readonly ICreateNotificationCommand _createNotificationCommand; + private readonly IPushNotificationService _pushNotificationService; + + public CreateManyTaskNotificationsCommand( + IGetSecurityTasksNotificationDetailsQuery getSecurityTasksNotificationDetailsQuery, + IOrganizationRepository organizationRepository, + IMailService mailService, + ICreateNotificationCommand createNotificationCommand, + IPushNotificationService pushNotificationService) + { + _getSecurityTasksNotificationDetailsQuery = getSecurityTasksNotificationDetailsQuery; + _organizationRepository = organizationRepository; + _mailService = mailService; + _createNotificationCommand = createNotificationCommand; + _pushNotificationService = pushNotificationService; + } + + public async Task CreateAsync(Guid orgId, IEnumerable securityTasks) + { + var securityTaskCiphers = await _getSecurityTasksNotificationDetailsQuery.GetNotificationDetailsByManyIds(orgId, securityTasks); + + // Get the number of tasks for each user + var userTaskCount = securityTaskCiphers.GroupBy(x => x.UserId).Select(x => new UserSecurityTasksCount + { + UserId = x.Key, + Email = x.First().Email, + TaskCount = x.Count() + }).ToList(); + + var organization = await _organizationRepository.GetByIdAsync(orgId); + + await _mailService.SendBulkSecurityTaskNotificationsAsync(organization.Name, userTaskCount); + + // Break securityTaskCiphers into separate lists by user Id + var securityTaskCiphersByUser = securityTaskCiphers.GroupBy(x => x.UserId) + .ToDictionary(g => g.Key, g => g.ToList()); + + foreach (var userId in securityTaskCiphersByUser.Keys) + { + // Get the security tasks by the user Id + var userSecurityTaskCiphers = securityTaskCiphersByUser[userId]; + + // Process each user's security task ciphers + for (int i = 0; i < userSecurityTaskCiphers.Count; i++) + { + var userSecurityTaskCipher = userSecurityTaskCiphers[i]; + + // Create a notification for the user with the associated task + var notification = new Notification + { + UserId = userSecurityTaskCipher.UserId, + OrganizationId = orgId, + Priority = Priority.Informational, + ClientType = ClientType.Browser, + TaskId = userSecurityTaskCipher.TaskId + }; + + await _createNotificationCommand.CreateAsync(notification, false); + } + + // Notify the user that they have pending security tasks + await _pushNotificationService.PushPendingSecurityTasksAsync(userId); + } + } +} diff --git a/src/Core/Vault/Commands/Interfaces/ICreateManyTaskNotificationsCommand.cs b/src/Core/Vault/Commands/Interfaces/ICreateManyTaskNotificationsCommand.cs new file mode 100644 index 0000000000..465d9c6fee --- /dev/null +++ b/src/Core/Vault/Commands/Interfaces/ICreateManyTaskNotificationsCommand.cs @@ -0,0 +1,13 @@ +using Bit.Core.Vault.Entities; + +namespace Bit.Core.Vault.Commands.Interfaces; + +public interface ICreateManyTaskNotificationsCommand +{ + /// + /// Creates email and push notifications for the given security tasks. + /// + /// The organization Id + /// All applicable security tasks + Task CreateAsync(Guid organizationId, IEnumerable securityTasks); +} diff --git a/src/Core/Vault/Models/Data/UserCipherForTask.cs b/src/Core/Vault/Models/Data/UserCipherForTask.cs new file mode 100644 index 0000000000..3ddaa141b1 --- /dev/null +++ b/src/Core/Vault/Models/Data/UserCipherForTask.cs @@ -0,0 +1,23 @@ +namespace Bit.Core.Vault.Models.Data; + +/// +/// Minimal data model that represents a User and the associated cipher for a security task. +/// Only to be used for query responses. For full data model, . +/// +public class UserCipherForTask +{ + /// + /// The user's Id. + /// + public Guid UserId { get; set; } + + /// + /// The user's email. + /// + public string Email { get; set; } + + /// + /// The cipher Id of the security task. + /// + public Guid CipherId { get; set; } +} diff --git a/src/Core/Vault/Models/Data/UserSecurityTaskCipher.cs b/src/Core/Vault/Models/Data/UserSecurityTaskCipher.cs new file mode 100644 index 0000000000..20e59ec4f7 --- /dev/null +++ b/src/Core/Vault/Models/Data/UserSecurityTaskCipher.cs @@ -0,0 +1,27 @@ +namespace Bit.Core.Vault.Models.Data; + +/// +/// Data model that represents a User and the associated cipher for a security task. +/// +public class UserSecurityTaskCipher +{ + /// + /// The user's Id. + /// + public Guid UserId { get; set; } + + /// + /// The user's email. + /// + public string Email { get; set; } + + /// + /// The cipher Id of the security task. + /// + public Guid CipherId { get; set; } + + /// + /// The Id of the security task. + /// + public Guid TaskId { get; set; } +} diff --git a/src/Core/Vault/Models/Data/UserSecurityTasksCount.cs b/src/Core/Vault/Models/Data/UserSecurityTasksCount.cs new file mode 100644 index 0000000000..c8d2707db6 --- /dev/null +++ b/src/Core/Vault/Models/Data/UserSecurityTasksCount.cs @@ -0,0 +1,22 @@ +namespace Bit.Core.Vault.Models.Data; + +/// +/// Data model that represents a User and the amount of actionable security tasks. +/// +public class UserSecurityTasksCount +{ + /// + /// The user's Id. + /// + public Guid UserId { get; set; } + + /// + /// The user's email. + /// + public string Email { get; set; } + + /// + /// The number of actionable security tasks for the respective users. + /// + public int TaskCount { get; set; } +} diff --git a/src/Core/Vault/Queries/GetSecurityTasksNotificationDetailsQuery.cs b/src/Core/Vault/Queries/GetSecurityTasksNotificationDetailsQuery.cs new file mode 100644 index 0000000000..00104f1919 --- /dev/null +++ b/src/Core/Vault/Queries/GetSecurityTasksNotificationDetailsQuery.cs @@ -0,0 +1,33 @@ +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Repositories; + +namespace Bit.Core.Vault.Queries; + +public class GetSecurityTasksNotificationDetailsQuery : IGetSecurityTasksNotificationDetailsQuery +{ + private readonly ICurrentContext _currentContext; + private readonly ICipherRepository _cipherRepository; + + public GetSecurityTasksNotificationDetailsQuery(ICurrentContext currentContext, ICipherRepository cipherRepository) + { + _currentContext = currentContext; + _cipherRepository = cipherRepository; + } + + public async Task> GetNotificationDetailsByManyIds(Guid organizationId, IEnumerable tasks) + { + var org = _currentContext.GetOrganization(organizationId); + + if (org == null) + { + throw new NotFoundException(); + } + + var userSecurityTaskCiphers = await _cipherRepository.GetUserSecurityTasksByCipherIdsAsync(organizationId, tasks); + + return userSecurityTaskCiphers; + } +} diff --git a/src/Core/Vault/Queries/IGetSecurityTasksNotificationDetailsQuery.cs b/src/Core/Vault/Queries/IGetSecurityTasksNotificationDetailsQuery.cs new file mode 100644 index 0000000000..df81765817 --- /dev/null +++ b/src/Core/Vault/Queries/IGetSecurityTasksNotificationDetailsQuery.cs @@ -0,0 +1,16 @@ +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Models.Data; + +namespace Bit.Core.Vault.Queries; + +public interface IGetSecurityTasksNotificationDetailsQuery +{ + /// + /// Retrieves all users within the given organization that are applicable to the given security tasks. + /// + /// + /// + /// A dictionary of UserIds and the corresponding amount of security tasks applicable to them. + /// + public Task> GetNotificationDetailsByManyIds(Guid organizationId, IEnumerable tasks); +} diff --git a/src/Core/Vault/Repositories/ICipherRepository.cs b/src/Core/Vault/Repositories/ICipherRepository.cs index 2950cb99c2..b094b42044 100644 --- a/src/Core/Vault/Repositories/ICipherRepository.cs +++ b/src/Core/Vault/Repositories/ICipherRepository.cs @@ -4,6 +4,7 @@ using Bit.Core.Repositories; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Models.Data; + namespace Bit.Core.Vault.Repositories; public interface ICipherRepository : IRepository @@ -49,6 +50,13 @@ public interface ICipherRepository : IRepository Task> GetCipherPermissionsForOrganizationAsync(Guid organizationId, Guid userId); + /// + /// Returns the users and the cipher ids for security tawsks that are applicable to them. + /// + /// Security tasks are actionable when a user has manage access to the associated cipher. + /// + Task> GetUserSecurityTasksByCipherIdsAsync(Guid organizationId, IEnumerable tasks); + /// /// Updates encrypted data for ciphers during a key rotation /// diff --git a/src/Core/Vault/VaultServiceCollectionExtensions.cs b/src/Core/Vault/VaultServiceCollectionExtensions.cs index fcb9259135..1f361cb613 100644 --- a/src/Core/Vault/VaultServiceCollectionExtensions.cs +++ b/src/Core/Vault/VaultServiceCollectionExtensions.cs @@ -21,6 +21,8 @@ public static class VaultServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); } } diff --git a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs index b8304fbbb0..b85f1991f7 100644 --- a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs @@ -323,6 +323,28 @@ public class CipherRepository : Repository, ICipherRepository } } + public async Task> GetUserSecurityTasksByCipherIdsAsync( + Guid organizationId, IEnumerable tasks) + { + var cipherIds = tasks.Where(t => t.CipherId.HasValue).Select(t => t.CipherId.Value).Distinct().ToList(); + using (var connection = new SqlConnection(ConnectionString)) + { + + var results = await connection.QueryAsync( + $"[{Schema}].[UserSecurityTasks_GetManyByCipherIds]", + new { OrganizationId = organizationId, CipherIds = cipherIds.ToGuidIdArrayTVP() }, + commandType: CommandType.StoredProcedure); + + return results.Select(r => new UserSecurityTaskCipher + { + UserId = r.UserId, + Email = r.Email, + CipherId = r.CipherId, + TaskId = tasks.First(t => t.CipherId == r.CipherId).Id + }).ToList(); + } + } + /// public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation( Guid userId, IEnumerable ciphers) diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs index 9c91609b1b..e4930cb795 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs @@ -348,6 +348,51 @@ public class CipherRepository : Repository> GetUserSecurityTasksByCipherIdsAsync(Guid organizationId, IEnumerable tasks) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var cipherIds = tasks.Where(t => t.CipherId.HasValue).Select(t => t.CipherId.Value); + var dbContext = GetDatabaseContext(scope); + var query = new UserSecurityTasksByCipherIdsQuery(organizationId, cipherIds).Run(dbContext); + + ICollection userTaskCiphers; + + // SQLite does not support the GROUP BY clause + if (dbContext.Database.IsSqlite()) + { + userTaskCiphers = (await query.ToListAsync()) + .GroupBy(c => new { c.UserId, c.Email, c.CipherId }) + .Select(g => new UserSecurityTaskCipher + { + UserId = g.Key.UserId, + Email = g.Key.Email, + CipherId = g.Key.CipherId, + }).ToList(); + } + else + { + var groupByQuery = from p in query + group p by new { p.UserId, p.Email, p.CipherId } + into g + select new UserSecurityTaskCipher + { + UserId = g.Key.UserId, + CipherId = g.Key.CipherId, + Email = g.Key.Email, + }; + userTaskCiphers = await groupByQuery.ToListAsync(); + } + + foreach (var userTaskCipher in userTaskCiphers) + { + userTaskCipher.TaskId = tasks.First(t => t.CipherId == userTaskCipher.CipherId).Id; + } + + return userTaskCiphers; + } + } + public async Task GetByIdAsync(Guid id, Guid userId) { using (var scope = ServiceScopeFactory.CreateScope()) diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/UserSecurityTasksByCipherIdsQuery.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/UserSecurityTasksByCipherIdsQuery.cs new file mode 100644 index 0000000000..c36c0d87c4 --- /dev/null +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/UserSecurityTasksByCipherIdsQuery.cs @@ -0,0 +1,71 @@ +using Bit.Core.Vault.Models.Data; +using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.Infrastructure.EntityFramework.Repositories.Queries; + +namespace Bit.Infrastructure.EntityFramework.Vault.Repositories.Queries; + +public class UserSecurityTasksByCipherIdsQuery : IQuery +{ + private readonly Guid _organizationId; + private readonly IEnumerable _cipherIds; + + public UserSecurityTasksByCipherIdsQuery(Guid organizationId, IEnumerable cipherIds) + { + _organizationId = organizationId; + _cipherIds = cipherIds; + } + + public IQueryable Run(DatabaseContext dbContext) + { + var baseCiphers = + from c in dbContext.Ciphers + where _cipherIds.Contains(c.Id) + join o in dbContext.Organizations + on c.OrganizationId equals o.Id + where o.Id == _organizationId && o.Enabled + select c; + + var userPermissions = + from c in baseCiphers + join cc in dbContext.CollectionCiphers + on c.Id equals cc.CipherId + join cu in dbContext.CollectionUsers + on cc.CollectionId equals cu.CollectionId + join ou in dbContext.OrganizationUsers + on cu.OrganizationUserId equals ou.Id + where ou.OrganizationId == _organizationId + && cu.Manage == true + select new { ou.UserId, c.Id }; + + var groupPermissions = + from c in baseCiphers + join cc in dbContext.CollectionCiphers + on c.Id equals cc.CipherId + join cg in dbContext.CollectionGroups + on cc.CollectionId equals cg.CollectionId + join gu in dbContext.GroupUsers + on cg.GroupId equals gu.GroupId + join ou in dbContext.OrganizationUsers + on gu.OrganizationUserId equals ou.Id + where ou.OrganizationId == _organizationId + && cg.Manage == true + && !userPermissions.Any(up => up.Id == c.Id && up.UserId == ou.UserId) + select new { ou.UserId, c.Id }; + + return userPermissions.Union(groupPermissions) + .Join( + dbContext.Users, + p => p.UserId, + u => u.Id, + (p, u) => new { p.UserId, p.Id, u.Email } + ) + .GroupBy(x => new { x.UserId, x.Email, x.Id }) + .Select(g => new UserCipherForTask + { + UserId = (Guid)g.Key.UserId, + Email = g.Key.Email, + CipherId = g.Key.Id + }) + .OrderByDescending(x => x.Email); + } +} diff --git a/src/Sql/Vault/dbo/Stored Procedures/Cipher/UserSecurityTasks_GetManyByCipherIds.sql b/src/Sql/Vault/dbo/Stored Procedures/Cipher/UserSecurityTasks_GetManyByCipherIds.sql new file mode 100644 index 0000000000..be39ee9eb6 --- /dev/null +++ b/src/Sql/Vault/dbo/Stored Procedures/Cipher/UserSecurityTasks_GetManyByCipherIds.sql @@ -0,0 +1,67 @@ +CREATE PROCEDURE [dbo].[UserSecurityTasks_GetManyByCipherIds] + @OrganizationId UNIQUEIDENTIFIER, + @CipherIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + ;WITH BaseCiphers AS ( + SELECT C.[Id], C.[OrganizationId] + FROM [dbo].[Cipher] C + INNER JOIN @CipherIds CI ON C.[Id] = CI.[Id] + INNER JOIN [dbo].[Organization] O ON + O.[Id] = C.[OrganizationId] + AND O.[Id] = @OrganizationId + AND O.[Enabled] = 1 + ), + UserPermissions AS ( + SELECT DISTINCT + CC.[CipherId], + OU.[UserId], + COALESCE(CU.[Manage], 0) as [Manage] + FROM [dbo].[CollectionCipher] CC + INNER JOIN [dbo].[CollectionUser] CU ON + CU.[CollectionId] = CC.[CollectionId] + INNER JOIN [dbo].[OrganizationUser] OU ON + CU.[OrganizationUserId] = OU.[Id] + AND OU.[OrganizationId] = @OrganizationId + WHERE COALESCE(CU.[Manage], 0) = 1 + ), + GroupPermissions AS ( + SELECT DISTINCT + CC.[CipherId], + OU.[UserId], + COALESCE(CG.[Manage], 0) as [Manage] + FROM [dbo].[CollectionCipher] CC + INNER JOIN [dbo].[CollectionGroup] CG ON + CG.[CollectionId] = CC.[CollectionId] + INNER JOIN [dbo].[GroupUser] GU ON + GU.[GroupId] = CG.[GroupId] + INNER JOIN [dbo].[OrganizationUser] OU ON + GU.[OrganizationUserId] = OU.[Id] + AND OU.[OrganizationId] = @OrganizationId + WHERE COALESCE(CG.[Manage], 0) = 1 + AND NOT EXISTS ( + SELECT 1 + FROM UserPermissions UP + WHERE UP.[CipherId] = CC.[CipherId] + AND UP.[UserId] = OU.[UserId] + ) + ), + CombinedPermissions AS ( + SELECT CipherId, UserId, [Manage] + FROM UserPermissions + UNION + SELECT CipherId, UserId, [Manage] + FROM GroupPermissions + ) + SELECT + P.[UserId], + U.[Email], + C.[Id] as CipherId + FROM BaseCiphers C + INNER JOIN CombinedPermissions P ON P.CipherId = C.[Id] + INNER JOIN [dbo].[User] U ON U.[Id] = P.[UserId] + WHERE P.[Manage] = 1 + ORDER BY U.[Email], C.[Id] +END diff --git a/test/Core.Test/NotificationCenter/Commands/CreateNotificationCommandTest.cs b/test/Core.Test/NotificationCenter/Commands/CreateNotificationCommandTest.cs index 3256f2f9cb..3c67cceb2e 100644 --- a/test/Core.Test/NotificationCenter/Commands/CreateNotificationCommandTest.cs +++ b/test/Core.Test/NotificationCenter/Commands/CreateNotificationCommandTest.cs @@ -69,4 +69,19 @@ public class CreateNotificationCommandTest .Received(0) .PushNotificationStatusAsync(Arg.Any(), Arg.Any()); } + + [Theory] + [BitAutoData] + public async Task CreateAsync_Authorized_NotificationPushSkipped( + SutProvider sutProvider, + Notification notification) + { + Setup(sutProvider, notification, true); + + var newNotification = await sutProvider.Sut.CreateAsync(notification, false); + + await sutProvider.GetDependency() + .Received(0) + .PushNotificationAsync(newNotification); + } } diff --git a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs index b64a1ded76..6f02740cf5 100644 --- a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs @@ -704,4 +704,183 @@ public class CipherRepositoryTests Data = "" }); } + + [DatabaseTheory, DatabaseData] + public async Task GetUserSecurityTasksByCipherIdsAsync_Works( + ICipherRepository cipherRepository, + IUserRepository userRepository, + ICollectionCipherRepository collectionCipherRepository, + ICollectionRepository collectionRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IGroupRepository groupRepository + ) + { + // Users + var user1 = await userRepository.CreateAsync(new User + { + Name = "Test User 1", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var user2 = await userRepository.CreateAsync(new User + { + Name = "Test User 2", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + // Organization + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Organization", + BillingEmail = user1.Email, + Plan = "Test" + }); + + // Org Users + var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser + { + UserId = user1.Id, + OrganizationId = organization.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Owner, + }); + + var orgUser2 = await organizationUserRepository.CreateAsync(new OrganizationUser + { + UserId = user2.Id, + OrganizationId = organization.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User, + }); + + // A group that will be assigned Edit permissions to any collections + var editGroup = await groupRepository.CreateAsync(new Group + { + OrganizationId = organization.Id, + Name = "Edit Group", + }); + await groupRepository.UpdateUsersAsync(editGroup.Id, new[] { orgUser1.Id }); + + // Add collections to Org + var manageCollection = await collectionRepository.CreateAsync(new Collection + { + Name = "Manage Collection", + OrganizationId = organization.Id + }); + + // Use a 2nd collection to differentiate between the two users + var manageCollection2 = await collectionRepository.CreateAsync(new Collection + { + Name = "Manage Collection 2", + OrganizationId = organization.Id + }); + var viewOnlyCollection = await collectionRepository.CreateAsync(new Collection + { + Name = "View Only Collection", + OrganizationId = organization.Id + }); + + // Ciphers + var manageCipher1 = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "" + }); + + var manageCipher2 = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "" + }); + + var viewOnlyCipher = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "" + }); + + await collectionCipherRepository.UpdateCollectionsForAdminAsync(manageCipher1.Id, organization.Id, + new List { manageCollection.Id }); + + await collectionCipherRepository.UpdateCollectionsForAdminAsync(manageCipher2.Id, organization.Id, + new List { manageCollection2.Id }); + + await collectionCipherRepository.UpdateCollectionsForAdminAsync(viewOnlyCipher.Id, organization.Id, + new List { viewOnlyCollection.Id }); + + await collectionRepository.UpdateUsersAsync(manageCollection.Id, new List + { + new() + { + Id = orgUser1.Id, + HidePasswords = false, + ReadOnly = false, + Manage = true + }, + new() + { + Id = orgUser2.Id, + HidePasswords = false, + ReadOnly = false, + Manage = true + } + }); + + // Only add second user to the second manage collection + await collectionRepository.UpdateUsersAsync(manageCollection2.Id, new List + { + new() + { + Id = orgUser2.Id, + HidePasswords = false, + ReadOnly = false, + Manage = true + }, + }); + + await collectionRepository.UpdateUsersAsync(viewOnlyCollection.Id, new List + { + new() + { + Id = orgUser1.Id, + HidePasswords = false, + ReadOnly = false, + Manage = false + } + }); + + var securityTasks = new List + { + new SecurityTask { CipherId = manageCipher1.Id, Id = Guid.NewGuid() }, + new SecurityTask { CipherId = manageCipher2.Id, Id = Guid.NewGuid() }, + new SecurityTask { CipherId = viewOnlyCipher.Id, Id = Guid.NewGuid() } + }; + + var userSecurityTaskCiphers = await cipherRepository.GetUserSecurityTasksByCipherIdsAsync(organization.Id, securityTasks); + + Assert.NotEmpty(userSecurityTaskCiphers); + Assert.Equal(3, userSecurityTaskCiphers.Count); + + var user1TaskCiphers = userSecurityTaskCiphers.Where(t => t.UserId == user1.Id); + Assert.Single(user1TaskCiphers); + Assert.Equal(user1.Email, user1TaskCiphers.First().Email); + Assert.Equal(user1.Id, user1TaskCiphers.First().UserId); + Assert.Equal(manageCipher1.Id, user1TaskCiphers.First().CipherId); + + var user2TaskCiphers = userSecurityTaskCiphers.Where(t => t.UserId == user2.Id); + Assert.NotNull(user2TaskCiphers); + Assert.Equal(2, user2TaskCiphers.Count()); + Assert.Equal(user2.Email, user2TaskCiphers.Last().Email); + Assert.Equal(user2.Id, user2TaskCiphers.Last().UserId); + Assert.Contains(user2TaskCiphers, t => t.CipherId == manageCipher1.Id && t.TaskId == securityTasks[0].Id); + Assert.Contains(user2TaskCiphers, t => t.CipherId == manageCipher2.Id && t.TaskId == securityTasks[1].Id); + } } diff --git a/util/Migrator/DbScripts/2025-02-11_00_UserSecurityTasks_GetManyByCipherIds.sql b/util/Migrator/DbScripts/2025-02-11_00_UserSecurityTasks_GetManyByCipherIds.sql new file mode 100644 index 0000000000..6d16f77161 --- /dev/null +++ b/util/Migrator/DbScripts/2025-02-11_00_UserSecurityTasks_GetManyByCipherIds.sql @@ -0,0 +1,68 @@ +CREATE OR ALTER PROCEDURE [dbo].[UserSecurityTasks_GetManyByCipherIds] + @OrganizationId UNIQUEIDENTIFIER, + @CipherIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + ;WITH BaseCiphers AS ( + SELECT C.[Id], C.[OrganizationId] + FROM [dbo].[Cipher] C + INNER JOIN @CipherIds CI ON C.[Id] = CI.[Id] + INNER JOIN [dbo].[Organization] O ON + O.[Id] = C.[OrganizationId] + AND O.[Id] = @OrganizationId + AND O.[Enabled] = 1 + ), + UserPermissions AS ( + SELECT DISTINCT + CC.[CipherId], + OU.[UserId], + COALESCE(CU.[Manage], 0) as [Manage] + FROM [dbo].[CollectionCipher] CC + INNER JOIN [dbo].[CollectionUser] CU ON + CU.[CollectionId] = CC.[CollectionId] + INNER JOIN [dbo].[OrganizationUser] OU ON + CU.[OrganizationUserId] = OU.[Id] + AND OU.[OrganizationId] = @OrganizationId + WHERE COALESCE(CU.[Manage], 0) = 1 + ), + GroupPermissions AS ( + SELECT DISTINCT + CC.[CipherId], + OU.[UserId], + COALESCE(CG.[Manage], 0) as [Manage] + FROM [dbo].[CollectionCipher] CC + INNER JOIN [dbo].[CollectionGroup] CG ON + CG.[CollectionId] = CC.[CollectionId] + INNER JOIN [dbo].[GroupUser] GU ON + GU.[GroupId] = CG.[GroupId] + INNER JOIN [dbo].[OrganizationUser] OU ON + GU.[OrganizationUserId] = OU.[Id] + AND OU.[OrganizationId] = @OrganizationId + WHERE COALESCE(CG.[Manage], 0) = 1 + AND NOT EXISTS ( + SELECT 1 + FROM UserPermissions UP + WHERE UP.[CipherId] = CC.[CipherId] + AND UP.[UserId] = OU.[UserId] + ) + ), + CombinedPermissions AS ( + SELECT CipherId, UserId, [Manage] + FROM UserPermissions + UNION + SELECT CipherId, UserId, [Manage] + FROM GroupPermissions + ) + SELECT + P.[UserId], + U.[Email], + C.[Id] as CipherId + FROM BaseCiphers C + INNER JOIN CombinedPermissions P ON P.CipherId = C.[Id] + INNER JOIN [dbo].[User] U ON U.[Id] = P.[UserId] + WHERE P.[Manage] = 1 + ORDER BY U.[Email], C.[Id] +END +GO