1
0
mirror of https://github.com/bitwarden/server.git synced 2025-06-30 15:42:48 -05:00

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
This commit is contained in:
Oscar Hinton
2021-05-25 19:23:47 +02:00
committed by GitHub
parent 93fd1c9c9a
commit d4cf6d929a
19 changed files with 669 additions and 102 deletions

View File

@ -138,7 +138,7 @@ namespace Bit.Api.Controllers
}
[HttpPost("reinvite")]
public async Task BulkReinvite(string orgId, [FromBody]OrganizationUserBulkRequestModel model)
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> 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<OrganizationUserBulkResponseModel>(
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<ListResponseModel<OrganizationUserBulkResponseModel>> 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<OrganizationUserBulkResponseModel>(results.Select(r =>
new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2)));
}
[HttpPost("public-keys")]
public async Task<ListResponseModel<OrganizationUserPublicKeyResponseModel>> 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<OrganizationUserPublicKeyResponseModel>(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<ListResponseModel<OrganizationUserBulkResponseModel>> 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<OrganizationUserBulkResponseModel>(result.Select(r =>
new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2)));
}
}
}

View File

@ -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<OrganizationUserBulkConfirmRequestModelEntry> Keys { get; set; }
public Dictionary<Guid, string> ToDictionary()
{
return Keys.ToDictionary(e => e.Id, e => e.Key);
}
}
public class OrganizationUserUpdateRequestModel
{
[Required]

View File

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

View File

@ -0,0 +1,10 @@
using System;
namespace Bit.Core.Models.Data
{
public class OrganizationUserPublicKey
{
public Guid Id { get; set; }
public string PublicKey { get; set; }
}
}

View File

@ -128,5 +128,10 @@ namespace Bit.Core.Repositories.EntityFramework
{
throw new NotImplementedException();
}
public Task<IEnumerable<User>> GetManyAsync(IEnumerable<Guid> ids)
{
throw new NotImplementedException();
}
}
}

View File

@ -36,5 +36,6 @@ namespace Bit.Core.Repositories
Task<ICollection<OrganizationUser>> GetManyAsync(IEnumerable<Guid> Ids);
Task DeleteManyAsync(IEnumerable<Guid> userIds);
Task<OrganizationUser> GetByOrganizationEmailAsync(Guid organizationId, string email);
Task<IEnumerable<OrganizationUserPublicKey>> GetManyPublicKeysByOrganizationUserAsync(Guid organizationId, IEnumerable<Guid> Ids);
}
}

View File

@ -17,5 +17,6 @@ namespace Bit.Core.Repositories
Task<DateTime> GetAccountRevisionDateAsync(Guid id);
Task UpdateStorageAsync(Guid id);
Task UpdateRenewalReminderDateAsync(Guid id, DateTime renewalReminderDate);
Task<IEnumerable<User>> GetManyAsync(IEnumerable<Guid> ids);
}
}

View File

@ -161,5 +161,10 @@ namespace Bit.Core.Repositories.PostgreSql
{
throw new NotImplementedException();
}
public Task<IEnumerable<User>> GetManyAsync(IEnumerable<Guid> ids)
{
throw new NotImplementedException();
}
}
}

View File

@ -365,5 +365,19 @@ namespace Bit.Core.Repositories.SqlServer
commandType: CommandType.StoredProcedure);
}
}
public async Task<IEnumerable<OrganizationUserPublicKey>> GetManyPublicKeysByOrganizationUserAsync(
Guid organizationId, IEnumerable<Guid> Ids)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<OrganizationUserPublicKey>(
"[dbo].[User_ReadPublicKeysByOrganizationUserIds]",
new { OrganizationId = organizationId, OrganizationUserIds = Ids.ToGuidIdArrayTVP() },
commandType: CommandType.StoredProcedure);
return results.ToList();
}
}
}
}

View File

@ -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<IEnumerable<User>> GetManyAsync(IEnumerable<Guid> ids)
{
using (var connection = new SqlConnection(ReadOnlyConnectionString))
{
var results = await connection.QueryAsync<User>(
$"[{Schema}].[{Table}_ReadByIds]",
new { Ids = ids.ToGuidIdArrayTVP() },
commandType: CommandType.StoredProcedure);
return results.ToList();
}
}
}
}

View File

@ -34,17 +34,20 @@ namespace Bit.Core.Services
Task<OrganizationUser> InviteUserAsync(Guid organizationId, Guid? invitingUserId, string email,
OrganizationUserType type, bool accessAll, string externalId, IEnumerable<SelectionReadOnly> collections);
Task<List<OrganizationUser>> InviteUserAsync(Guid organizationId, Guid? invitingUserId, string externalId, OrganizationUserInvite orgUserInvite);
Task ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable<Guid> organizationUsersId);
Task<IEnumerable<Tuple<OrganizationUser, string>>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable<Guid> organizationUsersId);
Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId);
Task<OrganizationUser> AcceptUserAsync(Guid organizationUserId, User user, string token,
IUserService userService);
Task<OrganizationUser> AcceptUserAsync(string orgIdentifier, User user, IUserService userService);
Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key,
Guid confirmingUserId, IUserService userService);
Task<List<Tuple<OrganizationUser, string>>> ConfirmUsersAsync(Guid organizationId, Dictionary<Guid, string> keys,
Guid confirmingUserId, IUserService userService);
Task SaveUserAsync(OrganizationUser user, Guid? savingUserId, IEnumerable<SelectionReadOnly> collections);
Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId);
Task DeleteUserAsync(Guid organizationId, Guid userId);
Task DeleteUsersAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, Guid? deleteingUserId);
Task<List<Tuple<OrganizationUser, string>>> DeleteUsersAsync(Guid organizationId,
IEnumerable<Guid> organizationUserIds, Guid? deletingUserId);
Task UpdateUserGroupsAsync(OrganizationUser organizationUser, IEnumerable<Guid> groupIds, Guid? loggedInUserId);
Task UpdateUserResetPasswordEnrollmentAsync(Guid organizationId, Guid organizationUserId, string resetPasswordKey, Guid? callingUserId);
Task<OrganizationLicense> GenerateLicenseAsync(Guid organizationId, Guid installationId);

View File

@ -1205,23 +1205,26 @@ namespace Bit.Core.Services
return orgUsers;
}
public async Task ResendInvitesAsync(Guid organizationId, Guid? invitingUserId,
public async Task<IEnumerable<Tuple<OrganizationUser, string>>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId,
IEnumerable<Guid> 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<Tuple<OrganizationUser, string>>();
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<OrganizationUser> 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<Guid, string>() {{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<List<Tuple<OrganizationUser, string>>> ConfirmUsersAsync(Guid organizationId, Dictionary<Guid, string> 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<Tuple<OrganizationUser, string>>();
}
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<OrganizationUser>();
var result = new List<Tuple<OrganizationUser, string>>();
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<OrganizationUser>());
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<Policy> policies, Guid organizationId, User user,
ICollection<OrganizationUser> 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<Guid> organizationUsersId,
public async Task<List<Tuple<OrganizationUser, string>>> DeleteUsersAsync(Guid organizationId,
IEnumerable<Guid> 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<Tuple<OrganizationUser, string>>();
var deletedUserIds = new List<Guid>();
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<bool> HasConfirmedOwnersExceptAsync(Guid organizationId, IEnumerable<Guid> organizationUsersId)

View File

@ -138,6 +138,7 @@
<Build Include="dbo\Stored Procedures\User_BumpAccountRevisionDateByOrganizationUserIds.sql" />
<Build Include="dbo\Stored Procedures\Cipher_Delete.sql" />
<Build Include="dbo\Stored Procedures\User_ReadPublicKeyById.sql" />
<Build Include="dbo\Stored Procedures\User_ReadPublicKeysByOrganizationUserIds.sql" />
<Build Include="dbo\Stored Procedures\Cipher_Move.sql" />
<Build Include="dbo\Stored Procedures\Cipher_UpdatePartial.sql" />
<Build Include="dbo\Stored Procedures\Device_ClearPushTokenById.sql" />
@ -160,6 +161,7 @@
<Build Include="dbo\Stored Procedures\Cipher_ReadCanEditByIdUserId.sql" />
<Build Include="dbo\Stored Procedures\Cipher_Create.sql" />
<Build Include="dbo\Stored Procedures\Cipher_DeleteById.sql" />
<Build Include="dbo\Stored Procedures\Cipher_DeleteDeleted.sql" />
<Build Include="dbo\Stored Procedures\Cipher_ReadById.sql" />
<Build Include="dbo\Stored Procedures\Cipher_Update.sql" />
<Build Include="dbo\Stored Procedures\Device_Create.sql" />
@ -174,6 +176,7 @@
<Build Include="dbo\Stored Procedures\User_ReadByEmail.sql" />
<Build Include="dbo\Stored Procedures\Collection_UpdateWithGroups.sql" />
<Build Include="dbo\Stored Procedures\User_ReadById.sql" />
<Build Include="dbo\Stored Procedures\User_ReadByIds.sql" />
<Build Include="dbo\Stored Procedures\CollectionUser_Delete.sql" />
<Build Include="dbo\Stored Procedures\User_Update.sql" />
<Build Include="dbo\Stored Procedures\CollectionUser_ReadByCollectionId.sql" />

View File

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

View File

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