diff --git a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs index 516b4614af49..8825f9722a6f 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs @@ -60,4 +60,12 @@ UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(Guid userId, Task> GetManyByOrganizationWithClaimedDomainsAsync(Guid organizationId); Task RevokeManyByIdAsync(IEnumerable organizationUserIds); + + /// + /// Returns a list of OrganizationUsersUserDetails with the specified role. + /// + /// The organization to search within + /// The role to search for + /// A list of OrganizationUsersUserDetails with the specified role + Task> GetManyDetailsByRoleAsync(Guid organizationId, OrganizationUserType role); } diff --git a/src/Core/Auth/Services/Implementations/AuthRequestService.cs b/src/Core/Auth/Services/Implementations/AuthRequestService.cs index 5e41e3a679c1..b70a69033865 100644 --- a/src/Core/Auth/Services/Implementations/AuthRequestService.cs +++ b/src/Core/Auth/Services/Implementations/AuthRequestService.cs @@ -297,10 +297,34 @@ private async Task NotifyAdminsOfDeviceApprovalRequestAsync(OrganizationUser org return; } - var admins = await _organizationUserRepository.GetManyByMinimumRoleAsync( + var adminEmails = await GetAdminAndAccountRecoveryEmailsAsync(organizationUser.OrganizationId); + + await _mailService.SendDeviceApprovalRequestedNotificationEmailAsync( + adminEmails, organizationUser.OrganizationId, + user.Email, + user.Name); + } + + /// + /// Returns a list of emails for admins and custom users with the ManageResetPassword permission. + /// + /// The organization to search within + private async Task> GetAdminAndAccountRecoveryEmailsAsync(Guid organizationId) + { + var admins = await _organizationUserRepository.GetManyByMinimumRoleAsync( + organizationId, OrganizationUserType.Admin); - var adminEmails = admins.Select(a => a.Email).Distinct().ToList(); - await _mailService.SendDeviceApprovalRequestedNotificationEmailAsync(adminEmails, organizationUser.OrganizationId, user.Email, user.Name); + + var customUsers = await _organizationUserRepository.GetManyDetailsByRoleAsync( + organizationId, + OrganizationUserType.Custom); + + return admins.Select(a => a.Email) + .Concat(customUsers + .Where(a => a.GetPermissions().ManageResetPassword) + .Select(a => a.Email)) + .Distinct() + .ToList(); } } diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs index 42f79852f3c8..9b77fb216e97 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -567,4 +567,17 @@ await connection.ExecuteAsync( new { OrganizationUserIds = JsonSerializer.Serialize(organizationUserIds), Status = OrganizationUserStatusType.Revoked }, commandType: CommandType.StoredProcedure); } + + public async Task> GetManyDetailsByRoleAsync(Guid organizationId, OrganizationUserType role) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + "[dbo].[OrganizationUser_ReadManyDetailsByRole]", + new { OrganizationId = organizationId, Role = role }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs index 007ff1a7ff6e..ef6460df0e1c 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -733,4 +733,25 @@ await dbContext.OrganizationUsers.Where(x => organizationUserIds.Contains(x.Id)) await dbContext.UserBumpAccountRevisionDateByOrganizationUserIdsAsync(organizationUserIds); } + + public async Task> GetManyDetailsByRoleAsync(Guid organizationId, OrganizationUserType role) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var query = from ou in dbContext.OrganizationUsers + join u in dbContext.Users + on ou.UserId equals u.Id + where ou.OrganizationId == organizationId && + ou.Type == role && + ou.Status == OrganizationUserStatusType.Confirmed + select new OrganizationUserUserDetails + { + Id = ou.Id, + Email = ou.Email ?? u.Email, + Permissions = ou.Permissions + }; + return await query.ToListAsync(); + } + } } diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadManyDetailsByRole.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadManyDetailsByRole.sql new file mode 100644 index 000000000000..e8bf8bb70157 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadManyDetailsByRole.sql @@ -0,0 +1,16 @@ +CREATE PROCEDURE [dbo].[OrganizationUser_ReadManyDetailsByRole] + @OrganizationId UNIQUEIDENTIFIER, + @Role TINYINT +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[OrganizationUserUserDetailsView] + WHERE + OrganizationId = @OrganizationId + AND Status = 2 -- 2 = Confirmed + AND [Type] = @Role +END diff --git a/test/Core.Test/Auth/Services/AuthRequestServiceTests.cs b/test/Core.Test/Auth/Services/AuthRequestServiceTests.cs index 3894ac90a8d8..8feef2faccdd 100644 --- a/test/Core.Test/Auth/Services/AuthRequestServiceTests.cs +++ b/test/Core.Test/Auth/Services/AuthRequestServiceTests.cs @@ -7,11 +7,13 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.Helpers; @@ -347,14 +349,24 @@ public async Task CreateAuthRequestAsync_AdminApproval_WithAdminNotifications_Cr User user, OrganizationUser organizationUser1, OrganizationUserUserDetails admin1, + OrganizationUserUserDetails customUser1, OrganizationUser organizationUser2, OrganizationUserUserDetails admin2, - OrganizationUserUserDetails admin3) + OrganizationUserUserDetails admin3, + OrganizationUserUserDetails customUser2) { createModel.Type = AuthRequestType.AdminApproval; user.Email = createModel.Email; organizationUser1.UserId = user.Id; organizationUser2.UserId = user.Id; + customUser1.Permissions = CoreHelpers.ClassToJsonData(new Permissions + { + ManageResetPassword = false, + }); + customUser2.Permissions = CoreHelpers.ClassToJsonData(new Permissions + { + ManageResetPassword = true, + }); sutProvider.GetDependency() .IsEnabled(FeatureFlagKeys.DeviceApprovalRequestAdminNotifications) @@ -392,6 +404,13 @@ public async Task CreateAuthRequestAsync_AdminApproval_WithAdminNotifications_Cr admin1, ]); + sutProvider.GetDependency() + .GetManyDetailsByRoleAsync(organizationUser1.OrganizationId, OrganizationUserType.Custom) + .Returns( + [ + customUser1, + ]); + sutProvider.GetDependency() .GetManyByMinimumRoleAsync(organizationUser2.OrganizationId, OrganizationUserType.Admin) .Returns( @@ -400,6 +419,13 @@ public async Task CreateAuthRequestAsync_AdminApproval_WithAdminNotifications_Cr admin3, ]); + sutProvider.GetDependency() + .GetManyDetailsByRoleAsync(organizationUser2.OrganizationId, OrganizationUserType.Custom) + .Returns( + [ + customUser2, + ]); + sutProvider.GetDependency() .CreateAsync(Arg.Any()) .Returns(c => c.ArgAt(0)); @@ -435,7 +461,9 @@ await sutProvider.GetDependency() await sutProvider.GetDependency() .Received(1) .SendDeviceApprovalRequestedNotificationEmailAsync( - Arg.Is>(emails => emails.Count() == 2 && emails.Contains(admin2.Email) && emails.Contains(admin3.Email)), + Arg.Is>(emails => emails.Count() == 3 && + emails.Contains(admin2.Email) && emails.Contains(admin3.Email) && + emails.Contains(customUser2.Email)), organizationUser2.OrganizationId, user.Email, user.Name); diff --git a/util/Migrator/DbScripts/2025-02-03_00_OrgUserReadManyDetailsByRole.sql b/util/Migrator/DbScripts/2025-02-03_00_OrgUserReadManyDetailsByRole.sql new file mode 100644 index 000000000000..4d687f0bb135 --- /dev/null +++ b/util/Migrator/DbScripts/2025-02-03_00_OrgUserReadManyDetailsByRole.sql @@ -0,0 +1,16 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_ReadManyDetailsByRole] + @OrganizationId UNIQUEIDENTIFIER, + @Role TINYINT +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[OrganizationUserUserDetailsView] + WHERE + OrganizationId = @OrganizationId + AND Status = 2 -- 2 = Confirmed + AND [Type] = @Role +END