diff --git a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs index 08ed9b7c1f3f..acca6e725c02 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs @@ -171,4 +171,21 @@ Task> ConfirmManyOrganizationUsersAsync( /// The organization user entity with updated properties (status, userId, key) /// An action that can be executed within a transaction Func BuildConfirmOwnerAction(OrganizationUser organizationUser); + + /// + /// Returns a delegate that updates the status, key, and revision date of the given + /// organization user. + /// + /// Id of the organization user to update + /// The status to set + /// The key to set + /// The revision date to set + DatabaseTransactionAction UpdateStatusAndKeyById(Guid id, + OrganizationUserStatusType status, string? key, DateTime revisionDate); + + /// + /// Returns a delegate that deletes organization users and their associated data. + /// + /// Ids of the organization users to delete + DatabaseTransactionAction DeleteManyByIds(IEnumerable ids); } diff --git a/src/Core/Auth/Repositories/IEmergencyAccessRepository.cs b/src/Core/Auth/Repositories/IEmergencyAccessRepository.cs index e46a0520e5fe..cbb3f7250ea3 100644 --- a/src/Core/Auth/Repositories/IEmergencyAccessRepository.cs +++ b/src/Core/Auth/Repositories/IEmergencyAccessRepository.cs @@ -1,4 +1,5 @@ using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; using Bit.Core.KeyManagement.UserKey; @@ -43,6 +44,17 @@ public interface IEmergencyAccessRepository : IRepository UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(Guid grantorId, IEnumerable emergencyAccessKeys); + /// + /// Returns a delegate that updates the status, encrypted key, and revision date of the given + /// emergency access record. + /// + /// Id of the emergency access record to update + /// The status to set + /// The encrypted key to set + /// The revision date to set + DatabaseTransactionAction UpdateStatusAndKeyEncryptedById(Guid id, + EmergencyAccessStatusType status, string? keyEncrypted, DateTime revisionDate); + /// /// Deletes multiple emergency access records by their IDs /// diff --git a/src/Core/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommand.cs b/src/Core/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommand.cs index 9b93d44182fe..92d9f868779e 100644 --- a/src/Core/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommand.cs +++ b/src/Core/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommand.cs @@ -9,6 +9,8 @@ using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.Repositories; using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; using Microsoft.Extensions.Logging; namespace Bit.Core.KeyManagement.Commands; @@ -19,17 +21,29 @@ public class RegenerateUserAsymmetricKeysCommand : IRegenerateUserAsymmetricKeys private readonly ILogger _logger; private readonly IUserAsymmetricKeysRepository _userAsymmetricKeysRepository; private readonly IPushNotificationService _pushService; + private readonly IEmergencyAccessRepository _emergencyAccessRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IEventService _eventService; + private readonly IMailService _mailService; public RegenerateUserAsymmetricKeysCommand( ICurrentContext currentContext, IUserAsymmetricKeysRepository userAsymmetricKeysRepository, IPushNotificationService pushService, - ILogger logger) + ILogger logger, + IEmergencyAccessRepository emergencyAccessRepository, + IOrganizationUserRepository organizationUserRepository, + IEventService eventService, + IMailService mailService) { _currentContext = currentContext; _logger = logger; _userAsymmetricKeysRepository = userAsymmetricKeysRepository; _pushService = pushService; + _emergencyAccessRepository = emergencyAccessRepository; + _organizationUserRepository = organizationUserRepository; + _eventService = eventService; + _mailService = mailService; } public async Task RegenerateKeysAsync(UserAsymmetricKeys userAsymmetricKeys, @@ -55,17 +69,61 @@ x.Status is EmergencyAccessStatusType.Confirmed or EmergencyAccessStatusType.Rec "User asymmetric keys regeneration requested. UserId: {userId} OrganizationMembership: {inOrganizations} DesignatedEmergencyAccess: {hasDesignatedEmergencyAccess} DeviceType: {deviceType}", userAsymmetricKeys.UserId, inOrganizations, hasDesignatedEmergencyAccess, _currentContext.DeviceType); - // For now, don't regenerate asymmetric keys for user's with organization membership and designated emergency access. - if (inOrganizations || hasDesignatedEmergencyAccess) + var updateDataActions = new List(); + var utcNow = DateTime.UtcNow; + + var eaToReset = designatedEmergencyAccess + .Where(ea => ea.Status is EmergencyAccessStatusType.Confirmed + or EmergencyAccessStatusType.RecoveryInitiated + or EmergencyAccessStatusType.RecoveryApproved) + .ToList(); + foreach (var ea in eaToReset) + { + updateDataActions.Add(_emergencyAccessRepository.UpdateStatusAndKeyEncryptedById( + ea.Id, EmergencyAccessStatusType.Accepted, null, utcNow)); + } + + var orgUsersToReset = usersOrganizationAccounts + .Where(ou => ou.Status == OrganizationUserStatusType.Confirmed) + .ToList(); + foreach (var orgUser in orgUsersToReset) { - throw new BadRequestException("Key regeneration not supported for this user."); + updateDataActions.Add(_organizationUserRepository.UpdateStatusAndKeyById( + orgUser.Id, OrganizationUserStatusType.Accepted, null, utcNow)); } - await _userAsymmetricKeysRepository.RegenerateUserAsymmetricKeysAsync(userAsymmetricKeys); + var orgUsersToRemove = usersOrganizationAccounts + .Where(ou => ou.Status == OrganizationUserStatusType.Revoked) + .ToList(); + if (orgUsersToRemove.Count > 0) + { + updateDataActions.Add( + _organizationUserRepository.DeleteManyByIds(orgUsersToRemove.Select(ou => ou.Id))); + } + + await _userAsymmetricKeysRepository.RegenerateUserAsymmetricKeysAsync( + userAsymmetricKeys, updateDataActions); + _logger.LogInformation( "User's asymmetric keys regenerated. UserId: {userId} OrganizationMembership: {inOrganizations} DesignatedEmergencyAccess: {hasDesignatedEmergencyAccess} DeviceType: {deviceType}", userAsymmetricKeys.UserId, inOrganizations, hasDesignatedEmergencyAccess, _currentContext.DeviceType); await _pushService.PushSyncSettingsAsync(userId.Value); + + foreach (var orgUser in orgUsersToRemove) + { + await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Left); + } + + foreach (var ea in eaToReset) + { + if (ea.GranteeEmail is null || ea.GrantorEmail is null) + { + continue; + } + + await _mailService.SendEmergencyAccessAcceptedEmailAsync( + ea.GranteeEmail, ea.GrantorEmail); + } } } diff --git a/src/Core/KeyManagement/Repositories/IUserAsymmetricKeysRepository.cs b/src/Core/KeyManagement/Repositories/IUserAsymmetricKeysRepository.cs index fee9aee3bbb3..871e1b044b07 100644 --- a/src/Core/KeyManagement/Repositories/IUserAsymmetricKeysRepository.cs +++ b/src/Core/KeyManagement/Repositories/IUserAsymmetricKeysRepository.cs @@ -1,9 +1,11 @@ #nullable enable using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Repositories; namespace Bit.Core.KeyManagement.Repositories; public interface IUserAsymmetricKeysRepository { - Task RegenerateUserAsymmetricKeysAsync(UserAsymmetricKeys userAsymmetricKeys); + Task RegenerateUserAsymmetricKeysAsync(UserAsymmetricKeys userAsymmetricKeys, + IEnumerable updateDataActions); } diff --git a/src/Core/Repositories/DatabaseTransactionAction.cs b/src/Core/Repositories/DatabaseTransactionAction.cs new file mode 100644 index 000000000000..ae4aa92a19f4 --- /dev/null +++ b/src/Core/Repositories/DatabaseTransactionAction.cs @@ -0,0 +1,9 @@ +using System.Data.Common; + +namespace Bit.Core.Repositories; + +/// +/// A database operation that participates in an existing database connection and transaction. +/// Used to compose multiple repository operations into a single atomic transaction. +/// +public delegate Task DatabaseTransactionAction(DbConnection connection, DbTransaction transaction); diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs index e2bb1fafeaee..26875db5c695 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -763,4 +763,43 @@ public async Task> GetManyPendingAutoConfirmAsync( return results.ToList(); } } + + /// + public DatabaseTransactionAction UpdateStatusAndKeyById(Guid id, + OrganizationUserStatusType status, string? key, DateTime revisionDate) + { + return async (connection, transaction) => + { + await connection.ExecuteAsync( + "[dbo].[OrganizationUser_UpdateStatusKeyById]", + new + { + Id = id, + Status = (short)status, + Key = key, + RevisionDate = revisionDate + }, + transaction: transaction, + commandType: CommandType.StoredProcedure); + }; + } + + /// + public DatabaseTransactionAction DeleteManyByIds(IEnumerable ids) + { + return async (connection, transaction) => + { + var idsList = ids.ToList(); + if (idsList.Count == 0) + { + return; + } + + await connection.ExecuteAsync( + "[dbo].[OrganizationUser_DeleteByIds]", + new { Ids = idsList.ToGuidIdArrayTVP() }, + transaction: transaction, + commandType: CommandType.StoredProcedure); + }; + } } diff --git a/src/Infrastructure.Dapper/Auth/Repositories/EmergencyAccessRepository.cs b/src/Infrastructure.Dapper/Auth/Repositories/EmergencyAccessRepository.cs index c76dfb525d17..e55897f2aa42 100644 --- a/src/Infrastructure.Dapper/Auth/Repositories/EmergencyAccessRepository.cs +++ b/src/Infrastructure.Dapper/Auth/Repositories/EmergencyAccessRepository.cs @@ -1,5 +1,6 @@ using System.Data; using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Repositories; @@ -177,6 +178,26 @@ INNER JOIN }; } + /// + public DatabaseTransactionAction UpdateStatusAndKeyEncryptedById(Guid id, + EmergencyAccessStatusType status, string? keyEncrypted, DateTime revisionDate) + { + return async (connection, transaction) => + { + await connection.ExecuteAsync( + "[dbo].[EmergencyAccess_UpdateStatusKeyEncryptedById]", + new + { + Id = id, + Status = (byte)status, + KeyEncrypted = keyEncrypted, + RevisionDate = revisionDate + }, + transaction: transaction, + commandType: CommandType.StoredProcedure); + }; + } + /// public async Task DeleteManyAsync(ICollection emergencyAccessIds) { diff --git a/src/Infrastructure.Dapper/KeyManagement/Repositories/UserAsymmetricKeysRepository.cs b/src/Infrastructure.Dapper/KeyManagement/Repositories/UserAsymmetricKeysRepository.cs index f176327f4ff8..d9d64ce94602 100644 --- a/src/Infrastructure.Dapper/KeyManagement/Repositories/UserAsymmetricKeysRepository.cs +++ b/src/Infrastructure.Dapper/KeyManagement/Repositories/UserAsymmetricKeysRepository.cs @@ -2,6 +2,7 @@ using System.Data; using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.Repositories; +using Bit.Core.Repositories; using Bit.Core.Settings; using Bit.Infrastructure.Dapper.Repositories; using Dapper; @@ -21,16 +22,33 @@ public UserAsymmetricKeysRepository(string connectionString, string readOnlyConn { } - public async Task RegenerateUserAsymmetricKeysAsync(UserAsymmetricKeys userAsymmetricKeys) + public async Task RegenerateUserAsymmetricKeysAsync(UserAsymmetricKeys userAsymmetricKeys, + IEnumerable updateDataActions) { await using var connection = new SqlConnection(ConnectionString); + await connection.OpenAsync(); + await using var transaction = connection.BeginTransaction(); + try + { + await connection.ExecuteAsync("[dbo].[UserAsymmetricKeys_Regenerate]", + new + { + userAsymmetricKeys.UserId, + userAsymmetricKeys.PublicKey, + PrivateKey = userAsymmetricKeys.UserKeyEncryptedPrivateKey + }, transaction: transaction, commandType: CommandType.StoredProcedure); - await connection.ExecuteAsync("[dbo].[UserAsymmetricKeys_Regenerate]", - new + foreach (var action in updateDataActions) { - userAsymmetricKeys.UserId, - userAsymmetricKeys.PublicKey, - PrivateKey = userAsymmetricKeys.UserKeyEncryptedPrivateKey - }, commandType: CommandType.StoredProcedure); + await action(connection, transaction); + } + + await transaction.CommitAsync(); + } + catch + { + await transaction.RollbackAsync(); + throw; + } } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs index cd13a4eb1319..9e5b95294e4e 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -181,80 +181,7 @@ public async Task DeleteManyAsync(IEnumerable organizationUserIds) try { - await dbContext.UserBumpAccountRevisionDateByOrganizationUserIdsAsync(targetOrganizationUserIds); - - var organizationUsersToDelete = await dbContext.OrganizationUsers - .Where(ou => targetOrganizationUserIds.Contains(ou.Id)) - .Include(ou => ou.User) - .ToListAsync(); - - var collectionUsers = await dbContext.CollectionUsers - .Where(cu => targetOrganizationUserIds.Contains(cu.OrganizationUserId)) - .ToListAsync(); - - var collectionIds = collectionUsers.Select(cu => cu.CollectionId).Distinct().ToList(); - - var collections = await dbContext.Collections - .Where(c => collectionIds.Contains(c.Id)) - .ToListAsync(); - - var collectionsToUpdate = collections - .Where(c => c.Type == CollectionType.DefaultUserCollection) - .ToList(); - - var collectionUserLookup = collectionUsers.ToLookup(cu => cu.CollectionId); - - foreach (var collection in collectionsToUpdate) - { - var collectionUser = collectionUserLookup[collection.Id].FirstOrDefault(); - if (collectionUser != null) - { - var orgUser = organizationUsersToDelete.FirstOrDefault(ou => ou.Id == collectionUser.OrganizationUserId); - - if (orgUser?.User != null) - { - if (string.IsNullOrEmpty(collection.DefaultUserCollectionEmail)) - { - var emailToUse = !string.IsNullOrEmpty(orgUser.Email) - ? orgUser.Email - : orgUser.User.Email; - - if (!string.IsNullOrEmpty(emailToUse)) - { - collection.DefaultUserCollectionEmail = emailToUse; - } - } - collection.Type = CollectionType.SharedCollection; - } - } - } - - await dbContext.CollectionUsers - .Where(cu => targetOrganizationUserIds.Contains(cu.OrganizationUserId)) - .ExecuteDeleteAsync(); - - await dbContext.GroupUsers - .Where(gu => targetOrganizationUserIds.Contains(gu.OrganizationUserId)) - .ExecuteDeleteAsync(); - - await dbContext.UserProjectAccessPolicy - .Where(ap => targetOrganizationUserIds.Contains(ap.OrganizationUserId!.Value)) - .ExecuteDeleteAsync(); - - await dbContext.UserServiceAccountAccessPolicy - .Where(ap => targetOrganizationUserIds.Contains(ap.OrganizationUserId!.Value)) - .ExecuteDeleteAsync(); - - await dbContext.UserSecretAccessPolicy - .Where(ap => targetOrganizationUserIds.Contains(ap.OrganizationUserId!.Value)) - .ExecuteDeleteAsync(); - - await dbContext.OrganizationSponsorships - .Where(os => targetOrganizationUserIds.Contains(os.SponsoringOrganizationUserId)) - .ExecuteDeleteAsync(); - - await dbContext.OrganizationUsers - .Where(ou => targetOrganizationUserIds.Contains(ou.Id)).ExecuteDeleteAsync(); + await DeleteManyOrganizationUsersAndRelatedDataAsync(dbContext, targetOrganizationUserIds); await dbContext.SaveChangesAsync(); await transaction.CommitAsync(); @@ -1102,6 +1029,123 @@ public Func BuildConfirmOwnerAction(Core.Enti return Mapper.Map>(await query.ToListAsync()); } } + + /// + public DatabaseTransactionAction UpdateStatusAndKeyById(Guid id, + OrganizationUserStatusType status, string? key, DateTime revisionDate) + { + return async (connection, transaction) => + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetTransactionalDatabaseContext(scope, connection, transaction); + + await dbContext.OrganizationUsers + .Where(ou => ou.Id == id) + .ExecuteUpdateAsync(setters => setters + .SetProperty(ou => ou.Status, status) + .SetProperty(ou => ou.Key, key) + .SetProperty(ou => ou.RevisionDate, revisionDate)); + }; + } + + /// + public DatabaseTransactionAction DeleteManyByIds(IEnumerable ids) + { + return async (connection, transaction) => + { + var idsList = ids.ToList(); + if (idsList.Count == 0) + { + return; + } + + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetTransactionalDatabaseContext(scope, connection, transaction); + + await DeleteManyOrganizationUsersAndRelatedDataAsync(dbContext, idsList); + }; + } + + private static async Task DeleteManyOrganizationUsersAndRelatedDataAsync( + DatabaseContext dbContext, List organizationUserIds) + { + await dbContext.UserBumpAccountRevisionDateByOrganizationUserIdsAsync(organizationUserIds); + + var organizationUsersToDelete = await dbContext.OrganizationUsers + .Where(ou => organizationUserIds.Contains(ou.Id)) + .Include(ou => ou.User) + .ToListAsync(); + + var collectionUsers = await dbContext.CollectionUsers + .Where(cu => organizationUserIds.Contains(cu.OrganizationUserId)) + .ToListAsync(); + + var collectionIds = collectionUsers.Select(cu => cu.CollectionId).Distinct().ToList(); + + var collections = await dbContext.Collections + .Where(c => collectionIds.Contains(c.Id)) + .ToListAsync(); + + var collectionsToUpdate = collections + .Where(c => c.Type == CollectionType.DefaultUserCollection) + .ToList(); + + var collectionUserLookup = collectionUsers.ToLookup(cu => cu.CollectionId); + + foreach (var collection in collectionsToUpdate) + { + var collectionUser = collectionUserLookup[collection.Id].FirstOrDefault(); + if (collectionUser != null) + { + var orgUser = organizationUsersToDelete.FirstOrDefault(ou => ou.Id == collectionUser.OrganizationUserId); + + if (orgUser?.User != null) + { + if (string.IsNullOrEmpty(collection.DefaultUserCollectionEmail)) + { + var emailToUse = !string.IsNullOrEmpty(orgUser.Email) + ? orgUser.Email + : orgUser.User.Email; + + if (!string.IsNullOrEmpty(emailToUse)) + { + collection.DefaultUserCollectionEmail = emailToUse; + } + } + collection.Type = CollectionType.SharedCollection; + } + } + } + + await dbContext.CollectionUsers + .Where(cu => organizationUserIds.Contains(cu.OrganizationUserId)) + .ExecuteDeleteAsync(); + + await dbContext.GroupUsers + .Where(gu => organizationUserIds.Contains(gu.OrganizationUserId)) + .ExecuteDeleteAsync(); + + await dbContext.UserProjectAccessPolicy + .Where(ap => organizationUserIds.Contains(ap.OrganizationUserId!.Value)) + .ExecuteDeleteAsync(); + + await dbContext.UserServiceAccountAccessPolicy + .Where(ap => organizationUserIds.Contains(ap.OrganizationUserId!.Value)) + .ExecuteDeleteAsync(); + + await dbContext.UserSecretAccessPolicy + .Where(ap => organizationUserIds.Contains(ap.OrganizationUserId!.Value)) + .ExecuteDeleteAsync(); + + await dbContext.OrganizationSponsorships + .Where(os => organizationUserIds.Contains(os.SponsoringOrganizationUserId)) + .ExecuteDeleteAsync(); + + await dbContext.OrganizationUsers + .Where(ou => organizationUserIds.Contains(ou.Id)) + .ExecuteDeleteAsync(); + } + #nullable disable diff --git a/src/Infrastructure.EntityFramework/Auth/Repositories/EmergencyAccessRepository.cs b/src/Infrastructure.EntityFramework/Auth/Repositories/EmergencyAccessRepository.cs index ed811e9e0983..68ccf0808194 100644 --- a/src/Infrastructure.EntityFramework/Auth/Repositories/EmergencyAccessRepository.cs +++ b/src/Infrastructure.EntityFramework/Auth/Repositories/EmergencyAccessRepository.cs @@ -29,7 +29,7 @@ public override async Task DeleteAsync(Core.Auth.Entities.EmergencyAccess emerge using (var scope = ServiceScopeFactory.CreateScope()) { var dbContext = GetDatabaseContext(scope); - // TODO: in future, this probably is not necessary as we have no synced EA data. + // TODO: in future, this probably is not necessary as we have no synced EA data. // if we delete from here, also delete from stored proc as well + update repo tests. await dbContext.UserBumpAccountRevisionDateByEmergencyAccessGranteeIdAsync(emergencyAccess.Id); await dbContext.SaveChangesAsync(); @@ -177,6 +177,24 @@ public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation( }; } + /// + public DatabaseTransactionAction UpdateStatusAndKeyEncryptedById(Guid id, + EmergencyAccessStatusType status, string? keyEncrypted, DateTime revisionDate) + { + return async (connection, transaction) => + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetTransactionalDatabaseContext(scope, connection, transaction); + + await GetDbSet(dbContext) + .Where(ea => ea.Id == id) + .ExecuteUpdateAsync(setters => setters + .SetProperty(ea => ea.Status, status) + .SetProperty(ea => ea.KeyEncrypted, keyEncrypted) + .SetProperty(ea => ea.RevisionDate, revisionDate)); + }; + } + /// public async Task DeleteManyAsync(ICollection emergencyAccessIds) { diff --git a/src/Infrastructure.EntityFramework/KeyManagement/Repositories/UserAsymmetricKeysRepository.cs b/src/Infrastructure.EntityFramework/KeyManagement/Repositories/UserAsymmetricKeysRepository.cs index c680424f56a2..c6e6b4e5aa0b 100644 --- a/src/Infrastructure.EntityFramework/KeyManagement/Repositories/UserAsymmetricKeysRepository.cs +++ b/src/Infrastructure.EntityFramework/KeyManagement/Repositories/UserAsymmetricKeysRepository.cs @@ -2,7 +2,9 @@ using AutoMapper; using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.Repositories; +using Bit.Core.Repositories; using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace Bit.Infrastructure.EntityFramework.KeyManagement.Repositories; @@ -15,11 +17,17 @@ public UserAsymmetricKeysRepository(IServiceScopeFactory serviceScopeFactory, IM { } - public async Task RegenerateUserAsymmetricKeysAsync(UserAsymmetricKeys userAsymmetricKeys) + public async Task RegenerateUserAsymmetricKeysAsync(UserAsymmetricKeys userAsymmetricKeys, + IEnumerable updateDataActions) { await using var scope = ServiceScopeFactory.CreateAsyncScope(); var dbContext = GetDatabaseContext(scope); + var connection = dbContext.Database.GetDbConnection(); + await connection.OpenAsync(); + await using var transaction = await connection.BeginTransactionAsync(); + await dbContext.Database.UseTransactionAsync(transaction); + var entity = await dbContext.Users.FindAsync(userAsymmetricKeys.UserId); if (entity != null) { @@ -30,5 +38,12 @@ public async Task RegenerateUserAsymmetricKeysAsync(UserAsymmetricKeys userAsymm entity.AccountRevisionDate = utcNow; await dbContext.SaveChangesAsync(); } + + foreach (var action in updateDataActions) + { + await action(connection, transaction); + } + + await transaction.CommitAsync(); } } diff --git a/src/Infrastructure.EntityFramework/Repositories/BaseEntityFrameworkRepository.cs b/src/Infrastructure.EntityFramework/Repositories/BaseEntityFrameworkRepository.cs index 6cf7cbb46efc..c98c8ff085f1 100644 --- a/src/Infrastructure.EntityFramework/Repositories/BaseEntityFrameworkRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/BaseEntityFrameworkRepository.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System.Data.Common; +using System.Text.Json; using AutoMapper; using Bit.Infrastructure.EntityFramework.AdminConsole.Models; using Bit.Infrastructure.EntityFramework.Repositories.Queries; @@ -31,6 +32,15 @@ public DatabaseContext GetDatabaseContext(IServiceScope serviceScope) return serviceScope.ServiceProvider.GetRequiredService(); } + protected DatabaseContext GetTransactionalDatabaseContext( + IServiceScope scope, DbConnection connection, DbTransaction transaction) + { + var dbContext = GetDatabaseContext(scope); + dbContext.Database.SetDbConnection(connection); + dbContext.Database.UseTransaction(transaction); + return dbContext; + } + public void ClearChangeTracking() { using (var scope = ServiceScopeFactory.CreateScope()) diff --git a/src/Sql/dbo/Auth/Stored Procedures/EmergencyAccess_UpdateStatusKeyEncryptedById.sql b/src/Sql/dbo/Auth/Stored Procedures/EmergencyAccess_UpdateStatusKeyEncryptedById.sql new file mode 100644 index 000000000000..9f9247bb46c8 --- /dev/null +++ b/src/Sql/dbo/Auth/Stored Procedures/EmergencyAccess_UpdateStatusKeyEncryptedById.sql @@ -0,0 +1,19 @@ +CREATE PROCEDURE [dbo].[EmergencyAccess_UpdateStatusKeyEncryptedById] + @Id UNIQUEIDENTIFIER, + @Status TINYINT, + @KeyEncrypted VARCHAR(MAX), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[EmergencyAccess] + SET + [Status] = @Status, + [KeyEncrypted] = @KeyEncrypted, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id +END +GO diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateStatusKeyById.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateStatusKeyById.sql new file mode 100644 index 000000000000..78ef1b9ebf57 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateStatusKeyById.sql @@ -0,0 +1,19 @@ +CREATE PROCEDURE [dbo].[OrganizationUser_UpdateStatusKeyById] + @Id UNIQUEIDENTIFIER, + @Status SMALLINT, + @Key VARCHAR(MAX), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[OrganizationUser] + SET + [Status] = @Status, + [Key] = @Key, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id +END +GO diff --git a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs index 20c56f7c5e44..f92ccd58e78f 100644 --- a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -93,38 +93,96 @@ public async Task RegenerateKeysAsync_NotLoggedIn_Unauthorized(KeyRegenerationRe } [Theory] - [BitAutoData(OrganizationUserStatusType.Confirmed, EmergencyAccessStatusType.Confirmed)] - [BitAutoData(OrganizationUserStatusType.Confirmed, EmergencyAccessStatusType.RecoveryApproved)] - [BitAutoData(OrganizationUserStatusType.Confirmed, EmergencyAccessStatusType.RecoveryInitiated)] - [BitAutoData(OrganizationUserStatusType.Revoked, EmergencyAccessStatusType.Confirmed)] - [BitAutoData(OrganizationUserStatusType.Revoked, EmergencyAccessStatusType.RecoveryApproved)] - [BitAutoData(OrganizationUserStatusType.Revoked, EmergencyAccessStatusType.RecoveryInitiated)] - [BitAutoData(OrganizationUserStatusType.Confirmed, null)] - [BitAutoData(OrganizationUserStatusType.Revoked, null)] - [BitAutoData(OrganizationUserStatusType.Invited, EmergencyAccessStatusType.Confirmed)] - [BitAutoData(OrganizationUserStatusType.Invited, EmergencyAccessStatusType.RecoveryApproved)] - [BitAutoData(OrganizationUserStatusType.Invited, EmergencyAccessStatusType.RecoveryInitiated)] - public async Task RegenerateKeysAsync_UserInOrgOrHasDesignatedEmergencyAccess_ThrowsBadRequest( - OrganizationUserStatusType organizationUserStatus, - EmergencyAccessStatusType? emergencyAccessStatus, + [BitAutoData] + public async Task RegenerateKeysAsync_WithConfirmedOrgUser_TransitionsToAccepted( KeyRegenerationRequestModel request) { - if (organizationUserStatus is OrganizationUserStatusType.Confirmed or OrganizationUserStatusType.Revoked) - { - await CreateOrganizationUserAsync(organizationUserStatus); - } + var orgUser = await CreateOrganizationUserAsync(OrganizationUserStatusType.Confirmed); + await _loginHelper.LoginAsync(_ownerEmail); + request.UserKeyEncryptedUserPrivateKey = _mockEncryptedString; - if (emergencyAccessStatus != null) - { - await CreateDesignatedEmergencyAccessAsync(emergencyAccessStatus.Value); - } + var response = await _client.PostAsJsonAsync("/accounts/key-management/regenerate-keys", request); + response.EnsureSuccessStatusCode(); + + var updatedOrgUser = await _organizationUserRepository.GetByIdAsync(orgUser.Id); + Assert.NotNull(updatedOrgUser); + Assert.Equal(OrganizationUserStatusType.Accepted, updatedOrgUser.Status); + Assert.Null(updatedOrgUser.Key); + } + [Theory] + [BitAutoData] + public async Task RegenerateKeysAsync_WithRevokedOrgUser_DeletesOrgUser( + KeyRegenerationRequestModel request) + { + var orgUser = await CreateOrganizationUserAsync(OrganizationUserStatusType.Revoked); await _loginHelper.LoginAsync(_ownerEmail); request.UserKeyEncryptedUserPrivateKey = _mockEncryptedString; var response = await _client.PostAsJsonAsync("/accounts/key-management/regenerate-keys", request); + response.EnsureSuccessStatusCode(); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var deletedOrgUser = await _organizationUserRepository.GetByIdAsync(orgUser.Id); + Assert.Null(deletedOrgUser); + } + + [Theory] + [BitAutoData(OrganizationUserStatusType.Invited)] + [BitAutoData(OrganizationUserStatusType.Accepted)] + public async Task RegenerateKeysAsync_WithInvitedOrAcceptedOrgUser_NoChange( + OrganizationUserStatusType orgUserStatus, + KeyRegenerationRequestModel request) + { + var orgUser = await CreateOrganizationUserAsync(orgUserStatus); + await _loginHelper.LoginAsync(_ownerEmail); + request.UserKeyEncryptedUserPrivateKey = _mockEncryptedString; + + var response = await _client.PostAsJsonAsync("/accounts/key-management/regenerate-keys", request); + response.EnsureSuccessStatusCode(); + + var updatedOrgUser = await _organizationUserRepository.GetByIdAsync(orgUser.Id); + Assert.NotNull(updatedOrgUser); + Assert.Equal(orgUserStatus, updatedOrgUser.Status); + } + + [Theory] + [BitAutoData(EmergencyAccessStatusType.Confirmed)] + [BitAutoData(EmergencyAccessStatusType.RecoveryInitiated)] + [BitAutoData(EmergencyAccessStatusType.RecoveryApproved)] + public async Task RegenerateKeysAsync_WithDesignatedEmergencyAccess_TransitionsToAccepted( + EmergencyAccessStatusType emergencyAccessStatus, + KeyRegenerationRequestModel request) + { + var ea = await CreateDesignatedEmergencyAccessAsync(emergencyAccessStatus); + await _loginHelper.LoginAsync(_ownerEmail); + request.UserKeyEncryptedUserPrivateKey = _mockEncryptedString; + + var response = await _client.PostAsJsonAsync("/accounts/key-management/regenerate-keys", request); + response.EnsureSuccessStatusCode(); + + var updatedEa = await _emergencyAccessRepository.GetByIdAsync(ea.Id); + Assert.NotNull(updatedEa); + Assert.Equal(EmergencyAccessStatusType.Accepted, updatedEa.Status); + Assert.Null(updatedEa.KeyEncrypted); + } + + [Theory] + [BitAutoData(EmergencyAccessStatusType.Invited)] + [BitAutoData(EmergencyAccessStatusType.Accepted)] + public async Task RegenerateKeysAsync_WithInvitedOrAcceptedEmergencyAccess_NoChange( + EmergencyAccessStatusType emergencyAccessStatus, + KeyRegenerationRequestModel request) + { + var ea = await CreateDesignatedEmergencyAccessAsync(emergencyAccessStatus); + await _loginHelper.LoginAsync(_ownerEmail); + request.UserKeyEncryptedUserPrivateKey = _mockEncryptedString; + + var response = await _client.PostAsJsonAsync("/accounts/key-management/regenerate-keys", request); + response.EnsureSuccessStatusCode(); + + var updatedEa = await _emergencyAccessRepository.GetByIdAsync(ea.Id); + Assert.NotNull(updatedEa); + Assert.Equal(emergencyAccessStatus, updatedEa.Status); } [Theory] @@ -143,16 +201,17 @@ public async Task RegenerateKeysAsync_Success(KeyRegenerationRequestModel reques Assert.Equal(request.UserKeyEncryptedUserPrivateKey, user.PrivateKey); } - private async Task CreateOrganizationUserAsync(OrganizationUserStatusType organizationUserStatus) + private async Task CreateOrganizationUserAsync(OrganizationUserStatusType organizationUserStatus) { var (_, organizationUser) = await OrganizationTestHelpers.SignUpAsync(_factory, PlanType.EnterpriseAnnually, _ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card); organizationUser.Status = organizationUserStatus; await _organizationUserRepository.ReplaceAsync(organizationUser); + return organizationUser; } - private async Task CreateDesignatedEmergencyAccessAsync(EmergencyAccessStatusType emergencyAccessStatus) + private async Task CreateDesignatedEmergencyAccessAsync(EmergencyAccessStatusType emergencyAccessStatus) { var tempEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; await _factory.LoginWithNewAccount(tempEmail); @@ -171,6 +230,7 @@ private async Task CreateDesignatedEmergencyAccessAsync(EmergencyAccessStatusTyp RevisionDate = DateTime.UtcNow }; await _emergencyAccessRepository.CreateAsync(emergencyAccess); + return emergencyAccess; } [Theory] diff --git a/test/Core.Test/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommandTests.cs b/test/Core.Test/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommandTests.cs index ba40198ef682..d78d6ba37cbb 100644 --- a/test/Core.Test/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommandTests.cs +++ b/test/Core.Test/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommandTests.cs @@ -9,6 +9,8 @@ using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.Repositories; using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -36,7 +38,7 @@ await Assert.ThrowsAsync(() => sutProvider.Sut.RegenerateKeys [Theory] [BitAutoData] - public async Task RegenerateKeysAsync_UserHasNoSharedAccess_Success( + public async Task RegenerateKeysAsync_NoOrgMembershipOrEmergencyAccess_RegeneratesKeysWithNoStatusChanges( SutProvider sutProvider, UserAsymmetricKeys userAsymmetricKeys) { @@ -49,10 +51,23 @@ await sutProvider.Sut.RegenerateKeysAsync(userAsymmetricKeys, await sutProvider.GetDependency() .Received(1) - .RegenerateUserAsymmetricKeysAsync(Arg.Is(userAsymmetricKeys)); + .RegenerateUserAsymmetricKeysAsync( + Arg.Is(userAsymmetricKeys), + Arg.Is>(actions => !actions.Any())); await sutProvider.GetDependency() .Received(1) .PushSyncSettingsAsync(Arg.Is(userAsymmetricKeys.UserId)); + sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateStatusAndKeyEncryptedById(Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateStatusAndKeyById(Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteManyByIds(Arg.Any>()); } [Theory] @@ -91,42 +106,68 @@ await Assert.ThrowsAsync(() => sutProvider.Sut.RegenerateKeys await sutProvider.GetDependency() .ReceivedWithAnyArgs(0) - .RegenerateUserAsymmetricKeysAsync(Arg.Any()); + .RegenerateUserAsymmetricKeysAsync(Arg.Any(), + Arg.Any>()); await sutProvider.GetDependency() .ReceivedWithAnyArgs(0) .PushSyncSettingsAsync(Arg.Any()); } [Theory] - [BitAutoData(OrganizationUserStatusType.Confirmed)] - [BitAutoData(OrganizationUserStatusType.Revoked)] - public async Task RegenerateKeysAsync_UserInOrganizations_BadRequestException( - OrganizationUserStatusType organizationUserStatus, + [BitAutoData(EmergencyAccessStatusType.Confirmed)] + [BitAutoData(EmergencyAccessStatusType.RecoveryInitiated)] + [BitAutoData(EmergencyAccessStatusType.RecoveryApproved)] + public async Task RegenerateKeysAsync_EmergencyAccessNeedsReset_TransitionsToAccepted( + EmergencyAccessStatusType statusType, SutProvider sutProvider, UserAsymmetricKeys userAsymmetricKeys, - ICollection usersOrganizationAccounts) + ICollection designatedEmergencyAccess) { sutProvider.GetDependency().UserId.ReturnsForAnyArgs(userAsymmetricKeys.UserId); - usersOrganizationAccounts = CreateInOrganizationAccounts(userAsymmetricKeys.UserId, organizationUserStatus, - usersOrganizationAccounts); - var designatedEmergencyAccess = new List(); + designatedEmergencyAccess = + CreateDesignatedEmergencyAccess(userAsymmetricKeys.UserId, statusType, designatedEmergencyAccess); + var usersOrganizationAccounts = new List(); - await Assert.ThrowsAsync(() => sutProvider.Sut.RegenerateKeysAsync(userAsymmetricKeys, - usersOrganizationAccounts, designatedEmergencyAccess)); + var beforeRevision = DateTime.UtcNow; + await sutProvider.Sut.RegenerateKeysAsync(userAsymmetricKeys, + usersOrganizationAccounts, designatedEmergencyAccess); + var afterRevision = DateTime.UtcNow; + foreach (var ea in designatedEmergencyAccess) + { + sutProvider.GetDependency() + .Received(1) + .UpdateStatusAndKeyEncryptedById(Arg.Is(ea.Id), Arg.Is(EmergencyAccessStatusType.Accepted), + Arg.Is(key => key == null), + Arg.Is(date => date >= beforeRevision && date <= afterRevision)); + } + sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateStatusAndKeyById(Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteManyByIds(Arg.Any>()); await sutProvider.GetDependency() - .ReceivedWithAnyArgs(0) - .RegenerateUserAsymmetricKeysAsync(Arg.Any()); + .Received(1) + .RegenerateUserAsymmetricKeysAsync( + Arg.Is(userAsymmetricKeys), + Arg.Is>(actions => actions.Count() == designatedEmergencyAccess.Count)); await sutProvider.GetDependency() - .ReceivedWithAnyArgs(0) - .PushSyncSettingsAsync(Arg.Any()); + .Received(1) + .PushSyncSettingsAsync(Arg.Is(userAsymmetricKeys.UserId)); + foreach (var ea in designatedEmergencyAccess) + { + await sutProvider.GetDependency() + .Received(1) + .SendEmergencyAccessAcceptedEmailAsync(ea.GranteeEmail!, ea.GrantorEmail!); + } } [Theory] - [BitAutoData(EmergencyAccessStatusType.Confirmed)] - [BitAutoData(EmergencyAccessStatusType.RecoveryApproved)] - [BitAutoData(EmergencyAccessStatusType.RecoveryInitiated)] - public async Task RegenerateKeysAsync_UserHasDesignatedEmergencyAccess_BadRequestException( + [BitAutoData(EmergencyAccessStatusType.Invited)] + [BitAutoData(EmergencyAccessStatusType.Accepted)] + public async Task RegenerateKeysAsync_EmergencyAccessNoResetNeeded_NoChange( EmergencyAccessStatusType statusType, SutProvider sutProvider, UserAsymmetricKeys userAsymmetricKeys, @@ -137,16 +178,139 @@ public async Task RegenerateKeysAsync_UserHasDesignatedEmergencyAccess_BadReques CreateDesignatedEmergencyAccess(userAsymmetricKeys.UserId, statusType, designatedEmergencyAccess); var usersOrganizationAccounts = new List(); + await sutProvider.Sut.RegenerateKeysAsync(userAsymmetricKeys, + usersOrganizationAccounts, designatedEmergencyAccess); - await Assert.ThrowsAsync(() => sutProvider.Sut.RegenerateKeysAsync(userAsymmetricKeys, - usersOrganizationAccounts, designatedEmergencyAccess)); + sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateStatusAndKeyEncryptedById(Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateStatusAndKeyById(Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteManyByIds(Arg.Any>()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SendEmergencyAccessAcceptedEmailAsync(Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task RegenerateKeysAsync_OrgUserConfirmed_TransitionsToAccepted( + SutProvider sutProvider, + UserAsymmetricKeys userAsymmetricKeys, + ICollection usersOrganizationAccounts) + { + sutProvider.GetDependency().UserId.ReturnsForAnyArgs(userAsymmetricKeys.UserId); + usersOrganizationAccounts = CreateInOrganizationAccounts(userAsymmetricKeys.UserId, + OrganizationUserStatusType.Confirmed, usersOrganizationAccounts); + var designatedEmergencyAccess = new List(); + + var beforeRevision = DateTime.UtcNow; + await sutProvider.Sut.RegenerateKeysAsync(userAsymmetricKeys, + usersOrganizationAccounts, designatedEmergencyAccess); + var afterRevision = DateTime.UtcNow; + sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateStatusAndKeyEncryptedById(Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + foreach (var orgUser in usersOrganizationAccounts) + { + sutProvider.GetDependency() + .Received(1) + .UpdateStatusAndKeyById(Arg.Is(orgUser.Id), Arg.Is(OrganizationUserStatusType.Accepted), + Arg.Is(key => key == null), + Arg.Is(date => date >= beforeRevision && date <= afterRevision)); + } + sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteManyByIds(Arg.Any>()); await sutProvider.GetDependency() - .ReceivedWithAnyArgs(0) - .RegenerateUserAsymmetricKeysAsync(Arg.Any()); + .Received(1) + .RegenerateUserAsymmetricKeysAsync( + Arg.Is(userAsymmetricKeys), + Arg.Is>(actions => actions.Count() == usersOrganizationAccounts.Count)); await sutProvider.GetDependency() - .ReceivedWithAnyArgs(0) - .PushSyncSettingsAsync(Arg.Any()); + .Received(1) + .PushSyncSettingsAsync(Arg.Is(userAsymmetricKeys.UserId)); + } + + [Theory] + [BitAutoData] + public async Task RegenerateKeysAsync_OrgUserRevoked_DeletedAndEventLogged( + SutProvider sutProvider, + UserAsymmetricKeys userAsymmetricKeys, + ICollection usersOrganizationAccounts) + { + sutProvider.GetDependency().UserId.ReturnsForAnyArgs(userAsymmetricKeys.UserId); + usersOrganizationAccounts = CreateInOrganizationAccounts(userAsymmetricKeys.UserId, + OrganizationUserStatusType.Revoked, usersOrganizationAccounts); + var designatedEmergencyAccess = new List(); + + await sutProvider.Sut.RegenerateKeysAsync(userAsymmetricKeys, + usersOrganizationAccounts, designatedEmergencyAccess); + + sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateStatusAndKeyEncryptedById(Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateStatusAndKeyById(Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + sutProvider.GetDependency() + .Received(1) + .DeleteManyByIds(Arg.Is>(ids => + ids.OrderBy(id => id).SequenceEqual( + usersOrganizationAccounts.Select(ou => ou.Id).OrderBy(id => id)))); + await sutProvider.GetDependency() + .Received(1) + .RegenerateUserAsymmetricKeysAsync( + Arg.Is(userAsymmetricKeys), + Arg.Is>(actions => actions.Count() == 1)); + await sutProvider.GetDependency() + .Received(1) + .PushSyncSettingsAsync(Arg.Is(userAsymmetricKeys.UserId)); + await sutProvider.GetDependency() + .Received(usersOrganizationAccounts.Count) + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Is(EventType.OrganizationUser_Left)); + } + + [Theory] + [BitAutoData(OrganizationUserStatusType.Invited)] + [BitAutoData(OrganizationUserStatusType.Accepted)] + public async Task RegenerateKeysAsync_OrgUserNoResetNeeded_NoChange( + OrganizationUserStatusType organizationUserStatus, + SutProvider sutProvider, + UserAsymmetricKeys userAsymmetricKeys, + ICollection usersOrganizationAccounts) + { + sutProvider.GetDependency().UserId.ReturnsForAnyArgs(userAsymmetricKeys.UserId); + usersOrganizationAccounts = CreateInOrganizationAccounts(userAsymmetricKeys.UserId, + organizationUserStatus, usersOrganizationAccounts); + var designatedEmergencyAccess = new List(); + + await sutProvider.Sut.RegenerateKeysAsync(userAsymmetricKeys, + usersOrganizationAccounts, designatedEmergencyAccess); + + sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateStatusAndKeyEncryptedById(Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateStatusAndKeyById(Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteManyByIds(Arg.Any>()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any()); } private static ICollection CreateInOrganizationAccounts(Guid userId, diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs index f843093a0ccb..97934029ee5c 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs @@ -1678,4 +1678,46 @@ public async Task UpdateGroupsAsync_BumpsGroupRevisionDate( Assert.Equal(expectedRevisionDate, actualGroup1.RevisionDate, TimeSpan.FromMilliseconds(10)); Assert.Equal(expectedRevisionDate, actualGroup2.RevisionDate, TimeSpan.FromMilliseconds(10)); } + + [Theory, DatabaseData] + public async Task UpdateStatusAndKeyById_ConfirmedUser_SetsStatusAndClearsKey( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + Database database, + IServiceProvider serviceProvider) + { + var user = await userRepository.CreateTestUserAsync(); + var org = await organizationRepository.CreateTestOrganizationAsync(); + var orgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(org, user); + orgUser.Key = "old-org-key"; + await organizationUserRepository.ReplaceAsync(orgUser); + + var action = organizationUserRepository.UpdateStatusAndKeyById( + orgUser.Id, OrganizationUserStatusType.Accepted, null, DateTime.UtcNow); + await DatabaseTransactionActionTestHelper.ExecuteAsync(database, action, serviceProvider); + + var updatedOrgUser = await organizationUserRepository.GetByIdAsync(orgUser.Id); + Assert.NotNull(updatedOrgUser); + Assert.Equal(OrganizationUserStatusType.Accepted, updatedOrgUser.Status); + Assert.Null(updatedOrgUser.Key); + } + + [Theory, DatabaseData] + public async Task DeleteManyByIds_RevokedUser_DeletesUser( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + Database database, + IServiceProvider serviceProvider) + { + var user = await userRepository.CreateTestUserAsync(); + var org = await organizationRepository.CreateTestOrganizationAsync(); + var orgUser = await organizationUserRepository.CreateRevokedTestOrganizationUserAsync(org, user); + + var action = organizationUserRepository.DeleteManyByIds([orgUser.Id]); + await DatabaseTransactionActionTestHelper.ExecuteAsync(database, action, serviceProvider); + + Assert.Null(await organizationUserRepository.GetByIdAsync(orgUser.Id)); + } } diff --git a/test/Infrastructure.IntegrationTest/Auth/Repositories/EmergencyAccessRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Auth/Repositories/EmergencyAccessRepositoryTests.cs index 1015ef052021..9935ac36c9c3 100644 --- a/test/Infrastructure.IntegrationTest/Auth/Repositories/EmergencyAccessRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Auth/Repositories/EmergencyAccessRepositoryTests.cs @@ -700,4 +700,65 @@ public async Task GetManyToNotifyAsync_ExcludesRecords_WithNullLastNotificationD // Assert Assert.DoesNotContain(results, r => r.Id == ea.Id); } + + [Theory, DatabaseData] + public async Task UpdateStatusAndKeyEncryptedById_AllApplicableStatuses_SetsStatusAndClearsKey( + IUserRepository userRepository, + IEmergencyAccessRepository emergencyAccessRepository, + Database database, + IServiceProvider serviceProvider) + { + var grantorUser = await userRepository.CreateAsync(new User + { + Name = "Test Grantor", + Email = $"test+grantor{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var granteeUser = await userRepository.CreateAsync(new User + { + Name = "Test Grantee", + Email = $"test+grantee{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var statuses = new[] + { + EmergencyAccessStatusType.Confirmed, + EmergencyAccessStatusType.RecoveryInitiated, + EmergencyAccessStatusType.RecoveryApproved, + }; + + var emergencyAccesses = new List(); + foreach (var status in statuses) + { + emergencyAccesses.Add(await emergencyAccessRepository.CreateAsync(new EmergencyAccess + { + GrantorId = grantorUser.Id, + GranteeId = granteeUser.Id, + KeyEncrypted = "old-encrypted-key", + Status = status, + Type = EmergencyAccessType.View, + WaitTimeDays = 10, + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow, + })); + } + + var actions = emergencyAccesses + .Select(ea => emergencyAccessRepository.UpdateStatusAndKeyEncryptedById( + ea.Id, EmergencyAccessStatusType.Accepted, null, DateTime.UtcNow)) + .ToList(); + await DatabaseTransactionActionTestHelper.ExecuteAsync(database, actions, serviceProvider); + + foreach (var ea in emergencyAccesses) + { + var updated = await emergencyAccessRepository.GetByIdAsync(ea.Id); + Assert.NotNull(updated); + Assert.Equal(EmergencyAccessStatusType.Accepted, updated.Status); + Assert.Null(updated.KeyEncrypted); + } + } } diff --git a/test/Infrastructure.IntegrationTest/DatabaseTransactionActionTestHelper.cs b/test/Infrastructure.IntegrationTest/DatabaseTransactionActionTestHelper.cs new file mode 100644 index 000000000000..7a308a100437 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/DatabaseTransactionActionTestHelper.cs @@ -0,0 +1,48 @@ +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Infrastructure.IntegrationTest; + +/// +/// Executes delegates in integration tests. +/// Opens a connection and transaction appropriate for the database provider, executes the actions, and commits. +/// +public static class DatabaseTransactionActionTestHelper +{ + public static Task ExecuteAsync(Database database, DatabaseTransactionAction action, + IServiceProvider serviceProvider) + => ExecuteAsync(database, [action], serviceProvider); + + public static async Task ExecuteAsync(Database database, IEnumerable actions, + IServiceProvider serviceProvider) + { + var isDapper = database.Type == SupportedDatabaseProviders.SqlServer && !database.UseEf; + var connection = isDapper + ? new SqlConnection(database.ConnectionString) + : serviceProvider.GetRequiredService().Database.GetDbConnection(); + + try + { + await connection.OpenAsync(); + await using var transaction = await connection.BeginTransactionAsync(); + + foreach (var action in actions) + { + await action(connection, transaction); + } + + await transaction.CommitAsync(); + } + finally + { + if (isDapper) + { + await connection.DisposeAsync(); + } + } + } +} diff --git a/test/Infrastructure.IntegrationTest/KeyManagement/Repositories/UserAsymmetricKeysRepositoryTests.cs b/test/Infrastructure.IntegrationTest/KeyManagement/Repositories/UserAsymmetricKeysRepositoryTests.cs new file mode 100644 index 000000000000..e3a48821b324 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/KeyManagement/Repositories/UserAsymmetricKeysRepositoryTests.cs @@ -0,0 +1,214 @@ +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.KeyManagement.Repositories; +using Bit.Core.Repositories; +using Bit.Infrastructure.IntegrationTest.AdminConsole; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.KeyManagement.Repositories; + +public class UserAsymmetricKeysRepositoryTests +{ + [Theory, DatabaseData] + public async Task RegenerateUserAsymmetricKeysAsync_UpdatesKeysOnUser( + IUserRepository userRepository, + IUserAsymmetricKeysRepository userAsymmetricKeysRepository) + { + var user = await userRepository.CreateTestUserAsync(); + user.AccountRevisionDate = DateTime.UtcNow.AddDays(-1); + await userRepository.ReplaceAsync(user); + + var newKeys = new UserAsymmetricKeys + { + UserId = user.Id, + PublicKey = "new-public-key", + UserKeyEncryptedPrivateKey = "new-encrypted-private-key", + }; + + await userAsymmetricKeysRepository.RegenerateUserAsymmetricKeysAsync(newKeys, []); + + var updatedUser = await userRepository.GetByIdAsync(user.Id); + Assert.NotNull(updatedUser); + Assert.Equal("new-public-key", updatedUser.PublicKey); + Assert.Equal("new-encrypted-private-key", updatedUser.PrivateKey); + Assert.Equal(DateTime.UtcNow, updatedUser.AccountRevisionDate, TimeSpan.FromMinutes(1)); + } + + [Theory, DatabaseData] + public async Task RegenerateUserAsymmetricKeysAsync_WithEmergencyAccessDelegate_SetsStatusToAccepted( + IUserRepository userRepository, + IUserAsymmetricKeysRepository userAsymmetricKeysRepository, + IEmergencyAccessRepository emergencyAccessRepository) + { + var user = await userRepository.CreateTestUserAsync(); + var grantorUser = await userRepository.CreateTestUserAsync("grantor"); + + var ea = await emergencyAccessRepository.CreateAsync(new EmergencyAccess + { + GrantorId = grantorUser.Id, + GranteeId = user.Id, + KeyEncrypted = "old-encrypted-key", + Status = EmergencyAccessStatusType.Confirmed, + Type = EmergencyAccessType.View, + WaitTimeDays = 10, + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow, + }); + + var newKeys = new UserAsymmetricKeys + { + UserId = user.Id, + PublicKey = "new-public-key", + UserKeyEncryptedPrivateKey = "new-encrypted-private-key", + }; + + var updateActions = new[] + { + emergencyAccessRepository.UpdateStatusAndKeyEncryptedById( + ea.Id, EmergencyAccessStatusType.Accepted, null, DateTime.UtcNow) + }; + + await userAsymmetricKeysRepository.RegenerateUserAsymmetricKeysAsync(newKeys, updateActions); + + var updatedEa = await emergencyAccessRepository.GetByIdAsync(ea.Id); + Assert.NotNull(updatedEa); + Assert.Equal(EmergencyAccessStatusType.Accepted, updatedEa.Status); + Assert.Null(updatedEa.KeyEncrypted); + } + + [Theory, DatabaseData] + public async Task RegenerateUserAsymmetricKeysAsync_WithOrgUserStatusDelegate_SetsStatusToAccepted( + IUserRepository userRepository, + IUserAsymmetricKeysRepository userAsymmetricKeysRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository) + { + var user = await userRepository.CreateTestUserAsync(); + var org = await organizationRepository.CreateTestOrganizationAsync(); + var orgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(org, user); + orgUser.Key = "old-org-key"; + await organizationUserRepository.ReplaceAsync(orgUser); + + var newKeys = new UserAsymmetricKeys + { + UserId = user.Id, + PublicKey = "new-public-key", + UserKeyEncryptedPrivateKey = "new-encrypted-private-key", + }; + + var updateActions = new[] + { + organizationUserRepository.UpdateStatusAndKeyById( + orgUser.Id, OrganizationUserStatusType.Accepted, null, DateTime.UtcNow) + }; + + await userAsymmetricKeysRepository.RegenerateUserAsymmetricKeysAsync(newKeys, updateActions); + + var updatedOrgUser = await organizationUserRepository.GetByIdAsync(orgUser.Id); + Assert.NotNull(updatedOrgUser); + Assert.Equal(OrganizationUserStatusType.Accepted, updatedOrgUser.Status); + Assert.Null(updatedOrgUser.Key); + } + + [Theory, DatabaseData] + public async Task RegenerateUserAsymmetricKeysAsync_WithRemoveDelegate_DeletesOrgUser( + IUserRepository userRepository, + IUserAsymmetricKeysRepository userAsymmetricKeysRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository) + { + var user = await userRepository.CreateTestUserAsync(); + var org = await organizationRepository.CreateTestOrganizationAsync(); + var orgUser = await organizationUserRepository.CreateRevokedTestOrganizationUserAsync(org, user); + + var newKeys = new UserAsymmetricKeys + { + UserId = user.Id, + PublicKey = "new-public-key", + UserKeyEncryptedPrivateKey = "new-encrypted-private-key", + }; + + var updateActions = new[] + { + organizationUserRepository.DeleteManyByIds([orgUser.Id]) + }; + + await userAsymmetricKeysRepository.RegenerateUserAsymmetricKeysAsync(newKeys, updateActions); + + var deletedOrgUser = await organizationUserRepository.GetByIdAsync(orgUser.Id); + Assert.Null(deletedOrgUser); + } + + [Theory, DatabaseData] + public async Task RegenerateUserAsymmetricKeysAsync_WithMultipleDelegates_AllChangesApplied( + IUserRepository userRepository, + IUserAsymmetricKeysRepository userAsymmetricKeysRepository, + IEmergencyAccessRepository emergencyAccessRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository) + { + var user = await userRepository.CreateTestUserAsync(); + user.AccountRevisionDate = DateTime.UtcNow.AddDays(-1); + await userRepository.ReplaceAsync(user); + var grantorUser = await userRepository.CreateTestUserAsync("grantor"); + + var ea = await emergencyAccessRepository.CreateAsync(new EmergencyAccess + { + GrantorId = grantorUser.Id, + GranteeId = user.Id, + KeyEncrypted = "old-encrypted-key", + Status = EmergencyAccessStatusType.RecoveryInitiated, + Type = EmergencyAccessType.View, + WaitTimeDays = 10, + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow, + }); + + var org1 = await organizationRepository.CreateTestOrganizationAsync(); + var confirmedOrgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(org1, user); + confirmedOrgUser.Key = "old-org-key"; + await organizationUserRepository.ReplaceAsync(confirmedOrgUser); + + var org2 = await organizationRepository.CreateTestOrganizationAsync(); + var revokedOrgUser = await organizationUserRepository.CreateRevokedTestOrganizationUserAsync(org2, user); + + var newKeys = new UserAsymmetricKeys + { + UserId = user.Id, + PublicKey = "new-public-key", + UserKeyEncryptedPrivateKey = "new-encrypted-private-key", + }; + + var updateActions = new[] + { + emergencyAccessRepository.UpdateStatusAndKeyEncryptedById( + ea.Id, EmergencyAccessStatusType.Accepted, null, DateTime.UtcNow), + organizationUserRepository.UpdateStatusAndKeyById( + confirmedOrgUser.Id, OrganizationUserStatusType.Accepted, null, DateTime.UtcNow), + organizationUserRepository.DeleteManyByIds([revokedOrgUser.Id]), + }; + + await userAsymmetricKeysRepository.RegenerateUserAsymmetricKeysAsync(newKeys, updateActions); + + var updatedUser = await userRepository.GetByIdAsync(user.Id); + Assert.NotNull(updatedUser); + Assert.Equal("new-public-key", updatedUser.PublicKey); + Assert.Equal("new-encrypted-private-key", updatedUser.PrivateKey); + Assert.Equal(DateTime.UtcNow, updatedUser.AccountRevisionDate, TimeSpan.FromMinutes(1)); + + var updatedEa = await emergencyAccessRepository.GetByIdAsync(ea.Id); + Assert.NotNull(updatedEa); + Assert.Equal(EmergencyAccessStatusType.Accepted, updatedEa.Status); + Assert.Null(updatedEa.KeyEncrypted); + + var updatedConfirmedOrgUser = await organizationUserRepository.GetByIdAsync(confirmedOrgUser.Id); + Assert.NotNull(updatedConfirmedOrgUser); + Assert.Equal(OrganizationUserStatusType.Accepted, updatedConfirmedOrgUser.Status); + Assert.Null(updatedConfirmedOrgUser.Key); + + var deletedOrgUser = await organizationUserRepository.GetByIdAsync(revokedOrgUser.Id); + Assert.Null(deletedOrgUser); + } +} diff --git a/util/Migrator/DbScripts/2026-06-11_00_AddKeyRegenerationStoredProcedures.sql b/util/Migrator/DbScripts/2026-06-11_00_AddKeyRegenerationStoredProcedures.sql new file mode 100644 index 000000000000..72f9e94bcb78 --- /dev/null +++ b/util/Migrator/DbScripts/2026-06-11_00_AddKeyRegenerationStoredProcedures.sql @@ -0,0 +1,39 @@ +CREATE OR ALTER PROCEDURE [dbo].[EmergencyAccess_UpdateStatusKeyEncryptedById] + @Id UNIQUEIDENTIFIER, + @Status TINYINT, + @KeyEncrypted VARCHAR(MAX), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[EmergencyAccess] + SET + [Status] = @Status, + [KeyEncrypted] = @KeyEncrypted, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_UpdateStatusKeyById] + @Id UNIQUEIDENTIFIER, + @Status SMALLINT, + @Key VARCHAR(MAX), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[OrganizationUser] + SET + [Status] = @Status, + [Key] = @Key, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id +END +GO