mirror of
https://github.com/bitwarden/server
synced 2026-02-11 14:03:24 +00:00
[PM-26376] Emergency Access Delete Command (#6857)
* feat: Add initial DeleteEmergencyContactCommand * chore: remove nullable enable and add comments * test: add tests for new delete command * test: update tests to test IMailer was called. * feat: add delete by GranteeId and allow for multiple grantors to be contacted. * feat: add DeleteMany stored procedure for EmergencyAccess * test: add database tests for new SP * feat: commands use DeleteManyById for emergencyAccessDeletes * claude: send one email per grantor instead of a bulk email to all grantors. Modified tests to validate. * feat: change revision dates for confirmed grantees; * feat: add AccountRevisionDate bump for grantee users in the confirmed status * test: update integration test to validate only confirmed users are updated as well as proper deletion of emergency access
This commit is contained in:
@@ -1,7 +1,4 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
@@ -14,8 +11,8 @@ public class EmergencyAccess : ITableObject<Guid>
|
||||
public Guid GrantorId { get; set; }
|
||||
public Guid? GranteeId { get; set; }
|
||||
[MaxLength(256)]
|
||||
public string Email { get; set; }
|
||||
public string KeyEncrypted { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? KeyEncrypted { get; set; }
|
||||
public EmergencyAccessType Type { get; set; }
|
||||
public EmergencyAccessStatusType Status { get; set; }
|
||||
public short WaitTimeDays { get; set; }
|
||||
|
||||
@@ -19,7 +19,7 @@ public enum EmergencyAccessStatusType : byte
|
||||
/// </summary>
|
||||
RecoveryInitiated = 3,
|
||||
/// <summary>
|
||||
/// The grantee has excercised their emergency access.
|
||||
/// The grantee has exercised their emergency access.
|
||||
/// </summary>
|
||||
RecoveryApproved = 4,
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Entities;
|
||||
|
||||
namespace Bit.Core.Auth.Models.Data;
|
||||
|
||||
public class EmergencyAccessDetails : EmergencyAccess
|
||||
{
|
||||
public string GranteeName { get; set; }
|
||||
public string GranteeEmail { get; set; }
|
||||
public string GranteeAvatarColor { get; set; }
|
||||
public string GrantorName { get; set; }
|
||||
public string GrantorEmail { get; set; }
|
||||
public string GrantorAvatarColor { get; set; }
|
||||
public string? GranteeName { get; set; }
|
||||
public string? GranteeEmail { get; set; }
|
||||
public string? GranteeAvatarColor { get; set; }
|
||||
public string? GrantorName { get; set; }
|
||||
/// <summary>
|
||||
/// Grantor email is assumed not null because in order to create an emergency access the grantor must be an existing user.
|
||||
/// </summary>
|
||||
public required string GrantorEmail { get; set; }
|
||||
public string? GrantorAvatarColor { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Entities;
|
||||
|
||||
namespace Bit.Core.Auth.Models.Data;
|
||||
|
||||
public class EmergencyAccessNotify : EmergencyAccess
|
||||
{
|
||||
public string GrantorEmail { get; set; }
|
||||
public string GranteeName { get; set; }
|
||||
public string GranteeEmail { get; set; }
|
||||
public string? GrantorEmail { get; set; }
|
||||
public string? GranteeName { get; set; }
|
||||
public string? GranteeEmail { get; set; }
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.KeyManagement.UserKey;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Repositories;
|
||||
|
||||
public interface IEmergencyAccessRepository : IRepository<EmergencyAccess, Guid>
|
||||
@@ -11,7 +9,17 @@ public interface IEmergencyAccessRepository : IRepository<EmergencyAccess, Guid>
|
||||
Task<int> GetCountByGrantorIdEmailAsync(Guid grantorId, string email, bool onlyRegisteredUsers);
|
||||
Task<ICollection<EmergencyAccessDetails>> GetManyDetailsByGrantorIdAsync(Guid grantorId);
|
||||
Task<ICollection<EmergencyAccessDetails>> GetManyDetailsByGranteeIdAsync(Guid granteeId);
|
||||
/// <summary>
|
||||
/// Fetches emergency access details by EmergencyAccess id and grantor id
|
||||
/// </summary>
|
||||
/// <param name="id">Emergency Access Id</param>
|
||||
/// <param name="grantorId">Grantor Id</param>
|
||||
/// <returns>EmergencyAccessDetails or null</returns>
|
||||
Task<EmergencyAccessDetails?> GetDetailsByIdGrantorIdAsync(Guid id, Guid grantorId);
|
||||
/// <summary>
|
||||
/// Database call to fetch emergency accesses that need notification emails sent through a Job
|
||||
/// </summary>
|
||||
/// <returns>collection of EmergencyAccessNotify objects that require notification</returns>
|
||||
Task<ICollection<EmergencyAccessNotify>> GetManyToNotifyAsync();
|
||||
Task<ICollection<EmergencyAccessDetails>> GetExpiredRecoveriesAsync();
|
||||
|
||||
@@ -22,4 +30,11 @@ public interface IEmergencyAccessRepository : IRepository<EmergencyAccess, Guid>
|
||||
/// <param name="emergencyAccessKeys">A list of emergency access with updated keys</param>
|
||||
UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(Guid grantorId,
|
||||
IEnumerable<EmergencyAccess> emergencyAccessKeys);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes multiple emergency access records by their IDs
|
||||
/// </summary>
|
||||
/// <param name="emergencyAccessIds">Ids of records to be deleted</param>
|
||||
/// <returns>void</returns>
|
||||
Task DeleteManyAsync(ICollection<Guid> emergencyAccessIds);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces;
|
||||
using Bit.Core.Auth.UserFeatures.EmergencyAccess.Mail;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Platform.Mail.Mailer;
|
||||
using Bit.Core.Repositories;
|
||||
|
||||
namespace Bit.Core.Auth.UserFeatures.EmergencyAccess.Commands;
|
||||
|
||||
public class DeleteEmergencyAccessCommand(
|
||||
IEmergencyAccessRepository _emergencyAccessRepository,
|
||||
IMailer mailer) : IDeleteEmergencyAccessCommand
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<EmergencyAccessDetails> DeleteByIdGrantorIdAsync(Guid emergencyAccessId, Guid grantorId)
|
||||
{
|
||||
var emergencyAccessDetails = await _emergencyAccessRepository.GetDetailsByIdGrantorIdAsync(emergencyAccessId, grantorId);
|
||||
|
||||
if (emergencyAccessDetails == null || emergencyAccessDetails.GrantorId != grantorId)
|
||||
{
|
||||
throw new BadRequestException("Emergency Access not valid.");
|
||||
}
|
||||
|
||||
var (grantorEmails, granteeEmails) = await DeleteEmergencyAccessAsync([emergencyAccessDetails]);
|
||||
|
||||
// Send notification email to grantor
|
||||
await SendEmergencyAccessRemoveGranteesEmailAsync(grantorEmails, granteeEmails);
|
||||
return emergencyAccessDetails;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ICollection<EmergencyAccessDetails>?> DeleteAllByGrantorIdAsync(Guid grantorId)
|
||||
{
|
||||
var emergencyAccessDetails = await _emergencyAccessRepository.GetManyDetailsByGrantorIdAsync(grantorId);
|
||||
|
||||
// if there is nothing return an empty array and do not send an email
|
||||
if (emergencyAccessDetails.Count == 0)
|
||||
{
|
||||
return emergencyAccessDetails;
|
||||
}
|
||||
|
||||
var (grantorEmails, granteeEmails) = await DeleteEmergencyAccessAsync(emergencyAccessDetails);
|
||||
|
||||
// Send notification email to grantor
|
||||
await SendEmergencyAccessRemoveGranteesEmailAsync(grantorEmails, granteeEmails);
|
||||
|
||||
return emergencyAccessDetails;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ICollection<EmergencyAccessDetails>?> DeleteAllByGranteeIdAsync(Guid granteeId)
|
||||
{
|
||||
var emergencyAccessDetails = await _emergencyAccessRepository.GetManyDetailsByGranteeIdAsync(granteeId);
|
||||
|
||||
// if there is nothing return an empty array
|
||||
if (emergencyAccessDetails == null || emergencyAccessDetails.Count == 0)
|
||||
{
|
||||
return emergencyAccessDetails;
|
||||
}
|
||||
|
||||
var (grantorEmails, granteeEmails) = await DeleteEmergencyAccessAsync(emergencyAccessDetails);
|
||||
|
||||
// Send notification email to grantor(s)
|
||||
await SendEmergencyAccessRemoveGranteesEmailAsync(grantorEmails, granteeEmails);
|
||||
|
||||
return emergencyAccessDetails;
|
||||
}
|
||||
|
||||
private async Task<(HashSet<string> grantorEmails, HashSet<string> granteeEmails)> DeleteEmergencyAccessAsync(IEnumerable<EmergencyAccessDetails> emergencyAccessDetails)
|
||||
{
|
||||
var grantorEmails = new HashSet<string>();
|
||||
var granteeEmails = new HashSet<string>();
|
||||
|
||||
await _emergencyAccessRepository.DeleteManyAsync([.. emergencyAccessDetails.Select(ea => ea.Id)]);
|
||||
|
||||
foreach (var details in emergencyAccessDetails)
|
||||
{
|
||||
granteeEmails.Add(details.GranteeEmail ?? string.Empty);
|
||||
grantorEmails.Add(details.GrantorEmail);
|
||||
}
|
||||
|
||||
return (grantorEmails, granteeEmails);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends an email notification to the grantor about removed grantees.
|
||||
/// </summary>
|
||||
/// <param name="grantorEmails">The email addresses of the grantors to notify when deleting by grantee</param>
|
||||
/// <param name="formattedGranteeIdentifiers">The formatted identifiers of the removed grantees to include in the email</param>
|
||||
/// <returns></returns>
|
||||
private async Task SendEmergencyAccessRemoveGranteesEmailAsync(IEnumerable<string> grantorEmails, IEnumerable<string> formattedGranteeIdentifiers)
|
||||
{
|
||||
foreach (var email in grantorEmails)
|
||||
{
|
||||
var emailViewModel = new EmergencyAccessRemoveGranteesMail
|
||||
{
|
||||
ToEmails = [email],
|
||||
View = new EmergencyAccessRemoveGranteesMailView
|
||||
{
|
||||
RemovedGranteeEmails = formattedGranteeIdentifiers
|
||||
}
|
||||
};
|
||||
|
||||
await mailer.SendEmail(emailViewModel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Exceptions;
|
||||
|
||||
namespace Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Command for deleting emergency access records based on the grantor's user ID.
|
||||
/// </summary>
|
||||
public interface IDeleteEmergencyAccessCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Deletes a single emergency access record for the specified grantor.
|
||||
/// </summary>
|
||||
/// <param name="emergencyAccessId">The ID of the emergency access record to delete.</param>
|
||||
/// <param name="grantorId">The ID of the grantor user who owns the emergency access record.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
/// <exception cref="BadRequestException">
|
||||
/// Thrown when the emergency access record is not found or does not belong to the specified grantor.
|
||||
/// </exception>
|
||||
Task<EmergencyAccessDetails> DeleteByIdGrantorIdAsync(Guid emergencyAccessId, Guid grantorId);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all emergency access records for the specified grantor.
|
||||
/// </summary>
|
||||
/// <param name="grantorId">The ID of the grantor user whose emergency access records should be deleted.</param>
|
||||
/// <returns>A collection of the deleted emergency access records.</returns>
|
||||
Task<ICollection<EmergencyAccessDetails>?> DeleteAllByGrantorIdAsync(Guid grantorId);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all emergency access records for the specified grantee.
|
||||
/// </summary>
|
||||
/// <param name="granteeId">The ID of the grantee user whose emergency access records should be deleted.</param>
|
||||
/// <returns>A collection of the deleted emergency access records.</returns>
|
||||
Task<ICollection<EmergencyAccessDetails>?> DeleteAllByGranteeIdAsync(Guid granteeId);
|
||||
}
|
||||
@@ -4,8 +4,9 @@ namespace Bit.Core.Auth.UserFeatures.EmergencyAccess.Mail;
|
||||
|
||||
public class EmergencyAccessRemoveGranteesMailView : BaseMailView
|
||||
{
|
||||
public required IEnumerable<string> RemovedGranteeNames { get; set; }
|
||||
public string EmergencyAccessHelpPageUrl => "https://bitwarden.com/help/emergency-access/";
|
||||
|
||||
public required IEnumerable<string> RemovedGranteeEmails { get; set; }
|
||||
public static string EmergencyAccessHelpPageUrl => "https://bitwarden.com/help/emergency-access/";
|
||||
}
|
||||
|
||||
public class EmergencyAccessRemoveGranteesMail : BaseMail<EmergencyAccessRemoveGranteesMailView>
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
|
||||
|
||||
|
||||
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-70 { width:70% !important; max-width: 70%; }
|
||||
@@ -43,10 +43,10 @@
|
||||
.moz-text-html .mj-column-per-30 { width:30% !important; max-width: 30%; }
|
||||
.moz-text-html .mj-column-per-100 { width:100% !important; max-width: 100%; }
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<style type="text/css">
|
||||
|
||||
@media only screen and (max-width:480px) {
|
||||
@@ -54,15 +54,15 @@
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@media only screen and (max-width:479px) {
|
||||
table.mj-full-width-mobile { width: 100% !important; }
|
||||
td.mj-full-width-mobile { width: auto !important; }
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
<style type="text/css">
|
||||
.border-fix > table {
|
||||
border-collapse: separate !important;
|
||||
@@ -71,230 +71,230 @@
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
</head>
|
||||
<body style="word-spacing:normal;background-color:#e6e9ef;">
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="border-fix" style="background-color:#e6e9ef;" lang="und" dir="auto">
|
||||
<!-- Blue Header Section -->
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="border-fix-outlook" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="border-fix" style="margin:0px auto;max-width:660px;">
|
||||
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 20px 0px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><![endif]-->
|
||||
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#175ddc;background-color:#175ddc;width:100%;border-radius:4px 4px 0px 0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#175ddc" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
|
||||
|
||||
<div style="margin:0px auto;border-radius:4px 4px 0px 0px;max-width:620px;">
|
||||
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;border-radius:4px 4px 0px 0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:434px;" ><![endif]-->
|
||||
|
||||
|
||||
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:150px;">
|
||||
|
||||
|
||||
<img alt src="https://bitwarden.com/images/logo-horizontal-white.png" style="border:0;display:block;outline:none;text-decoration:none;height:30px;width:100%;font-size:16px;" width="150" height="30">
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0;padding-bottom:0;word-break:break-word;">
|
||||
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#ffffff;"><h1 style="font-weight: normal; font-size: 24px; line-height: 32px">
|
||||
|
||||
|
||||
</h1></div>
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td><td class="" style="vertical-align:bottom;width:186px;" ><![endif]-->
|
||||
|
||||
|
||||
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:bottom;width:100%;">
|
||||
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:bottom;" width="100%">
|
||||
<tbody>
|
||||
|
||||
|
||||
<tr>
|
||||
<td align="center" class="mj-bw-hero-responsive-img" style="font-size:0px;padding:0px;word-break:break-word;">
|
||||
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:155px;">
|
||||
|
||||
|
||||
<img alt src="undefined" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="155" height="auto">
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
<!-- Main Content -->
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:660px;">
|
||||
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:0px 20px 0px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
|
||||
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
|
||||
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:0px 10px 0px 10px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 15px;word-break:break-word;">
|
||||
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">The following emergency contacts have been removed from your account:
|
||||
<ul>
|
||||
{{#each RemovedGranteeNames}}
|
||||
{{#each RemovedGranteeEmails}}
|
||||
<li>{{this}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
Learn more about <a href="{{EmergencyAccessHelpPageUrl}}">emergency access</a>.</div>
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
<!-- Footer -->
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:660px;">
|
||||
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:620px;" ><![endif]-->
|
||||
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:0;word-break:break-word;">
|
||||
|
||||
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
|
||||
<tr>
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
@@ -309,15 +309,15 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
|
||||
<tr>
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
@@ -332,15 +332,15 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
|
||||
<tr>
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
@@ -355,15 +355,15 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
|
||||
<tr>
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
@@ -378,15 +378,15 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
|
||||
<tr>
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
@@ -401,15 +401,15 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
|
||||
<tr>
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
@@ -424,15 +424,15 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
|
||||
<tr>
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
@@ -447,20 +447,20 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px; margin-top: 5px">
|
||||
© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
|
||||
Barbara, CA, USA
|
||||
@@ -471,29 +471,28 @@
|
||||
<a href="https://bitwarden.com/" style="text-decoration:none;color:#175ddc; font-weight:400">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/" style="text-decoration:none; color:#175ddc; font-weight:400">Learn why we include this</a>
|
||||
</p></div>
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
The following emergency contacts have been removed from your account:
|
||||
|
||||
{{#each RemovedGranteeNames}}
|
||||
{{#each RemovedGranteeEmails}}
|
||||
{{this}}
|
||||
{{/each}}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Bit.Core.Auth.Sso;
|
||||
using Bit.Core.Auth.UserFeatures.DeviceTrust;
|
||||
using Bit.Core.Auth.UserFeatures.EmergencyAccess.Commands;
|
||||
using Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces;
|
||||
using Bit.Core.Auth.UserFeatures.Registration;
|
||||
using Bit.Core.Auth.UserFeatures.Registration.Implementations;
|
||||
using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces;
|
||||
@@ -23,6 +25,7 @@ public static class UserServiceCollectionExtensions
|
||||
{
|
||||
services.AddScoped<IUserService, UserService>();
|
||||
services.AddDeviceTrustCommands();
|
||||
services.AddEmergencyAccessCommands();
|
||||
services.AddUserPasswordCommands();
|
||||
services.AddUserRegistrationCommands();
|
||||
services.AddWebAuthnLoginCommands();
|
||||
@@ -36,6 +39,11 @@ public static class UserServiceCollectionExtensions
|
||||
services.AddScoped<IUntrustDevicesCommand, UntrustDevicesCommand>();
|
||||
}
|
||||
|
||||
private static void AddEmergencyAccessCommands(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IDeleteEmergencyAccessCommand, DeleteEmergencyAccessCommand>();
|
||||
}
|
||||
|
||||
public static void AddUserKeyCommands(this IServiceCollection services, IGlobalSettings globalSettings)
|
||||
{
|
||||
services.AddScoped<IRotateUserAccountKeysCommand, RotateUserAccountKeysCommand>();
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<mj-text font-size="16px" line-height="24px" padding="10px 15px">
|
||||
The following emergency contacts have been removed from your account:
|
||||
<ul>
|
||||
{{#each RemovedGranteeNames}}
|
||||
{{#each RemovedGranteeEmails}}
|
||||
<li>{{this}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
@@ -13,6 +11,7 @@ using Bit.Core.Auth.Models.Mail;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Models.Mail;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Models.Mail;
|
||||
using Bit.Core.Models.Mail.Auth;
|
||||
@@ -1020,6 +1019,11 @@ public class HandlebarsMailService : IMailService
|
||||
|
||||
public async Task SendEmergencyAccessInviteEmailAsync(EmergencyAccess emergencyAccess, string name, string token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(emergencyAccess.Email))
|
||||
{
|
||||
throw new BadRequestException("Emergency Access not valid.");
|
||||
}
|
||||
|
||||
var message = CreateDefaultMessage($"Emergency Access Contact Invite", emergencyAccess.Email);
|
||||
var model = new EmergencyAccessInvitedViewModel
|
||||
{
|
||||
|
||||
@@ -9,8 +9,6 @@ using Bit.Infrastructure.Dapper.Repositories;
|
||||
using Dapper;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Infrastructure.Dapper.Auth.Repositories;
|
||||
|
||||
public class EmergencyAccessRepository : Repository<EmergencyAccess, Guid>, IEmergencyAccessRepository
|
||||
@@ -152,4 +150,14 @@ public class EmergencyAccessRepository : Repository<EmergencyAccess, Guid>, IEme
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteManyAsync(ICollection<Guid> emergencyAccessIds)
|
||||
{
|
||||
using var connection = new SqlConnection(ConnectionString);
|
||||
await connection.ExecuteAsync(
|
||||
"[dbo].[EmergencyAccess_DeleteManyById]",
|
||||
new { EmergencyAccessIds = emergencyAccessIds.ToGuidIdArrayTVP() },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.Auth.Repositories;
|
||||
|
||||
public class EmergencyAccessRepository : Repository<Core.Auth.Entities.EmergencyAccess, EmergencyAccess, Guid>, IEmergencyAccessRepository
|
||||
@@ -146,4 +144,23 @@ public class EmergencyAccessRepository : Repository<Core.Auth.Entities.Emergency
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteManyAsync(ICollection<Guid> emergencyAccessIds)
|
||||
{
|
||||
using var scope = ServiceScopeFactory.CreateScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var entitiesToRemove = from ea in dbContext.EmergencyAccesses
|
||||
where emergencyAccessIds.Contains(ea.Id)
|
||||
select ea;
|
||||
|
||||
var granteeIds = entitiesToRemove
|
||||
.Where(ea => ea.Status == EmergencyAccessStatusType.Confirmed)
|
||||
.Where(ea => ea.GranteeId.HasValue)
|
||||
.Select(ea => ea.GranteeId!.Value) // .Value is safe here due to the Where above
|
||||
.Distinct();
|
||||
|
||||
dbContext.EmergencyAccesses.RemoveRange(entitiesToRemove);
|
||||
await dbContext.UserBumpManyAccountRevisionDatesAsync([.. granteeIds]);
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories.Queries;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.Auth.Repositories.Queries;
|
||||
|
||||
public class EmergencyAccessDetailsViewQuery : IQuery<EmergencyAccessDetails>
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories.Queries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.Repositories;
|
||||
|
||||
public static class DatabaseContextExtensions
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
CREATE PROCEDURE [dbo].[EmergencyAccess_DeleteManyById]
|
||||
@EmergencyAccessIds [dbo].[GuidIdArray] READONLY
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
DECLARE @UserIds AS [GuidIdArray];
|
||||
DECLARE @BatchSize INT = 100
|
||||
|
||||
INSERT INTO @UserIds
|
||||
SELECT DISTINCT
|
||||
[GranteeId]
|
||||
FROM
|
||||
[dbo].[EmergencyAccess] EA
|
||||
INNER JOIN
|
||||
@EmergencyAccessIds EAI ON EAI.[Id] = EA.[Id]
|
||||
WHERE
|
||||
EA.[Status] = 2 -- 2 = Bit.Core.Auth.Enums.EmergencyAccessStatusType.Confirmed
|
||||
AND
|
||||
EA.[GranteeId] IS NOT NULL
|
||||
|
||||
|
||||
-- Delete EmergencyAccess Records
|
||||
WHILE @BatchSize > 0
|
||||
BEGIN
|
||||
|
||||
DELETE TOP(@BatchSize) EA
|
||||
FROM
|
||||
[dbo].[EmergencyAccess] EA
|
||||
INNER JOIN
|
||||
@EmergencyAccessIds EAI ON EAI.[Id] = EA.[Id]
|
||||
|
||||
SET @BatchSize = @@ROWCOUNT
|
||||
|
||||
END
|
||||
|
||||
-- Bump AccountRevisionDate for affected users after deletions
|
||||
Exec [dbo].[User_BumpManyAccountRevisionDates] @UserIds
|
||||
|
||||
END
|
||||
GO
|
||||
@@ -30,7 +30,7 @@ public class EmergencyAccessRotationValidatorTests
|
||||
KeyEncrypted = e.KeyEncrypted,
|
||||
Type = e.Type
|
||||
}).ToList();
|
||||
userEmergencyAccess.Add(new EmergencyAccessDetails { Id = Guid.NewGuid(), KeyEncrypted = "TestKey" });
|
||||
userEmergencyAccess.Add(new EmergencyAccessDetails { Id = Guid.NewGuid(), GrantorEmail = "grantor@example.com", KeyEncrypted = "TestKey" });
|
||||
sutProvider.GetDependency<IEmergencyAccessRepository>().GetManyDetailsByGrantorIdAsync(user.Id)
|
||||
.Returns(userEmergencyAccess);
|
||||
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.UserFeatures.EmergencyAccess.Commands;
|
||||
using Bit.Core.Auth.UserFeatures.EmergencyAccess.Mail;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Platform.Mail.Mailer;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Auth.UserFeatures.EmergencyAccess;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class DeleteEmergencyAccessCommandTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that attempting to delete a non-existent emergency access record
|
||||
/// throws a <see cref="BadRequestException"/> and does not call delete or send email.
|
||||
/// </summary>
|
||||
[Theory, BitAutoData]
|
||||
public async Task DeleteByIdGrantorIdAsync_EmergencyAccessNotFound_ThrowsBadRequest(
|
||||
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
|
||||
Guid emergencyAccessId,
|
||||
Guid grantorId)
|
||||
{
|
||||
sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.GetDetailsByIdGrantorIdAsync(emergencyAccessId, grantorId)
|
||||
.Returns((EmergencyAccessDetails)null);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.DeleteByIdGrantorIdAsync(emergencyAccessId, grantorId));
|
||||
|
||||
Assert.Contains("Emergency Access not valid.", exception.Message);
|
||||
await sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.DeleteAsync(default);
|
||||
await sutProvider.GetDependency<IMailer>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.SendEmail<EmergencyAccessRemoveGranteesMailView>(default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies successful deletion of an emergency access record by ID and grantor ID,
|
||||
/// and ensures that a notification email is sent to the grantor.
|
||||
/// </summary>
|
||||
[Theory, BitAutoData]
|
||||
public async Task DeleteByIdGrantorIdAsync_DeletesEmergencyAccessAndSendsEmail(
|
||||
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
|
||||
EmergencyAccessDetails emergencyAccessDetails)
|
||||
{
|
||||
sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.GetDetailsByIdGrantorIdAsync(emergencyAccessDetails.Id, emergencyAccessDetails.GrantorId)
|
||||
.Returns(emergencyAccessDetails);
|
||||
|
||||
var result = await sutProvider.Sut.DeleteByIdGrantorIdAsync(emergencyAccessDetails.Id, emergencyAccessDetails.GrantorId);
|
||||
|
||||
await sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.Received(1)
|
||||
.DeleteManyAsync(Arg.Any<ICollection<Guid>>());
|
||||
await sutProvider.GetDependency<IMailer>()
|
||||
.Received(1)
|
||||
.SendEmail(Arg.Any<EmergencyAccessRemoveGranteesMail>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when a grantor has no emergency access records, the method returns
|
||||
/// an empty collection and does not attempt to delete or send email.
|
||||
/// </summary>
|
||||
[Theory, BitAutoData]
|
||||
public async Task DeleteAllByGrantorIdAsync_NoEmergencyAccessRecords_ReturnsEmptyCollection(
|
||||
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
|
||||
Guid grantorId)
|
||||
{
|
||||
sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.GetManyDetailsByGrantorIdAsync(grantorId)
|
||||
.Returns([]);
|
||||
|
||||
var result = await sutProvider.Sut.DeleteAllByGrantorIdAsync(grantorId);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
await sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.DeleteManyAsync(default);
|
||||
await sutProvider.GetDependency<IMailer>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.SendEmail<EmergencyAccessRemoveGranteesMailView>(default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when a grantor has multiple emergency access records, all records are deleted,
|
||||
/// the details are returned, and a single notification email is sent to the grantor.
|
||||
/// </summary>
|
||||
[Theory, BitAutoData]
|
||||
public async Task DeleteAllByGrantorIdAsync_MultipleRecords_DeletesAllReturnsDetailsSendsSingleEmail(
|
||||
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
|
||||
EmergencyAccessDetails emergencyAccessDetails1,
|
||||
EmergencyAccessDetails emergencyAccessDetails2,
|
||||
EmergencyAccessDetails emergencyAccessDetails3)
|
||||
{
|
||||
// Arrange
|
||||
// link all details to the same grantor
|
||||
emergencyAccessDetails2.GrantorId = emergencyAccessDetails1.GrantorId;
|
||||
emergencyAccessDetails2.GrantorEmail = emergencyAccessDetails1.GrantorEmail;
|
||||
emergencyAccessDetails3.GrantorId = emergencyAccessDetails1.GrantorId;
|
||||
emergencyAccessDetails3.GrantorEmail = emergencyAccessDetails1.GrantorEmail;
|
||||
|
||||
var allDetails = new List<EmergencyAccessDetails>
|
||||
{
|
||||
emergencyAccessDetails1,
|
||||
emergencyAccessDetails2,
|
||||
emergencyAccessDetails3
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.GetManyDetailsByGrantorIdAsync(emergencyAccessDetails1.GrantorId)
|
||||
.Returns(allDetails);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.DeleteAllByGrantorIdAsync(emergencyAccessDetails1.GrantorId);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(3, result.Count);
|
||||
await sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.Received(1)
|
||||
.DeleteManyAsync(Arg.Any<ICollection<Guid>>());
|
||||
await sutProvider.GetDependency<IMailer>()
|
||||
.Received(1)
|
||||
.SendEmail(Arg.Any<EmergencyAccessRemoveGranteesMail>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when a grantor has a single emergency access record, it is deleted,
|
||||
/// the details are returned, and a notification email is sent.
|
||||
/// </summary>
|
||||
[Theory, BitAutoData]
|
||||
public async Task DeleteAllByGrantorIdAsync_SingleRecord_DeletesAndReturnsDetailsSendsSingleEmail(
|
||||
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
|
||||
EmergencyAccessDetails emergencyAccessDetails,
|
||||
Guid grantorId)
|
||||
{
|
||||
sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.GetManyDetailsByGrantorIdAsync(grantorId)
|
||||
.Returns([emergencyAccessDetails]);
|
||||
|
||||
var result = await sutProvider.Sut.DeleteAllByGrantorIdAsync(grantorId);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(emergencyAccessDetails.Id, result.First().Id);
|
||||
await sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.Received(1)
|
||||
.DeleteManyAsync(Arg.Any<ICollection<Guid>>());
|
||||
await sutProvider.GetDependency<IMailer>()
|
||||
.Received(1)
|
||||
.SendEmail(Arg.Any<EmergencyAccessRemoveGranteesMail>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when a grantee has no emergency access records, the method returns
|
||||
/// an empty collection and does not attempt to delete or send email.
|
||||
/// </summary>
|
||||
[Theory, BitAutoData]
|
||||
public async Task DeleteAllByGranteeIdAsync_NoEmergencyAccessRecords_ReturnsEmptyCollection(
|
||||
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
|
||||
Guid granteeId)
|
||||
{
|
||||
sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.GetManyDetailsByGranteeIdAsync(granteeId)
|
||||
.Returns([]);
|
||||
|
||||
var result = await sutProvider.Sut.DeleteAllByGranteeIdAsync(granteeId);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
await sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.DeleteManyAsync(default);
|
||||
await sutProvider.GetDependency<IMailer>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.SendEmail<EmergencyAccessRemoveGranteesMailView>(default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when a grantee has a single emergency access record, it is deleted,
|
||||
/// the details are returned, and a notification email is sent to the grantor.
|
||||
/// </summary>
|
||||
[Theory, BitAutoData]
|
||||
public async Task DeleteAllByGranteeIdAsync_SingleRecord_DeletesAndReturnsDetailsSendsSingleEmail(
|
||||
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
|
||||
EmergencyAccessDetails emergencyAccessDetails,
|
||||
Guid granteeId)
|
||||
{
|
||||
sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.GetManyDetailsByGranteeIdAsync(granteeId)
|
||||
.Returns([emergencyAccessDetails]);
|
||||
|
||||
var result = await sutProvider.Sut.DeleteAllByGranteeIdAsync(granteeId);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(emergencyAccessDetails.Id, result.First().Id);
|
||||
await sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.Received(1)
|
||||
.DeleteManyAsync(Arg.Any<ICollection<Guid>>());
|
||||
await sutProvider.GetDependency<IMailer>()
|
||||
.Received(1)
|
||||
.SendEmail(Arg.Any<EmergencyAccessRemoveGranteesMail>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when a grantee has multiple emergency access records from different grantors,
|
||||
/// all records are deleted, the details are returned, and a single notification email is sent
|
||||
/// to all affected grantors individually.
|
||||
/// </summary>
|
||||
[Theory, BitAutoData]
|
||||
public async Task DeleteAllByGranteeIdAsync_MultipleRecords_DeletesAllReturnsDetailsSendsMultipleEmails(
|
||||
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
|
||||
EmergencyAccessDetails emergencyAccessDetails1,
|
||||
EmergencyAccessDetails emergencyAccessDetails2,
|
||||
EmergencyAccessDetails emergencyAccessDetails3)
|
||||
{
|
||||
// link all details to the same grantee
|
||||
emergencyAccessDetails2.GranteeId = emergencyAccessDetails1.GranteeId;
|
||||
emergencyAccessDetails2.GranteeEmail = emergencyAccessDetails1.GranteeEmail;
|
||||
emergencyAccessDetails3.GranteeId = emergencyAccessDetails1.GranteeId;
|
||||
emergencyAccessDetails3.GranteeEmail = emergencyAccessDetails1.GranteeEmail;
|
||||
|
||||
var allDetails = new List<EmergencyAccessDetails>
|
||||
{
|
||||
emergencyAccessDetails1,
|
||||
emergencyAccessDetails2,
|
||||
emergencyAccessDetails3
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.GetManyDetailsByGranteeIdAsync((Guid)emergencyAccessDetails1.GranteeId)
|
||||
.Returns(allDetails);
|
||||
|
||||
var result = await sutProvider.Sut.DeleteAllByGranteeIdAsync((Guid)emergencyAccessDetails1.GranteeId);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(3, result.Count);
|
||||
await sutProvider.GetDependency<IEmergencyAccessRepository>()
|
||||
.Received(1)
|
||||
.DeleteManyAsync(Arg.Any<ICollection<Guid>>());
|
||||
await sutProvider.GetDependency<IMailer>()
|
||||
.Received(allDetails.Count)
|
||||
.SendEmail(Arg.Any<EmergencyAccessRemoveGranteesMail>());
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ public class EmergencyAccessMailTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task SendEmergencyAccessRemoveGranteesEmail_SingleGrantee_Success(
|
||||
string grantorEmail,
|
||||
string granteeName)
|
||||
string granteeEmail)
|
||||
{
|
||||
// Arrange
|
||||
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
|
||||
@@ -41,7 +41,7 @@ public class EmergencyAccessMailTests
|
||||
ToEmails = [grantorEmail],
|
||||
View = new EmergencyAccessRemoveGranteesMailView
|
||||
{
|
||||
RemovedGranteeNames = [granteeName]
|
||||
RemovedGranteeEmails = [granteeEmail]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -58,8 +58,8 @@ public class EmergencyAccessMailTests
|
||||
Assert.Contains(grantorEmail, sentMessage.ToEmails);
|
||||
|
||||
// Verify the content contains the grantee name
|
||||
Assert.Contains(granteeName, sentMessage.TextContent);
|
||||
Assert.Contains(granteeName, sentMessage.HtmlContent);
|
||||
Assert.Contains(granteeEmail, sentMessage.TextContent);
|
||||
Assert.Contains(granteeEmail, sentMessage.HtmlContent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -77,14 +77,14 @@ public class EmergencyAccessMailTests
|
||||
new HandlebarMailRenderer(logger, globalSettings),
|
||||
deliveryService);
|
||||
|
||||
var granteeNames = new[] { "Alice", "Bob", "Carol" };
|
||||
var granteeEmails = new[] { "Alice@test.dev", "Bob@test.dev", "Carol@test.dev" };
|
||||
|
||||
var mail = new EmergencyAccessRemoveGranteesMail
|
||||
{
|
||||
ToEmails = [grantorEmail],
|
||||
View = new EmergencyAccessRemoveGranteesMailView
|
||||
{
|
||||
RemovedGranteeNames = granteeNames
|
||||
RemovedGranteeEmails = granteeEmails
|
||||
}
|
||||
};
|
||||
|
||||
@@ -98,10 +98,10 @@ public class EmergencyAccessMailTests
|
||||
|
||||
// Assert - All grantee names should appear in the email
|
||||
Assert.NotNull(sentMessage);
|
||||
foreach (var granteeName in granteeNames)
|
||||
foreach (var granteeEmail in granteeEmails)
|
||||
{
|
||||
Assert.Contains(granteeName, sentMessage.TextContent);
|
||||
Assert.Contains(granteeName, sentMessage.HtmlContent);
|
||||
Assert.Contains(granteeEmail, sentMessage.TextContent);
|
||||
Assert.Contains(granteeEmail, sentMessage.HtmlContent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,14 +119,14 @@ public class EmergencyAccessMailTests
|
||||
View = new EmergencyAccessRemoveGranteesMailView
|
||||
{
|
||||
// Required: at least one removed grantee name
|
||||
RemovedGranteeNames = ["Example Grantee"]
|
||||
RemovedGranteeEmails = ["Example Grantee"]
|
||||
}
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(mail);
|
||||
Assert.NotNull(mail.View);
|
||||
Assert.NotEmpty(mail.View.RemovedGranteeNames);
|
||||
Assert.NotEmpty(mail.View.RemovedGranteeEmails);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -141,13 +141,13 @@ public class EmergencyAccessMailTests
|
||||
var mail = new EmergencyAccessRemoveGranteesMail
|
||||
{
|
||||
ToEmails = [grantorEmail],
|
||||
View = new EmergencyAccessRemoveGranteesMailView { RemovedGranteeNames = [granteeName] }
|
||||
View = new EmergencyAccessRemoveGranteesMailView { RemovedGranteeEmails = [granteeName] }
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(mail);
|
||||
Assert.NotNull(mail.View);
|
||||
Assert.Equal(_emergencyAccessMailSubject, mail.Subject);
|
||||
Assert.Equal(_emergencyAccessHelpUrl, mail.View.EmergencyAccessHelpPageUrl);
|
||||
Assert.Equal(_emergencyAccessHelpUrl, EmergencyAccessRemoveGranteesMailView.EmergencyAccessHelpPageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,4 +42,97 @@ public class EmergencyAccessRepositoriesTests
|
||||
Assert.NotNull(updatedGrantee);
|
||||
Assert.NotEqual(updatedGrantee.AccountRevisionDate, granteeUser.AccountRevisionDate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates 3 Emergency Access records all connected to a single grantor, but separate grantees.
|
||||
/// All 3 records are then deleted in a single call to DeleteManyAsync.
|
||||
/// </summary>
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task DeleteManyAsync_DeletesMultipleGranteeRecords_UpdatesUserRevisionDates(
|
||||
IUserRepository userRepository,
|
||||
IEmergencyAccessRepository emergencyAccessRepository)
|
||||
{
|
||||
// Arrange
|
||||
var grantorUser = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test Grantor User",
|
||||
Email = $"test+grantor{Guid.NewGuid()}@email.com",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
});
|
||||
|
||||
var confirmedGranteeUser1 = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test Grantee User 1",
|
||||
Email = $"test+grantee{Guid.NewGuid()}@email.com",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
});
|
||||
|
||||
var invitedGranteeUser2 = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test Grantee User 2",
|
||||
Email = $"test+grantee{Guid.NewGuid()}@email.com",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
});
|
||||
|
||||
// The inmemory datetime has a precision issue, so we need to refresh the user to get the stored AccountRevisionDate
|
||||
invitedGranteeUser2 = await userRepository.GetByIdAsync(invitedGranteeUser2.Id);
|
||||
|
||||
var granteeUser3 = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test Grantee User 3",
|
||||
Email = $"test+grantee{Guid.NewGuid()}@email.com",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
});
|
||||
|
||||
var confirmedEmergencyAccess = await emergencyAccessRepository.CreateAsync(new EmergencyAccess
|
||||
{
|
||||
GrantorId = grantorUser.Id,
|
||||
GranteeId = confirmedGranteeUser1.Id,
|
||||
Status = EmergencyAccessStatusType.Confirmed,
|
||||
});
|
||||
|
||||
var invitedEmergencyAccess = await emergencyAccessRepository.CreateAsync(new EmergencyAccess
|
||||
{
|
||||
GrantorId = grantorUser.Id,
|
||||
GranteeId = invitedGranteeUser2.Id,
|
||||
Status = EmergencyAccessStatusType.Invited,
|
||||
});
|
||||
|
||||
var acceptedEmergencyAccess = await emergencyAccessRepository.CreateAsync(new EmergencyAccess
|
||||
{
|
||||
GrantorId = grantorUser.Id,
|
||||
GranteeId = granteeUser3.Id,
|
||||
Status = EmergencyAccessStatusType.Accepted,
|
||||
});
|
||||
|
||||
|
||||
// Act
|
||||
await emergencyAccessRepository.DeleteManyAsync([confirmedEmergencyAccess.Id, invitedEmergencyAccess.Id, acceptedEmergencyAccess.Id]);
|
||||
|
||||
// Assert
|
||||
// ensure Grantor records deleted
|
||||
var grantorEmergencyAccess = await emergencyAccessRepository.GetManyDetailsByGrantorIdAsync(grantorUser.Id);
|
||||
Assert.Empty(grantorEmergencyAccess);
|
||||
|
||||
// ensure Grantee records deleted
|
||||
foreach (var grantee in (List<User>)[confirmedGranteeUser1, invitedGranteeUser2, granteeUser3])
|
||||
{
|
||||
var granteeEmergencyAccess = await emergencyAccessRepository.GetManyDetailsByGranteeIdAsync(grantee.Id);
|
||||
Assert.Empty(granteeEmergencyAccess);
|
||||
}
|
||||
|
||||
// Only the Status.Confirmed grantee's AccountRevisionDate should be updated
|
||||
var updatedConfirmedGrantee = await userRepository.GetByIdAsync(confirmedGranteeUser1.Id);
|
||||
Assert.NotNull(updatedConfirmedGrantee);
|
||||
Assert.NotEqual(updatedConfirmedGrantee.AccountRevisionDate, confirmedGranteeUser1.AccountRevisionDate);
|
||||
|
||||
// Invited user should not have an updated AccountRevisionDate
|
||||
var updatedInvitedGrantee = await userRepository.GetByIdAsync(invitedGranteeUser2.Id);
|
||||
Assert.NotNull(updatedInvitedGrantee);
|
||||
Assert.Equal(updatedInvitedGrantee.AccountRevisionDate, invitedGranteeUser2.AccountRevisionDate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
CREATE OR ALTER PROCEDURE [dbo].[EmergencyAccess_DeleteManyById]
|
||||
@EmergencyAccessIds [dbo].[GuidIdArray] READONLY
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
DECLARE @UserIds AS [GuidIdArray];
|
||||
DECLARE @BatchSize INT = 100
|
||||
|
||||
INSERT INTO @UserIds
|
||||
SELECT DISTINCT
|
||||
[GranteeId]
|
||||
FROM
|
||||
[dbo].[EmergencyAccess] EA
|
||||
INNER JOIN
|
||||
@EmergencyAccessIds EAI ON EAI.[Id] = EA.[Id]
|
||||
WHERE
|
||||
EA.[Status] = 2 -- 2 = Bit.Core.Auth.Enums.EmergencyAccessStatusType.Confirmed
|
||||
AND
|
||||
EA.[GranteeId] IS NOT NULL
|
||||
|
||||
|
||||
-- Delete EmergencyAccess Records
|
||||
WHILE @BatchSize > 0
|
||||
BEGIN
|
||||
|
||||
DELETE TOP(@BatchSize) EA
|
||||
FROM
|
||||
[dbo].[EmergencyAccess] EA
|
||||
INNER JOIN
|
||||
@EmergencyAccessIds EAI ON EAI.[Id] = EA.[Id]
|
||||
|
||||
SET @BatchSize = @@ROWCOUNT
|
||||
|
||||
END
|
||||
|
||||
-- Bump AccountRevisionDate for affected users after deletions
|
||||
Exec [dbo].[User_BumpManyAccountRevisionDates] @UserIds
|
||||
|
||||
END
|
||||
GO
|
||||
Reference in New Issue
Block a user