1
0
mirror of https://github.com/bitwarden/server synced 2025-12-06 00:03:34 +00:00

[PM-20140] Prevent accidental bulk removal of users without a Master Password (#6173)

This commit is contained in:
Thomas Rittson
2025-08-12 10:21:29 +10:00
committed by GitHub
parent 3c5de319d1
commit 9022ad2360
6 changed files with 118 additions and 31 deletions

View File

@@ -1,12 +1,7 @@
#nullable enable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Pricing;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@@ -17,7 +12,7 @@ using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Core.Services;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Import;
public class ImportOrganizationUsersAndGroupsCommand : IImportOrganizationUsersAndGroupsCommand
{
@@ -26,10 +21,8 @@ public class ImportOrganizationUsersAndGroupsCommand : IImportOrganizationUsersA
private readonly IPaymentService _paymentService;
private readonly IGroupRepository _groupRepository;
private readonly IEventService _eventService;
private readonly ICurrentContext _currentContext;
private readonly IOrganizationService _organizationService;
private readonly IInviteOrganizationUsersCommand _inviteOrganizationUsersCommand;
private readonly IPricingClient _pricingClient;
private readonly IFeatureService _featureService;
private readonly EventSystemUser _EventSystemUser = EventSystemUser.PublicApi;
@@ -38,21 +31,16 @@ public class ImportOrganizationUsersAndGroupsCommand : IImportOrganizationUsersA
IPaymentService paymentService,
IGroupRepository groupRepository,
IEventService eventService,
ICurrentContext currentContext,
IOrganizationService organizationService,
IInviteOrganizationUsersCommand inviteOrganizationUsersCommand,
IPricingClient pricingClient
)
IFeatureService featureService)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
_paymentService = paymentService;
_groupRepository = groupRepository;
_eventService = eventService;
_currentContext = currentContext;
_organizationService = organizationService;
_inviteOrganizationUsersCommand = inviteOrganizationUsersCommand;
_pricingClient = pricingClient;
_featureService = featureService;
}
/// <summary>
@@ -243,10 +231,23 @@ public class ImportOrganizationUsersAndGroupsCommand : IImportOrganizationUsersA
List<(OrganizationUserUserDetails ou, EventType e, DateTime? d)> events,
OrganizationUserImportData importUserData)
{
var usersToDelete = importUserData.ExistingExternalUsers.Where(u =>
u.Type != OrganizationUserType.Owner &&
!importUserData.ImportedExternalIds.Contains(u.ExternalId) &&
importUserData.ExistingExternalUsersIdDict.ContainsKey(u.ExternalId));
var usersToDelete = importUserData.ExistingExternalUsers
.Where(u =>
u.Type != OrganizationUserType.Owner &&
!importUserData.ImportedExternalIds.Contains(u.ExternalId) &&
importUserData.ExistingExternalUsersIdDict.ContainsKey(u.ExternalId))
.ToList();
if (_featureService.IsEnabled(FeatureFlagKeys.DirectoryConnectorPreventUserRemoval) &&
usersToDelete.Any(u => !u.HasMasterPassword))
{
// Removing users without an MP will put their account in an unrecoverable state.
// We allow this during normal syncs for offboarding, but overwriteExisting risks bricking every user in
// the organization, so you don't get to do it here.
throw new BadRequestException(
"Sync failed. To proceed, disable the 'Remove and re-add users during next sync' setting and try again.");
}
await _organizationUserRepository.DeleteManyAsync(usersToDelete.Select(u => u.Id));
events.AddRange(usersToDelete.Select(u => (
u,

View File

@@ -116,6 +116,7 @@ public static class FeatureFlagKeys
public const string ImportAsyncRefactor = "pm-22583-refactor-import-async";
public const string CreateDefaultLocation = "pm-19467-create-default-location";
public const string MembersGetEndpointOptimization = "pm-23113-optimize-get-members-endpoint";
public const string DirectoryConnectorPreventUserRemoval = "pm-24592-directory-connector-prevent-user-removal";
/* Auth Team */
public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence";

View File

@@ -2,6 +2,7 @@
using Bit.Core.AdminConsole.OrganizationAuth.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Import;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections;

View File

@@ -26,8 +26,13 @@ public class ImportOrganizationUsersAndGroupsCommandTests : IClassFixture<ApiApp
{
_factory = factory;
_factory.SubstituteService((IFeatureService featureService)
=> featureService.IsEnabled(FeatureFlagKeys.ImportAsyncRefactor)
.Returns(true));
=>
{
featureService.IsEnabled(FeatureFlagKeys.ImportAsyncRefactor)
.Returns(true);
featureService.IsEnabled(FeatureFlagKeys.DirectoryConnectorPreventUserRemoval)
.Returns(true);
});
_client = _factory.CreateClient();
_loginHelper = new LoginHelper(_factory, _client);
}
@@ -309,4 +314,29 @@ public class ImportOrganizationUsersAndGroupsCommandTests : IClassFixture<ApiApp
Assert.Equal("new-name", existingGroupInDb.Name);
Assert.Equal(existingGroup.ExternalId, existingGroupInDb.ExternalId);
}
[Fact]
public async Task Import_Remove_Member_Without_Master_Password_Throws_400_Error()
{
// ARRANGE: a member without a master password
await OrganizationTestHelpers.CreateUserWithoutMasterPasswordAsync(_factory, Guid.NewGuid() + "@example.com",
_organization.Id);
// ACT: an import request that would remove that member
var request = new OrganizationImportRequestModel
{
LargeImport = false,
OverwriteExisting = true, // removes all members not in the request
Groups = [],
Members = []
};
var response = await _client.PostAsync($"/public/organization/import", JsonContent.Create(request));
// ASSERT: that a 400 error is thrown with the correct error message
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var responseContent = await response.Content.ReadAsStringAsync();
Assert.Contains("Sync failed. To proceed, disable the 'Remove and re-add users during next sync' setting and try again.", responseContent);
}
}

View File

@@ -62,7 +62,8 @@ public static class OrganizationTestHelpers
OrganizationUserType type,
bool accessSecretsManager = false,
Permissions? permissions = null,
OrganizationUserStatusType userStatusType = OrganizationUserStatusType.Confirmed
OrganizationUserStatusType userStatusType = OrganizationUserStatusType.Confirmed,
string? externalId = null
) where T : class
{
var userRepository = factory.GetService<IUserRepository>();
@@ -78,7 +79,7 @@ public static class OrganizationTestHelpers
Key = null,
Type = type,
Status = userStatusType,
ExternalId = null,
ExternalId = externalId,
AccessSecretsManager = accessSecretsManager,
Email = userEmail
};
@@ -110,7 +111,7 @@ public static class OrganizationTestHelpers
await factory.LoginWithNewAccount(email);
// Create organizationUser
var organizationUser = await OrganizationTestHelpers.CreateUserAsync(factory, organizationId, email, userType,
var organizationUser = await CreateUserAsync(factory, organizationId, email, userType,
permissions: permissions);
return (email, organizationUser);
@@ -168,4 +169,27 @@ public static class OrganizationTestHelpers
await policyRepository.CreateAsync(policy);
}
/// <summary>
/// Creates a user account without a Master Password and adds them as a member to the specified organization.
/// </summary>
public static async Task<(User User, OrganizationUser OrganizationUser)> CreateUserWithoutMasterPasswordAsync(ApiApplicationFactory factory, string email, Guid organizationId)
{
var userRepository = factory.GetService<IUserRepository>();
var user = await userRepository.CreateAsync(new User
{
Email = email,
Culture = "en-US",
SecurityStamp = "D7ZH62BWAZ5R5CASKULCDDIQGKDA2EJ6",
PublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwMj7W00xS7H0NWasGn7PfEq8VfH3fa5XuZucsKxLLRAHHZk0xGRZJH2lFIznizv3GpF8vzhHhe9VpmMkrdIa5oWhwHpy+D7Z1QCQxuUXzvMKpa95GOntr89nN/mWKpk6abjgjmDcqFJ0lhDqkKnDfes+d8BBd5oEA8p41/Ykz7OfG7AiktVBpTQFW09MQh1NOvcLxVgiUUVRPwNRKrOeCekWDtOjZhASMETv3kI1ogvhHukOQ3ztDzrxvmwnLQ+cXl1EeD8gQnGDp3QLiJqxPgh2EdmANh4IzjRexoDn6BqhRGqLLIoLAbbkoiNrd6NYujrWW0N8KMMoVEXuJL2g4wIDAQAB",
PrivateKey = "2.Ytudv+Qk3ET9hN8whqpuGg==|ijsFhmjaf1aaT9uz+IPhVTzMS+2W/ldAP8LdT5VyJaFdx4HSdLcWSZvz5xWuuW94zfv1Qh+p3iQIuZOr29G4jcx47rYtz4ssiFtB7Ia552ZeF+cb7uuVg40CIe7ycuJQITk00o8gots+wFnaEvk0Vjgycnqutm0jpeBJ1joWJWqTVgSsYdUGLu7PiJywQ9NgY4+bJXqadlcviS3rhPKJXtiXYJhqJqSw+vI0Yxp96MJ0HcFJk/LG22YJPTvL5kzuDq/Wzj40kj8blQ+ag+xHD4P/KJ/MppEB3OpDw3UoJ50Ek+YB9pOqGxZtvqMEzBDsgh0yoz1O992UnhaUqtJ5e9Bxy3PA6cJsdyn9npduNOreEb8vePCidN2XC+chjJpPFpjms9muHLKgfaTIfpiJA2Tz8E9dvSyhHHTE1mY+xEA7P08BYKN3LNoSGIjdiZuouJ1V/KZvCssDfVG1tli2qpnhTIh4m3rAMhbM8WW3B7wCV8N0MpcJJSvndkVcMgRbgWcbivLeXuKdE/K98n01RvOLSJyslhLGCGEQQKw6N3HQ2iELfv84YQZi2fjDK+OqAmXDq1pNcjKX2I8dqBwl31tPC8qSZiWnfinwLdqQTvSQjOIyAHb4sSjAwgdMbCRzUTChRr09l+PAZqGWdMC5N2Bw+bA8WP0l2Wdxuv9Abxl3F7xGeAA9Rw9PU5wGKujaMRmO4V9MFjNyyCcw4D9pzKMW6OUKsHsHE7tsG7KskCzksHzrZGawAt0S41BYQA/JwePCrD3F6dM92anlC1LfA00KJb0tmFdU0yJNmJfR+S78yn8yM6wDgIs2cFB3W1fYfpfUvQm+zzPoEQihNxBxnwFsBtMAOtPy54FjSzKmxsQTrYT9E6NFb8k6ZIIm2gNeOPK9OUJgjw+4g2BXErM6ikHTzM3xcaTq/cQaePZ52emndw1qOtdV06hr2EeuLM8frfLHpsknUe8JeYeW5p9E8QdZjjSN9034usdYNamUdxzmn/Mw/ar8z1xSKS6zcaQoTQ7aYLEX3dWJndc4W64HyiaRkLjO6qLUFeOerfz5UvcxxRY89eAA0KLC2xnGkBMOhXxYzIB3lF8Zxqb4JMhoBGw1n31TDfhRDGDHHEAsZuAIcH7aC5RDVxU08Jxmw4oLmeTDZA5BFcqp2A3fusNVZUnfpmMy6DCJyFprlRl8jSlJMAvhbxVuuLFDZnjl77Z2of796Ur6DgmNwYtMPNEntZPIcZ76VPLWAL8lqiRBm20c4qiwr5rNSr5kry9bR1EfXHwFRjy5pxFQ+5+ilpRl8WPfT/iUuORd8J2wnCmghm7uxiJd9t82kX0s6benhL29dQ1etqt5soX2RnlfKan16GVWoI3xrljIQrCAY4xpdptSpglOnrpSClbN1nhGkDfFPNq2pWhQrDbznDknAJ9MxQaVnLYPhn7I849GMd7EvpSkydwQu7QXn9+H4jxn6UEntNGxcL0xkG+xippvZEe+HBvcDD40efDQW1bDbILLjPb4rNRx4d3xaQnVNaF7L33osm5LgfXAQSwHJiURdkU4zmhtPP4zn0br0OdFlR3mPcrkeNeSvs7FxiKtD6n6s+av+4bKjbLL1OyuwmTnMilL6p+m8ldte0yos/r+zOuxWeI=|euhiXWXehYbFQhlAV6LIECSIPCIRaHbNdr9OI4cTPUM=",
ApiKey = "CfGrD4MoJu3NprOBZNL8tu5ocmtnmU",
KdfIterations = 600000
});
var organizationUser = await CreateUserAsync(factory, organizationId, user.Email,
OrganizationUserType.User, externalId: email);
return (user, organizationUser);
}
}

View File

@@ -1,9 +1,9 @@
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.Import;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
@@ -18,11 +18,10 @@ using NSubstitute;
using Xunit;
using Organization = Bit.Core.AdminConsole.Entities.Organization;
namespace Bit.Core.Test.OrganizationFeatures.OrganizationUsers;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Import;
public class ImportOrganizationUsersAndGroupsCommandTests
{
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory = new FakeDataProtectorTokenFactory<OrgUserInviteTokenable>();
[Theory, PaidOrganizationCustomize, BitAutoData]
@@ -66,7 +65,6 @@ public class ImportOrganizationUsersAndGroupsCommandTests
Users = existingUsers.Count,
Sponsored = 0
});
sutProvider.GetDependency<ICurrentContext>().ManageUsers(org.Id).Returns(true);
sutProvider.GetDependency<IOrganizationService>().InviteUsersAsync(org.Id, Guid.Empty, EventSystemUser.PublicApi,
Arg.Any<IEnumerable<(OrganizationUserInvite, string)>>())
.Returns(orgUsers);
@@ -92,6 +90,38 @@ public class ImportOrganizationUsersAndGroupsCommandTests
.LogOrganizationUserEventsAsync(Arg.Any<IEnumerable<(OrganizationUserUserDetails, EventType, EventSystemUser, DateTime?)>>());
}
[Theory, PaidOrganizationCustomize, BitAutoData]
public async Task OverwriteExistingUsers_WhenRemovingUserWithoutMasterPassword_Throws(
SutProvider<ImportOrganizationUsersAndGroupsCommand> sutProvider,
Organization org, List<OrganizationUserUserDetails> existingUsers)
{
SetupOrganizationConfigForImport(sutProvider, org, existingUsers, []);
// Existing user does not have a master password
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.DirectoryConnectorPreventUserRemoval)
.Returns(true);
existingUsers.First().HasMasterPassword = false;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(org.Id).Returns(org);
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyDetailsByOrganizationAsync(org.Id).Returns(existingUsers);
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.ImportAsync(org.Id, [], [], [], true));
Assert.Contains("Sync failed. To proceed, disable the 'Remove and re-add users during next sync' setting and try again.", exception.Message);
await sutProvider.GetDependency<IOrganizationUserRepository>().DidNotReceiveWithAnyArgs()
.UpsertAsync(default);
await sutProvider.GetDependency<IOrganizationUserRepository>().DidNotReceiveWithAnyArgs()
.UpsertManyAsync(default);
await sutProvider.GetDependency<IOrganizationUserRepository>().DidNotReceiveWithAnyArgs()
.CreateAsync(default);
await sutProvider.GetDependency<IOrganizationService>().DidNotReceiveWithAnyArgs()
.InviteUsersAsync(default, default, default, default);
await sutProvider.GetDependency<IEventService>().DidNotReceiveWithAnyArgs()
.LogOrganizationUserEventsAsync(Arg.Any<IEnumerable<(OrganizationUserUserDetails, EventType, EventSystemUser, DateTime?)>>());
}
[Theory, PaidOrganizationCustomize, BitAutoData]
public async Task OrgImportCreateNewUsersAndMarryExistingUser(
SutProvider<ImportOrganizationUsersAndGroupsCommand> sutProvider,