diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 544dbb87a7..c4aea8d3a3 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -20,6 +20,7 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -186,6 +187,20 @@ public class OrganizationUsersController : Controller return new OrganizationUserResetPasswordDetailsResponseModel(new OrganizationUserResetPasswordDetails(organizationUser, user, org)); } + [RequireFeature(FeatureFlagKeys.BulkDeviceApproval)] + [HttpPost("account-recovery-details")] + public async Task> GetAccountRecoveryDetails(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) + { + // Make sure the calling user can reset passwords for this org + if (!await _currentContext.ManageResetPassword(orgId)) + { + throw new NotFoundException(); + } + + var responses = await _organizationUserRepository.GetManyAccountRecoveryDetailsByOrganizationUserAsync(orgId, model.Ids); + return new ListResponseModel(responses.Select(r => new OrganizationUserResetPasswordDetailsResponseModel(r))); + } + [HttpPost("invite")] public async Task Invite(Guid orgId, [FromBody] OrganizationUserInviteRequestModel model) { diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs index ee1d790fa6..278e55c4ca 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs @@ -128,6 +128,7 @@ public class OrganizationUserResetPasswordDetailsResponseModel : ResponseModel throw new ArgumentNullException(nameof(orgUser)); } + OrganizationUserId = orgUser.OrganizationUserId; Kdf = orgUser.Kdf; KdfIterations = orgUser.KdfIterations; KdfMemory = orgUser.KdfMemory; @@ -136,6 +137,7 @@ public class OrganizationUserResetPasswordDetailsResponseModel : ResponseModel EncryptedPrivateKey = orgUser.EncryptedPrivateKey; } + public Guid OrganizationUserId { get; set; } public KdfType Kdf { get; set; } public int KdfIterations { get; set; } public int? KdfMemory { get; set; } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserResetPasswordDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserResetPasswordDetails.cs index ba3c821b2a..05d6807fad 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserResetPasswordDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserResetPasswordDetails.cs @@ -6,6 +6,8 @@ namespace Bit.Core.Models.Data.Organizations.OrganizationUsers; public class OrganizationUserResetPasswordDetails { + public OrganizationUserResetPasswordDetails() { } + public OrganizationUserResetPasswordDetails(OrganizationUser orgUser, User user, Organization org) { if (orgUser == null) @@ -23,6 +25,7 @@ public class OrganizationUserResetPasswordDetails throw new ArgumentNullException(nameof(org)); } + OrganizationUserId = orgUser.Id; Kdf = user.Kdf; KdfIterations = user.KdfIterations; KdfMemory = user.KdfMemory; @@ -30,6 +33,7 @@ public class OrganizationUserResetPasswordDetails ResetPasswordKey = orgUser.ResetPasswordKey; EncryptedPrivateKey = org.PrivateKey; } + public Guid OrganizationUserId { get; set; } public KdfType Kdf { get; set; } public int KdfIterations { get; set; } public int? KdfMemory { get; set; } diff --git a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs index ec18f4c573..c8bf3a56c6 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs @@ -43,6 +43,7 @@ public interface IOrganizationUserRepository : IRepository> GetByUserIdWithPolicyDetailsAsync(Guid userId, PolicyType policyType); Task GetOccupiedSmSeatCountByOrganizationIdAsync(Guid organizationId); + Task> GetManyAccountRecoveryDetailsByOrganizationUserAsync(Guid organizationId, IEnumerable organizationUserIds); /// /// Updates encrypted data for organization users during a key rotation diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index fc1d69ffb0..4dc90d4b45 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -127,6 +127,7 @@ public static class FeatureFlagKeys public const string ExtensionRefresh = "extension-refresh"; public const string RestrictProviderAccess = "restrict-provider-access"; public const string VaultBulkManagementAction = "vault-bulk-management-action"; + public const string BulkDeviceApproval = "bulk-device-approval"; public static List GetAllKeys() { diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs index 1a9a83602a..a61fa80948 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -523,6 +523,20 @@ public class OrganizationUserRepository : Repository, IO } } + public async Task> GetManyAccountRecoveryDetailsByOrganizationUserAsync( + Guid organizationId, IEnumerable organizationUserIds) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + "[dbo].[OrganizationUser_ReadManyAccountRecoveryDetailsByOrganizationUserIds]", + new { OrganizationId = organizationId, OrganizationUserIds = organizationUserIds.ToGuidIdArrayTVP() }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } + /// public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation( Guid userId, IEnumerable resetPasswordKeys) diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs index 221defb8d3..7e1301e2a1 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -661,6 +661,26 @@ public class OrganizationUserRepository : Repository> + GetManyAccountRecoveryDetailsByOrganizationUserAsync(Guid organizationId, IEnumerable organizationUserIds) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var query = from ou in dbContext.OrganizationUsers + where organizationUserIds.Contains(ou.Id) + join u in dbContext.Users + on ou.UserId equals u.Id + join o in dbContext.Organizations + on ou.OrganizationId equals o.Id + where ou.OrganizationId == organizationId + select new { ou, u, o }; + var data = await query + .Select(x => new OrganizationUserResetPasswordDetails(x.ou, x.u, x.o)).ToListAsync(); + return data; + } + } + /// public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation( Guid userId, IEnumerable resetPasswordKeys) diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadManyResetPasswordDetailsByOrganizationUserIds.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadManyResetPasswordDetailsByOrganizationUserIds.sql new file mode 100644 index 0000000000..269a297b6e --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadManyResetPasswordDetailsByOrganizationUserIds.sql @@ -0,0 +1,24 @@ +CREATE PROCEDURE [dbo].[OrganizationUser_ReadManyAccountRecoveryDetailsByOrganizationUserIds] + @OrganizationId UNIQUEIDENTIFIER, + @OrganizationUserIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + SELECT + OU.[Id] AS OrganizationUserId, + U.[Kdf], + U.[KdfIterations], + U.[KdfMemory], + U.[KdfParallelism], + OU.[ResetPasswordKey], + O.[PrivateKey] AS EncryptedPrivateKey + FROM @OrganizationUserIds AS OUIDs + INNER JOIN [dbo].[OrganizationUser] AS OU + ON OUIDs.[Id] = OU.[Id] + INNER JOIN [dbo].[Organization] AS O + ON OU.[OrganizationId] = O.[Id] + INNER JOIN [dbo].[User] U + ON U.[Id] = OU.[UserId] + WHERE OU.[OrganizationId] = @OrganizationId +END diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs index d6f8f845eb..def8c6c213 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs @@ -471,6 +471,45 @@ public class OrganizationUsersControllerTests Assert.False(customUserResponse.Permissions.DeleteAssignedCollections); } + [Theory] + [BitAutoData] + public async Task GetAccountRecoveryDetails_ReturnsDetails( + Guid organizationId, + OrganizationUserBulkRequestModel bulkRequestModel, + ICollection resetPasswordDetails, + SutProvider sutProvider) + { + sutProvider.GetDependency().ManageResetPassword(organizationId).Returns(true); + sutProvider.GetDependency() + .GetManyAccountRecoveryDetailsByOrganizationUserAsync(organizationId, bulkRequestModel.Ids) + .Returns(resetPasswordDetails); + + var response = await sutProvider.Sut.GetAccountRecoveryDetails(organizationId, bulkRequestModel); + + Assert.Equal(resetPasswordDetails.Count, response.Data.Count()); + Assert.True(response.Data.All(r => + resetPasswordDetails.Any(ou => + ou.OrganizationUserId == r.OrganizationUserId && + ou.Kdf == r.Kdf && + ou.KdfIterations == r.KdfIterations && + ou.KdfMemory == r.KdfMemory && + ou.KdfParallelism == r.KdfParallelism && + ou.ResetPasswordKey == r.ResetPasswordKey && + ou.EncryptedPrivateKey == r.EncryptedPrivateKey))); + } + + [Theory] + [BitAutoData] + public async Task GetAccountRecoveryDetails_WithoutManageResetPasswordPermission_Throws( + Guid organizationId, + OrganizationUserBulkRequestModel bulkRequestModel, + SutProvider sutProvider) + { + sutProvider.GetDependency().ManageResetPassword(organizationId).Returns(false); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetAccountRecoveryDetails(organizationId, bulkRequestModel)); + } + private void Put_Setup(SutProvider sutProvider, OrganizationAbility organizationAbility, OrganizationUser organizationUser, Guid savingUserId, OrganizationUserUpdateRequestModel model, bool authorizeAll) { diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs index 539ac0856f..a4eded05c1 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs @@ -95,4 +95,85 @@ public class OrganizationUserRepositoryTests Assert.NotEqual(updatedUser1.AccountRevisionDate, user1.AccountRevisionDate); Assert.NotEqual(updatedUser2.AccountRevisionDate, user2.AccountRevisionDate); } + + [DatabaseTheory, DatabaseData] + public async Task GetManyAccountRecoveryDetailsByOrganizationUserAsync_Works(IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository) + { + var user1 = await userRepository.CreateAsync(new User + { + Name = "Test User 1", + Email = $"test+{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 1, + KdfMemory = 2, + KdfParallelism = 3 + }); + + var user2 = await userRepository.CreateAsync(new User + { + Name = "Test User 2", + Email = $"test+{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + Kdf = KdfType.Argon2id, + KdfIterations = 4, + KdfMemory = 5, + KdfParallelism = 6 + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + BillingEmail = user1.Email, // TODO: EF does not enforce this being NOT NULl + Plan = "Test", // TODO: EF does not enforce this being NOT NULl + PrivateKey = "privatekey", + }); + + var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user1.Id, + Status = OrganizationUserStatusType.Confirmed, + ResetPasswordKey = "resetpasswordkey1", + }); + + var orgUser2 = await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user2.Id, + Status = OrganizationUserStatusType.Confirmed, + ResetPasswordKey = "resetpasswordkey2", + }); + + var recoveryDetails = await organizationUserRepository.GetManyAccountRecoveryDetailsByOrganizationUserAsync( + organization.Id, + new[] + { + orgUser1.Id, + orgUser2.Id, + }); + + Assert.NotNull(recoveryDetails); + Assert.Equal(2, recoveryDetails.Count()); + Assert.Contains(recoveryDetails, r => + r.OrganizationUserId == orgUser1.Id && + r.Kdf == KdfType.PBKDF2_SHA256 && + r.KdfIterations == 1 && + r.KdfMemory == 2 && + r.KdfParallelism == 3 && + r.ResetPasswordKey == "resetpasswordkey1" && + r.EncryptedPrivateKey == "privatekey"); + Assert.Contains(recoveryDetails, r => + r.OrganizationUserId == orgUser2.Id && + r.Kdf == KdfType.Argon2id && + r.KdfIterations == 4 && + r.KdfMemory == 5 && + r.KdfParallelism == 6 && + r.ResetPasswordKey == "resetpasswordkey2" && + r.EncryptedPrivateKey == "privatekey"); + } } diff --git a/util/Migrator/DbScripts/2024-05-10_00_OrgUserReadManyAccountRecoveryDetailsByOrgUserIds.sql b/util/Migrator/DbScripts/2024-05-10_00_OrgUserReadManyAccountRecoveryDetailsByOrgUserIds.sql new file mode 100644 index 0000000000..bb875b17b2 --- /dev/null +++ b/util/Migrator/DbScripts/2024-05-10_00_OrgUserReadManyAccountRecoveryDetailsByOrgUserIds.sql @@ -0,0 +1,24 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_ReadManyAccountRecoveryDetailsByOrganizationUserIds] + @OrganizationId UNIQUEIDENTIFIER, + @OrganizationUserIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + SELECT + OU.[Id] AS OrganizationUserId, + U.[Kdf], + U.[KdfIterations], + U.[KdfMemory], + U.[KdfParallelism], + OU.[ResetPasswordKey], + O.[PrivateKey] AS EncryptedPrivateKey + FROM @OrganizationUserIds AS OUIDs + INNER JOIN [dbo].[OrganizationUser] AS OU + ON OUIDs.[Id] = OU.[Id] + INNER JOIN [dbo].[Organization] AS O + ON OU.[OrganizationId] = O.[Id] + INNER JOIN [dbo].[User] U + ON U.[Id] = OU.[UserId] + WHERE OU.[OrganizationId] = @OrganizationId +END