From b77ee017e379104011c4e01096955e42d34b1b7f Mon Sep 17 00:00:00 2001 From: Jake Fink Date: Thu, 14 Dec 2023 15:05:19 -0500 Subject: [PATCH] [PM-3797 Part 5] Add reset password keys to key rotation (#3445) * Add reset password validator with tests * add organization user rotation methods to repository - move organization user TVP helper to admin console ownership * rename account recovery to reset password * formatting * move registration of RotateUserKeyCommand to Core and make internal * add admin console ValidatorServiceCollectionExtensions --- .../OrganizationUserRequestModels.cs | 6 + .../OrganizationUserRotationValidator.cs | 64 ++++++++ .../Auth/Controllers/AccountsController.cs | 15 +- .../Request/Accounts/UpdateKeyRequestModel.cs | 2 +- src/Api/Startup.cs | 13 +- .../IOrganizationUserRepository.cs | 10 ++ .../Auth/Models/Data/RotateUserKeyData.cs | 4 +- .../Implementations/RotateUserKeyCommand.cs | 18 ++- .../UserServiceCollectionExtensions.cs | 7 + .../Helpers/OrganizationUserHelpers.cs | 34 +++++ .../OrganizationUserRepository.cs | 30 ++++ src/Infrastructure.Dapper/DapperHelpers.cs | 26 ---- .../OrganizationUserRepository.cs | 32 ++++ .../OrganizationUserRotationValidatorTests.cs | 143 ++++++++++++++++++ .../Controllers/AccountsControllerTests.cs | 10 +- 15 files changed, 372 insertions(+), 42 deletions(-) create mode 100644 src/Api/AdminConsole/Validators/OrganizationUserRotationValidator.cs create mode 100644 src/Infrastructure.Dapper/AdminConsole/Helpers/OrganizationUserHelpers.cs create mode 100644 test/Api.Test/AdminConsole/Validators/OrganizationUserRotationValidatorTests.cs diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs index bf10d85c0..6965b37fd 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs @@ -118,3 +118,9 @@ public class OrganizationUserBulkRequestModel [Required] public IEnumerable Ids { get; set; } } + +public class ResetPasswordWithOrgIdRequestModel : OrganizationUserResetPasswordEnrollmentRequestModel +{ + [Required] + public Guid OrganizationId { get; set; } +} diff --git a/src/Api/AdminConsole/Validators/OrganizationUserRotationValidator.cs b/src/Api/AdminConsole/Validators/OrganizationUserRotationValidator.cs new file mode 100644 index 000000000..ad3913435 --- /dev/null +++ b/src/Api/AdminConsole/Validators/OrganizationUserRotationValidator.cs @@ -0,0 +1,64 @@ +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.Auth.Validators; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; + +namespace Bit.Api.AdminConsole.Validators; + +/// +/// Organization user implementation for +/// Currently responsible for validation of user reset password keys (used by admins to perform account recovery) during user key rotation +/// +public class OrganizationUserRotationValidator : IRotationValidator, + IReadOnlyList> +{ + private readonly IOrganizationUserRepository _organizationUserRepository; + + public OrganizationUserRotationValidator(IOrganizationUserRepository organizationUserRepository) => + _organizationUserRepository = organizationUserRepository; + + public async Task> ValidateAsync(User user, + IEnumerable resetPasswordKeys) + { + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + var result = new List(); + if (resetPasswordKeys == null || !resetPasswordKeys.Any()) + { + return result; + } + + var existing = await _organizationUserRepository.GetManyByUserAsync(user.Id); + if (existing == null || !existing.Any()) + { + return result; + } + + // Exclude any account recovery that do not have a key. + existing = existing.Where(o => o.ResetPasswordKey != null).ToList(); + + + foreach (var ou in existing) + { + var organizationUser = resetPasswordKeys.FirstOrDefault(a => a.OrganizationId == ou.OrganizationId); + if (organizationUser == null) + { + throw new BadRequestException("All existing reset password keys must be included in the rotation."); + } + + if (organizationUser.ResetPasswordKey == null) + { + throw new BadRequestException("Reset Password keys cannot be set to null during rotation."); + } + + ou.ResetPasswordKey = organizationUser.ResetPasswordKey; + result.Add(ou); + } + + return result; + } +} diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 88681f98c..c7cfa9db3 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -1,4 +1,5 @@ -using Bit.Api.AdminConsole.Models.Response; +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.AdminConsole.Models.Response; using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.Auth.Validators; @@ -72,6 +73,9 @@ public class AccountsController : Controller private readonly IRotationValidator, IReadOnlyList> _sendValidator; private readonly IRotationValidator, IEnumerable> _emergencyAccessValidator; + private readonly IRotationValidator, + IReadOnlyList> + _organizationUserValidator; public AccountsController( @@ -96,7 +100,9 @@ public class AccountsController : Controller IRotationValidator, IEnumerable> folderValidator, IRotationValidator, IReadOnlyList> sendValidator, IRotationValidator, IEnumerable> - emergencyAccessValidator + emergencyAccessValidator, + IRotationValidator, IReadOnlyList> + organizationUserValidator ) { _cipherRepository = cipherRepository; @@ -120,6 +126,7 @@ public class AccountsController : Controller _folderValidator = folderValidator; _sendValidator = sendValidator; _emergencyAccessValidator = emergencyAccessValidator; + _organizationUserValidator = organizationUserValidator; } #region DEPRECATED (Moved to Identity Service) @@ -428,8 +435,8 @@ public class AccountsController : Controller Ciphers = await _cipherValidator.ValidateAsync(user, model.Ciphers), Folders = await _folderValidator.ValidateAsync(user, model.Folders), Sends = await _sendValidator.ValidateAsync(user, model.Sends), - EmergencyAccessKeys = await _emergencyAccessValidator.ValidateAsync(user, model.EmergencyAccessKeys), - ResetPasswordKeys = new List(), + EmergencyAccesses = await _emergencyAccessValidator.ValidateAsync(user, model.EmergencyAccessKeys), + OrganizationUsers = await _organizationUserValidator.ValidateAsync(user, model.ResetPasswordKeys) }; result = await _rotateUserKeyCommand.RotateUserKeyAsync(user, dataModel); diff --git a/src/Api/Auth/Models/Request/Accounts/UpdateKeyRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/UpdateKeyRequestModel.cs index a34ce6fd2..cfeaec324 100644 --- a/src/Api/Auth/Models/Request/Accounts/UpdateKeyRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/UpdateKeyRequestModel.cs @@ -18,6 +18,6 @@ public class UpdateKeyRequestModel public IEnumerable Folders { get; set; } public IEnumerable Sends { get; set; } public IEnumerable EmergencyAccessKeys { get; set; } - public IEnumerable ResetPasswordKeys { get; set; } + public IEnumerable ResetPasswordKeys { get; set; } } diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 404320330..c92e764c6 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -7,6 +7,8 @@ using Stripe; using Bit.Core.Utilities; using IdentityModel; using System.Globalization; +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.AdminConsole.Validators; using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Validators; using Bit.Api.Tools.Models.Request; @@ -22,8 +24,8 @@ using Bit.SharedWeb.Utilities; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.Extensions.DependencyInjection.Extensions; using Bit.Core.Auth.Identity; -using Bit.Core.Auth.UserFeatures.UserKey; -using Bit.Core.Auth.UserFeatures.UserKey.Implementations; +using Bit.Core.Auth.UserFeatures; +using Bit.Core.Entities; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions; using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; @@ -143,7 +145,7 @@ public class Startup services.AddScoped(); // Key Rotation - services.AddScoped(); + services.AddUserKeyCommands(globalSettings); services .AddScoped, IEnumerable>, CipherRotationValidator>(); @@ -156,6 +158,11 @@ public class Startup services .AddScoped, IEnumerable>, EmergencyAccessRotationValidator>(); + services + .AddScoped, + IReadOnlyList> + , OrganizationUserRotationValidator>(); + // Services services.AddBaseServices(globalSettings); diff --git a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs index 751bfdc4a..ec18f4c57 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Enums; +using Bit.Core.Auth.UserFeatures.UserKey; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; @@ -42,4 +43,13 @@ public interface IOrganizationUserRepository : IRepository> GetByUserIdWithPolicyDetailsAsync(Guid userId, PolicyType policyType); Task GetOccupiedSmSeatCountByOrganizationIdAsync(Guid organizationId); + + /// + /// Updates encrypted data for organization users during a key rotation + /// + /// The user that initiated the key rotation + /// A list of organization users with updated reset password keys + UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(Guid userId, + IEnumerable resetPasswordKeys); + } diff --git a/src/Core/Auth/Models/Data/RotateUserKeyData.cs b/src/Core/Auth/Models/Data/RotateUserKeyData.cs index 88cd2d549..52c651477 100644 --- a/src/Core/Auth/Models/Data/RotateUserKeyData.cs +++ b/src/Core/Auth/Models/Data/RotateUserKeyData.cs @@ -13,6 +13,6 @@ public class RotateUserKeyData public IEnumerable Ciphers { get; set; } public IEnumerable Folders { get; set; } public IReadOnlyList Sends { get; set; } - public IEnumerable EmergencyAccessKeys { get; set; } - public IEnumerable ResetPasswordKeys { get; set; } + public IEnumerable EmergencyAccesses { get; set; } + public IReadOnlyList OrganizationUsers { get; set; } } diff --git a/src/Core/Auth/UserFeatures/UserKey/Implementations/RotateUserKeyCommand.cs b/src/Core/Auth/UserFeatures/UserKey/Implementations/RotateUserKeyCommand.cs index 0d31b0c3c..a58062986 100644 --- a/src/Core/Auth/UserFeatures/UserKey/Implementations/RotateUserKeyCommand.cs +++ b/src/Core/Auth/UserFeatures/UserKey/Implementations/RotateUserKeyCommand.cs @@ -17,6 +17,7 @@ public class RotateUserKeyCommand : IRotateUserKeyCommand private readonly IFolderRepository _folderRepository; private readonly ISendRepository _sendRepository; private readonly IEmergencyAccessRepository _emergencyAccessRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IPushNotificationService _pushService; private readonly IdentityErrorDescriber _identityErrorDescriber; @@ -33,7 +34,7 @@ public class RotateUserKeyCommand : IRotateUserKeyCommand /// Provides a password mismatch error if master password hash validation fails public RotateUserKeyCommand(IUserService userService, IUserRepository userRepository, ICipherRepository cipherRepository, IFolderRepository folderRepository, ISendRepository sendRepository, - IEmergencyAccessRepository emergencyAccessRepository, + IEmergencyAccessRepository emergencyAccessRepository, IOrganizationUserRepository organizationUserRepository, IPushNotificationService pushService, IdentityErrorDescriber errors) { _userService = userService; @@ -42,6 +43,7 @@ public class RotateUserKeyCommand : IRotateUserKeyCommand _folderRepository = folderRepository; _sendRepository = sendRepository; _emergencyAccessRepository = emergencyAccessRepository; + _organizationUserRepository = organizationUserRepository; _pushService = pushService; _identityErrorDescriber = errors; } @@ -65,8 +67,8 @@ public class RotateUserKeyCommand : IRotateUserKeyCommand user.SecurityStamp = Guid.NewGuid().ToString(); user.Key = model.Key; user.PrivateKey = model.PrivateKey; - if (model.Ciphers.Any() || model.Folders.Any() || model.Sends.Any() || model.EmergencyAccessKeys.Any() || - model.ResetPasswordKeys.Any()) + if (model.Ciphers.Any() || model.Folders.Any() || model.Sends.Any() || model.EmergencyAccesses.Any() || + model.OrganizationUsers.Any()) { List saveEncryptedDataActions = new(); @@ -85,10 +87,16 @@ public class RotateUserKeyCommand : IRotateUserKeyCommand saveEncryptedDataActions.Add(_sendRepository.UpdateForKeyRotation(user.Id, model.Sends)); } - if (model.EmergencyAccessKeys.Any()) + if (model.EmergencyAccesses.Any()) { saveEncryptedDataActions.Add( - _emergencyAccessRepository.UpdateForKeyRotation(user.Id, model.EmergencyAccessKeys)); + _emergencyAccessRepository.UpdateForKeyRotation(user.Id, model.EmergencyAccesses)); + } + + if (model.OrganizationUsers.Any()) + { + saveEncryptedDataActions.Add( + _organizationUserRepository.UpdateForKeyRotation(user.Id, model.OrganizationUsers)); } await _userRepository.UpdateUserKeyAndEncryptedDataAsync(user, saveEncryptedDataActions); diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs index 3f5171ca2..e4945ce4f 100644 --- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs +++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs @@ -1,5 +1,7 @@  +using Bit.Core.Auth.UserFeatures.UserKey; +using Bit.Core.Auth.UserFeatures.UserKey.Implementations; using Bit.Core.Auth.UserFeatures.UserMasterPassword; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Auth.UserFeatures.WebAuthnLogin; @@ -19,6 +21,11 @@ public static class UserServiceCollectionExtensions services.AddWebAuthnLoginCommands(); } + public static void AddUserKeyCommands(this IServiceCollection services, IGlobalSettings globalSettings) + { + services.AddScoped(); + } + private static void AddUserPasswordCommands(this IServiceCollection services) { services.AddScoped(); diff --git a/src/Infrastructure.Dapper/AdminConsole/Helpers/OrganizationUserHelpers.cs b/src/Infrastructure.Dapper/AdminConsole/Helpers/OrganizationUserHelpers.cs new file mode 100644 index 000000000..fac7f6f09 --- /dev/null +++ b/src/Infrastructure.Dapper/AdminConsole/Helpers/OrganizationUserHelpers.cs @@ -0,0 +1,34 @@ +using System.Data; +using Bit.Core.Entities; +using Dapper; + +namespace Bit.Infrastructure.Dapper.AdminConsole.Helpers; + +public static class OrganizationUserHelpers +{ + public static DataTable ToTvp(this IEnumerable orgUsers) + { + var table = new DataTable(); + table.SetTypeName("[dbo].[OrganizationUserType2]"); + + var columnData = new List<(string name, Type type, Func getter)> + { + (nameof(OrganizationUser.Id), typeof(Guid), ou => ou.Id), + (nameof(OrganizationUser.OrganizationId), typeof(Guid), ou => ou.OrganizationId), + (nameof(OrganizationUser.UserId), typeof(Guid), ou => ou.UserId), + (nameof(OrganizationUser.Email), typeof(string), ou => ou.Email), + (nameof(OrganizationUser.Key), typeof(string), ou => ou.Key), + (nameof(OrganizationUser.Status), typeof(byte), ou => ou.Status), + (nameof(OrganizationUser.Type), typeof(byte), ou => ou.Type), + (nameof(OrganizationUser.AccessAll), typeof(bool), ou => ou.AccessAll), + (nameof(OrganizationUser.ExternalId), typeof(string), ou => ou.ExternalId), + (nameof(OrganizationUser.CreationDate), typeof(DateTime), ou => ou.CreationDate), + (nameof(OrganizationUser.RevisionDate), typeof(DateTime), ou => ou.RevisionDate), + (nameof(OrganizationUser.Permissions), typeof(string), ou => ou.Permissions), + (nameof(OrganizationUser.ResetPasswordKey), typeof(string), ou => ou.ResetPasswordKey), + (nameof(OrganizationUser.AccessSecretsManager), typeof(bool), ou => ou.AccessSecretsManager), + }; + + return orgUsers.BuildTable(table, columnData); + } +} diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs index 517b737ee..1a9a83602 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -2,12 +2,14 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.Auth.UserFeatures.UserKey; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Settings; +using Bit.Infrastructure.Dapper.AdminConsole.Helpers; using Dapper; using Microsoft.Data.SqlClient; @@ -520,4 +522,32 @@ public class OrganizationUserRepository : Repository, IO return results.ToList(); } } + + /// + public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation( + Guid userId, IEnumerable resetPasswordKeys) + { + return async (SqlConnection connection, SqlTransaction transaction) => + { + const string sql = @" + UPDATE + [dbo].[OrganizationUser] + SET + [ResetPasswordKey] = AR.[ResetPasswordKey] + FROM + [dbo].[OrganizationUser] OU + INNER JOIN + @ResetPasswordKeys AR ON OU.Id = AR.Id + WHERE + OU.[UserId] = @UserId"; + + var organizationUsersTVP = resetPasswordKeys.ToTvp(); + + await connection.ExecuteAsync( + sql, + new { UserId = userId, resetPasswordKeys = organizationUsersTVP }, + transaction: transaction, + commandType: CommandType.Text); + }; + } } diff --git a/src/Infrastructure.Dapper/DapperHelpers.cs b/src/Infrastructure.Dapper/DapperHelpers.cs index 4156c1691..d72ed0745 100644 --- a/src/Infrastructure.Dapper/DapperHelpers.cs +++ b/src/Infrastructure.Dapper/DapperHelpers.cs @@ -59,32 +59,6 @@ public static class DapperHelpers return table; } - public static DataTable ToTvp(this IEnumerable orgUsers) - { - var table = new DataTable(); - table.SetTypeName("[dbo].[OrganizationUserType2]"); - - var columnData = new List<(string name, Type type, Func getter)> - { - (nameof(OrganizationUser.Id), typeof(Guid), ou => ou.Id), - (nameof(OrganizationUser.OrganizationId), typeof(Guid), ou => ou.OrganizationId), - (nameof(OrganizationUser.UserId), typeof(Guid), ou => ou.UserId), - (nameof(OrganizationUser.Email), typeof(string), ou => ou.Email), - (nameof(OrganizationUser.Key), typeof(string), ou => ou.Key), - (nameof(OrganizationUser.Status), typeof(byte), ou => ou.Status), - (nameof(OrganizationUser.Type), typeof(byte), ou => ou.Type), - (nameof(OrganizationUser.AccessAll), typeof(bool), ou => ou.AccessAll), - (nameof(OrganizationUser.ExternalId), typeof(string), ou => ou.ExternalId), - (nameof(OrganizationUser.CreationDate), typeof(DateTime), ou => ou.CreationDate), - (nameof(OrganizationUser.RevisionDate), typeof(DateTime), ou => ou.RevisionDate), - (nameof(OrganizationUser.Permissions), typeof(string), ou => ou.Permissions), - (nameof(OrganizationUser.ResetPasswordKey), typeof(string), ou => ou.ResetPasswordKey), - (nameof(OrganizationUser.AccessSecretsManager), typeof(bool), ou => ou.AccessSecretsManager), - }; - - return orgUsers.BuildTable(table, columnData); - } - public static DataTable ToTvp(this IEnumerable organizationSponsorships) { var table = new DataTable(); diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs index ed86bae04..5049f9be7 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -1,5 +1,6 @@ using AutoMapper; using Bit.Core.AdminConsole.Enums; +using Bit.Core.Auth.UserFeatures.UserKey; using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; @@ -640,4 +641,35 @@ public class OrganizationUserRepository : Repository + public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation( + Guid userId, IEnumerable resetPasswordKeys) + { + return async (_, _) => + { + var newOrganizationUsers = resetPasswordKeys.ToList(); + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + + // Get user organization users + var userOrganizationUsers = await GetDbSet(dbContext) + .Where(c => c.UserId == userId) + .ToListAsync(); + + // Filter to only organization users that are included + var validOrganizationUsers = userOrganizationUsers + .Where(organizationUser => + newOrganizationUsers.Any(newOrganizationUser => newOrganizationUser.Id == organizationUser.Id)); + + foreach (var organizationUser in validOrganizationUsers) + { + var updateOrganizationUser = + newOrganizationUsers.First(newOrganizationUser => newOrganizationUser.Id == organizationUser.Id); + organizationUser.ResetPasswordKey = updateOrganizationUser.ResetPasswordKey; + } + + await dbContext.SaveChangesAsync(); + }; + } + } diff --git a/test/Api.Test/AdminConsole/Validators/OrganizationUserRotationValidatorTests.cs b/test/Api.Test/AdminConsole/Validators/OrganizationUserRotationValidatorTests.cs new file mode 100644 index 000000000..ea429b33e --- /dev/null +++ b/test/Api.Test/AdminConsole/Validators/OrganizationUserRotationValidatorTests.cs @@ -0,0 +1,143 @@ +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.AdminConsole.Validators; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Validators; + +[SutProviderCustomize] +public class OrganizationUserRotationValidatorTests +{ + [Theory] + [BitAutoData] + public async Task ValidateAsync_Success_ReturnsValid( + SutProvider sutProvider, User user, + IEnumerable resetPasswordKeys) + { + var existingUserResetPassword = resetPasswordKeys + .Select(a => + new OrganizationUser + { + Id = new Guid(), + ResetPasswordKey = a.ResetPasswordKey, + OrganizationId = a.OrganizationId + }).ToList(); + sutProvider.GetDependency().GetManyByUserAsync(user.Id) + .Returns(existingUserResetPassword); + + var result = await sutProvider.Sut.ValidateAsync(user, resetPasswordKeys); + + Assert.Equal(result.Select(r => r.OrganizationId), resetPasswordKeys.Select(a => a.OrganizationId)); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_NullResetPasswordKeys_ReturnsEmptyList( + SutProvider sutProvider, User user) + { + // Arrange + IEnumerable resetPasswordKeys = null; + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, resetPasswordKeys); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_NoOrgUsers_ReturnsEmptyList( + SutProvider sutProvider, User user, + IEnumerable resetPasswordKeys) + { + // Arrange + sutProvider.GetDependency().GetManyByUserAsync(user.Id) + .Returns(new List()); // Return an empty list + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, resetPasswordKeys); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_MissingResetPassword_Throws( + SutProvider sutProvider, User user, + IEnumerable resetPasswordKeys) + { + var existingUserResetPassword = resetPasswordKeys + .Select(a => + new OrganizationUser + { + Id = new Guid(), + ResetPasswordKey = a.ResetPasswordKey, + OrganizationId = a.OrganizationId + }).ToList(); + existingUserResetPassword.Add(new OrganizationUser + { + Id = Guid.NewGuid(), + ResetPasswordKey = "Missing ResetPasswordKey" + }); + sutProvider.GetDependency().GetManyByUserAsync(user.Id) + .Returns(existingUserResetPassword); + + + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.ValidateAsync(user, resetPasswordKeys)); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_ResetPasswordDoesNotBelongToUser_NotReturned( + SutProvider sutProvider, User user, + IEnumerable resetPasswordKeys) + { + var existingUserResetPassword = resetPasswordKeys + .Select(a => + new OrganizationUser + { + Id = new Guid(), + ResetPasswordKey = a.ResetPasswordKey, + OrganizationId = a.OrganizationId + }).ToList(); + existingUserResetPassword.RemoveAt(0); + sutProvider.GetDependency().GetManyByUserAsync(user.Id) + .Returns(existingUserResetPassword); + + var result = await sutProvider.Sut.ValidateAsync(user, resetPasswordKeys); + + Assert.DoesNotContain(result, c => c.Id == resetPasswordKeys.First().OrganizationId); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_AttemptToSetKeyToNull_Throws( + SutProvider sutProvider, User user, + IEnumerable resetPasswordKeys) + { + var existingUserResetPassword = resetPasswordKeys + .Select(a => + new OrganizationUser + { + Id = new Guid(), + ResetPasswordKey = a.ResetPasswordKey, + OrganizationId = a.OrganizationId + }).ToList(); + sutProvider.GetDependency().GetManyByUserAsync(user.Id) + .Returns(existingUserResetPassword); + resetPasswordKeys.First().ResetPasswordKey = null; + + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.ValidateAsync(user, resetPasswordKeys)); + } +} diff --git a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs index 764d6e054..51a04b65a 100644 --- a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.Auth.Controllers; using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Request.Accounts; @@ -60,6 +61,9 @@ public class AccountsControllerTests : IDisposable private readonly IRotationValidator, IReadOnlyList> _sendValidator; private readonly IRotationValidator, IEnumerable> _emergencyAccessValidator; + private readonly IRotationValidator, + IReadOnlyList> + _resetPasswordValidator; public AccountsControllerTests() @@ -88,6 +92,9 @@ public class AccountsControllerTests : IDisposable _sendValidator = Substitute.For, IReadOnlyList>>(); _emergencyAccessValidator = Substitute.For, IEnumerable>>(); + _resetPasswordValidator = Substitute + .For, + IReadOnlyList>>(); _sut = new AccountsController( _globalSettings, @@ -110,7 +117,8 @@ public class AccountsControllerTests : IDisposable _cipherValidator, _folderValidator, _sendValidator, - _emergencyAccessValidator + _emergencyAccessValidator, + _resetPasswordValidator ); }