1
0
mirror of https://github.com/bitwarden/server.git synced 2025-04-05 05:00:19 -05:00

[PM-15637] Notify Custom Users with “Manage Account Recovery” permission for Device Approval Requests (#5359)

* Add stored procedure to read organization user details by role

* Add OrganizationUserRepository method to retrieve OrganizationUser details by role

* Enhance AuthRequestService to send notifications to custom users with ManageResetPassword permission

* Enhance AuthRequestServiceTests to include custom user permissions and validate notification email recipients
This commit is contained in:
Rui Tomé 2025-02-05 14:47:06 +00:00 committed by GitHub
parent 617bb5015f
commit 03c390de74
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 131 additions and 5 deletions

View File

@ -60,4 +60,12 @@ public interface IOrganizationUserRepository : IRepository<OrganizationUser, Gui
Task<ICollection<OrganizationUser>> GetManyByOrganizationWithClaimedDomainsAsync(Guid organizationId);
Task RevokeManyByIdAsync(IEnumerable<Guid> organizationUserIds);
/// <summary>
/// Returns a list of OrganizationUsersUserDetails with the specified role.
/// </summary>
/// <param name="organizationId">The organization to search within</param>
/// <param name="role">The role to search for</param>
/// <returns>A list of OrganizationUsersUserDetails with the specified role</returns>
Task<IEnumerable<OrganizationUserUserDetails>> GetManyDetailsByRoleAsync(Guid organizationId, OrganizationUserType role);
}

View File

@ -297,10 +297,34 @@ public class AuthRequestService : IAuthRequestService
return;
}
var admins = await _organizationUserRepository.GetManyByMinimumRoleAsync(
var adminEmails = await GetAdminAndAccountRecoveryEmailsAsync(organizationUser.OrganizationId);
await _mailService.SendDeviceApprovalRequestedNotificationEmailAsync(
adminEmails,
organizationUser.OrganizationId,
user.Email,
user.Name);
}
/// <summary>
/// Returns a list of emails for admins and custom users with the ManageResetPassword permission.
/// </summary>
/// <param name="organizationId">The organization to search within</param>
private async Task<List<string>> 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();
}
}

View File

@ -567,4 +567,17 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
new { OrganizationUserIds = JsonSerializer.Serialize(organizationUserIds), Status = OrganizationUserStatusType.Revoked },
commandType: CommandType.StoredProcedure);
}
public async Task<IEnumerable<OrganizationUserUserDetails>> GetManyDetailsByRoleAsync(Guid organizationId, OrganizationUserType role)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<OrganizationUserUserDetails>(
"[dbo].[OrganizationUser_ReadManyDetailsByRole]",
new { OrganizationId = organizationId, Role = role },
commandType: CommandType.StoredProcedure);
return results.ToList();
}
}
}

View File

@ -733,4 +733,25 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
await dbContext.UserBumpAccountRevisionDateByOrganizationUserIdsAsync(organizationUserIds);
}
public async Task<IEnumerable<OrganizationUserUserDetails>> 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();
}
}
}

View File

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

View File

@ -7,11 +7,13 @@ using Bit.Core.Context;
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 class AuthRequestServiceTests
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<IFeatureService>()
.IsEnabled(FeatureFlagKeys.DeviceApprovalRequestAdminNotifications)
@ -392,6 +404,13 @@ public class AuthRequestServiceTests
admin1,
]);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByRoleAsync(organizationUser1.OrganizationId, OrganizationUserType.Custom)
.Returns(
[
customUser1,
]);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByMinimumRoleAsync(organizationUser2.OrganizationId, OrganizationUserType.Admin)
.Returns(
@ -400,6 +419,13 @@ public class AuthRequestServiceTests
admin3,
]);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByRoleAsync(organizationUser2.OrganizationId, OrganizationUserType.Custom)
.Returns(
[
customUser2,
]);
sutProvider.GetDependency<IAuthRequestRepository>()
.CreateAsync(Arg.Any<AuthRequest>())
.Returns(c => c.ArgAt<AuthRequest>(0));
@ -435,7 +461,9 @@ public class AuthRequestServiceTests
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendDeviceApprovalRequestedNotificationEmailAsync(
Arg.Is<IEnumerable<string>>(emails => emails.Count() == 2 && emails.Contains(admin2.Email) && emails.Contains(admin3.Email)),
Arg.Is<IEnumerable<string>>(emails => emails.Count() == 3 &&
emails.Contains(admin2.Email) && emails.Contains(admin3.Email) &&
emails.Contains(customUser2.Email)),
organizationUser2.OrganizationId,
user.Email,
user.Name);

View File

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