From d4cf6d929af3c66a3dfb10ecd1996f2cd45a287b Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Tue, 25 May 2021 19:23:47 +0200 Subject: [PATCH] Bulk Confirm (#1345) * Add support for bulk confirm * Add missing sproc to migration * Change ConfirmUserAsync to internally use ConfirmUsersAsync * Refactor to be a bit more readable * Change BulkReinvite and BulkRemove to return a list of errors/success * Refactor * Fix removing owner preventing removing non owners * Add another unit test * Use fixtures for OrganizationUser and Policies * Fix spelling --- .../OrganizationUsersController.cs | 44 ++- .../OrganizationUserRequestModels.cs | 19 ++ .../Response/OrganizationUserResponseModel.cs | 25 ++ .../Models/Data/OrganizationUserPublicKey.cs | 10 + .../EntityFramework/UserRepository.cs | 5 + .../IOrganizationUserRepository.cs | 1 + src/Core/Repositories/IUserRepository.cs | 1 + .../Repositories/PostgreSql/UserRepository.cs | 5 + .../SqlServer/OrganizationUserRepository.cs | 14 + .../Repositories/SqlServer/UserRepository.cs | 14 + src/Core/Services/IOrganizationService.cs | 7 +- .../Implementations/OrganizationService.cs | 184 ++++++++---- src/Sql/Sql.sqlproj | 3 + .../dbo/Stored Procedures/User_ReadByIds.sql | 18 ++ ...er_ReadPublicKeysByOrganizationUserIds.sql | 19 ++ .../AutoFixture/OrganizationUserFixtures.cs | 45 +++ test/Core.Test/AutoFixture/PolicyFixtures.cs | 39 +++ .../Services/OrganizationServiceTests.cs | 267 +++++++++++++++--- .../DbScripts/2021-05-18_00_BulkConfirm.sql | 51 ++++ 19 files changed, 669 insertions(+), 102 deletions(-) create mode 100644 src/Core/Models/Data/OrganizationUserPublicKey.cs create mode 100644 src/Sql/dbo/Stored Procedures/User_ReadByIds.sql create mode 100644 src/Sql/dbo/Stored Procedures/User_ReadPublicKeysByOrganizationUserIds.sql create mode 100644 test/Core.Test/AutoFixture/OrganizationUserFixtures.cs create mode 100644 test/Core.Test/AutoFixture/PolicyFixtures.cs create mode 100644 util/Migrator/DbScripts/2021-05-18_00_BulkConfirm.sql diff --git a/src/Api/Controllers/OrganizationUsersController.cs b/src/Api/Controllers/OrganizationUsersController.cs index 2a0eaa21aa..d2f9117621 100644 --- a/src/Api/Controllers/OrganizationUsersController.cs +++ b/src/Api/Controllers/OrganizationUsersController.cs @@ -138,7 +138,7 @@ namespace Bit.Api.Controllers } [HttpPost("reinvite")] - public async Task BulkReinvite(string orgId, [FromBody]OrganizationUserBulkRequestModel model) + public async Task> BulkReinvite(string orgId, [FromBody]OrganizationUserBulkRequestModel model) { var orgGuidId = new Guid(orgId); if (!_currentContext.ManageUsers(orgGuidId)) @@ -147,7 +147,9 @@ namespace Bit.Api.Controllers } var userId = _userService.GetProperUserId(User); - await _organizationService.ResendInvitesAsync(orgGuidId, userId.Value, model.Ids); + var result = await _organizationService.ResendInvitesAsync(orgGuidId, userId.Value, model.Ids); + return new ListResponseModel( + result.Select(t => new OrganizationUserBulkResponseModel(t.Item1.Id, t.Item2))); } [HttpPost("{id}/reinvite")] @@ -189,6 +191,38 @@ namespace Bit.Api.Controllers _userService); } + [HttpPost("confirm")] + public async Task> BulkConfirm(string orgId, + [FromBody]OrganizationUserBulkConfirmRequestModel model) + { + var orgGuidId = new Guid(orgId); + if (!_currentContext.ManageUsers(orgGuidId)) + { + throw new NotFoundException(); + } + + var userId = _userService.GetProperUserId(User); + var results = await _organizationService.ConfirmUsersAsync(orgGuidId, model.ToDictionary(), userId.Value, + _userService); + + return new ListResponseModel(results.Select(r => + new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2))); + } + + [HttpPost("public-keys")] + public async Task> UserPublicKeys(string orgId, [FromBody]OrganizationUserBulkRequestModel model) + { + var orgGuidId = new Guid(orgId); + if (!_currentContext.ManageUsers(orgGuidId)) + { + throw new NotFoundException(); + } + + var result = await _organizationUserRepository.GetManyPublicKeysByOrganizationUserAsync(orgGuidId, model.Ids); + var responses = result.Select(r => new OrganizationUserPublicKeyResponseModel(r.Id, r.PublicKey)).ToList(); + return new ListResponseModel(responses); + } + [HttpPut("{id}")] [HttpPost("{id}")] public async Task Put(string orgId, string id, [FromBody]OrganizationUserUpdateRequestModel model) @@ -287,7 +321,7 @@ namespace Bit.Api.Controllers [HttpDelete("")] [HttpPost("delete")] - public async Task BulkDelete(string orgId, [FromBody]OrganizationUserBulkRequestModel model) + public async Task> BulkDelete(string orgId, [FromBody]OrganizationUserBulkRequestModel model) { var orgGuidId = new Guid(orgId); if (!_currentContext.ManageUsers(orgGuidId)) @@ -296,7 +330,9 @@ namespace Bit.Api.Controllers } var userId = _userService.GetProperUserId(User); - await _organizationService.DeleteUsersAsync(orgGuidId, model.Ids, userId.Value); + var result = await _organizationService.DeleteUsersAsync(orgGuidId, model.Ids, userId.Value); + return new ListResponseModel(result.Select(r => + new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2))); } } } diff --git a/src/Core/Models/Api/Request/Organizations/OrganizationUserRequestModels.cs b/src/Core/Models/Api/Request/Organizations/OrganizationUserRequestModels.cs index 6d480ae209..e4100d91b2 100644 --- a/src/Core/Models/Api/Request/Organizations/OrganizationUserRequestModels.cs +++ b/src/Core/Models/Api/Request/Organizations/OrganizationUserRequestModels.cs @@ -60,6 +60,25 @@ namespace Bit.Core.Models.Api public string Key { get; set; } } + public class OrganizationUserBulkConfirmRequestModelEntry + { + [Required] + public Guid Id { get; set; } + [Required] + public string Key { get; set; } + } + + public class OrganizationUserBulkConfirmRequestModel + { + [Required] + public IEnumerable Keys { get; set; } + + public Dictionary ToDictionary() + { + return Keys.ToDictionary(e => e.Id, e => e.Key); + } + } + public class OrganizationUserUpdateRequestModel { [Required] diff --git a/src/Core/Models/Api/Response/OrganizationUserResponseModel.cs b/src/Core/Models/Api/Response/OrganizationUserResponseModel.cs index f3a632f2bf..6395d7a5b3 100644 --- a/src/Core/Models/Api/Response/OrganizationUserResponseModel.cs +++ b/src/Core/Models/Api/Response/OrganizationUserResponseModel.cs @@ -108,4 +108,29 @@ namespace Bit.Core.Models.Api public string ResetPasswordKey { get; set; } public string EncryptedPrivateKey { get; set; } } + + public class OrganizationUserPublicKeyResponseModel : ResponseModel + { + public OrganizationUserPublicKeyResponseModel(Guid id, string key, + string obj = "organizationUserPublicKeyResponseModel") : base(obj) + { + Id = id; + Key = key; + } + + public Guid Id { get; set; } + public string Key { get; set; } + } + + public class OrganizationUserBulkResponseModel : ResponseModel + { + public OrganizationUserBulkResponseModel(Guid id, string error, + string obj = "OrganizationBulkConfirmResponseModel") : base(obj) + { + Id = id; + Error = error; + } + public Guid Id { get; set; } + public string Error { get; set; } + } } diff --git a/src/Core/Models/Data/OrganizationUserPublicKey.cs b/src/Core/Models/Data/OrganizationUserPublicKey.cs new file mode 100644 index 0000000000..004a487684 --- /dev/null +++ b/src/Core/Models/Data/OrganizationUserPublicKey.cs @@ -0,0 +1,10 @@ +using System; + +namespace Bit.Core.Models.Data +{ + public class OrganizationUserPublicKey + { + public Guid Id { get; set; } + public string PublicKey { get; set; } + } +} diff --git a/src/Core/Repositories/EntityFramework/UserRepository.cs b/src/Core/Repositories/EntityFramework/UserRepository.cs index 936ab975b1..75e3a22f5b 100644 --- a/src/Core/Repositories/EntityFramework/UserRepository.cs +++ b/src/Core/Repositories/EntityFramework/UserRepository.cs @@ -128,5 +128,10 @@ namespace Bit.Core.Repositories.EntityFramework { throw new NotImplementedException(); } + + public Task> GetManyAsync(IEnumerable ids) + { + throw new NotImplementedException(); + } } } diff --git a/src/Core/Repositories/IOrganizationUserRepository.cs b/src/Core/Repositories/IOrganizationUserRepository.cs index 357833d7ad..e2d29d20bf 100644 --- a/src/Core/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/Repositories/IOrganizationUserRepository.cs @@ -36,5 +36,6 @@ namespace Bit.Core.Repositories Task> GetManyAsync(IEnumerable Ids); Task DeleteManyAsync(IEnumerable userIds); Task GetByOrganizationEmailAsync(Guid organizationId, string email); + Task> GetManyPublicKeysByOrganizationUserAsync(Guid organizationId, IEnumerable Ids); } } diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs index 46f7523025..3e655622ca 100644 --- a/src/Core/Repositories/IUserRepository.cs +++ b/src/Core/Repositories/IUserRepository.cs @@ -17,5 +17,6 @@ namespace Bit.Core.Repositories Task GetAccountRevisionDateAsync(Guid id); Task UpdateStorageAsync(Guid id); Task UpdateRenewalReminderDateAsync(Guid id, DateTime renewalReminderDate); + Task> GetManyAsync(IEnumerable ids); } } diff --git a/src/Core/Repositories/PostgreSql/UserRepository.cs b/src/Core/Repositories/PostgreSql/UserRepository.cs index 57a35ffb57..a5ab31c4f0 100644 --- a/src/Core/Repositories/PostgreSql/UserRepository.cs +++ b/src/Core/Repositories/PostgreSql/UserRepository.cs @@ -161,5 +161,10 @@ namespace Bit.Core.Repositories.PostgreSql { throw new NotImplementedException(); } + + public Task> GetManyAsync(IEnumerable ids) + { + throw new NotImplementedException(); + } } } diff --git a/src/Core/Repositories/SqlServer/OrganizationUserRepository.cs b/src/Core/Repositories/SqlServer/OrganizationUserRepository.cs index 95be480e54..91b9da85f9 100644 --- a/src/Core/Repositories/SqlServer/OrganizationUserRepository.cs +++ b/src/Core/Repositories/SqlServer/OrganizationUserRepository.cs @@ -365,5 +365,19 @@ namespace Bit.Core.Repositories.SqlServer commandType: CommandType.StoredProcedure); } } + + public async Task> GetManyPublicKeysByOrganizationUserAsync( + Guid organizationId, IEnumerable Ids) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + "[dbo].[User_ReadPublicKeysByOrganizationUserIds]", + new { OrganizationId = organizationId, OrganizationUserIds = Ids.ToGuidIdArrayTVP() }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } } } diff --git a/src/Core/Repositories/SqlServer/UserRepository.cs b/src/Core/Repositories/SqlServer/UserRepository.cs index 6ccbe5a71b..a558bed3cc 100644 --- a/src/Core/Repositories/SqlServer/UserRepository.cs +++ b/src/Core/Repositories/SqlServer/UserRepository.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Bit.Core.Models.Data; using Bit.Core.Models.Table; using Bit.Core.Settings; +using Bit.Core.Utilities; using Dapper; namespace Bit.Core.Repositories.SqlServer @@ -157,5 +158,18 @@ namespace Bit.Core.Repositories.SqlServer commandType: CommandType.StoredProcedure); } } + + public async Task> GetManyAsync(IEnumerable ids) + { + using (var connection = new SqlConnection(ReadOnlyConnectionString)) + { + var results = await connection.QueryAsync( + $"[{Schema}].[{Table}_ReadByIds]", + new { Ids = ids.ToGuidIdArrayTVP() }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } } } diff --git a/src/Core/Services/IOrganizationService.cs b/src/Core/Services/IOrganizationService.cs index d6d62e4da1..7a5b908224 100644 --- a/src/Core/Services/IOrganizationService.cs +++ b/src/Core/Services/IOrganizationService.cs @@ -34,17 +34,20 @@ namespace Bit.Core.Services Task InviteUserAsync(Guid organizationId, Guid? invitingUserId, string email, OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections); Task> InviteUserAsync(Guid organizationId, Guid? invitingUserId, string externalId, OrganizationUserInvite orgUserInvite); - Task ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable organizationUsersId); + Task>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable organizationUsersId); Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId); Task AcceptUserAsync(Guid organizationUserId, User user, string token, IUserService userService); Task AcceptUserAsync(string orgIdentifier, User user, IUserService userService); Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId, IUserService userService); + Task>> ConfirmUsersAsync(Guid organizationId, Dictionary keys, + Guid confirmingUserId, IUserService userService); Task SaveUserAsync(OrganizationUser user, Guid? savingUserId, IEnumerable collections); Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId); Task DeleteUserAsync(Guid organizationId, Guid userId); - Task DeleteUsersAsync(Guid organizationId, IEnumerable organizationUserIds, Guid? deleteingUserId); + Task>> DeleteUsersAsync(Guid organizationId, + IEnumerable organizationUserIds, Guid? deletingUserId); Task UpdateUserGroupsAsync(OrganizationUser organizationUser, IEnumerable groupIds, Guid? loggedInUserId); Task UpdateUserResetPasswordEnrollmentAsync(Guid organizationId, Guid organizationUserId, string resetPasswordKey, Guid? callingUserId); Task GenerateLicenseAsync(Guid organizationId, Guid installationId); diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index 63178459c7..5dd063e158 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -1205,23 +1205,26 @@ namespace Bit.Core.Services return orgUsers; } - public async Task ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, + public async Task>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable organizationUsersId) { var orgUsers = await _organizationUserRepository.GetManyAsync(organizationUsersId); - var filteredUsers = orgUsers - .Where(u => u.Status == OrganizationUserStatusType.Invited && u.OrganizationId == organizationId); - - if (!filteredUsers.Any()) - { - throw new BadRequestException("Users invalid."); - } - var org = await GetOrgById(organizationId); - foreach (var orgUser in filteredUsers) + + var result = new List>(); + foreach (var orgUser in orgUsers) { + if (orgUser.Status != OrganizationUserStatusType.Invited || orgUser.OrganizationId != organizationId) + { + result.Add(Tuple.Create(orgUser, "User invalid.")); + continue; + } + await SendInviteAsync(orgUser, org); + result.Add(Tuple.Create(orgUser, "")); } + + return result; } public async Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId) @@ -1384,29 +1387,97 @@ namespace Bit.Core.Services public async Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId, IUserService userService) { - var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); - if (orgUser == null || orgUser.Status != OrganizationUserStatusType.Accepted || - orgUser.OrganizationId != organizationId) + var result = await ConfirmUsersAsync(organizationId, new Dictionary() {{organizationUserId, key}}, + confirmingUserId, userService); + + if (!result.Any()) { throw new BadRequestException("User not valid."); } - var org = await GetOrgById(organizationId); - if (org.PlanType == PlanType.Free && - (orgUser.Type == OrganizationUserType.Admin || orgUser.Type == OrganizationUserType.Owner)) + var (orgUser, error) = result[0]; + if (error != "") { - var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync( - orgUser.UserId.Value); - if (adminCount > 0) + throw new BadRequestException(error); + } + return orgUser; + } + + public async Task>> ConfirmUsersAsync(Guid organizationId, Dictionary keys, + Guid confirmingUserId, IUserService userService) + { + var organizationUsers = await _organizationUserRepository.GetManyAsync(keys.Keys); + var validOrganizationUsers = organizationUsers + .Where(u => u.Status == OrganizationUserStatusType.Accepted && u.OrganizationId == organizationId && u.UserId != null) + .ToList(); + + if (!validOrganizationUsers.Any()) + { + return new List>(); + } + + var validOrganizationUserIds = validOrganizationUsers.Select(u => u.UserId.Value).ToList(); + + var organization = await GetOrgById(organizationId); + var policies = await _policyRepository.GetManyByOrganizationIdAsync(organizationId); + var usersOrgs = await _organizationUserRepository.GetManyByManyUsersAsync(validOrganizationUserIds); + var users = await _userRepository.GetManyAsync(validOrganizationUserIds); + + var keyedFilteredUsers = validOrganizationUsers.ToDictionary(u => u.UserId.Value, u => u); + var keyedOrganizationUsers = usersOrgs.GroupBy(u => u.UserId.Value) + .ToDictionary(u => u.Key, u => u.ToList()); + + var succeededUsers = new List(); + var result = new List>(); + + foreach (var user in users) + { + if (!keyedFilteredUsers.ContainsKey(user.Id)) { - throw new BadRequestException("User can only be an admin of one free organization."); + continue; + } + var orgUser = keyedFilteredUsers[user.Id]; + var orgUsers = keyedOrganizationUsers.GetValueOrDefault(user.Id, new List()); + try + { + if (organization.PlanType == PlanType.Free && orgUser.Type == OrganizationUserType.Admin + || orgUser.Type == OrganizationUserType.Owner) + { + // Since free organizations only supports a few users there is not much point in avoiding N+1 queries for this. + var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(user.Id); + if (adminCount > 0) + { + throw new BadRequestException("User can only be an admin of one free organization."); + } + } + + await CheckPolicies(policies, organizationId, user, orgUsers, userService); + orgUser.Status = OrganizationUserStatusType.Confirmed; + orgUser.Key = keys[orgUser.Id]; + orgUser.Email = null; + + await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); + await _mailService.SendOrganizationConfirmedEmailAsync(organization.Name, user.Email); + await DeleteAndPushUserRegistrationAsync(organizationId, user.Id); + succeededUsers.Add(orgUser); + result.Add(Tuple.Create(orgUser, "")); + } + catch (BadRequestException e) + { + result.Add(Tuple.Create(orgUser, e.Message)); } } - var user = await _userRepository.GetByIdAsync(orgUser.UserId.Value); - var policies = await _policyRepository.GetManyByOrganizationIdAsync(organizationId); + await _organizationUserRepository.ReplaceManyAsync(succeededUsers); + + return result; + } + + private async Task CheckPolicies(ICollection policies, Guid organizationId, User user, + ICollection userOrgs, IUserService userService) + { var usingTwoFactorPolicy = policies.Any(p => p.Type == PolicyType.TwoFactorAuthentication && p.Enabled); - if (usingTwoFactorPolicy && !(await userService.TwoFactorIsEnabledAsync(user))) + if (usingTwoFactorPolicy && !await userService.TwoFactorIsEnabledAsync(user)) { throw new BadRequestException("User does not have two-step login enabled."); } @@ -1414,23 +1485,11 @@ namespace Bit.Core.Services var usingSingleOrgPolicy = policies.Any(p => p.Type == PolicyType.SingleOrg && p.Enabled); if (usingSingleOrgPolicy) { - var userOrgs = await _organizationUserRepository.GetManyByUserAsync(user.Id); if (userOrgs.Any(ou => ou.OrganizationId != organizationId && ou.Status != OrganizationUserStatusType.Invited)) { throw new BadRequestException("User is a member of another organization."); } } - - orgUser.Status = OrganizationUserStatusType.Confirmed; - orgUser.Key = key; - orgUser.Email = null; - await _organizationUserRepository.ReplaceAsync(orgUser); - await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); - await _mailService.SendOrganizationConfirmedEmailAsync(org.Name, user.Email); - - await DeleteAndPushUserRegistrationAsync(organizationId, orgUser.UserId.Value); - - return orgUser; } public async Task SaveUserAsync(OrganizationUser user, Guid? savingUserId, @@ -1522,7 +1581,8 @@ namespace Bit.Core.Services } } - public async Task DeleteUsersAsync(Guid organizationId, IEnumerable organizationUsersId, + public async Task>> DeleteUsersAsync(Guid organizationId, + IEnumerable organizationUsersId, Guid? deletingUserId) { var orgUsers = await _organizationUserRepository.GetManyAsync(organizationUsersId); @@ -1533,34 +1593,52 @@ namespace Bit.Core.Services { throw new BadRequestException("Users invalid."); } - - if (deletingUserId.HasValue && filteredUsers.Exists(u => u.UserId == deletingUserId.Value)) - { - throw new BadRequestException("You cannot remove yourself."); - } - - var owners = filteredUsers.Where(u => u.Type == OrganizationUserType.Owner); - if (owners.Any() && deletingUserId.HasValue && !await UserIsOwnerAsync(organizationId, deletingUserId.Value)) - { - throw new BadRequestException("Only owners can delete other owners."); - } if (!await HasConfirmedOwnersExceptAsync(organizationId, organizationUsersId)) { throw new BadRequestException("Organization must have at least one confirmed owner."); } + var deletingUserIsOwner = false; + if (deletingUserId.HasValue) + { + deletingUserIsOwner = await UserIsOwnerAsync(organizationId, deletingUserId.Value); + } + + var result = new List>(); + var deletedUserIds = new List(); foreach (var orgUser in filteredUsers) { - // TODO: We should replace this call with `DeleteManyAsync`. - await _organizationUserRepository.DeleteAsync(orgUser); - await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Removed); - - if (orgUser.UserId.HasValue) + try { - await DeleteAndPushUserRegistrationAsync(organizationId, orgUser.UserId.Value); + if (deletingUserId.HasValue && orgUser.UserId == deletingUserId) + { + throw new BadRequestException("You cannot remove yourself."); + } + + if (orgUser.Type == OrganizationUserType.Owner && deletingUserId.HasValue && !deletingUserIsOwner) + { + throw new BadRequestException("Only owners can delete other owners."); + } + + await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Removed); + + if (orgUser.UserId.HasValue) + { + await DeleteAndPushUserRegistrationAsync(organizationId, orgUser.UserId.Value); + } + result.Add(Tuple.Create(orgUser, "")); + deletedUserIds.Add(orgUser.Id); } + catch (BadRequestException e) + { + result.Add(Tuple.Create(orgUser, e.Message)); + } + + await _organizationUserRepository.DeleteManyAsync(deletedUserIds); } + + return result; } private async Task HasConfirmedOwnersExceptAsync(Guid organizationId, IEnumerable organizationUsersId) diff --git a/src/Sql/Sql.sqlproj b/src/Sql/Sql.sqlproj index 0bdf48c142..5bc8142421 100644 --- a/src/Sql/Sql.sqlproj +++ b/src/Sql/Sql.sqlproj @@ -138,6 +138,7 @@ + @@ -160,6 +161,7 @@ + @@ -174,6 +176,7 @@ + diff --git a/src/Sql/dbo/Stored Procedures/User_ReadByIds.sql b/src/Sql/dbo/Stored Procedures/User_ReadByIds.sql new file mode 100644 index 0000000000..8bf8413116 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/User_ReadByIds.sql @@ -0,0 +1,18 @@ +CREATE PROCEDURE [dbo].[User_ReadByIds] +@Ids AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + IF (SELECT COUNT(1) FROM @Ids) < 1 + BEGIN + RETURN(-1) + END + + SELECT + * + FROM + [dbo].[UserView] + WHERE + [Id] IN (SELECT [Id] FROM @Ids) +END diff --git a/src/Sql/dbo/Stored Procedures/User_ReadPublicKeysByOrganizationUserIds.sql b/src/Sql/dbo/Stored Procedures/User_ReadPublicKeysByOrganizationUserIds.sql new file mode 100644 index 0000000000..db263e0f54 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/User_ReadPublicKeysByOrganizationUserIds.sql @@ -0,0 +1,19 @@ +CREATE PROCEDURE [dbo].[User_ReadPublicKeysByOrganizationUserIds] + @OrganizationId UNIQUEIDENTIFIER, + @OrganizationUserIds [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + SELECT + OU.[Id], + U.[PublicKey] + FROM + @OrganizationUserIds OUIDs + INNER JOIN + [dbo].[OrganizationUser] OU ON OUIDs.Id = OU.Id AND OU.[Status] = 1 -- Accepted + INNER JOIN + [dbo].[User] U ON OU.UserId = U.Id + WHERE + OU.OrganizationId = @OrganizationId +END diff --git a/test/Core.Test/AutoFixture/OrganizationUserFixtures.cs b/test/Core.Test/AutoFixture/OrganizationUserFixtures.cs new file mode 100644 index 0000000000..96c4f95afa --- /dev/null +++ b/test/Core.Test/AutoFixture/OrganizationUserFixtures.cs @@ -0,0 +1,45 @@ +using System.Reflection; +using AutoFixture; +using AutoFixture.Xunit2; +using Bit.Core.Enums; + +namespace Bit.Core.Test.AutoFixture.OrganizationUserFixtures +{ + internal class OrganizationUser : ICustomization + { + public OrganizationUserStatusType Status { get; set; } + public OrganizationUserType Type { get; set; } + + public OrganizationUser(OrganizationUserStatusType status, OrganizationUserType type) + { + Status = status; + Type = type; + } + + public void Customize(IFixture fixture) + { + fixture.Customize(composer => composer + .With(o => o.Type, Type) + .With(o => o.Status, Status)); + } + } + + public class OrganizationUserAttribute : CustomizeAttribute + { + private readonly OrganizationUserStatusType _status; + private readonly OrganizationUserType _type; + + public OrganizationUserAttribute( + OrganizationUserStatusType status = OrganizationUserStatusType.Confirmed, + OrganizationUserType type = OrganizationUserType.User) + { + _status = status; + _type = type; + } + + public override ICustomization GetCustomization(ParameterInfo parameter) + { + return new OrganizationUser(_status, _type); + } + } +} diff --git a/test/Core.Test/AutoFixture/PolicyFixtures.cs b/test/Core.Test/AutoFixture/PolicyFixtures.cs new file mode 100644 index 0000000000..ff791676fb --- /dev/null +++ b/test/Core.Test/AutoFixture/PolicyFixtures.cs @@ -0,0 +1,39 @@ +using System.Reflection; +using AutoFixture; +using AutoFixture.Xunit2; +using Bit.Core.Enums; + +namespace Bit.Core.Test.AutoFixture.OrganizationUserFixtures +{ + internal class Policy : ICustomization + { + public PolicyType Type { get; set; } + + public Policy(PolicyType type) + { + Type = type; + } + + public void Customize(IFixture fixture) + { + fixture.Customize(composer => composer + .With(o => o.Type, Type) + .With(o => o.Enabled, true)); + } + } + + public class PolicyAttribute : CustomizeAttribute + { + private readonly PolicyType _type; + + public PolicyAttribute(PolicyType type) + { + _type = type; + } + + public override ICustomization GetCustomization(ParameterInfo parameter) + { + return new Policy(_type); + } + } +} diff --git a/test/Core.Test/Services/OrganizationServiceTests.cs b/test/Core.Test/Services/OrganizationServiceTests.cs index f92d017866..9576f576ff 100644 --- a/test/Core.Test/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/Services/OrganizationServiceTests.cs @@ -15,8 +15,10 @@ using Bit.Core.Enums; using Bit.Core.Test.AutoFixture.Attributes; using Bit.Core.Test.AutoFixture.OrganizationFixtures; using System.Text.Json; +using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; using Organization = Bit.Core.Models.Table.Organization; -using System.Linq; +using OrganizationUser = Bit.Core.Models.Table.OrganizationUser; +using Policy = Bit.Core.Models.Table.Policy; namespace Bit.Core.Test.Services { @@ -28,6 +30,7 @@ namespace Bit.Core.Test.Services Organization org, List existingUsers, List newUsers) { org.UseDirectory = true; + org.Seats = 10; newUsers.Add(new ImportedOrganizationUser { Email = existingUsers.First().Email, @@ -335,15 +338,18 @@ namespace Bit.Core.Test.Services } [Theory, CustomAutoData(typeof(SutProviderCustomization))] - public async Task SaveUser_Passes(OrganizationUser oldUserData, OrganizationUser newUserData, - IEnumerable collections, OrganizationUser savingUser, SutProvider sutProvider) + public async Task SaveUser_Passes( + OrganizationUser oldUserData, + OrganizationUser newUserData, + IEnumerable collections, + [OrganizationUser(type: OrganizationUserType.Owner)]OrganizationUser savingUser, + SutProvider sutProvider) { var organizationUserRepository = sutProvider.GetDependency(); newUserData.Id = oldUserData.Id; newUserData.UserId = oldUserData.UserId; newUserData.OrganizationId = savingUser.OrganizationId = oldUserData.OrganizationId; - savingUser.Type = OrganizationUserType.Owner; organizationUserRepository.GetByIdAsync(oldUserData.Id).Returns(oldUserData); organizationUserRepository.GetManyByOrganizationAsync(savingUser.OrganizationId, OrganizationUserType.Owner) .Returns(new List { savingUser }); @@ -378,13 +384,14 @@ namespace Bit.Core.Test.Services } [Theory, CustomAutoData(typeof(SutProviderCustomization))] - public async Task DeleteUser_NonOwnerRemoveOwner(OrganizationUser organizationUser, OrganizationUser deletingUser, + public async Task DeleteUser_NonOwnerRemoveOwner( + [OrganizationUser(type: OrganizationUserType.Owner)]OrganizationUser organizationUser, + [OrganizationUser(type: OrganizationUserType.Admin)]OrganizationUser deletingUser, SutProvider sutProvider) { var organizationUserRepository = sutProvider.GetDependency(); organizationUser.OrganizationId = deletingUser.OrganizationId; - organizationUser.Type = OrganizationUserType.Owner; organizationUserRepository.GetByIdAsync(organizationUser.Id).Returns(organizationUser); organizationUserRepository.GetManyByUserAsync(deletingUser.UserId.Value).Returns(new[] { deletingUser }); @@ -394,13 +401,14 @@ namespace Bit.Core.Test.Services } [Theory, CustomAutoData(typeof(SutProviderCustomization))] - public async Task DeleteUser_LastOwner(OrganizationUser organizationUser, OrganizationUser deletingUser, + public async Task DeleteUser_LastOwner( + [OrganizationUser(type: OrganizationUserType.Owner)]OrganizationUser organizationUser, + OrganizationUser deletingUser, SutProvider sutProvider) { var organizationUserRepository = sutProvider.GetDependency(); organizationUser.OrganizationId = deletingUser.OrganizationId; - organizationUser.Type = OrganizationUserType.Owner; organizationUserRepository.GetByIdAsync(organizationUser.Id).Returns(organizationUser); organizationUserRepository.GetManyByOrganizationAsync(deletingUser.OrganizationId, OrganizationUserType.Owner) .Returns(new[] { organizationUser }); @@ -411,13 +419,13 @@ namespace Bit.Core.Test.Services } [Theory, CustomAutoData(typeof(SutProviderCustomization))] - public async Task DeleteUser_Success(OrganizationUser organizationUser, OrganizationUser deletingUser, + public async Task DeleteUser_Success( + OrganizationUser organizationUser, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)]OrganizationUser deletingUser, SutProvider sutProvider) { var organizationUserRepository = sutProvider.GetDependency(); - deletingUser.Type = OrganizationUserType.Owner; - deletingUser.Status = OrganizationUserStatusType.Confirmed; organizationUser.OrganizationId = deletingUser.OrganizationId; organizationUserRepository.GetByIdAsync(organizationUser.Id).Returns(organizationUser); organizationUserRepository.GetByIdAsync(deletingUser.Id).Returns(deletingUser); @@ -435,7 +443,7 @@ namespace Bit.Core.Test.Services var organizationUserRepository = sutProvider.GetDependency(); var organizationUsers = new[] { organizationUser }; var organizationUserIds = organizationUsers.Select(u => u.Id); - organizationUserRepository.GetManyAsync(organizationUserIds).Returns(organizationUsers); + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(organizationUsers); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.DeleteUsersAsync(deletingUser.OrganizationId, organizationUserIds, deletingUser.UserId)); @@ -443,46 +451,50 @@ namespace Bit.Core.Test.Services } [Theory, CustomAutoData(typeof(SutProviderCustomization))] - public async Task DeleteUsers_RemoveYourself(OrganizationUser deletingUser, SutProvider sutProvider) + public async Task DeleteUsers_RemoveYourself( + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)]OrganizationUser orgUser, + OrganizationUser deletingUser, + SutProvider sutProvider) { var organizationUserRepository = sutProvider.GetDependency(); var organizationUsers = new[] { deletingUser }; var organizationUserIds = organizationUsers.Select(u => u.Id); - organizationUserRepository.GetManyAsync(organizationUserIds).Returns(organizationUsers); + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(organizationUsers); + organizationUserRepository.GetManyByOrganizationAsync(default, default).ReturnsForAnyArgs(new[] {orgUser}); - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.DeleteUsersAsync(deletingUser.OrganizationId, organizationUserIds, deletingUser.UserId)); - Assert.Contains("You cannot remove yourself.", exception.Message); + var result = await sutProvider.Sut.DeleteUsersAsync(deletingUser.OrganizationId, organizationUserIds, deletingUser.UserId); + Assert.Contains("You cannot remove yourself.", result[0].Item2); } [Theory, CustomAutoData(typeof(SutProviderCustomization))] - public async Task DeleteUsers_NonOwnerRemoveOwner(OrganizationUser deletingUser, OrganizationUser orgUser1, OrganizationUser orgUser2, + public async Task DeleteUsers_NonOwnerRemoveOwner( + [OrganizationUser(type: OrganizationUserType.Admin)]OrganizationUser deletingUser, + [OrganizationUser(type: OrganizationUserType.Owner)]OrganizationUser orgUser1, + [OrganizationUser(OrganizationUserStatusType.Confirmed)]OrganizationUser orgUser2, + SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + + orgUser1.OrganizationId = orgUser2.OrganizationId = deletingUser.OrganizationId; + var organizationUsers = new[] { orgUser1 }; + var organizationUserIds = organizationUsers.Select(u => u.Id); + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(organizationUsers); + organizationUserRepository.GetManyByOrganizationAsync(default, default).ReturnsForAnyArgs(new[] {orgUser2}); + + var result = await sutProvider.Sut.DeleteUsersAsync(deletingUser.OrganizationId, organizationUserIds, deletingUser.UserId); + Assert.Contains("Only owners can delete other owners.", result[0].Item2); + } + + [Theory, CustomAutoData(typeof(SutProviderCustomization))] + public async Task DeleteUsers_LastOwner( + [OrganizationUser(status: OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)]OrganizationUser orgUser, SutProvider sutProvider) { var organizationUserRepository = sutProvider.GetDependency(); - deletingUser.Type = OrganizationUserType.Admin; - orgUser1.Type = OrganizationUserType.Owner; - orgUser1.OrganizationId = orgUser2.OrganizationId = deletingUser.OrganizationId; - var organizationUsers = new[] { orgUser1, orgUser2 }; - var organizationUserIds = organizationUsers.Select(u => u.Id); - organizationUserRepository.GetManyAsync(organizationUserIds).Returns(organizationUsers); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.DeleteUsersAsync(deletingUser.OrganizationId, organizationUserIds, deletingUser.UserId)); - Assert.Contains("Only owners can delete other owners.", exception.Message); - } - - [Theory, CustomAutoData(typeof(SutProviderCustomization))] - public async Task DeleteUsers_LastOwner(OrganizationUser orgUser, SutProvider sutProvider) - { - var organizationUserRepository = sutProvider.GetDependency(); - - orgUser.Type = OrganizationUserType.Owner; - orgUser.Status = OrganizationUserStatusType.Confirmed; var organizationUsers = new[] { orgUser }; var organizationUserIds = organizationUsers.Select(u => u.Id); - organizationUserRepository.GetManyAsync(organizationUserIds).Returns(organizationUsers); + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(organizationUsers); organizationUserRepository.GetManyByOrganizationAsync(orgUser.OrganizationId, OrganizationUserType.Owner).Returns(organizationUsers); var exception = await Assert.ThrowsAsync( @@ -491,18 +503,17 @@ namespace Bit.Core.Test.Services } [Theory, CustomAutoData(typeof(SutProviderCustomization))] - public async Task DeleteUsers_Success(OrganizationUser deletingUser, OrganizationUser orgUser1, OrganizationUser orgUser2, + public async Task DeleteUsers_Success( + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)]OrganizationUser deletingUser, + [OrganizationUser(type: OrganizationUserType.Owner)]OrganizationUser orgUser1, OrganizationUser orgUser2, SutProvider sutProvider) { var organizationUserRepository = sutProvider.GetDependency(); - deletingUser.Type = OrganizationUserType.Owner; - deletingUser.Status = OrganizationUserStatusType.Confirmed; orgUser1.OrganizationId = orgUser2.OrganizationId = deletingUser.OrganizationId; - orgUser1.Type = OrganizationUserType.Owner; var organizationUsers = new[] { orgUser1, orgUser2 }; var organizationUserIds = organizationUsers.Select(u => u.Id); - organizationUserRepository.GetManyAsync(organizationUserIds).Returns(organizationUsers); + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(organizationUsers); organizationUserRepository.GetByIdAsync(deletingUser.Id).Returns(deletingUser); organizationUserRepository.GetManyByUserAsync(deletingUser.UserId.Value).Returns(new[] { deletingUser }); organizationUserRepository.GetManyByOrganizationAsync(deletingUser.OrganizationId, OrganizationUserType.Owner) @@ -510,5 +521,175 @@ namespace Bit.Core.Test.Services await sutProvider.Sut.DeleteUsersAsync(deletingUser.OrganizationId, organizationUserIds, deletingUser.UserId); } + + [Theory, CustomAutoData(typeof(SutProviderCustomization))] + public async Task ConfirmUser_InvalidStatus(OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Invited)]OrganizationUser orgUser, string key, + SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var userService = Substitute.For(); + + organizationUserRepository.GetByIdAsync(orgUser.Id).Returns(orgUser); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, userService)); + Assert.Contains("User not valid.", exception.Message); + } + + [Theory, CustomAutoData(typeof(SutProviderCustomization))] + public async Task ConfirmUser_WrongOrganization(OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)]OrganizationUser orgUser, string key, + SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var userService = Substitute.For(); + + organizationUserRepository.GetByIdAsync(orgUser.Id).Returns(orgUser); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(confirmingUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, userService)); + Assert.Contains("User not valid.", exception.Message); + } + + [Theory, CustomAutoData(typeof(SutProviderCustomization))] + public async Task ConfirmUser_AlreadyAdmin(Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted, OrganizationUserType.Admin)]OrganizationUser orgUser, User user, + string key, SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var userService = Substitute.For(); + var userRepository = sutProvider.GetDependency(); + + org.PlanType = PlanType.Free; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = user.Id; + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] {orgUser}); + organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(orgUser.UserId.Value).Returns(1); + organizationRepository.GetByIdAsync(org.Id).Returns(org); + userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] {user}); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, userService)); + Assert.Contains("User can only be an admin of one free organization.", exception.Message); + } + + [Theory, CustomAutoData(typeof(SutProviderCustomization))] + public async Task ConfirmUser_SingleOrgPolicy(Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)]OrganizationUser orgUser, User user, + OrganizationUser orgUserAnotherOrg, [Policy(PolicyType.SingleOrg)]Policy singleOrgPolicy, + string key, SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var policyRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var userService = Substitute.For(); + + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.Status = OrganizationUserStatusType.Accepted; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = orgUserAnotherOrg.UserId = user.Id; + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] {orgUser}); + organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] {orgUserAnotherOrg}); + organizationRepository.GetByIdAsync(org.Id).Returns(org); + userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] {user}); + policyRepository.GetManyByOrganizationIdAsync(org.Id).Returns(new[] {singleOrgPolicy}); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, userService)); + Assert.Contains("User is a member of another organization.", exception.Message); + } + + [Theory, CustomAutoData(typeof(SutProviderCustomization))] + public async Task ConfirmUser_TwoFactorPolicy(Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)]OrganizationUser orgUser, User user, + OrganizationUser orgUserAnotherOrg, [Policy(PolicyType.TwoFactorAuthentication)]Policy twoFactorPolicy, + string key, SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var policyRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var userService = Substitute.For(); + + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = orgUserAnotherOrg.UserId = user.Id; + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] {orgUser}); + organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] {orgUserAnotherOrg}); + organizationRepository.GetByIdAsync(org.Id).Returns(org); + userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] {user}); + policyRepository.GetManyByOrganizationIdAsync(org.Id).Returns(new[] {twoFactorPolicy}); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, userService)); + Assert.Contains("User does not have two-step login enabled.", exception.Message); + } + + [Theory, CustomAutoData(typeof(SutProviderCustomization))] + public async Task ConfirmUser_Success(Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)]OrganizationUser orgUser, User user, + [Policy(PolicyType.TwoFactorAuthentication)]Policy twoFactorPolicy, + [Policy(PolicyType.SingleOrg)]Policy singleOrgPolicy, string key, SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var policyRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var userService = Substitute.For(); + + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = user.Id; + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] {orgUser}); + organizationRepository.GetByIdAsync(org.Id).Returns(org); + userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] {user}); + policyRepository.GetManyByOrganizationIdAsync(org.Id).Returns(new[] {twoFactorPolicy, singleOrgPolicy}); + userService.TwoFactorIsEnabledAsync(user).Returns(true); + + await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, userService); + } + + [Theory, CustomAutoData(typeof(SutProviderCustomization))] + public async Task ConfirmUsers_Success(Organization org, + OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)]OrganizationUser orgUser1, + [OrganizationUser(OrganizationUserStatusType.Accepted)]OrganizationUser orgUser2, + [OrganizationUser(OrganizationUserStatusType.Accepted)]OrganizationUser orgUser3, + OrganizationUser anotherOrgUser, User user1, User user2, User user3, + [Policy(PolicyType.TwoFactorAuthentication)]Policy twoFactorPolicy, + [Policy(PolicyType.SingleOrg)]Policy singleOrgPolicy, string key, SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var policyRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var userService = Substitute.For(); + + org.PlanType = PlanType.EnterpriseAnnually; + orgUser1.OrganizationId = orgUser2.OrganizationId = orgUser3.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser1.UserId = user1.Id; + orgUser2.UserId = user2.Id; + orgUser3.UserId = user3.Id; + anotherOrgUser.UserId = user3.Id; + var orgUsers = new[] {orgUser1, orgUser2, orgUser3}; + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(orgUsers); + organizationRepository.GetByIdAsync(org.Id).Returns(org); + userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] {user1, user2, user3}); + policyRepository.GetManyByOrganizationIdAsync(org.Id).Returns(new[] {twoFactorPolicy, singleOrgPolicy}); + userService.TwoFactorIsEnabledAsync(user1).Returns(true); + userService.TwoFactorIsEnabledAsync(user2).Returns(false); + userService.TwoFactorIsEnabledAsync(user3).Returns(true); + organizationUserRepository.GetManyByManyUsersAsync(default) + .ReturnsForAnyArgs(new[] {orgUser1, orgUser2, orgUser3, anotherOrgUser}); + + var keys = orgUsers.ToDictionary(ou => ou.Id, _ => key); + var result = await sutProvider.Sut.ConfirmUsersAsync(confirmingUser.OrganizationId, keys, confirmingUser.Id, userService); + Assert.Contains("", result[0].Item2); + Assert.Contains("User does not have two-step login enabled.", result[1].Item2); + Assert.Contains("User is a member of another organization.", result[2].Item2); + } } } diff --git a/util/Migrator/DbScripts/2021-05-18_00_BulkConfirm.sql b/util/Migrator/DbScripts/2021-05-18_00_BulkConfirm.sql new file mode 100644 index 0000000000..0f1e2f570d --- /dev/null +++ b/util/Migrator/DbScripts/2021-05-18_00_BulkConfirm.sql @@ -0,0 +1,51 @@ +IF OBJECT_ID('[dbo].[User_ReadByIds]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[User_ReadByIds] +END +GO + +CREATE PROCEDURE [dbo].[User_ReadByIds] +@Ids AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + IF (SELECT COUNT(1) FROM @Ids) < 1 + BEGIN + RETURN(-1) + END + + SELECT + * + FROM + [dbo].[UserView] + WHERE + [Id] IN (SELECT [Id] FROM @Ids) +END +GO + +IF OBJECT_ID('[dbo].[User_ReadPublicKeysByOrganizationUserIds]') IS NOT NULL + BEGIN + DROP PROCEDURE [dbo].[User_ReadPublicKeysByOrganizationUserIds] + END +GO + +CREATE PROCEDURE [dbo].[User_ReadPublicKeysByOrganizationUserIds] + @OrganizationId UNIQUEIDENTIFIER, + @OrganizationUserIds [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + SELECT + OU.[Id], + U.[PublicKey] + FROM + @OrganizationUserIds OUIDs + INNER JOIN + [dbo].[OrganizationUser] OU ON OUIDs.Id = OU.Id AND OU.[Status] = 1 -- Accepted + INNER JOIN + [dbo].[User] U ON OU.UserId = U.Id + WHERE + OU.OrganizationId = @OrganizationId +END