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:
parent
a2e665cb96
commit
1267332b5b
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -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<SecurityTasksResponseModel>(response);
|
||||
}
|
||||
|
@ -29,5 +29,7 @@ public enum PushType : byte
|
||||
SyncOrganizationCollectionSettingChanged = 19,
|
||||
|
||||
Notification = 20,
|
||||
NotificationStatus = 21
|
||||
NotificationStatus = 21,
|
||||
|
||||
PendingSecurityTasks = 22
|
||||
}
|
||||
|
@ -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;">
|
||||
We’re 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}}
|
@ -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}}
|
@ -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}}
|
@ -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}}
|
12
src/Core/Models/Mail/SecurityTaskNotificationViewModel.cs
Normal file
12
src/Core/Models/Mail/SecurityTaskNotificationViewModel.cs
Normal 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";
|
||||
}
|
@ -28,7 +28,7 @@ public class CreateNotificationCommand : ICreateNotificationCommand
|
||||
_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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
@ -5,5 +5,5 @@ namespace Bit.Core.NotificationCenter.Commands.Interfaces;
|
||||
|
||||
public interface ICreateNotificationCommand
|
||||
{
|
||||
Task<Notification> CreateAsync(Notification notification);
|
||||
Task<Notification> CreateAsync(Notification notification, bool sendPush = true);
|
||||
}
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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)
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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<IPushNotificationService, Task> pushFunc)
|
||||
{
|
||||
if (!_services.Any())
|
||||
|
@ -121,4 +121,9 @@ public class NoopPushNotificationService : IPushNotificationService
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task PushPendingSecurityTasksAsync(Guid userId)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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<string> adminEmails, Guid organizationId, string email, string userName);
|
||||
Task SendBulkSecurityTaskNotificationsAsync(string orgName, IEnumerable<UserSecurityTasksCount> securityTaskNotificaitons);
|
||||
}
|
||||
|
||||
|
@ -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<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)
|
||||
{
|
||||
return string.IsNullOrEmpty(userName) ? email : CoreHelpers.SanitizeForEmail(userName, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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<UserSecurityTasksCount> securityTaskNotificaitons)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
23
src/Core/Vault/Models/Data/UserCipherForTask.cs
Normal file
23
src/Core/Vault/Models/Data/UserCipherForTask.cs
Normal 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; }
|
||||
}
|
27
src/Core/Vault/Models/Data/UserSecurityTaskCipher.cs
Normal file
27
src/Core/Vault/Models/Data/UserSecurityTaskCipher.cs
Normal 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; }
|
||||
}
|
22
src/Core/Vault/Models/Data/UserSecurityTasksCount.cs
Normal file
22
src/Core/Vault/Models/Data/UserSecurityTasksCount.cs
Normal 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; }
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
@ -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<Cipher, Guid>
|
||||
@ -49,6 +50,13 @@ public interface ICipherRepository : IRepository<Cipher, Guid>
|
||||
Task<ICollection<OrganizationCipherPermission>> GetCipherPermissionsForOrganizationAsync(Guid organizationId,
|
||||
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>
|
||||
/// Updates encrypted data for ciphers during a key rotation
|
||||
/// </summary>
|
||||
|
@ -21,6 +21,8 @@ public static class VaultServiceCollectionExtensions
|
||||
services.AddScoped<IMarkTaskAsCompleteCommand, MarkTaskAsCompletedCommand>();
|
||||
services.AddScoped<IGetCipherPermissionsForUserQuery, GetCipherPermissionsForUserQuery>();
|
||||
services.AddScoped<IGetTasksForOrganizationQuery, GetTasksForOrganizationQuery>();
|
||||
services.AddScoped<IGetSecurityTasksNotificationDetailsQuery, GetSecurityTasksNotificationDetailsQuery>();
|
||||
services.AddScoped<ICreateManyTaskNotificationsCommand, CreateManyTaskNotificationsCommand>();
|
||||
services.AddScoped<ICreateManyTasksCommand, CreateManyTasksCommand>();
|
||||
}
|
||||
}
|
||||
|
@ -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 />
|
||||
public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(
|
||||
Guid userId, IEnumerable<Cipher> ciphers)
|
||||
|
@ -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)
|
||||
{
|
||||
using (var scope = ServiceScopeFactory.CreateScope())
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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
|
@ -69,4 +69,19 @@ public class CreateNotificationCommandTest
|
||||
.Received(0)
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
@ -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<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);
|
||||
}
|
||||
}
|
||||
|
@ -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
|
Loading…
Reference in New Issue
Block a user