1
0
mirror of https://github.com/bitwarden/server.git synced 2025-03-01 04:01:11 +01:00

[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
This commit is contained in:
Nick Krantz 2025-02-27 08:34:42 -06:00 committed by GitHub
parent a2e665cb96
commit 1267332b5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 893 additions and 8 deletions

View File

@ -22,19 +22,22 @@ public class SecurityTaskController : Controller
private readonly IMarkTaskAsCompleteCommand _markTaskAsCompleteCommand; private readonly IMarkTaskAsCompleteCommand _markTaskAsCompleteCommand;
private readonly IGetTasksForOrganizationQuery _getTasksForOrganizationQuery; private readonly IGetTasksForOrganizationQuery _getTasksForOrganizationQuery;
private readonly ICreateManyTasksCommand _createManyTasksCommand; private readonly ICreateManyTasksCommand _createManyTasksCommand;
private readonly ICreateManyTaskNotificationsCommand _createManyTaskNotificationsCommand;
public SecurityTaskController( public SecurityTaskController(
IUserService userService, IUserService userService,
IGetTaskDetailsForUserQuery getTaskDetailsForUserQuery, IGetTaskDetailsForUserQuery getTaskDetailsForUserQuery,
IMarkTaskAsCompleteCommand markTaskAsCompleteCommand, IMarkTaskAsCompleteCommand markTaskAsCompleteCommand,
IGetTasksForOrganizationQuery getTasksForOrganizationQuery, IGetTasksForOrganizationQuery getTasksForOrganizationQuery,
ICreateManyTasksCommand createManyTasksCommand) ICreateManyTasksCommand createManyTasksCommand,
ICreateManyTaskNotificationsCommand createManyTaskNotificationsCommand)
{ {
_userService = userService; _userService = userService;
_getTaskDetailsForUserQuery = getTaskDetailsForUserQuery; _getTaskDetailsForUserQuery = getTaskDetailsForUserQuery;
_markTaskAsCompleteCommand = markTaskAsCompleteCommand; _markTaskAsCompleteCommand = markTaskAsCompleteCommand;
_getTasksForOrganizationQuery = getTasksForOrganizationQuery; _getTasksForOrganizationQuery = getTasksForOrganizationQuery;
_createManyTasksCommand = createManyTasksCommand; _createManyTasksCommand = createManyTasksCommand;
_createManyTaskNotificationsCommand = createManyTaskNotificationsCommand;
} }
/// <summary> /// <summary>
@ -87,6 +90,9 @@ public class SecurityTaskController : Controller
[FromBody] BulkCreateSecurityTasksRequestModel model) [FromBody] BulkCreateSecurityTasksRequestModel model)
{ {
var securityTasks = await _createManyTasksCommand.CreateAsync(orgId, model.Tasks); var securityTasks = await _createManyTasksCommand.CreateAsync(orgId, model.Tasks);
await _createManyTaskNotificationsCommand.CreateAsync(orgId, securityTasks);
var response = securityTasks.Select(x => new SecurityTasksResponseModel(x)).ToList(); var response = securityTasks.Select(x => new SecurityTasksResponseModel(x)).ToList();
return new ListResponseModel<SecurityTasksResponseModel>(response); return new ListResponseModel<SecurityTasksResponseModel>(response);
} }

View File

@ -29,5 +29,7 @@ public enum PushType : byte
SyncOrganizationCollectionSettingChanged = 19, SyncOrganizationCollectionSettingChanged = 19,
Notification = 20, Notification = 20,
NotificationStatus = 21 NotificationStatus = 21,
PendingSecurityTasks = 22
} }

View File

@ -0,0 +1,61 @@
{{#>FullUpdatedHtmlLayout}}
<table border="0" cellpadding="0" cellspacing="0" width="100%"
style="background-color: #175DDC;padding-top:25px;padding-bottom:15px;">
<tr>
<td align="center" valign="top" width="70%" class="templateColumnContainer">
<table border="0" cellpadding="0" cellspacing="0" width="100%"
style="padding-left:30px; padding-right: 5px; padding-top: 20px;">
<tr>
<td
style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 24px; color: #ffffff; line-height: 32px; font-weight: 500; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
{{OrgName}} has identified {{TaskCount}} critical login{{#if TaskCountPlural}}s{{/if}} that require{{#unless
TaskCountPlural}}s{{/unless}} a
password change
</td>
</tr>
</table>
</td>
<td align="right" valign="bottom" class="templateColumnContainer" style="padding-right: 15px;">
<img width="140" height="140" align="right" valign="bottom"
style="width: 140px; height:140px; font-size: 0; vertical-align: bottom; text-align: right;" alt=''
src='https://assets.bitwarden.com/email/v1/business-warning.png' />
</td>
</tr>
</table>
{{>@partial-block}}
<table width="100%" style="display:table; background-color: #FBFBFB; vertical-align: middle; padding:30px" border="0"
cellpadding="0" cellspacing="0" valign="middle">
<tr>
<td width="70%" class="footer-text" style="padding-right: 20px;">
<table align="left" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-style: normal; font-weight: 400; font-size: 16px; line-height: 24px;">
<p
style="margin: 0; padding: 0; margin-bottom: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-style: normal; font-weight: 600; font-size: 20px; line-height: 28px;">
Were here for you!</p>
If you have any questions, search the Bitwarden <a
style="text-decoration: none; color: #175DDC; font-weight: 600;"
href="https://bitwarden.com/help/">Help</a> site or <a
style="text-decoration: none; color: #175DDC; font-weight: 600;"
href="https://bitwarden.com/contact/">contact us</a>.
</td>
</tr>
</table>
</td>
<td width="30%">
<table align="right" valign="bottom" class="footer-image" border="0" cellpadding="0" cellspacing="0"
style="padding-left: 40px;">
<tr>
<td>
<img width="94" height="77" src="https://assets.bitwarden.com/email/v1/chat.png"
style="width: 94px; height: 77px;" alt="" />
</td>
</tr>
</table>
</td>
</tr>
</table>
{{/FullUpdatedHtmlLayout}}

View File

@ -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}}
Were 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}}

View File

@ -0,0 +1,28 @@
{{#>SecurityTasksHtmlLayout}}
<table width="100%" border="0" style="display: block; padding: 30px;" align="center" cellpadding="0" cellspacing="0">
<tr>
<td
style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-style: normal; font-weight: 400; font-size: 16px; line-height: 24px;">
Keep you and your organization's data safe by changing passwords that are weak, reused, or have been exposed in a
data breach.
</td>
</tr>
<tr>
<td
style="padding-top: 24px; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-style: normal; font-weight: 400; font-size: 16px; line-height: 24px;">
Launch the Bitwarden extension to review your at-risk passwords.
</td>
</tr>
</table>
<table width="100%" border="0" cellpadding="0" cellspacing="0"
style="display: table; width:100%; padding-bottom: 35px; text-align: center;" align="center">
<tr>
<td display="display: table-cell">
<a href="{{ReviewPasswordsUrl}}" clicktracking=off target="_blank"
style="display: inline-block; color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; border-radius: 999px; background-color: #175DDC; border-color: #175DDC; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
Review at-risk passwords
</a>
</td>
</tr>
</table>
{{/SecurityTasksHtmlLayout}}

View File

@ -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}}

View File

@ -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";
}

View File

@ -28,7 +28,7 @@ public class CreateNotificationCommand : ICreateNotificationCommand
_pushNotificationService = pushNotificationService; _pushNotificationService = pushNotificationService;
} }
public async Task<Notification> CreateAsync(Notification notification) public async Task<Notification> CreateAsync(Notification notification, bool sendPush = true)
{ {
notification.CreationDate = notification.RevisionDate = DateTime.UtcNow; notification.CreationDate = notification.RevisionDate = DateTime.UtcNow;
@ -37,7 +37,10 @@ public class CreateNotificationCommand : ICreateNotificationCommand
var newNotification = await _notificationRepository.CreateAsync(notification); var newNotification = await _notificationRepository.CreateAsync(notification);
await _pushNotificationService.PushNotificationAsync(newNotification); if (sendPush)
{
await _pushNotificationService.PushNotificationAsync(newNotification);
}
return newNotification; return newNotification;
} }

View File

@ -5,5 +5,5 @@ namespace Bit.Core.NotificationCenter.Commands.Interfaces;
public interface ICreateNotificationCommand public interface ICreateNotificationCommand
{ {
Task<Notification> CreateAsync(Notification notification); Task<Notification> CreateAsync(Notification notification, bool sendPush = true);
} }

View File

@ -329,6 +329,11 @@ public class NotificationHubPushNotificationService : IPushNotificationService
GetContextIdentifier(excludeCurrentContext), clientType: clientType); 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, public async Task SendPayloadToInstallationAsync(string installationId, PushType type, object payload,
string? identifier, string? deviceId = null, ClientType? clientType = null) string? identifier, string? deviceId = null, ClientType? clientType = null)
{ {

View File

@ -219,6 +219,11 @@ public class AzureQueuePushNotificationService : IPushNotificationService
await SendMessageAsync(PushType.NotificationStatus, message, true); 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) private async Task PushSendAsync(Send send, PushType type)
{ {
if (send.UserId.HasValue) if (send.UserId.HasValue)

View File

@ -38,4 +38,5 @@ public interface IPushNotificationService
string? deviceId = null, ClientType? clientType = null); string? deviceId = null, ClientType? clientType = null);
Task SendPayloadToOrganizationAsync(string orgId, PushType type, object payload, string? identifier, Task SendPayloadToOrganizationAsync(string orgId, PushType type, object payload, string? identifier,
string? deviceId = null, ClientType? clientType = null); string? deviceId = null, ClientType? clientType = null);
Task PushPendingSecurityTasksAsync(Guid userId);
} }

View File

@ -179,6 +179,12 @@ public class MultiServicePushNotificationService : IPushNotificationService
return Task.FromResult(0); return Task.FromResult(0);
} }
public Task PushPendingSecurityTasksAsync(Guid userId)
{
PushToServices((s) => s.PushPendingSecurityTasksAsync(userId));
return Task.CompletedTask;
}
private void PushToServices(Func<IPushNotificationService, Task> pushFunc) private void PushToServices(Func<IPushNotificationService, Task> pushFunc)
{ {
if (!_services.Any()) if (!_services.Any())

View File

@ -121,4 +121,9 @@ public class NoopPushNotificationService : IPushNotificationService
{ {
return Task.FromResult(0); return Task.FromResult(0);
} }
public Task PushPendingSecurityTasksAsync(Guid userId)
{
return Task.FromResult(0);
}
} }

View File

@ -232,6 +232,11 @@ public class NotificationsApiPushNotificationService : BaseIdentityClientService
await SendMessageAsync(PushType.NotificationStatus, message, true); 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) private async Task PushSendAsync(Send send, PushType type)
{ {
if (send.UserId.HasValue) if (send.UserId.HasValue)

View File

@ -300,6 +300,11 @@ public class RelayPushNotificationService : BaseIdentityClientService, IPushNoti
false false
); );
public async Task PushPendingSecurityTasksAsync(Guid userId)
{
await PushUserAsync(userId, PushType.PendingSecurityTasks);
}
private async Task SendPayloadToInstallationAsync(PushType type, object payload, bool excludeCurrentContext, private async Task SendPayloadToInstallationAsync(PushType type, object payload, bool excludeCurrentContext,
ClientType? clientType = null) ClientType? clientType = null)
{ {

View File

@ -5,6 +5,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Data.Organizations;
using Bit.Core.Models.Mail; using Bit.Core.Models.Mail;
using Bit.Core.Vault.Models.Data;
namespace Bit.Core.Services; namespace Bit.Core.Services;
@ -98,5 +99,5 @@ public interface IMailService
string organizationName); string organizationName);
Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList); Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList);
Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable<string> adminEmails, Guid organizationId, string email, string userName); Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable<string> adminEmails, Guid organizationId, string email, string userName);
Task SendBulkSecurityTaskNotificationsAsync(string orgName, IEnumerable<UserSecurityTasksCount> securityTaskNotificaitons);
} }

View File

@ -15,6 +15,7 @@ using Bit.Core.Models.Mail.Provider;
using Bit.Core.SecretsManager.Models.Mail; using Bit.Core.SecretsManager.Models.Mail;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Core.Vault.Models.Data;
using HandlebarsDotNet; using HandlebarsDotNet;
namespace Bit.Core.Services; namespace Bit.Core.Services;
@ -654,6 +655,10 @@ public class HandlebarsMailService : IMailService
Handlebars.RegisterTemplate("TitleContactUsHtmlLayout", titleContactUsHtmlLayoutSource); Handlebars.RegisterTemplate("TitleContactUsHtmlLayout", titleContactUsHtmlLayoutSource);
var titleContactUsTextLayoutSource = await ReadSourceAsync("Layouts.TitleContactUs.text"); var titleContactUsTextLayoutSource = await ReadSourceAsync("Layouts.TitleContactUs.text");
Handlebars.RegisterTemplate("TitleContactUsTextLayout", titleContactUsTextLayoutSource); 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) => Handlebars.RegisterHelper("date", (writer, context, parameters) =>
{ {
@ -1196,9 +1201,26 @@ public class HandlebarsMailService : IMailService
await _mailDeliveryService.SendEmailAsync(message); await _mailDeliveryService.SendEmailAsync(message);
} }
public async Task SendBulkSecurityTaskNotificationsAsync(string orgName, IEnumerable<UserSecurityTasksCount> 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) private static string GetUserIdentifier(string email, string userName)
{ {
return string.IsNullOrEmpty(userName) ? email : CoreHelpers.SanitizeForEmail(userName, false); return string.IsNullOrEmpty(userName) ? email : CoreHelpers.SanitizeForEmail(userName, false);
} }
} }

View File

@ -5,6 +5,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Data.Organizations;
using Bit.Core.Models.Mail; using Bit.Core.Models.Mail;
using Bit.Core.Vault.Models.Data;
namespace Bit.Core.Services; namespace Bit.Core.Services;
@ -322,5 +323,9 @@ public class NoopMailService : IMailService
{ {
return Task.FromResult(0); return Task.FromResult(0);
} }
}
public Task SendBulkSecurityTaskNotificationsAsync(string orgName, IEnumerable<UserSecurityTasksCount> securityTaskNotificaitons)
{
return Task.FromResult(0);
}
}

View File

@ -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<SecurityTask> 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);
}
}
}

View File

@ -0,0 +1,13 @@
using Bit.Core.Vault.Entities;
namespace Bit.Core.Vault.Commands.Interfaces;
public interface ICreateManyTaskNotificationsCommand
{
/// <summary>
/// Creates email and push notifications for the given security tasks.
/// </summary>
/// <param name="organizationId">The organization Id </param>
/// <param name="securityTasks">All applicable security tasks</param>
Task CreateAsync(Guid organizationId, IEnumerable<SecurityTask> securityTasks);
}

View File

@ -0,0 +1,23 @@
namespace Bit.Core.Vault.Models.Data;
/// <summary>
/// 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, <see cref="UserSecurityTaskCipher"/>.
/// </summary>
public class UserCipherForTask
{
/// <summary>
/// The user's Id.
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// The user's email.
/// </summary>
public string Email { get; set; }
/// <summary>
/// The cipher Id of the security task.
/// </summary>
public Guid CipherId { get; set; }
}

View File

@ -0,0 +1,27 @@
namespace Bit.Core.Vault.Models.Data;
/// <summary>
/// Data model that represents a User and the associated cipher for a security task.
/// </summary>
public class UserSecurityTaskCipher
{
/// <summary>
/// The user's Id.
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// The user's email.
/// </summary>
public string Email { get; set; }
/// <summary>
/// The cipher Id of the security task.
/// </summary>
public Guid CipherId { get; set; }
/// <summary>
/// The Id of the security task.
/// </summary>
public Guid TaskId { get; set; }
}

View File

@ -0,0 +1,22 @@
namespace Bit.Core.Vault.Models.Data;
/// <summary>
/// Data model that represents a User and the amount of actionable security tasks.
/// </summary>
public class UserSecurityTasksCount
{
/// <summary>
/// The user's Id.
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// The user's email.
/// </summary>
public string Email { get; set; }
/// <summary>
/// The number of actionable security tasks for the respective users.
/// </summary>
public int TaskCount { get; set; }
}

View File

@ -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<ICollection<UserSecurityTaskCipher>> GetNotificationDetailsByManyIds(Guid organizationId, IEnumerable<SecurityTask> tasks)
{
var org = _currentContext.GetOrganization(organizationId);
if (org == null)
{
throw new NotFoundException();
}
var userSecurityTaskCiphers = await _cipherRepository.GetUserSecurityTasksByCipherIdsAsync(organizationId, tasks);
return userSecurityTaskCiphers;
}
}

View File

@ -0,0 +1,16 @@
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Models.Data;
namespace Bit.Core.Vault.Queries;
public interface IGetSecurityTasksNotificationDetailsQuery
{
/// <summary>
/// Retrieves all users within the given organization that are applicable to the given security tasks.
///
/// <param name="organizationId"></param>
/// <param name="tasks"></param>
/// <returns>A dictionary of UserIds and the corresponding amount of security tasks applicable to them.</returns>
/// </summary>
public Task<ICollection<UserSecurityTaskCipher>> GetNotificationDetailsByManyIds(Guid organizationId, IEnumerable<SecurityTask> tasks);
}

View File

@ -4,6 +4,7 @@ using Bit.Core.Repositories;
using Bit.Core.Vault.Entities; using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Models.Data;
namespace Bit.Core.Vault.Repositories; namespace Bit.Core.Vault.Repositories;
public interface ICipherRepository : IRepository<Cipher, Guid> public interface ICipherRepository : IRepository<Cipher, Guid>
@ -49,6 +50,13 @@ public interface ICipherRepository : IRepository<Cipher, Guid>
Task<ICollection<OrganizationCipherPermission>> GetCipherPermissionsForOrganizationAsync(Guid organizationId, Task<ICollection<OrganizationCipherPermission>> GetCipherPermissionsForOrganizationAsync(Guid organizationId,
Guid userId); Guid userId);
/// <summary>
/// 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.
/// </summary>
Task<ICollection<UserSecurityTaskCipher>> GetUserSecurityTasksByCipherIdsAsync(Guid organizationId, IEnumerable<SecurityTask> tasks);
/// <summary> /// <summary>
/// Updates encrypted data for ciphers during a key rotation /// Updates encrypted data for ciphers during a key rotation
/// </summary> /// </summary>

View File

@ -21,6 +21,8 @@ public static class VaultServiceCollectionExtensions
services.AddScoped<IMarkTaskAsCompleteCommand, MarkTaskAsCompletedCommand>(); services.AddScoped<IMarkTaskAsCompleteCommand, MarkTaskAsCompletedCommand>();
services.AddScoped<IGetCipherPermissionsForUserQuery, GetCipherPermissionsForUserQuery>(); services.AddScoped<IGetCipherPermissionsForUserQuery, GetCipherPermissionsForUserQuery>();
services.AddScoped<IGetTasksForOrganizationQuery, GetTasksForOrganizationQuery>(); services.AddScoped<IGetTasksForOrganizationQuery, GetTasksForOrganizationQuery>();
services.AddScoped<IGetSecurityTasksNotificationDetailsQuery, GetSecurityTasksNotificationDetailsQuery>();
services.AddScoped<ICreateManyTaskNotificationsCommand, CreateManyTaskNotificationsCommand>();
services.AddScoped<ICreateManyTasksCommand, CreateManyTasksCommand>(); services.AddScoped<ICreateManyTasksCommand, CreateManyTasksCommand>();
} }
} }

View File

@ -323,6 +323,28 @@ public class CipherRepository : Repository<Cipher, Guid>, ICipherRepository
} }
} }
public async Task<ICollection<UserSecurityTaskCipher>> GetUserSecurityTasksByCipherIdsAsync(
Guid organizationId, IEnumerable<SecurityTask> 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<UserCipherForTask>(
$"[{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();
}
}
/// <inheritdoc /> /// <inheritdoc />
public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation( public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(
Guid userId, IEnumerable<Cipher> ciphers) Guid userId, IEnumerable<Cipher> ciphers)

View File

@ -348,6 +348,51 @@ public class CipherRepository : Repository<Core.Vault.Entities.Cipher, Cipher, G
} }
} }
public async Task<ICollection<UserSecurityTaskCipher>> GetUserSecurityTasksByCipherIdsAsync(Guid organizationId, IEnumerable<Core.Vault.Entities.SecurityTask> 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<UserSecurityTaskCipher> 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<CipherDetails> GetByIdAsync(Guid id, Guid userId) public async Task<CipherDetails> GetByIdAsync(Guid id, Guid userId)
{ {
using (var scope = ServiceScopeFactory.CreateScope()) using (var scope = ServiceScopeFactory.CreateScope())

View File

@ -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<UserCipherForTask>
{
private readonly Guid _organizationId;
private readonly IEnumerable<Guid> _cipherIds;
public UserSecurityTasksByCipherIdsQuery(Guid organizationId, IEnumerable<Guid> cipherIds)
{
_organizationId = organizationId;
_cipherIds = cipherIds;
}
public IQueryable<UserCipherForTask> 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);
}
}

View File

@ -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

View File

@ -69,4 +69,19 @@ public class CreateNotificationCommandTest
.Received(0) .Received(0)
.PushNotificationStatusAsync(Arg.Any<Notification>(), Arg.Any<NotificationStatus>()); .PushNotificationStatusAsync(Arg.Any<Notification>(), Arg.Any<NotificationStatus>());
} }
[Theory]
[BitAutoData]
public async Task CreateAsync_Authorized_NotificationPushSkipped(
SutProvider<CreateNotificationCommand> sutProvider,
Notification notification)
{
Setup(sutProvider, notification, true);
var newNotification = await sutProvider.Sut.CreateAsync(notification, false);
await sutProvider.GetDependency<IPushNotificationService>()
.Received(0)
.PushNotificationAsync(newNotification);
}
} }

View File

@ -704,4 +704,183 @@ public class CipherRepositoryTests
Data = "" 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<Guid> { manageCollection.Id });
await collectionCipherRepository.UpdateCollectionsForAdminAsync(manageCipher2.Id, organization.Id,
new List<Guid> { manageCollection2.Id });
await collectionCipherRepository.UpdateCollectionsForAdminAsync(viewOnlyCipher.Id, organization.Id,
new List<Guid> { viewOnlyCollection.Id });
await collectionRepository.UpdateUsersAsync(manageCollection.Id, new List<CollectionAccessSelection>
{
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<CollectionAccessSelection>
{
new()
{
Id = orgUser2.Id,
HidePasswords = false,
ReadOnly = false,
Manage = true
},
});
await collectionRepository.UpdateUsersAsync(viewOnlyCollection.Id, new List<CollectionAccessSelection>
{
new()
{
Id = orgUser1.Id,
HidePasswords = false,
ReadOnly = false,
Manage = false
}
});
var securityTasks = new List<SecurityTask>
{
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);
}
} }

View File

@ -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