diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index b82ed1d2fd..c43d7d77ee 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -3,6 +3,7 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; @@ -23,6 +24,7 @@ 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; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Platform.Push; using Bit.Core.Repositories; @@ -1079,6 +1081,210 @@ public class OrganizationService : IOrganizationService EventType.OrganizationUser_ResetPassword_Enroll : EventType.OrganizationUser_ResetPassword_Withdraw); } + public async Task ImportAsync(Guid organizationId, + IEnumerable groups, + IEnumerable newUsers, + IEnumerable removeUserExternalIds, + bool overwriteExisting, + EventSystemUser eventSystemUser + ) + { + var organization = await GetOrgById(organizationId); + if (organization == null) + { + throw new NotFoundException(); + } + + if (!organization.UseDirectory) + { + throw new BadRequestException("Organization cannot use directory syncing."); + } + + var newUsersSet = new HashSet(newUsers?.Select(u => u.ExternalId) ?? new List()); + var existingUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); + var existingExternalUsers = existingUsers.Where(u => !string.IsNullOrWhiteSpace(u.ExternalId)).ToList(); + var existingExternalUsersIdDict = existingExternalUsers.ToDictionary(u => u.ExternalId, u => u.Id); + + // Users + + var events = new List<(OrganizationUserUserDetails ou, EventType e, DateTime? d)>(); + + // Remove Users + if (removeUserExternalIds?.Any() ?? false) + { + var existingUsersDict = existingExternalUsers.ToDictionary(u => u.ExternalId); + var removeUsersSet = new HashSet(removeUserExternalIds) + .Except(newUsersSet) + .Where(u => existingUsersDict.TryGetValue(u, out var existingUser) && existingUser.Type != OrganizationUserType.Owner) + .Select(u => existingUsersDict[u]); + + await _organizationUserRepository.DeleteManyAsync(removeUsersSet.Select(u => u.Id)); + events.AddRange(removeUsersSet.Select(u => ( + u, + EventType.OrganizationUser_Removed, + (DateTime?)DateTime.UtcNow + )) + ); + } + + if (overwriteExisting) + { + // Remove existing external users that are not in new user set + var usersToDelete = existingExternalUsers.Where(u => + u.Type != OrganizationUserType.Owner && + !newUsersSet.Contains(u.ExternalId) && + existingExternalUsersIdDict.ContainsKey(u.ExternalId)); + await _organizationUserRepository.DeleteManyAsync(usersToDelete.Select(u => u.Id)); + events.AddRange(usersToDelete.Select(u => ( + u, + EventType.OrganizationUser_Removed, + (DateTime?)DateTime.UtcNow + )) + ); + foreach (var deletedUser in usersToDelete) + { + existingExternalUsersIdDict.Remove(deletedUser.ExternalId); + } + } + + if (newUsers?.Any() ?? false) + { + // Marry existing users + var existingUsersEmailsDict = existingUsers + .Where(u => string.IsNullOrWhiteSpace(u.ExternalId)) + .ToDictionary(u => u.Email); + var newUsersEmailsDict = newUsers.ToDictionary(u => u.Email); + var usersToAttach = existingUsersEmailsDict.Keys.Intersect(newUsersEmailsDict.Keys).ToList(); + var usersToUpsert = new List(); + foreach (var user in usersToAttach) + { + var orgUserDetails = existingUsersEmailsDict[user]; + var orgUser = await _organizationUserRepository.GetByIdAsync(orgUserDetails.Id); + if (orgUser != null) + { + orgUser.ExternalId = newUsersEmailsDict[user].ExternalId; + usersToUpsert.Add(orgUser); + existingExternalUsersIdDict.Add(orgUser.ExternalId, orgUser.Id); + } + } + await _organizationUserRepository.UpsertManyAsync(usersToUpsert); + + // Add new users + var existingUsersSet = new HashSet(existingExternalUsersIdDict.Keys); + var usersToAdd = newUsersSet.Except(existingUsersSet).ToList(); + + var seatsAvailable = int.MaxValue; + var enoughSeatsAvailable = true; + if (organization.Seats.HasValue) + { + var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + seatsAvailable = organization.Seats.Value - occupiedSeats; + enoughSeatsAvailable = seatsAvailable >= usersToAdd.Count; + } + + var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization); + + var userInvites = new List<(OrganizationUserInvite, string)>(); + foreach (var user in newUsers) + { + if (!usersToAdd.Contains(user.ExternalId) || string.IsNullOrWhiteSpace(user.Email)) + { + continue; + } + + try + { + var invite = new OrganizationUserInvite + { + Emails = new List { user.Email }, + Type = OrganizationUserType.User, + Collections = new List(), + AccessSecretsManager = hasStandaloneSecretsManager + }; + userInvites.Add((invite, user.ExternalId)); + } + catch (BadRequestException) + { + // Thrown when the user is already invited to the organization + continue; + } + } + + var invitedUsers = await InviteUsersAsync(organizationId, invitingUserId: null, systemUser: eventSystemUser, userInvites); + foreach (var invitedUser in invitedUsers) + { + existingExternalUsersIdDict.Add(invitedUser.ExternalId, invitedUser.Id); + } + } + + + // Groups + if (groups?.Any() ?? false) + { + if (!organization.UseGroups) + { + throw new BadRequestException("Organization cannot use groups."); + } + + var groupsDict = groups.ToDictionary(g => g.Group.ExternalId); + var existingGroups = await _groupRepository.GetManyByOrganizationIdAsync(organizationId); + var existingExternalGroups = existingGroups + .Where(u => !string.IsNullOrWhiteSpace(u.ExternalId)).ToList(); + var existingExternalGroupsDict = existingExternalGroups.ToDictionary(g => g.ExternalId); + + var newGroups = groups + .Where(g => !existingExternalGroupsDict.ContainsKey(g.Group.ExternalId)) + .Select(g => g.Group).ToList(); + + var savedGroups = new List(); + foreach (var group in newGroups) + { + group.CreationDate = group.RevisionDate = DateTime.UtcNow; + + savedGroups.Add(await _groupRepository.CreateAsync(group)); + await UpdateUsersAsync(group, groupsDict[group.ExternalId].ExternalUserIds, + existingExternalUsersIdDict); + } + + await _eventService.LogGroupEventsAsync( + savedGroups.Select(g => (g, EventType.Group_Created, (EventSystemUser?)eventSystemUser, (DateTime?)DateTime.UtcNow))); + + var updateGroups = existingExternalGroups + .Where(g => groupsDict.ContainsKey(g.ExternalId)) + .ToList(); + + if (updateGroups.Any()) + { + var groupUsers = await _groupRepository.GetManyGroupUsersByOrganizationIdAsync(organizationId); + var existingGroupUsers = groupUsers + .GroupBy(gu => gu.GroupId) + .ToDictionary(g => g.Key, g => new HashSet(g.Select(gr => gr.OrganizationUserId))); + + foreach (var group in updateGroups) + { + var updatedGroup = groupsDict[group.ExternalId].Group; + if (group.Name != updatedGroup.Name) + { + group.RevisionDate = DateTime.UtcNow; + group.Name = updatedGroup.Name; + + await _groupRepository.ReplaceAsync(group); + } + + await UpdateUsersAsync(group, groupsDict[group.ExternalId].ExternalUserIds, + existingExternalUsersIdDict, + existingGroupUsers.ContainsKey(group.Id) ? existingGroupUsers[group.Id] : null); + + } + + await _eventService.LogGroupEventsAsync( + updateGroups.Select(g => (g, EventType.Group_Updated, (EventSystemUser?)eventSystemUser, (DateTime?)DateTime.UtcNow))); + } + } + + await _eventService.LogOrganizationUserEventsAsync(events.Select(e => (e.ou, e.e, eventSystemUser, e.d))); + } + public async Task DeleteSsoUserAsync(Guid userId, Guid? organizationId) { await _ssoUserRepository.DeleteAsync(userId, organizationId); diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index 4a436277e1..16dd84a6bf 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -13,6 +13,7 @@ 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; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Platform.Push; using Bit.Core.Repositories; @@ -40,6 +41,131 @@ public class OrganizationServiceTests { private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory = new FakeDataProtectorTokenFactory(); + [Theory, PaidOrganizationCustomize, BitAutoData] + public async Task OrgImportCreateNewUsers(SutProvider sutProvider, Organization org, List existingUsers, List newUsers) + { + // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks + sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); + sutProvider.Create(); + + org.UseDirectory = true; + org.Seats = 10; + newUsers.Add(new ImportedOrganizationUser + { + Email = existingUsers.First().Email, + ExternalId = existingUsers.First().ExternalId + }); + var expectedNewUsersCount = newUsers.Count - 1; + + existingUsers.First().Type = OrganizationUserType.Owner; + + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + + var organizationUserRepository = sutProvider.GetDependency(); + SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); + + organizationUserRepository.GetManyDetailsByOrganizationAsync(org.Id) + .Returns(existingUsers); + organizationUserRepository.GetCountByOrganizationIdAsync(org.Id) + .Returns(existingUsers.Count); + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync(org.Id, Arg.Any>()) + .Returns(true); + sutProvider.GetDependency().ManageUsers(org.Id).Returns(true); + + + await sutProvider.Sut.ImportAsync(org.Id, null, newUsers, null, false, EventSystemUser.PublicApi); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .UpsertAsync(default); + await sutProvider.GetDependency().Received(1) + .UpsertManyAsync(Arg.Is>(users => !users.Any())); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAsync(default); + + // Create new users + await sutProvider.GetDependency().Received(1) + .CreateManyAsync(Arg.Is>(users => users.Count() == expectedNewUsersCount)); + + await sutProvider.GetDependency().Received(1) + .SendInvitesAsync( + Arg.Is( + info => info.Users.Length == expectedNewUsersCount && + info.Organization == org)); + + // Send events + await sutProvider.GetDependency().Received(1) + .LogOrganizationUserEventsAsync(Arg.Is>(events => + events.Count() == expectedNewUsersCount)); + } + + [Theory, PaidOrganizationCustomize, BitAutoData] + public async Task OrgImportCreateNewUsersAndMarryExistingUser(SutProvider sutProvider, Organization org, List existingUsers, + List newUsers) + { + // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks + sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); + sutProvider.Create(); + + org.UseDirectory = true; + org.Seats = newUsers.Count + existingUsers.Count + 1; + var reInvitedUser = existingUsers.First(); + reInvitedUser.ExternalId = null; + newUsers.Add(new ImportedOrganizationUser + { + Email = reInvitedUser.Email, + ExternalId = reInvitedUser.Email, + }); + var expectedNewUsersCount = newUsers.Count - 1; + + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + sutProvider.GetDependency().GetManyDetailsByOrganizationAsync(org.Id) + .Returns(existingUsers); + sutProvider.GetDependency().GetCountByOrganizationIdAsync(org.Id) + .Returns(existingUsers.Count); + sutProvider.GetDependency().GetByIdAsync(reInvitedUser.Id) + .Returns(new OrganizationUser { Id = reInvitedUser.Id }); + + var organizationUserRepository = sutProvider.GetDependency(); + + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync(org.Id, Arg.Any>()) + .Returns(true); + + SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); + + var currentContext = sutProvider.GetDependency(); + currentContext.ManageUsers(org.Id).Returns(true); + + await sutProvider.Sut.ImportAsync(org.Id, null, newUsers, null, false, EventSystemUser.PublicApi); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .UpsertAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAsync(default, default); + + // Upserted existing user + await sutProvider.GetDependency().Received(1) + .UpsertManyAsync(Arg.Is>(users => users.Count() == 1)); + + // Created and invited new users + await sutProvider.GetDependency().Received(1) + .CreateManyAsync(Arg.Is>(users => users.Count() == expectedNewUsersCount)); + + await sutProvider.GetDependency().Received(1) + .SendInvitesAsync(Arg.Is(request => + request.Users.Length == expectedNewUsersCount && + request.Organization == org)); + + // Sent events + await sutProvider.GetDependency().Received(1) + .LogOrganizationUserEventsAsync(Arg.Is>(events => + events.Count(e => e.Item2 == EventType.OrganizationUser_Invited) == expectedNewUsersCount)); + } + + [Theory] [OrganizationInviteCustomize(InviteeUserType = OrganizationUserType.User, InvitorUserType = OrganizationUserType.Owner), OrganizationCustomize, BitAutoData]