diff --git a/src/Core/Auth/Entities/EmergencyAccess.cs b/src/Core/Auth/Entities/EmergencyAccess.cs index 36aaf46a8c..df66541d2a 100644 --- a/src/Core/Auth/Entities/EmergencyAccess.cs +++ b/src/Core/Auth/Entities/EmergencyAccess.cs @@ -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 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; } diff --git a/src/Core/Auth/Enums/EmergencyAccessStatusType.cs b/src/Core/Auth/Enums/EmergencyAccessStatusType.cs index d817d6a950..45bd5fdee4 100644 --- a/src/Core/Auth/Enums/EmergencyAccessStatusType.cs +++ b/src/Core/Auth/Enums/EmergencyAccessStatusType.cs @@ -19,7 +19,7 @@ public enum EmergencyAccessStatusType : byte /// RecoveryInitiated = 3, /// - /// The grantee has excercised their emergency access. + /// The grantee has exercised their emergency access. /// RecoveryApproved = 4, } diff --git a/src/Core/Auth/Models/Data/EmergencyAccessDetails.cs b/src/Core/Auth/Models/Data/EmergencyAccessDetails.cs index 03661c7276..86c1e6953f 100644 --- a/src/Core/Auth/Models/Data/EmergencyAccessDetails.cs +++ b/src/Core/Auth/Models/Data/EmergencyAccessDetails.cs @@ -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; } + /// + /// 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? GrantorAvatarColor { get; set; } } diff --git a/src/Core/Auth/Models/Data/EmergencyAccessNotify.cs b/src/Core/Auth/Models/Data/EmergencyAccessNotify.cs index 1c0d4bfe8b..492d565717 100644 --- a/src/Core/Auth/Models/Data/EmergencyAccessNotify.cs +++ b/src/Core/Auth/Models/Data/EmergencyAccessNotify.cs @@ -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; } } diff --git a/src/Core/Auth/Repositories/IEmergencyAccessRepository.cs b/src/Core/Auth/Repositories/IEmergencyAccessRepository.cs index 63ec04106e..5b4ad47180 100644 --- a/src/Core/Auth/Repositories/IEmergencyAccessRepository.cs +++ b/src/Core/Auth/Repositories/IEmergencyAccessRepository.cs @@ -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 @@ -11,7 +9,17 @@ public interface IEmergencyAccessRepository : IRepository Task GetCountByGrantorIdEmailAsync(Guid grantorId, string email, bool onlyRegisteredUsers); Task> GetManyDetailsByGrantorIdAsync(Guid grantorId); Task> GetManyDetailsByGranteeIdAsync(Guid granteeId); + /// + /// Fetches emergency access details by EmergencyAccess id and grantor id + /// + /// Emergency Access Id + /// Grantor Id + /// EmergencyAccessDetails or null Task GetDetailsByIdGrantorIdAsync(Guid id, Guid grantorId); + /// + /// Database call to fetch emergency accesses that need notification emails sent through a Job + /// + /// collection of EmergencyAccessNotify objects that require notification Task> GetManyToNotifyAsync(); Task> GetExpiredRecoveriesAsync(); @@ -22,4 +30,11 @@ public interface IEmergencyAccessRepository : IRepository /// A list of emergency access with updated keys UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(Guid grantorId, IEnumerable emergencyAccessKeys); + + /// + /// Deletes multiple emergency access records by their IDs + /// + /// Ids of records to be deleted + /// void + Task DeleteManyAsync(ICollection emergencyAccessIds); } diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/Commands/DeleteEmergencyAccessCommand.cs b/src/Core/Auth/UserFeatures/EmergencyAccess/Commands/DeleteEmergencyAccessCommand.cs new file mode 100644 index 0000000000..40779b266a --- /dev/null +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/Commands/DeleteEmergencyAccessCommand.cs @@ -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 +{ + /// + public async Task 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; + } + + /// + public async Task?> 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; + } + + /// + 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) + { + return emergencyAccessDetails; + } + + var (grantorEmails, granteeEmails) = await DeleteEmergencyAccessAsync(emergencyAccessDetails); + + // 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) + { + granteeEmails.Add(details.GranteeEmail ?? string.Empty); + grantorEmails.Add(details.GrantorEmail); + } + + return (grantorEmails, granteeEmails); + } + + /// + /// Sends an email notification to the grantor about 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) + { + foreach (var email in grantorEmails) + { + var emailViewModel = new EmergencyAccessRemoveGranteesMail + { + ToEmails = [email], + View = new EmergencyAccessRemoveGranteesMailView + { + RemovedGranteeEmails = formattedGranteeIdentifiers + } + }; + + await mailer.SendEmail(emailViewModel); + } + } +} diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/Interfaces/IDeleteEmergencyAccessCommand.cs b/src/Core/Auth/UserFeatures/EmergencyAccess/Interfaces/IDeleteEmergencyAccessCommand.cs new file mode 100644 index 0000000000..efdd864d60 --- /dev/null +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/Interfaces/IDeleteEmergencyAccessCommand.cs @@ -0,0 +1,35 @@ +using Bit.Core.Auth.Models.Data; +using Bit.Core.Exceptions; + +namespace Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces; + +/// +/// Command for deleting emergency access records based on the grantor's user ID. +/// +public interface IDeleteEmergencyAccessCommand +{ + /// + /// Deletes a single emergency access record for the specified grantor. + /// + /// The ID of the emergency access record to delete. + /// The ID of the grantor user who owns the emergency access record. + /// A task representing the asynchronous operation. + /// + /// Thrown when the emergency access record is not found or does not belong to the specified grantor. + /// + Task DeleteByIdGrantorIdAsync(Guid emergencyAccessId, Guid grantorId); + + /// + /// Deletes all emergency access records for the specified grantor. + /// + /// 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); + + /// + /// Deletes all emergency access records for the specified grantee. + /// + /// 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); +} diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.cs b/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.cs index 4d60556785..52106a089f 100644 --- a/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.cs +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.cs @@ -4,8 +4,9 @@ namespace Bit.Core.Auth.UserFeatures.EmergencyAccess.Mail; public class EmergencyAccessRemoveGranteesMailView : BaseMailView { - public required IEnumerable RemovedGranteeNames { get; set; } - public string EmergencyAccessHelpPageUrl => "https://bitwarden.com/help/emergency-access/"; + + public required IEnumerable RemovedGranteeEmails { get; set; } + public static string EmergencyAccessHelpPageUrl => "https://bitwarden.com/help/emergency-access/"; } public class EmergencyAccessRemoveGranteesMail : BaseMail diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.html.hbs b/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.html.hbs index 405f2744bd..ee43caa85d 100644 --- a/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.html.hbs +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.html.hbs @@ -29,8 +29,8 @@ .mj-outlook-group-fix { width:100% !important; } - - + + - - - - + + + + - + - + - - + +
- + - - + +
- +
- +
- - + + - - + +
- +
- +
- + - + - + - +
- +
- + - +
- +
- +

- +

- +
- +
- + - +
- + - + - +
- +
- + - +
- +
- +
- +
- +
- - + + - - + +
- +
- +
- - + + - + - + - - + +
- +
- - + +
- +
- +
- + - + - +
- +
The following emergency contacts have been removed from your account:
    - {{#each RemovedGranteeNames}} + {{#each RemovedGranteeEmails}}
  • {{this}}
  • {{/each}}
Learn more about emergency access.
- +
- +
- +
- +
- - + +
- +
- - + + - + - + - - + +
- +
- +
- + - + - + - +
- - + + - + - + - +
@@ -309,15 +309,15 @@
- + - + - +
@@ -332,15 +332,15 @@
- + - + - +
@@ -355,15 +355,15 @@
- + - + - +
@@ -378,15 +378,15 @@
- + - + - +
@@ -401,15 +401,15 @@
- + - + - +
@@ -424,15 +424,15 @@
- + - + - +
@@ -447,20 +447,20 @@
- - + +
- +

© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa Barbara, CA, USA @@ -471,29 +471,28 @@ bitwarden.com | Learn why we include this

- +
- +
- +
- +
- - + + - - + +
- + - \ No newline at end of file diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.text.hbs b/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.text.hbs index 3c17274f35..0a8446dd17 100644 --- a/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.text.hbs +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.text.hbs @@ -1,6 +1,6 @@ The following emergency contacts have been removed from your account: -{{#each RemovedGranteeNames}} +{{#each RemovedGranteeEmails}} {{this}} {{/each}} diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs index 6249d1cb1c..356d5bf2bc 100644 --- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs +++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs @@ -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(); services.AddDeviceTrustCommands(); + services.AddEmergencyAccessCommands(); services.AddUserPasswordCommands(); services.AddUserRegistrationCommands(); services.AddWebAuthnLoginCommands(); @@ -36,6 +39,11 @@ public static class UserServiceCollectionExtensions services.AddScoped(); } + private static void AddEmergencyAccessCommands(this IServiceCollection services) + { + services.AddScoped(); + } + public static void AddUserKeyCommands(this IServiceCollection services, IGlobalSettings globalSettings) { services.AddScoped(); diff --git a/src/Core/MailTemplates/Mjml/emails/Auth/UserFeatures/EmergencyAccess/emergency-access-remove-grantees.mjml b/src/Core/MailTemplates/Mjml/emails/Auth/UserFeatures/EmergencyAccess/emergency-access-remove-grantees.mjml index 3af29a4414..0dc9f93e45 100644 --- a/src/Core/MailTemplates/Mjml/emails/Auth/UserFeatures/EmergencyAccess/emergency-access-remove-grantees.mjml +++ b/src/Core/MailTemplates/Mjml/emails/Auth/UserFeatures/EmergencyAccess/emergency-access-remove-grantees.mjml @@ -15,7 +15,7 @@ The following emergency contacts have been removed from your account:
    - {{#each RemovedGranteeNames}} + {{#each RemovedGranteeEmails}}
  • {{this}}
  • {{/each}}
diff --git a/src/Core/Platform/Mail/HandlebarsMailService.cs b/src/Core/Platform/Mail/HandlebarsMailService.cs index 910285244d..179e887a9e 100644 --- a/src/Core/Platform/Mail/HandlebarsMailService.cs +++ b/src/Core/Platform/Mail/HandlebarsMailService.cs @@ -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 { diff --git a/src/Infrastructure.Dapper/Auth/Repositories/EmergencyAccessRepository.cs b/src/Infrastructure.Dapper/Auth/Repositories/EmergencyAccessRepository.cs index 4d597ab045..f7dd17784e 100644 --- a/src/Infrastructure.Dapper/Auth/Repositories/EmergencyAccessRepository.cs +++ b/src/Infrastructure.Dapper/Auth/Repositories/EmergencyAccessRepository.cs @@ -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, IEmergencyAccessRepository @@ -152,4 +150,14 @@ public class EmergencyAccessRepository : Repository, IEme } }; } + + /// + public async Task DeleteManyAsync(ICollection emergencyAccessIds) + { + using var connection = new SqlConnection(ConnectionString); + await connection.ExecuteAsync( + "[dbo].[EmergencyAccess_DeleteManyById]", + new { EmergencyAccessIds = emergencyAccessIds.ToGuidIdArrayTVP() }, + commandType: CommandType.StoredProcedure); + } } diff --git a/src/Infrastructure.EntityFramework/Auth/Repositories/EmergencyAccessRepository.cs b/src/Infrastructure.EntityFramework/Auth/Repositories/EmergencyAccessRepository.cs index e1ea9bc03f..66cf1e55e6 100644 --- a/src/Infrastructure.EntityFramework/Auth/Repositories/EmergencyAccessRepository.cs +++ b/src/Infrastructure.EntityFramework/Auth/Repositories/EmergencyAccessRepository.cs @@ -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, IEmergencyAccessRepository @@ -146,4 +144,23 @@ public class EmergencyAccessRepository : Repository + public async Task DeleteManyAsync(ICollection 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(); + } } diff --git a/src/Infrastructure.EntityFramework/Auth/Repositories/Queries/EmergencyAccessDetailsViewQuery.cs b/src/Infrastructure.EntityFramework/Auth/Repositories/Queries/EmergencyAccessDetailsViewQuery.cs index d666df76cf..7ddbcc346a 100644 --- a/src/Infrastructure.EntityFramework/Auth/Repositories/Queries/EmergencyAccessDetailsViewQuery.cs +++ b/src/Infrastructure.EntityFramework/Auth/Repositories/Queries/EmergencyAccessDetailsViewQuery.cs @@ -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 diff --git a/src/Infrastructure.EntityFramework/Repositories/DatabaseContextExtensions.cs b/src/Infrastructure.EntityFramework/Repositories/DatabaseContextExtensions.cs index 40f2a79887..8c15cc17fa 100644 --- a/src/Infrastructure.EntityFramework/Repositories/DatabaseContextExtensions.cs +++ b/src/Infrastructure.EntityFramework/Repositories/DatabaseContextExtensions.cs @@ -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 diff --git a/src/Sql/dbo/Auth/Stored Procedures/EmergencyAccess_DeleteManyById.sql b/src/Sql/dbo/Auth/Stored Procedures/EmergencyAccess_DeleteManyById.sql new file mode 100644 index 0000000000..75677ebbd9 --- /dev/null +++ b/src/Sql/dbo/Auth/Stored Procedures/EmergencyAccess_DeleteManyById.sql @@ -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 diff --git a/test/Api.Test/KeyManagement/Validators/EmergencyAccessRotationValidatorTests.cs b/test/Api.Test/KeyManagement/Validators/EmergencyAccessRotationValidatorTests.cs index e00129fd89..a69576f9dc 100644 --- a/test/Api.Test/KeyManagement/Validators/EmergencyAccessRotationValidatorTests.cs +++ b/test/Api.Test/KeyManagement/Validators/EmergencyAccessRotationValidatorTests.cs @@ -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().GetManyDetailsByGrantorIdAsync(user.Id) .Returns(userEmergencyAccess); diff --git a/test/Core.Test/Auth/UserFeatures/EmergencyAccess/DeleteEmergencyAccessCommandTests.cs b/test/Core.Test/Auth/UserFeatures/EmergencyAccess/DeleteEmergencyAccessCommandTests.cs new file mode 100644 index 0000000000..057357970b --- /dev/null +++ b/test/Core.Test/Auth/UserFeatures/EmergencyAccess/DeleteEmergencyAccessCommandTests.cs @@ -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 +{ + /// + /// Verifies that attempting to delete a non-existent emergency access record + /// throws a and does not call delete or send email. + /// + [Theory, BitAutoData] + public async Task DeleteByIdGrantorIdAsync_EmergencyAccessNotFound_ThrowsBadRequest( + SutProvider sutProvider, + Guid emergencyAccessId, + Guid grantorId) + { + sutProvider.GetDependency() + .GetDetailsByIdGrantorIdAsync(emergencyAccessId, grantorId) + .Returns((EmergencyAccessDetails)null); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.DeleteByIdGrantorIdAsync(emergencyAccessId, grantorId)); + + Assert.Contains("Emergency Access not valid.", exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteAsync(default); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SendEmail(default); + } + + /// + /// Verifies successful deletion of an emergency access record by ID and grantor ID, + /// and ensures that a notification email is sent to the grantor. + /// + [Theory, BitAutoData] + public async Task DeleteByIdGrantorIdAsync_DeletesEmergencyAccessAndSendsEmail( + SutProvider sutProvider, + EmergencyAccessDetails emergencyAccessDetails) + { + sutProvider.GetDependency() + .GetDetailsByIdGrantorIdAsync(emergencyAccessDetails.Id, emergencyAccessDetails.GrantorId) + .Returns(emergencyAccessDetails); + + var result = await sutProvider.Sut.DeleteByIdGrantorIdAsync(emergencyAccessDetails.Id, emergencyAccessDetails.GrantorId); + + await sutProvider.GetDependency() + .Received(1) + .DeleteManyAsync(Arg.Any>()); + await sutProvider.GetDependency() + .Received(1) + .SendEmail(Arg.Any()); + } + + /// + /// 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. + /// + [Theory, BitAutoData] + public async Task DeleteAllByGrantorIdAsync_NoEmergencyAccessRecords_ReturnsEmptyCollection( + SutProvider sutProvider, + Guid grantorId) + { + sutProvider.GetDependency() + .GetManyDetailsByGrantorIdAsync(grantorId) + .Returns([]); + + var result = await sutProvider.Sut.DeleteAllByGrantorIdAsync(grantorId); + + Assert.NotNull(result); + Assert.Empty(result); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteManyAsync(default); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SendEmail(default); + } + + /// + /// 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. + /// + [Theory, BitAutoData] + public async Task DeleteAllByGrantorIdAsync_MultipleRecords_DeletesAllReturnsDetailsSendsSingleEmail( + SutProvider 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 + { + emergencyAccessDetails1, + emergencyAccessDetails2, + emergencyAccessDetails3 + }; + + sutProvider.GetDependency() + .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() + .Received(1) + .DeleteManyAsync(Arg.Any>()); + await sutProvider.GetDependency() + .Received(1) + .SendEmail(Arg.Any()); + } + + /// + /// Verifies that when a grantor has a single emergency access record, it is deleted, + /// the details are returned, and a notification email is sent. + /// + [Theory, BitAutoData] + public async Task DeleteAllByGrantorIdAsync_SingleRecord_DeletesAndReturnsDetailsSendsSingleEmail( + SutProvider sutProvider, + EmergencyAccessDetails emergencyAccessDetails, + Guid grantorId) + { + sutProvider.GetDependency() + .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() + .Received(1) + .DeleteManyAsync(Arg.Any>()); + await sutProvider.GetDependency() + .Received(1) + .SendEmail(Arg.Any()); + } + + /// + /// 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. + /// + [Theory, BitAutoData] + public async Task DeleteAllByGranteeIdAsync_NoEmergencyAccessRecords_ReturnsEmptyCollection( + SutProvider sutProvider, + Guid granteeId) + { + sutProvider.GetDependency() + .GetManyDetailsByGranteeIdAsync(granteeId) + .Returns([]); + + var result = await sutProvider.Sut.DeleteAllByGranteeIdAsync(granteeId); + + Assert.NotNull(result); + Assert.Empty(result); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteManyAsync(default); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SendEmail(default); + } + + /// + /// 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. + /// + [Theory, BitAutoData] + public async Task DeleteAllByGranteeIdAsync_SingleRecord_DeletesAndReturnsDetailsSendsSingleEmail( + SutProvider sutProvider, + EmergencyAccessDetails emergencyAccessDetails, + Guid granteeId) + { + sutProvider.GetDependency() + .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() + .Received(1) + .DeleteManyAsync(Arg.Any>()); + await sutProvider.GetDependency() + .Received(1) + .SendEmail(Arg.Any()); + } + + /// + /// 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. + /// + [Theory, BitAutoData] + public async Task DeleteAllByGranteeIdAsync_MultipleRecords_DeletesAllReturnsDetailsSendsMultipleEmails( + SutProvider 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 + { + emergencyAccessDetails1, + emergencyAccessDetails2, + emergencyAccessDetails3 + }; + + sutProvider.GetDependency() + .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() + .Received(1) + .DeleteManyAsync(Arg.Any>()); + await sutProvider.GetDependency() + .Received(allDetails.Count) + .SendEmail(Arg.Any()); + } +} diff --git a/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessMailTests.cs b/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessMailTests.cs index 8cb6c2c2fe..60c3644dae 100644 --- a/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessMailTests.cs +++ b/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessMailTests.cs @@ -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>(); @@ -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); } /// @@ -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); } /// @@ -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); } } diff --git a/test/Infrastructure.IntegrationTest/Auth/Repositories/EmergencyAccessRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Auth/Repositories/EmergencyAccessRepositoryTests.cs index 0f6d12537f..a00fc35cd8 100644 --- a/test/Infrastructure.IntegrationTest/Auth/Repositories/EmergencyAccessRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Auth/Repositories/EmergencyAccessRepositoryTests.cs @@ -42,4 +42,97 @@ public class EmergencyAccessRepositoriesTests Assert.NotNull(updatedGrantee); Assert.NotEqual(updatedGrantee.AccountRevisionDate, granteeUser.AccountRevisionDate); } + + /// + /// 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. + /// + [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)[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); + } } diff --git a/util/Migrator/DbScripts/2026-01-23_00_AddDeleteManyEmergencyAccess.sql b/util/Migrator/DbScripts/2026-01-23_00_AddDeleteManyEmergencyAccess.sql new file mode 100644 index 0000000000..f3ffb7ed07 --- /dev/null +++ b/util/Migrator/DbScripts/2026-01-23_00_AddDeleteManyEmergencyAccess.sql @@ -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