From 18973a4f633996de75fabf9a5fc4b062cf270dd3 Mon Sep 17 00:00:00 2001
From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com>
Date: Thu, 26 Feb 2026 12:49:26 -0500
Subject: [PATCH] Auth/PM-32035 - Emergency Access -
DeleteEmergencyAccessCommand refactor (#7054)
* PM-32035 - EmergencyAccessService - fix interface docs, method docs, and tests to cover grantee / grantor deletion which is supported today.
* PM-32035 - EmergencyAccessService - mark existing delete as deprecated
* PM-32035 - EmergencyAccess readme docs - fix deletion docs
* PM-32035 - Add new EmergencyAccessDetails_ReadByUserIds stored proc
* PM-32035 - Add migration script for EmergencyAccessDetails_ReadByUserIds
* PM-32035 - Build out GetManyDetailsByUserIdsAsync in repository layer plus add tests
* PM-32035 - EmergencyAccessRepo - DeleteManyAsync - remove grantee revision bump as not necessary since no EA sync data exists + update tests
* PM-32035 - Fix incorrect nullability annotation on EmergencyAccessDetails.GrantorEmail. Both the SQL view and EF projection use a LEFT JOIN to the User table, meaning the value can be null if the grantor's account no longer exists. Changed to string? and removed the required modifier since the class is only ever materialized from database queries, never directly instantiated.
* PM-32035 - Refactor DeleteEmergencyAccess command to offer new DeleteAllByUserIdAsync and DeleteAllByUserIdsAsync methods. Need to build out DeleteByIdAndUserIdAsync with a new stored proc.
* PM-32035 - Build out IEmergencyAccessRepository.GetDetailsByIdAsync because we need such a method in order to meet the product requirements to send grantor email notifications for normal deletions in the future.
* PM-32035 - Wire up DeleteEmergencyAccessCommand.DeleteByIdAndUserIdAsync to use new repository method emergencyAccessRepository.GetDetailsByIdAsync so we can send notifications. Now, it is full replacement for the existing emergency access service deletion method + has the new notification functionaliy requested.
* PM-32035 - Add more test coverage for DeleteByIdAndUserIdAsync
* PM-32035 - Fix missing GranteeAvatarColor and GrantorAvatarColor projections in EmergencyAccessDetailsViewQuery. The EF view query omitted both avatar color fields from its Select projection, causing the integration tests to fail on all non-SqlServer databases (MySql, Postgres, Sqlite) where EF is used instead of Dapper.
* PM-32035 - Rename migration after main merge revealed collision
* PM-32035 - Rename migration script
* PM-32035 - PR feedback - add ticket + todos to deprecated delete async method.
* PM-32035 - DeleteEmergencyAccessCommand - add logs if we don't have user data required to send email notifications.
* PM-32035 - PR Feedback - rename EmergencyAccessDetails_ReadByUserIds to EmergencyAccessDetails_ReadManyByUserIds
---
.../Models/Data/EmergencyAccessDetails.cs | 5 +-
.../IEmergencyAccessRepository.cs | 12 +
.../Commands/DeleteEmergencyAccessCommand.cs | 165 ++--
.../EmergencyAccess/EmergencyAccessService.cs | 9 +-
.../IEmergencyAccessService.cs | 8 +-
.../IDeleteEmergencyAccessCommand.cs | 38 +-
.../UserFeatures/EmergencyAccess/readme.md | 14 +-
.../Repositories/EmergencyAccessRepository.cs | 26 +
.../Repositories/EmergencyAccessRepository.cs | 34 +-
.../EmergencyAccessDetailsViewQuery.cs | 2 +
.../EmergencyAccessDetails_ReadById.sql | 13 +
...ergencyAccessDetails_ReadManyByUserIds.sql | 15 +
.../DeleteEmergencyAccessCommandTests.cs | 916 +++++++++++++++---
.../EmergencyAccessServiceTests.cs | 51 +-
.../EmergencyAccessRepositoryTests.cs | 335 ++++++-
..._02_AddEmergencyAccessDetails_ReadById.sql | 14 +
...ergencyAccessDetails_ReadManyByUserIds.sql | 15 +
17 files changed, 1400 insertions(+), 272 deletions(-)
create mode 100644 src/Sql/dbo/Auth/Stored Procedures/EmergencyAccessDetails_ReadById.sql
create mode 100644 src/Sql/dbo/Auth/Stored Procedures/EmergencyAccessDetails_ReadManyByUserIds.sql
create mode 100644 util/Migrator/DbScripts/2026-02-24_02_AddEmergencyAccessDetails_ReadById.sql
create mode 100644 util/Migrator/DbScripts/2026-02-24_03_AddEmergencyAccessDetails_ReadManyByUserIds.sql
diff --git a/src/Core/Auth/Models/Data/EmergencyAccessDetails.cs b/src/Core/Auth/Models/Data/EmergencyAccessDetails.cs
index 86c1e6953f..43c8d3cbc8 100644
--- a/src/Core/Auth/Models/Data/EmergencyAccessDetails.cs
+++ b/src/Core/Auth/Models/Data/EmergencyAccessDetails.cs
@@ -8,9 +8,6 @@ public class EmergencyAccessDetails : EmergencyAccess
public string? GranteeEmail { get; set; }
public string? GranteeAvatarColor { get; set; }
public string? GrantorName { get; set; }
- ///
- /// Grantor email is assumed not null because in order to create an emergency access the grantor must be an existing user.
- ///
- public required string GrantorEmail { get; set; }
+ public string? GrantorEmail { get; set; }
public string? GrantorAvatarColor { get; set; }
}
diff --git a/src/Core/Auth/Repositories/IEmergencyAccessRepository.cs b/src/Core/Auth/Repositories/IEmergencyAccessRepository.cs
index 5b4ad47180..e46a0520e5 100644
--- a/src/Core/Auth/Repositories/IEmergencyAccessRepository.cs
+++ b/src/Core/Auth/Repositories/IEmergencyAccessRepository.cs
@@ -10,6 +10,12 @@ public interface IEmergencyAccessRepository : IRepository
Task> GetManyDetailsByGrantorIdAsync(Guid grantorId);
Task> GetManyDetailsByGranteeIdAsync(Guid granteeId);
///
+ /// Gets all emergency access details where the user IDs are either grantors or grantees
+ ///
+ /// Collection of user IDs to query
+ /// All emergency access details matching the user IDs
+ Task> GetManyDetailsByUserIdsAsync(ICollection userIds);
+ ///
/// Fetches emergency access details by EmergencyAccess id and grantor id
///
/// Emergency Access Id
@@ -17,6 +23,12 @@ public interface IEmergencyAccessRepository : IRepository
/// EmergencyAccessDetails or null
Task GetDetailsByIdGrantorIdAsync(Guid id, Guid grantorId);
///
+ /// Fetches emergency access details by EmergencyAccess id
+ ///
+ /// Emergency Access Id
+ /// EmergencyAccessDetails or null
+ Task GetDetailsByIdAsync(Guid id);
+ ///
/// Database call to fetch emergency accesses that need notification emails sent through a Job
///
/// collection of EmergencyAccessNotify objects that require notification
diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/Commands/DeleteEmergencyAccessCommand.cs b/src/Core/Auth/UserFeatures/EmergencyAccess/Commands/DeleteEmergencyAccessCommand.cs
index 40779b266a..90d9208bac 100644
--- a/src/Core/Auth/UserFeatures/EmergencyAccess/Commands/DeleteEmergencyAccessCommand.cs
+++ b/src/Core/Auth/UserFeatures/EmergencyAccess/Commands/DeleteEmergencyAccessCommand.cs
@@ -1,107 +1,148 @@
-using Bit.Core.Auth.Models.Data;
-using Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces;
+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;
+using Microsoft.Extensions.Logging;
namespace Bit.Core.Auth.UserFeatures.EmergencyAccess.Commands;
public class DeleteEmergencyAccessCommand(
IEmergencyAccessRepository _emergencyAccessRepository,
- IMailer mailer) : IDeleteEmergencyAccessCommand
+ IMailer mailer,
+ ILogger _logger) : IDeleteEmergencyAccessCommand
{
///
- public async Task DeleteByIdGrantorIdAsync(Guid emergencyAccessId, Guid grantorId)
+ public async Task DeleteByIdAndUserIdAsync(Guid emergencyAccessId, Guid userId)
{
- var emergencyAccessDetails = await _emergencyAccessRepository.GetDetailsByIdGrantorIdAsync(emergencyAccessId, grantorId);
+ var emergencyAccessDetails = await _emergencyAccessRepository.GetDetailsByIdAsync(emergencyAccessId);
- if (emergencyAccessDetails == null || emergencyAccessDetails.GrantorId != grantorId)
+ // Error if the emergency access doesn't exist or the user trying to delete is neither the grantor nor the grantee
+ if (emergencyAccessDetails == null || (emergencyAccessDetails.GrantorId != userId && emergencyAccessDetails.GranteeId != userId))
{
throw new BadRequestException("Emergency Access not valid.");
}
- var (grantorEmails, granteeEmails) = await DeleteEmergencyAccessAsync([emergencyAccessDetails]);
+ await _emergencyAccessRepository.DeleteAsync(emergencyAccessDetails);
- // Send notification email to grantor
- await SendEmergencyAccessRemoveGranteesEmailAsync(grantorEmails, granteeEmails);
- return emergencyAccessDetails;
+ // Emails may be null if the grantor or grantee user account has since been deleted
+ // so ensure we have both emails we need.
+ if (!string.IsNullOrEmpty(emergencyAccessDetails.GrantorEmail) &&
+ !string.IsNullOrEmpty(emergencyAccessDetails.GranteeEmail))
+ {
+ await SendGranteesRemovalNotificationToGrantorAsync(
+ emergencyAccessDetails.GrantorEmail,
+ [emergencyAccessDetails.GranteeEmail]);
+ }
+ else
+ {
+ // If we are missing the emails needed to send a notification, log this occurrence.
+ _logger.LogWarning(
+ "Emergency access deletion notification skipped for grantor {GrantorId} and grantee {GranteeId}: GrantorEmail missing: {GrantorEmailMissing}, GranteeEmail missing: {GranteeEmailMissing}.",
+ emergencyAccessDetails.GrantorId,
+ emergencyAccessDetails.GranteeId,
+ string.IsNullOrEmpty(emergencyAccessDetails.GrantorEmail),
+ string.IsNullOrEmpty(emergencyAccessDetails.GranteeEmail));
+ }
}
///
- public async Task?> DeleteAllByGrantorIdAsync(Guid grantorId)
+ public async Task DeleteAllByUserIdAsync(Guid userId)
{
- var emergencyAccessDetails = await _emergencyAccessRepository.GetManyDetailsByGrantorIdAsync(grantorId);
+ await DeleteAllByUserIdsAsync([userId]);
+ }
+
+
+ ///
+ public async Task DeleteAllByUserIdsAsync(ICollection userIds)
+ {
+ var emergencyAccessDetails = await _emergencyAccessRepository.GetManyDetailsByUserIdsAsync(userIds);
- // if there is nothing return an empty array and do not send an email
if (emergencyAccessDetails.Count == 0)
{
- return emergencyAccessDetails;
+ // No records found, so nothing to delete or notify
+ // However, don't throw an error since the end state of "no records for these user IDs"
+ // is already achieved
+ return;
}
- var (grantorEmails, granteeEmails) = await DeleteEmergencyAccessAsync(emergencyAccessDetails);
+ // Delete all records using existing DeleteManyAsync (batching already implemented)
+ var emergencyAccessIds = emergencyAccessDetails.Select(ea => ea.Id).ToList();
+ await _emergencyAccessRepository.DeleteManyAsync(emergencyAccessIds);
- // Send notification email to grantor
- await SendEmergencyAccessRemoveGranteesEmailAsync(grantorEmails, granteeEmails);
+ // After deletion, send notifications to grantors about their removed grantees.
+ // GrantorEmail may be null when a grantor's account has been deleted, since it is sourced
+ // entirely from a LEFT JOIN on the User table with no fallback column. Log any affected
+ // GrantorIds up front for traceability — the grantor's account is already gone so the ID
+ // cannot be used to look up the user, but it can be correlated with audit logs generated
+ // at the time of that account's deletion to understand why the notification was skipped.
+ var grantorIdsWithNullEmail = emergencyAccessDetails
+ .Where(ea => string.IsNullOrEmpty(ea.GrantorEmail))
+ .Select(ea => ea.GrantorId)
+ .Distinct()
+ .ToList();
- return emergencyAccessDetails;
- }
-
- ///
- public async Task?> DeleteAllByGranteeIdAsync(Guid granteeId)
- {
- var emergencyAccessDetails = await _emergencyAccessRepository.GetManyDetailsByGranteeIdAsync(granteeId);
-
- // if there is nothing return an empty array
- if (emergencyAccessDetails == null || emergencyAccessDetails.Count == 0)
+ if (grantorIdsWithNullEmail.Count > 0)
{
- return emergencyAccessDetails;
+ _logger.LogWarning(
+ "Emergency access deletion notification skipped for {Count} grantor(s) with missing GrantorEmail. GrantorIds: {GrantorIds}.",
+ grantorIdsWithNullEmail.Count,
+ grantorIdsWithNullEmail);
}
- var (grantorEmails, granteeEmails) = await DeleteEmergencyAccessAsync(emergencyAccessDetails);
+ // Group by grantor email to send each grantor a single email listing all their removed grantees.
+ // Records with null GrantorEmail are excluded above and will not receive a notification.
+ var grantorEmergencyAccessDetailGroups = emergencyAccessDetails
+ .Where(ea => !string.IsNullOrEmpty(ea.GrantorEmail))
+ .GroupBy(ea => ea.GrantorEmail!); // .GrantorEmail is safe here due to the Where above
- // Send notification email to grantor(s)
- await SendEmergencyAccessRemoveGranteesEmailAsync(grantorEmails, granteeEmails);
-
- return emergencyAccessDetails;
- }
-
- private async Task<(HashSet grantorEmails, HashSet granteeEmails)> DeleteEmergencyAccessAsync(IEnumerable emergencyAccessDetails)
- {
- var grantorEmails = new HashSet();
- var granteeEmails = new HashSet();
-
- await _emergencyAccessRepository.DeleteManyAsync([.. emergencyAccessDetails.Select(ea => ea.Id)]);
-
- foreach (var details in emergencyAccessDetails)
+ foreach (var grantorGroup in grantorEmergencyAccessDetailGroups)
{
- granteeEmails.Add(details.GranteeEmail ?? string.Empty);
- grantorEmails.Add(details.GrantorEmail);
- }
+ var grantorEmail = grantorGroup.Key;
+ var granteeEmails = grantorGroup
+ .Select(ea => ea.GranteeEmail)
+ // Filter out null grantee emails, which may occur if a grantee's account has been deleted
+ .Where(e => !string.IsNullOrEmpty(e))
+ .Cast() // Cast is safe here due to the Where above
+ .Distinct();
- return (grantorEmails, granteeEmails);
+ var granteeIdsWithNullEmail = grantorGroup
+ .Where(ea => string.IsNullOrEmpty(ea.GranteeEmail))
+ .Select(ea => ea.GranteeId)
+ .Distinct()
+ .ToList();
+
+ if (granteeIdsWithNullEmail.Count > 0)
+ {
+ _logger.LogWarning(
+ "Emergency access deletion notification skipped for {Count} grantee(s) with missing GranteeEmail. GranteeIds: {GranteeIds}.",
+ granteeIdsWithNullEmail.Count,
+ granteeIdsWithNullEmail);
+ }
+
+ if (granteeEmails.Any())
+ {
+ await SendGranteesRemovalNotificationToGrantorAsync(grantorEmail, granteeEmails);
+ }
+ }
}
///
- /// Sends an email notification to the grantor about removed grantees.
+ /// Sends an email notification to a grantor about their removed grantees.
///
- /// The email addresses of the grantors to notify when deleting by grantee
- /// The formatted identifiers of the removed grantees to include in the email
- ///
- private async Task SendEmergencyAccessRemoveGranteesEmailAsync(IEnumerable grantorEmails, IEnumerable formattedGranteeIdentifiers)
+ /// The email address of the grantor to notify
+ /// The email addresses of the removed grantees
+ private async Task SendGranteesRemovalNotificationToGrantorAsync(string grantorEmail, IEnumerable granteeEmails)
{
- foreach (var email in grantorEmails)
+ var emailViewModel = new EmergencyAccessRemoveGranteesMail
{
- var emailViewModel = new EmergencyAccessRemoveGranteesMail
+ ToEmails = [grantorEmail],
+ View = new EmergencyAccessRemoveGranteesMailView
{
- ToEmails = [email],
- View = new EmergencyAccessRemoveGranteesMailView
- {
- RemovedGranteeEmails = formattedGranteeIdentifiers
- }
- };
+ RemovedGranteeEmails = granteeEmails
+ }
+ };
- await mailer.SendEmail(emailViewModel);
- }
+ await mailer.SendEmail(emailViewModel);
}
}
diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs b/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs
index 6552f4bc69..8256fc2037 100644
--- a/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs
+++ b/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs
@@ -161,12 +161,13 @@ public class EmergencyAccessService : IEmergencyAccessService
return emergencyAccess;
}
- public async Task DeleteAsync(Guid emergencyAccessId, Guid grantorId)
+ // TODO: remove with PM-31327 when we migrate to the command.
+ public async Task DeleteAsync(Guid emergencyAccessId, Guid userId)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
- // TODO PM-19438/PM-21687
- // Not sure why the GrantorId and the GranteeId are supposed to be the same?
- if (emergencyAccess == null || (emergencyAccess.GrantorId != grantorId && emergencyAccess.GranteeId != grantorId))
+
+ // Error if the emergency access doesn't exist or the user trying to delete is neither the grantor nor the grantee
+ if (emergencyAccess == null || (emergencyAccess.GrantorId != userId && emergencyAccess.GranteeId != userId))
{
throw new BadRequestException("Emergency Access not valid.");
}
diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/IEmergencyAccessService.cs b/src/Core/Auth/UserFeatures/EmergencyAccess/IEmergencyAccessService.cs
index 860ae8bfb6..bfd725ac95 100644
--- a/src/Core/Auth/UserFeatures/EmergencyAccess/IEmergencyAccessService.cs
+++ b/src/Core/Auth/UserFeatures/EmergencyAccess/IEmergencyAccessService.cs
@@ -38,12 +38,14 @@ public interface IEmergencyAccessService
/// void
Task AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService);
///
- /// The creator of the emergency access request can delete the request.
+ /// Either the grantor OR the grantee can delete the emergency access.
///
/// Id of the emergency access being acted on
- /// Id of the owner trying to delete the emergency access request
+ /// Id of the user (needs to be the grantor or grantee) trying to delete the emergency access
/// void
- Task DeleteAsync(Guid emergencyAccessId, Guid grantorId);
+ /// TODO: Remove this deprecated method with PM-31327
+ [Obsolete("Use IDeleteEmergencyAccessCommand.DeleteByIdAndUserIdAsync instead.")]
+ Task DeleteAsync(Guid emergencyAccessId, Guid userId);
///
/// The grantor user confirms the acceptance of the emergency contact request. This stores the encrypted key allowing the grantee
/// access based on the emergency access type.
diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/Interfaces/IDeleteEmergencyAccessCommand.cs b/src/Core/Auth/UserFeatures/EmergencyAccess/Interfaces/IDeleteEmergencyAccessCommand.cs
index efdd864d60..c1588af696 100644
--- a/src/Core/Auth/UserFeatures/EmergencyAccess/Interfaces/IDeleteEmergencyAccessCommand.cs
+++ b/src/Core/Auth/UserFeatures/EmergencyAccess/Interfaces/IDeleteEmergencyAccessCommand.cs
@@ -1,35 +1,43 @@
-using Bit.Core.Auth.Models.Data;
-using Bit.Core.Exceptions;
+using Bit.Core.Exceptions;
namespace Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces;
///
-/// Command for deleting emergency access records based on the grantor's user ID.
+/// Command for deleting emergency access records.
///
public interface IDeleteEmergencyAccessCommand
{
///
- /// Deletes a single emergency access record for the specified grantor.
+ /// Deletes a single emergency access record if the requesting user is the grantor or grantee.
+ /// Sends an email notification to the grantor when a grantee is removed.
///
/// The ID of the emergency access record to delete.
- /// The ID of the grantor user who owns the emergency access record.
+ /// The ID of the requesting user; must be either the grantor or grantee of the record.
/// A task representing the asynchronous operation.
///
- /// Thrown when the emergency access record is not found or does not belong to the specified grantor.
+ /// Thrown when the emergency access record is not found or does not belong to the specified user.
///
- Task DeleteByIdGrantorIdAsync(Guid emergencyAccessId, Guid grantorId);
+ Task DeleteByIdAndUserIdAsync(Guid emergencyAccessId, Guid userId);
///
- /// Deletes all emergency access records for the specified grantor.
+ /// Deletes all emergency access records where the user IDs are either grantors or grantees.
+ /// Sends email notifications only to grantors when their grantees are removed.
///
- /// The ID of the grantor user whose emergency access records should be deleted.
- /// A collection of the deleted emergency access records.
- Task?> DeleteAllByGrantorIdAsync(Guid grantorId);
+ /// The IDs of users whose emergency access records — as grantor or grantee — will be deleted.
+ /// A task representing the asynchronous operation.
+ ///
+ /// If no records are found for the provided user IDs, the method returns.
+ ///
+ Task DeleteAllByUserIdsAsync(ICollection userIds);
///
- /// Deletes all emergency access records for the specified grantee.
+ /// Deletes all emergency access records where the user ID is either a grantor or a grantee.
+ /// Sends email notifications only to grantors when their grantees are removed.
///
- /// The ID of the grantee user whose emergency access records should be deleted.
- /// A collection of the deleted emergency access records.
- Task?> DeleteAllByGranteeIdAsync(Guid granteeId);
+ /// The ID of the user whose emergency access records — as grantor or grantee — will be deleted.
+ /// A task representing the asynchronous operation.
+ ///
+ /// If no records are found for the provided user ID, the method returns.
+ ///
+ Task DeleteAllByUserIdAsync(Guid userId);
}
diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/readme.md b/src/Core/Auth/UserFeatures/EmergencyAccess/readme.md
index e2bdec3916..b8040e2a62 100644
--- a/src/Core/Auth/UserFeatures/EmergencyAccess/readme.md
+++ b/src/Core/Auth/UserFeatures/EmergencyAccess/readme.md
@@ -1,4 +1,5 @@
# Emergency Access System
+
This system allows users to share their `User.Key` with other users using public key exchange. An emergency contact (a grantee user) can view or takeover (reset the password) of the grantor user.
When an account is taken over all two factor methods are turned off and device verification is disabled.
@@ -6,6 +7,7 @@ When an account is taken over all two factor methods are turned off and device v
This system is affected by the Key Rotation feature. The `EmergencyAccess.KeyEncrypted` is the `Grantor.UserKey` encrypted by the `Grantee.PublicKey`. So if the `User.Key` is rotated then all `EmergencyAccess` entities will need to be updated.
## Special Cases
+
Users who use `KeyConnector` are not able to allow `Takeover` of their accounts. However, they can allow `View`.
When a grantee user _takes over_ a grantor user's account, the grantor user will be removed from all organizations where the grantor user is not the `OrganizationUserType.Owner`. A grantor user will not be removed from organizations if the `EmergencyAccessType` is `View`. The grantee user will only be able to `View` the grantor user's ciphers, and not any of the organization ciphers, if any exist.
@@ -16,6 +18,7 @@ A grantor user invites another user to be their emergency contact, the grantee.
The `EmergencyAccess.KeyEncrypted` field is empty, and the `GranteeId` is `null` since the user being invited via email may not have an account yet.
### code
+
```csharp
// creates entity.
Task InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime);
@@ -32,6 +35,7 @@ At this point the grantee user can accept the request. This will set the `Emerge
If the grantee user does not have an account then they can create an account and accept the invitation.
### Code
+
```csharp
// accepts the request to be an emergency contact.
Task AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService);
@@ -44,6 +48,7 @@ Once the grantee user has accepted, the `EmergencyAccess.GranteeId` allows the g
The `EmergencyAccess.Status` is set to `Confirmed`, and the `EmergencyAccess.KeyEncrypted` is set.
### Code
+
```csharp
Task ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId);
```
@@ -53,6 +58,7 @@ Task ConfirmUserAsync(Guid emergencyAccessId, string key, Guid
The grantee user can now exercise the ability to view or takeover the account. This is done by initiating the recovery. Initiating recovery has a time delay specified by `EmergencyAccess.WaitTime`. `WaitTime` is set in the initial invite. The grantor user can approve the request before the `WaitTime`, but they _cannot_ reject the request _after_ the `WaitTime` has completed. If the recovery request is not rejected then once the `WaitTime` has passed the grantee user will be able to access the emergency access entity.
### Code
+
```csharp
// Initiates the recovery process; Will set EmergencyAccess.Status to RecoveryInitiated.
Task InitiateAsync(Guid id, User granteeUser);
@@ -69,6 +75,7 @@ Task HandleTimedOutRequestsAsync();
Once the `EmergencyAccess.Status` is `RecoveryApproved` the grantee user is able to exercise their ability to view or takeover the grantor account. Viewing allows the grantee user to view the vault data of the grantor user. Takeover allows the grantee to change the password of the grantor user.
### Takeover
+
`TakeoverAsync(Guid, User)` returns the grantor user object along with the `EmergencyAccess` entity. The grantor user object is required since to update the password the client needs access to the grantor kdf configuration. Once the password has been set in the `PasswordAsync(Guid, User, string, string)` the account has been successfully recovered.
Taking over the account will change the password of the grantor user, empty the two factor array on the grantor user, and disable device verification.
@@ -86,10 +93,11 @@ Task GetAttachmentDownloadAsync(Guid emergencyAccessId,
## Optional steps
-The grantor user is able to delete an emergency contact at anytime, at any point in the recovery process.
+Either the grantor or grantee is able to delete an emergency access record at any time, at any point in the recovery process.
### Code
+
```csharp
-// deletes the associated EmergencyAccess Entity
-Task DeleteAsync(Guid emergencyAccessId, Guid grantorId);
+// Deletes the associated EmergencyAccess entity. The requesting user must be the grantor or grantee.
+Task DeleteByIdAndUserIdAsync(Guid emergencyAccessId, Guid userId);
```
diff --git a/src/Infrastructure.Dapper/Auth/Repositories/EmergencyAccessRepository.cs b/src/Infrastructure.Dapper/Auth/Repositories/EmergencyAccessRepository.cs
index f7dd17784e..c76dfb525d 100644
--- a/src/Infrastructure.Dapper/Auth/Repositories/EmergencyAccessRepository.cs
+++ b/src/Infrastructure.Dapper/Auth/Repositories/EmergencyAccessRepository.cs
@@ -60,6 +60,19 @@ public class EmergencyAccessRepository : Repository, IEme
}
}
+ public async Task> GetManyDetailsByUserIdsAsync(ICollection userIds)
+ {
+ using (var connection = new SqlConnection(ConnectionString))
+ {
+ var results = await connection.QueryAsync(
+ "[dbo].[EmergencyAccessDetails_ReadManyByUserIds]",
+ new { UserIds = userIds.ToGuidIdArrayTVP() },
+ commandType: CommandType.StoredProcedure);
+
+ return results.ToList();
+ }
+ }
+
public async Task GetDetailsByIdGrantorIdAsync(Guid id, Guid grantorId)
{
using (var connection = new SqlConnection(ConnectionString))
@@ -73,6 +86,19 @@ public class EmergencyAccessRepository : Repository, IEme
}
}
+ public async Task GetDetailsByIdAsync(Guid id)
+ {
+ using (var connection = new SqlConnection(ConnectionString))
+ {
+ var results = await connection.QueryAsync(
+ "[dbo].[EmergencyAccessDetails_ReadById]",
+ new { Id = id },
+ commandType: CommandType.StoredProcedure);
+
+ return results.FirstOrDefault();
+ }
+ }
+
public async Task> GetManyToNotifyAsync()
{
using (var connection = new SqlConnection(ConnectionString))
diff --git a/src/Infrastructure.EntityFramework/Auth/Repositories/EmergencyAccessRepository.cs b/src/Infrastructure.EntityFramework/Auth/Repositories/EmergencyAccessRepository.cs
index 66cf1e55e6..57eefd52c9 100644
--- a/src/Infrastructure.EntityFramework/Auth/Repositories/EmergencyAccessRepository.cs
+++ b/src/Infrastructure.EntityFramework/Auth/Repositories/EmergencyAccessRepository.cs
@@ -29,6 +29,8 @@ public class EmergencyAccessRepository : Repository GetDetailsByIdAsync(Guid id)
+ {
+ using (var scope = ServiceScopeFactory.CreateScope())
+ {
+ var dbContext = GetDatabaseContext(scope);
+ var view = new EmergencyAccessDetailsViewQuery();
+ var query = view.Run(dbContext).Where(ea => ea.Id == id);
+ return await query.FirstOrDefaultAsync();
+ }
+ }
+
public async Task> GetExpiredRecoveriesAsync()
{
using (var scope = ServiceScopeFactory.CreateScope())
@@ -88,6 +101,20 @@ public class EmergencyAccessRepository : Repository> GetManyDetailsByUserIdsAsync(ICollection userIds)
+ {
+ using (var scope = ServiceScopeFactory.CreateScope())
+ {
+ var dbContext = GetDatabaseContext(scope);
+ var view = new EmergencyAccessDetailsViewQuery();
+ var query = view.Run(dbContext).Where(ea =>
+ userIds.Contains(ea.GrantorId) ||
+ (ea.GranteeId.HasValue && userIds.Contains(ea.GranteeId.Value))
+ );
+ return await query.ToListAsync();
+ }
+ }
+
public async Task> GetManyToNotifyAsync()
{
using (var scope = ServiceScopeFactory.CreateScope())
@@ -153,14 +180,7 @@ public class EmergencyAccessRepository : Repository 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();
}
}
diff --git a/src/Infrastructure.EntityFramework/Auth/Repositories/Queries/EmergencyAccessDetailsViewQuery.cs b/src/Infrastructure.EntityFramework/Auth/Repositories/Queries/EmergencyAccessDetailsViewQuery.cs
index 7ddbcc346a..f7e7b96e1d 100644
--- a/src/Infrastructure.EntityFramework/Auth/Repositories/Queries/EmergencyAccessDetailsViewQuery.cs
+++ b/src/Infrastructure.EntityFramework/Auth/Repositories/Queries/EmergencyAccessDetailsViewQuery.cs
@@ -32,8 +32,10 @@ public class EmergencyAccessDetailsViewQuery : IQuery
RevisionDate = x.ea.RevisionDate,
GranteeName = x.grantee.Name,
GranteeEmail = x.grantee.Email ?? x.ea.Email,
+ GranteeAvatarColor = x.grantee.AvatarColor,
GrantorName = x.grantor.Name,
GrantorEmail = x.grantor.Email,
+ GrantorAvatarColor = x.grantor.AvatarColor,
});
}
}
diff --git a/src/Sql/dbo/Auth/Stored Procedures/EmergencyAccessDetails_ReadById.sql b/src/Sql/dbo/Auth/Stored Procedures/EmergencyAccessDetails_ReadById.sql
new file mode 100644
index 0000000000..98ade13c24
--- /dev/null
+++ b/src/Sql/dbo/Auth/Stored Procedures/EmergencyAccessDetails_ReadById.sql
@@ -0,0 +1,13 @@
+CREATE PROCEDURE [dbo].[EmergencyAccessDetails_ReadById]
+ @Id UNIQUEIDENTIFIER
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ SELECT
+ *
+ FROM
+ [dbo].[EmergencyAccessDetailsView]
+ WHERE
+ [Id] = @Id
+END
diff --git a/src/Sql/dbo/Auth/Stored Procedures/EmergencyAccessDetails_ReadManyByUserIds.sql b/src/Sql/dbo/Auth/Stored Procedures/EmergencyAccessDetails_ReadManyByUserIds.sql
new file mode 100644
index 0000000000..89e17b8350
--- /dev/null
+++ b/src/Sql/dbo/Auth/Stored Procedures/EmergencyAccessDetails_ReadManyByUserIds.sql
@@ -0,0 +1,15 @@
+CREATE PROCEDURE [dbo].[EmergencyAccessDetails_ReadManyByUserIds]
+ @UserIds [dbo].[GuidIdArray] READONLY
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ SELECT
+ *
+ FROM
+ [dbo].[EmergencyAccessDetailsView]
+ WHERE
+ [GrantorId] IN (SELECT [Id] FROM @UserIds)
+ OR [GranteeId] IN (SELECT [Id] FROM @UserIds)
+END
+GO
diff --git a/test/Core.Test/Auth/UserFeatures/EmergencyAccess/DeleteEmergencyAccessCommandTests.cs b/test/Core.Test/Auth/UserFeatures/EmergencyAccess/DeleteEmergencyAccessCommandTests.cs
index 057357970b..33aedfa9a9 100644
--- a/test/Core.Test/Auth/UserFeatures/EmergencyAccess/DeleteEmergencyAccessCommandTests.cs
+++ b/test/Core.Test/Auth/UserFeatures/EmergencyAccess/DeleteEmergencyAccessCommandTests.cs
@@ -6,6 +6,7 @@ using Bit.Core.Platform.Mail.Mailer;
using Bit.Core.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
+using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
@@ -19,17 +20,17 @@ public class DeleteEmergencyAccessCommandTests
/// throws a and does not call delete or send email.
///
[Theory, BitAutoData]
- public async Task DeleteByIdGrantorIdAsync_EmergencyAccessNotFound_ThrowsBadRequest(
+ public async Task DeleteByIdAndUserIdAsync_EmergencyAccessNotFound_ThrowsBadRequestAsync(
SutProvider sutProvider,
Guid emergencyAccessId,
- Guid grantorId)
+ Guid userId)
{
sutProvider.GetDependency()
- .GetDetailsByIdGrantorIdAsync(emergencyAccessId, grantorId)
- .Returns((EmergencyAccessDetails)null);
+ .GetDetailsByIdAsync(emergencyAccessId)
+ .Returns((EmergencyAccessDetails?)null);
var exception = await Assert.ThrowsAsync(
- () => sutProvider.Sut.DeleteByIdGrantorIdAsync(emergencyAccessId, grantorId));
+ () => sutProvider.Sut.DeleteByIdAndUserIdAsync(emergencyAccessId, userId));
Assert.Contains("Emergency Access not valid.", exception.Message);
await sutProvider.GetDependency()
@@ -41,45 +42,194 @@ public class DeleteEmergencyAccessCommandTests
}
///
- /// Verifies successful deletion of an emergency access record by ID and grantor ID,
- /// and ensures that a notification email is sent to the grantor.
+ /// Verifies that an emergency access record is deleted by ID and user ID,
+ /// and that a notification email is sent to the grantor.
///
[Theory, BitAutoData]
- public async Task DeleteByIdGrantorIdAsync_DeletesEmergencyAccessAndSendsEmail(
+ public async Task DeleteByIdAndUserIdAsync_DeletesEmergencyAccessAndSendsEmailAsync(
SutProvider sutProvider,
EmergencyAccessDetails emergencyAccessDetails)
{
sutProvider.GetDependency()
- .GetDetailsByIdGrantorIdAsync(emergencyAccessDetails.Id, emergencyAccessDetails.GrantorId)
+ .GetDetailsByIdAsync(emergencyAccessDetails.Id)
.Returns(emergencyAccessDetails);
- var result = await sutProvider.Sut.DeleteByIdGrantorIdAsync(emergencyAccessDetails.Id, emergencyAccessDetails.GrantorId);
+ await sutProvider.Sut.DeleteByIdAndUserIdAsync(emergencyAccessDetails.Id, emergencyAccessDetails.GrantorId);
await sutProvider.GetDependency()
.Received(1)
- .DeleteManyAsync(Arg.Any>());
+ .DeleteAsync(emergencyAccessDetails);
await sutProvider.GetDependency()
.Received(1)
- .SendEmail(Arg.Any());
+ .SendEmail(Arg.Is(mail =>
+ mail.ToEmails.Contains(emergencyAccessDetails.GrantorEmail) &&
+ mail.View.RemovedGranteeEmails.Contains(emergencyAccessDetails.GranteeEmail)));
}
///
- /// 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.
+ /// Verifies that when the grantor email is null, the record is deleted
+ /// but no email notification is sent.
///
[Theory, BitAutoData]
- public async Task DeleteAllByGrantorIdAsync_NoEmergencyAccessRecords_ReturnsEmptyCollection(
+ public async Task DeleteByIdAndUserIdAsync_NullGrantorEmail_DeletesButDoesNotSendEmailAsync(
SutProvider sutProvider,
- Guid grantorId)
+ EmergencyAccessDetails emergencyAccessDetails)
+ {
+ emergencyAccessDetails.GrantorEmail = null;
+
+ sutProvider.GetDependency()
+ .GetDetailsByIdAsync(emergencyAccessDetails.Id)
+ .Returns(emergencyAccessDetails);
+
+ await sutProvider.Sut.DeleteByIdAndUserIdAsync(emergencyAccessDetails.Id, emergencyAccessDetails.GrantorId);
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .DeleteAsync(emergencyAccessDetails);
+ await sutProvider.GetDependency()
+ .DidNotReceiveWithAnyArgs()
+ .SendEmail(default);
+ sutProvider.GetDependency>()
+ .Received(1)
+ .Log(
+ LogLevel.Warning,
+ Arg.Any(),
+ Arg.Is