1
0
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:
Ike
2026-02-03 16:43:44 -05:00
committed by GitHub
parent 82e1a6bd71
commit 68e67e1853
23 changed files with 792 additions and 183 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
The following emergency contacts have been removed from your account:
{{#each RemovedGranteeNames}}
{{#each RemovedGranteeEmails}}
{{this}}
{{/each}}

View File

@@ -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>();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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