From 947ae8db516bef48c4a9da31aea1ff6c513e58ad Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Tue, 22 Jul 2025 17:30:25 -0400 Subject: [PATCH] [PM-19145] refactor organization service.import async (#5800) * initial lift and shift * extract function RemoveExistingExternalUsers * Extract function RemoveExistingUsers() * extract function OverwriteExisting() * create new model for sync data * extract add users to function, rename * rename OrganizatinUserInvite for command, implement command * implement command * refactor groups logic * fix imports * remove old tests, fix imports * fix namespace * fix CommandResult useage * tests wip * wip * wip * remove redundant code, remove looping db call, refactor tests * clean up * remove looping db call with bulk method * clean up * remove orgId param to use id already in request * change param * cleanup params * remove IReferenceEventService * fix test * fix tests * cr feedback * remove _timeProvider * add xmldoc, refactor to make InviteOrganizationUsersCommand vNext instead of default * switch back to command * re-add old ImportAsync impl * fix test * add feature flag * cleanup * clean up * fix tests * wip * wip * add api integration tests for users WIP * groups integration tests * cleanup * fix error from merging main * fix tests * cr feedback * fix test * fix test --- .../src/Scim/Models/ScimUserRequestModel.cs | 4 +- .../Controllers/OrganizationController.cs | 38 +- ...IImportOrganizationUserAndGroupsCommand.cs | 16 + ...ImportOrganizationUsersAndGroupsCommand.cs | 391 ++++++++++++++++++ .../Import/OrganizationGroupImportData.cs | 41 ++ .../Import/OrganizationUserImportData.cs | 32 ++ .../IInviteOrganizationUsersCommand.cs | 10 + .../InviteOrganizationUsersCommand.cs | 38 +- .../CreateOrganizationUserExtensions.cs | 2 +- .../Models/InviteOrganizationUsersRequest.cs | 4 +- ...nviteOrganizationUsersValidationRequest.cs | 2 +- ... => OrganizationUserInviteCommandModel.cs} | 8 +- .../InviteOrganizationUserValidator.cs | 3 +- src/Core/Constants.cs | 1 + ...OrganizationServiceCollectionExtensions.cs | 1 + ...tOrganizationUsersAndGroupsCommandTests.cs | 312 ++++++++++++++ .../Helpers/OrganizationTestHelpers.cs | 18 + .../InviteOrganizationUsersRequestTests.cs | 6 +- ...tOrganizationUsersAndGroupsCommandTests.cs | 215 ++++++++++ .../InviteOrganizationUserCommandTests.cs | 25 +- .../InviteOrganizationUsersValidatorTests.cs | 19 +- 21 files changed, 1137 insertions(+), 49 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Import/IImportOrganizationUserAndGroupsCommand.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Import/OrganizationGroupImportData.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Import/OrganizationUserImportData.cs rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/{OrganizationUserInvite.cs => OrganizationUserInviteCommandModel.cs} (88%) create mode 100644 test/Api.IntegrationTest/AdminConsole/Import/ImportOrganizationUsersAndGroupsCommandTests.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommandTests.cs diff --git a/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs b/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs index 0baf6469ff..fc4f781e42 100644 --- a/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs +++ b/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs @@ -6,9 +6,9 @@ using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Business; using Bit.Core.Models.Data; using Bit.Core.Utilities; -using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite; namespace Bit.Scim.Models; @@ -47,7 +47,7 @@ public class ScimUserRequestModel : BaseScimUserModel return new InviteOrganizationUsersRequest( invites: [ - new Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite( + new OrganizationUserInviteCommandModel( email: email, externalId: ExternalIdForInvite() ) diff --git a/src/Api/AdminConsole/Public/Controllers/OrganizationController.cs b/src/Api/AdminConsole/Public/Controllers/OrganizationController.cs index a1af1c3fb8..18afa10ac0 100644 --- a/src/Api/AdminConsole/Public/Controllers/OrganizationController.cs +++ b/src/Api/AdminConsole/Public/Controllers/OrganizationController.cs @@ -4,8 +4,9 @@ using System.Net; using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.Models.Public.Response; +using Bit.Core; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Context; -using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Services; using Bit.Core.Settings; @@ -21,15 +22,21 @@ public class OrganizationController : Controller private readonly IOrganizationService _organizationService; private readonly ICurrentContext _currentContext; private readonly GlobalSettings _globalSettings; + private readonly IImportOrganizationUsersAndGroupsCommand _importOrganizationUsersAndGroupsCommand; + private readonly IFeatureService _featureService; public OrganizationController( IOrganizationService organizationService, ICurrentContext currentContext, - GlobalSettings globalSettings) + GlobalSettings globalSettings, + IImportOrganizationUsersAndGroupsCommand importOrganizationUsersAndGroupsCommand, + IFeatureService featureService) { _organizationService = organizationService; _currentContext = currentContext; _globalSettings = globalSettings; + _importOrganizationUsersAndGroupsCommand = importOrganizationUsersAndGroupsCommand; + _featureService = featureService; } /// @@ -50,13 +57,26 @@ public class OrganizationController : Controller throw new BadRequestException("You cannot import this much data at once."); } - await _organizationService.ImportAsync( - _currentContext.OrganizationId.Value, - model.Groups.Select(g => g.ToImportedGroup(_currentContext.OrganizationId.Value)), - model.Members.Where(u => !u.Deleted).Select(u => u.ToImportedOrganizationUser()), - model.Members.Where(u => u.Deleted).Select(u => u.ExternalId), - model.OverwriteExisting.GetValueOrDefault(), - EventSystemUser.PublicApi); + if (_featureService.IsEnabled(FeatureFlagKeys.ImportAsyncRefactor)) + { + await _importOrganizationUsersAndGroupsCommand.ImportAsync( + _currentContext.OrganizationId.Value, + model.Groups.Select(g => g.ToImportedGroup(_currentContext.OrganizationId.Value)), + model.Members.Where(u => !u.Deleted).Select(u => u.ToImportedOrganizationUser()), + model.Members.Where(u => u.Deleted).Select(u => u.ExternalId), + model.OverwriteExisting.GetValueOrDefault()); + } + else + { + await _organizationService.ImportAsync( + _currentContext.OrganizationId.Value, + model.Groups.Select(g => g.ToImportedGroup(_currentContext.OrganizationId.Value)), + model.Members.Where(u => !u.Deleted).Select(u => u.ToImportedOrganizationUser()), + model.Members.Where(u => u.Deleted).Select(u => u.ExternalId), + model.OverwriteExisting.GetValueOrDefault(), + Core.Enums.EventSystemUser.PublicApi); + } + return new OkResult(); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Import/IImportOrganizationUserAndGroupsCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Import/IImportOrganizationUserAndGroupsCommand.cs new file mode 100644 index 0000000000..b74da0a2e8 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Import/IImportOrganizationUserAndGroupsCommand.cs @@ -0,0 +1,16 @@ +#nullable enable + +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.Models.Business; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; + +public interface IImportOrganizationUsersAndGroupsCommand +{ + Task ImportAsync(Guid organizationId, + IEnumerable groups, + IEnumerable newUsers, + IEnumerable removeUserExternalIds, + bool overwriteExisting + ); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs new file mode 100644 index 0000000000..89288eb4ba --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs @@ -0,0 +1,391 @@ +#nullable enable + +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; +using Bit.Core.Models.Business; +using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; + +public class ImportOrganizationUsersAndGroupsCommand : IImportOrganizationUsersAndGroupsCommand +{ + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + 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 EventSystemUser _EventSystemUser = EventSystemUser.PublicApi; + + public ImportOrganizationUsersAndGroupsCommand(IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IPaymentService paymentService, + IGroupRepository groupRepository, + IEventService eventService, + ICurrentContext currentContext, + IOrganizationService organizationService, + IInviteOrganizationUsersCommand inviteOrganizationUsersCommand, + IPricingClient pricingClient + ) + { + _organizationRepository = organizationRepository; + _organizationUserRepository = organizationUserRepository; + _paymentService = paymentService; + _groupRepository = groupRepository; + _eventService = eventService; + _currentContext = currentContext; + _organizationService = organizationService; + _inviteOrganizationUsersCommand = inviteOrganizationUsersCommand; + _pricingClient = pricingClient; + } + + /// + /// Imports and synchronizes organization users and groups. + /// + /// The unique identifier of the organization. + /// List of groups to import. + /// List of users to import. + /// A collection of ExternalUserIds to be removed from the organization. + /// Indicates whether to delete existing external users from the organization + /// who are not included in the current import. + /// Thrown if the organization does not exist. + /// Thrown if the organization is not configured to use directory syncing. + public async Task ImportAsync(Guid organizationId, + IEnumerable importedGroups, + IEnumerable importedUsers, + IEnumerable removeUserExternalIds, + bool overwriteExisting) + { + var organization = await GetOrgById(organizationId); + if (organization is null) + { + throw new NotFoundException(); + } + + if (!organization.UseDirectory) + { + throw new BadRequestException("Organization cannot use directory syncing."); + } + + var existingUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); + var importUserData = new OrganizationUserImportData(existingUsers, importedUsers); + var events = new List<(OrganizationUserUserDetails ou, EventType e, DateTime? d)>(); + + await RemoveExistingExternalUsers(removeUserExternalIds, events, importUserData); + + if (overwriteExisting) + { + await OverwriteExisting(events, importUserData); + } + + await UpdateExistingUsers(importedUsers, importUserData); + + await AddNewUsers(organization, importedUsers, importUserData); + + await ImportGroups(organization, importedGroups, importUserData); + + await _eventService.LogOrganizationUserEventsAsync(events.Select(e => (e.ou, e.e, _EventSystemUser, e.d))); + } + + /// + /// Deletes external users based on provided set of ExternalIds. + /// + /// A collection of external user IDs to be deleted. + /// A list to which user removal events will be added. + /// Data containing imported and existing external users. + + private async Task RemoveExistingExternalUsers(IEnumerable removeUserExternalIds, + List<(OrganizationUserUserDetails ou, EventType e, DateTime? d)> events, + OrganizationUserImportData importUserData) + { + if (!removeUserExternalIds.Any()) + { + return; + } + + var existingUsersDict = importUserData.ExistingExternalUsers.ToDictionary(u => u.ExternalId); + // Determine which ids in removeUserExternalIds to delete based on: + // They are not in ImportedExternalIds, they are in existingUsersDict, and they are not an owner. + var removeUsersSet = new HashSet(removeUserExternalIds) + .Except(importUserData.ImportedExternalIds) + .Where(u => existingUsersDict.ContainsKey(u) && existingUsersDict[u].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 + )) + ); + } + + /// + /// Updates existing organization users by assigning each an ExternalId from the imported user data + /// where a match is found by email and the existing user lacks an ExternalId. Saves the updated + /// users and updates the ExistingExternalUsersIdDict mapping. + /// + /// List of imported organization users. + /// Data containing existing and imported users, along with mapping dictionaries. + private async Task UpdateExistingUsers(IEnumerable importedUsers, OrganizationUserImportData importUserData) + { + if (!importedUsers.Any()) + { + return; + } + + var updateUsers = new List(); + + // Map existing and imported users to dicts keyed by Email + var existingUsersEmailsDict = importUserData.ExistingUsers + .Where(u => string.IsNullOrWhiteSpace(u.ExternalId)) + .ToDictionary(u => u.Email); + var importedUsersEmailsDict = importedUsers.ToDictionary(u => u.Email); + + // Determine which users to update. + var userEmailsToUpdate = existingUsersEmailsDict.Keys.Intersect(importedUsersEmailsDict.Keys).ToList(); + var userIdsToUpdate = userEmailsToUpdate.Select(e => existingUsersEmailsDict[e].Id).ToList(); + + var organizationUsers = (await _organizationUserRepository.GetManyAsync(userIdsToUpdate)).ToDictionary(u => u.Id); + + foreach (var userEmail in userEmailsToUpdate) + { + // verify userEmail has an associated OrganizationUser + existingUsersEmailsDict.TryGetValue(userEmail, out var existingUser); + organizationUsers.TryGetValue(existingUser!.Id, out var organizationUser); + importedUsersEmailsDict.TryGetValue(userEmail, out var importedUser); + + if (organizationUser is null || importedUser is null) + { + continue; + } + + organizationUser.ExternalId = importedUser.ExternalId; + updateUsers.Add(organizationUser); + importUserData.ExistingExternalUsersIdDict.Add(organizationUser.ExternalId, organizationUser.Id); + } + await _organizationUserRepository.UpsertManyAsync(updateUsers); + } + + /// + /// Adds new external users to the organization by inviting users who are present in the imported data + /// but not already part of the organization. Sends invitations, updates the user Id mapping on success, + /// and throws exceptions on failure. + /// + /// The target organization to which users are being added. + /// A collection of imported users to consider for addition. + /// Data containing imported user info and existing user mappings. + private async Task AddNewUsers(Organization organization, + IEnumerable importedUsers, + OrganizationUserImportData importUserData) + { + // Determine which users are already in the organization + var existingUsersSet = new HashSet(importUserData.ExistingExternalUsersIdDict.Keys); + var usersToAdd = importUserData.ImportedExternalIds.Except(existingUsersSet).ToList(); + var userInvites = new List<(OrganizationUserInvite, string)>(); + var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization); + + foreach (var user in importedUsers) + { + 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 _organizationService.InviteUsersAsync(organization.Id, Guid.Empty, _EventSystemUser, userInvites); + foreach (var invitedUser in invitedUsers) + { + importUserData.ExistingExternalUsersIdDict.TryAdd(invitedUser.ExternalId!, invitedUser.Id); + } + } + + /// + /// Deletes existing external users from the organization who are not included in the current import and are not owners. + /// Records corresponding removal events and updates the internal mapping by removing deleted users. + /// + /// A list to which user removal events will be added. + /// Data containing existing and imported external users along with their Id mappings. + private async Task OverwriteExisting( + 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)); + 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) + { + importUserData.ExistingExternalUsersIdDict.Remove(deletedUser.ExternalId); + } + } + + /// + /// Imports group data into the organization by saving new groups and updating existing ones. + /// + /// The organization into which groups are being imported. + /// A collection of groups to be imported. + /// Data containing information about existing and imported users. + private async Task ImportGroups(Organization organization, IEnumerable importedGroups, OrganizationUserImportData importUserData) + { + if (!importedGroups.Any()) + { + return; + } + + if (!organization.UseGroups) + { + throw new BadRequestException("Organization cannot use groups."); + } + + var existingGroups = await _groupRepository.GetManyByOrganizationIdAsync(organization.Id); + var importGroupData = new OrganizationGroupImportData(importedGroups, existingGroups); + + await SaveNewGroups(importGroupData, importUserData); + await UpdateExistingGroups(importGroupData, importUserData, organization); + } + + /// + /// Saves newly imported groups that do not already exist in the organization. + /// Sets their creation and revision dates, associates users with each group. + /// + /// Data containing both imported and existing groups. + /// Data containing information about existing and imported users. + private async Task SaveNewGroups(OrganizationGroupImportData importGroupData, OrganizationUserImportData importUserData) + { + var existingExternalGroupsDict = importGroupData.ExistingExternalGroups.ToDictionary(g => g.ExternalId!); + var newGroups = importGroupData.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, importGroupData.GroupsDict[group.ExternalId!].ExternalUserIds, + importUserData.ExistingExternalUsersIdDict); + } + + await _eventService.LogGroupEventsAsync( + savedGroups.Select(g => (g, EventType.Group_Created, (EventSystemUser?)_EventSystemUser, (DateTime?)DateTime.UtcNow))); + } + + /// + /// Updates existing groups in the organization based on imported group data. + /// If a group's name has changed, it updates the name and revision date in the repository. + /// Also updates group-user associations. + /// + /// Data containing imported groups and their user associations. + /// Data containing imported and existing organization users. + /// The organization to which the groups belong. + private async Task UpdateExistingGroups(OrganizationGroupImportData importGroupData, + OrganizationUserImportData importUserData, + Organization organization) + { + var updateGroups = importGroupData.ExistingExternalGroups + .Where(g => importGroupData.GroupsDict.ContainsKey(g.ExternalId!)) + .ToList(); + + if (updateGroups.Any()) + { + // get existing group users + var groupUsers = await _groupRepository.GetManyGroupUsersByOrganizationIdAsync(organization.Id); + var existingGroupUsers = groupUsers + .GroupBy(gu => gu.GroupId) + .ToDictionary(g => g.Key, g => new HashSet(g.Select(gr => gr.OrganizationUserId))); + + foreach (var group in updateGroups) + { + // Check for changes to the group, update if changed. + var updatedGroup = importGroupData.GroupsDict[group.ExternalId!].Group; + if (group.Name != updatedGroup.Name) + { + group.RevisionDate = DateTime.UtcNow; + group.Name = updatedGroup.Name; + + await _groupRepository.ReplaceAsync(group); + } + + // compare and update user group associations + await UpdateUsersAsync(group, importGroupData.GroupsDict[group.ExternalId!].ExternalUserIds, + importUserData.ExistingExternalUsersIdDict, + existingGroupUsers.ContainsKey(group.Id) ? existingGroupUsers[group.Id] : null); + + } + + await _eventService.LogGroupEventsAsync( + updateGroups.Select(g => (g, EventType.Group_Updated, (EventSystemUser?)_EventSystemUser, (DateTime?)DateTime.UtcNow))); + } + + } + + /// + /// Updates the user associations for a given group. + /// Only updates if the set of associated users differs from the current group membership. + /// Filters users based on those present in the existing user Id dictionary. + /// + /// The group whose user associations are being updated. + /// A set of ExternalUserIds to be associated with the group. + /// A dictionary mapping ExternalUserIds to internal user Ids. + /// Optional set of currently associated user Ids for comparison. + private async Task UpdateUsersAsync(Group group, HashSet groupUsers, + Dictionary existingUsersIdDict, HashSet? existingUsers = null) + { + var availableUsers = groupUsers.Intersect(existingUsersIdDict.Keys); + var users = new HashSet(availableUsers.Select(u => existingUsersIdDict[u])); + if (existingUsers is not null && existingUsers.Count == users.Count && users.SetEquals(existingUsers)) + { + return; + } + + await _groupRepository.UpdateUsersAsync(group.Id, users); + } + + private async Task GetOrgById(Guid id) + { + return await _organizationRepository.GetByIdAsync(id); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Import/OrganizationGroupImportData.cs b/src/Core/AdminConsole/OrganizationFeatures/Import/OrganizationGroupImportData.cs new file mode 100644 index 0000000000..6f49cb82e6 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Import/OrganizationGroupImportData.cs @@ -0,0 +1,41 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business; + +namespace Bit.Core.Models.Data.Organizations; + +/// +/// Represents the data required to import organization groups, +/// including newly imported groups and existing groups within the organization. +/// +public class OrganizationGroupImportData +{ + /// + /// The collection of groups that are being imported. + /// + public readonly IEnumerable Groups; + + /// + /// Collection of groups that already exist in the organization. + /// + public readonly ICollection ExistingGroups; + + /// + /// Existing groups with ExternalId set. + /// + public readonly IEnumerable ExistingExternalGroups; + + /// + /// Mapping of imported groups keyed by their ExternalId. + /// + public readonly IDictionary GroupsDict; + + public OrganizationGroupImportData(IEnumerable groups, ICollection existingGroups) + { + Groups = groups; + GroupsDict = groups.ToDictionary(g => g.Group.ExternalId!); + ExistingGroups = existingGroups; + ExistingExternalGroups = existingGroups.Where(u => !string.IsNullOrWhiteSpace(u.ExternalId)).ToList(); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Import/OrganizationUserImportData.cs b/src/Core/AdminConsole/OrganizationFeatures/Import/OrganizationUserImportData.cs new file mode 100644 index 0000000000..6575afe842 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Import/OrganizationUserImportData.cs @@ -0,0 +1,32 @@ +#nullable enable + +using Bit.Core.Models.Business; +namespace Bit.Core.Models.Data.Organizations.OrganizationUsers; + +public class OrganizationUserImportData +{ + /// + /// Set of user ExternalIds that are being imported + /// + public readonly HashSet ImportedExternalIds; + /// + /// All existing OrganizationUsers for the organization + /// + public readonly ICollection ExistingUsers; + /// + /// Existing OrganizationUsers with ExternalIds set. + /// + public readonly IEnumerable ExistingExternalUsers; + /// + /// Mapping of an existing users's ExternalId to their Id + /// + public readonly Dictionary ExistingExternalUsersIdDict; + + public OrganizationUserImportData(ICollection existingUsers, IEnumerable importedUsers) + { + ImportedExternalIds = new HashSet(importedUsers?.Select(u => u.ExternalId) ?? new List()); + ExistingUsers = existingUsers; + ExistingExternalUsers = ExistingUsers.Where(u => !string.IsNullOrWhiteSpace(u.ExternalId)).ToList(); + ExistingExternalUsersIdDict = ExistingExternalUsers.ToDictionary(u => u.ExternalId, u => u.Id); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IInviteOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IInviteOrganizationUsersCommand.cs index 7e0a8dc3cd..7e8fd4c30a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IInviteOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IInviteOrganizationUsersCommand.cs @@ -19,4 +19,14 @@ public interface IInviteOrganizationUsersCommand /// /// Response from InviteScimOrganiation Task> InviteScimOrganizationUserAsync(InviteOrganizationUsersRequest request); + /// + /// Sends invitations to add imported organization users via the public API. + /// This can be a Success or a Failure. Failure will contain the Error along with a representation of the errored value. + /// Success will be the successful return object. + /// + /// + /// Contains the details for inviting the imported organization users. + /// + /// Response from InviteOrganiationUsersAsync + Task> InviteImportedOrganizationUsersAsync(InviteOrganizationUsersRequest request); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs index addb1997a9..47003be5c6 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs @@ -12,13 +12,13 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Utilities.Commands; using Bit.Core.AdminConsole.Utilities.Errors; using Bit.Core.AdminConsole.Utilities.Validation; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; using Microsoft.Extensions.Logging; -using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; @@ -74,6 +74,40 @@ public class InviteOrganizationUsersCommand(IEventService eventService, } } + public async Task> InviteImportedOrganizationUsersAsync(InviteOrganizationUsersRequest request) + { + var result = await InviteOrganizationUsersAsync(request); + + switch (result) + { + case Failure failure: + return new Failure( + new Error( + failure.Error.Message, + new InviteOrganizationUsersResponse(failure.Error.ErroredValue.InvitedUsers, request.InviteOrganization.OrganizationId) + ) + ); + + case Success success when success.Value.InvitedUsers.Any(): + + List<(OrganizationUser, EventType, EventSystemUser, DateTime?)> events = new List<(OrganizationUser, EventType, EventSystemUser, DateTime?)>(); + foreach (var user in success.Value.InvitedUsers) + { + events.Add((user, EventType.OrganizationUser_Invited, EventSystemUser.PublicApi, request.PerformedAt.UtcDateTime)); + } + + await eventService.LogOrganizationUserEventsAsync(events); + + return new Success(new InviteOrganizationUsersResponse(success.Value.InvitedUsers, request.InviteOrganization.OrganizationId) + ); + + default: + return new Failure( + new InvalidResultTypeError( + new InviteOrganizationUsersResponse(request.InviteOrganization.OrganizationId))); + } + } + private async Task> InviteOrganizationUsersAsync(InviteOrganizationUsersRequest request) { var invitesToSend = (await FilterExistingUsersAsync(request)).ToArray(); @@ -141,7 +175,7 @@ public class InviteOrganizationUsersCommand(IEventService eventService, organizationId: organization!.Id)); } - private async Task> FilterExistingUsersAsync(InviteOrganizationUsersRequest request) + private async Task> FilterExistingUsersAsync(InviteOrganizationUsersRequest request) { var existingEmails = new HashSet(await organizationUserRepository.SelectKnownEmailsAsync( request.InviteOrganization.OrganizationId, request.Invites.Select(i => i.Email), false), diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUserExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUserExtensions.cs index 23c38a51cb..b0f81bd92a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUserExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUserExtensions.cs @@ -7,7 +7,7 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUse public static class CreateOrganizationUserExtensions { - public static CreateOrganizationUser MapToDataModel(this OrganizationUserInvite organizationUserInvite, + public static CreateOrganizationUser MapToDataModel(this OrganizationUserInviteCommandModel organizationUserInvite, DateTimeOffset performedAt, InviteOrganization organization) => new() diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersRequest.cs index 84b350c551..2a54f26eb8 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersRequest.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersRequest.cs @@ -4,12 +4,12 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUse public class InviteOrganizationUsersRequest { - public OrganizationUserInvite[] Invites { get; } = []; + public OrganizationUserInviteCommandModel[] Invites { get; } = []; public InviteOrganization InviteOrganization { get; } public Guid PerformedBy { get; } public DateTimeOffset PerformedAt { get; } - public InviteOrganizationUsersRequest(OrganizationUserInvite[] invites, + public InviteOrganizationUsersRequest(OrganizationUserInviteCommandModel[] invites, InviteOrganization inviteOrganization, Guid performedBy, DateTimeOffset performedAt) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersValidationRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersValidationRequest.cs index 56812e2617..e2eb91454c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersValidationRequest.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersValidationRequest.cs @@ -32,7 +32,7 @@ public class InviteOrganizationUsersValidationRequest SecretsManagerSubscriptionUpdate = smSubscriptionUpdate; } - public OrganizationUserInvite[] Invites { get; init; } = []; + public OrganizationUserInviteCommandModel[] Invites { get; init; } = []; public InviteOrganization InviteOrganization { get; init; } public Guid PerformedBy { get; init; } public DateTimeOffset PerformedAt { get; init; } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInvite.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInviteCommandModel.cs similarity index 88% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInvite.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInviteCommandModel.cs index 0b83680aa5..4d0f56efe4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInvite.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInviteCommandModel.cs @@ -7,7 +7,7 @@ using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Invite namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; -public class OrganizationUserInvite +public class OrganizationUserInviteCommandModel { public string Email { get; private init; } public CollectionAccessSelection[] AssignedCollections { get; private init; } @@ -17,7 +17,7 @@ public class OrganizationUserInvite public bool AccessSecretsManager { get; private init; } public Guid[] Groups { get; private init; } - public OrganizationUserInvite(string email, string externalId) : + public OrganizationUserInviteCommandModel(string email, string externalId) : this( email: email, assignedCollections: [], @@ -29,7 +29,7 @@ public class OrganizationUserInvite { } - public OrganizationUserInvite(OrganizationUserInvite invite, bool accessSecretsManager) : + public OrganizationUserInviteCommandModel(OrganizationUserInviteCommandModel invite, bool accessSecretsManager) : this(invite.Email, invite.AssignedCollections, invite.Groups, @@ -41,7 +41,7 @@ public class OrganizationUserInvite } - public OrganizationUserInvite(string email, + public OrganizationUserInviteCommandModel(string email, IEnumerable assignedCollections, IEnumerable groups, OrganizationUserType type, diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs index 557ece2104..a3b1e43a04 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs @@ -9,7 +9,6 @@ using Bit.Core.Models.Business; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; -using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; @@ -41,7 +40,7 @@ public class InviteOrganizationUsersValidator( request = new InviteOrganizationUsersValidationRequest(request) { Invites = request.Invites - .Select(x => new OrganizationUserInvite(x, accessSecretsManager: true)) + .Select(x => new OrganizationUserInviteCommandModel(x, accessSecretsManager: true)) .ToArray() }; } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 070ae6baf1..539ff6d977 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -113,6 +113,7 @@ public static class FeatureFlagKeys public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast"; public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations"; public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions"; + public const string ImportAsyncRefactor = "pm-22583-refactor-import-async"; public const string CreateDefaultLocation = "pm-19467-create-default-location"; /* Auth Team */ diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index b78a305d31..e28831e0ab 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -190,6 +190,7 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } // TODO: move to OrganizationSubscriptionServiceCollectionExtensions when OrganizationUser methods are moved out of diff --git a/test/Api.IntegrationTest/AdminConsole/Import/ImportOrganizationUsersAndGroupsCommandTests.cs b/test/Api.IntegrationTest/AdminConsole/Import/ImportOrganizationUsersAndGroupsCommandTests.cs new file mode 100644 index 0000000000..5c29b8b1b7 --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Import/ImportOrganizationUsersAndGroupsCommandTests.cs @@ -0,0 +1,312 @@ +using System.Net; +using Bit.Api.AdminConsole.Public.Models.Request; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Enums; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using NSubstitute; +using Xunit; + +namespace Bit.Api.IntegrationTest.AdminConsole.Import; + +public class ImportOrganizationUsersAndGroupsCommandTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + private readonly LoginHelper _loginHelper; + private Organization _organization = null!; + private string _ownerEmail = null!; + + public ImportOrganizationUsersAndGroupsCommandTests(ApiApplicationFactory factory) + { + _factory = factory; + _factory.SubstituteService((IFeatureService featureService) + => featureService.IsEnabled(FeatureFlagKeys.ImportAsyncRefactor) + .Returns(true)); + _client = _factory.CreateClient(); + _loginHelper = new LoginHelper(_factory, _client); + } + + public async Task InitializeAsync() + { + // Create the owner account + _ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(_ownerEmail); + + // Create the organization + (_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually2023, + ownerEmail: _ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card); + + // Authorize with the organization api key + await _loginHelper.LoginWithOrganizationApiKeyAsync(_organization.Id); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task Import_Existing_Organization_User_Succeeds() + { + var (email, ou) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, + OrganizationUserType.User); + + var externalId = Guid.NewGuid().ToString(); + var request = new OrganizationImportRequestModel(); + request.LargeImport = false; + request.OverwriteExisting = false; + request.Groups = []; + request.Members = [ + new OrganizationImportRequestModel.OrganizationImportMemberRequestModel + { + Email = email, + ExternalId = externalId, + Deleted = false + } + ]; + + var response = await _client.PostAsync($"/public/organization/import", JsonContent.Create(request)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Assert against the database values + var organizationUserRepository = _factory.GetService(); + var orgUser = await organizationUserRepository.GetByIdAsync(ou.Id); + + Assert.NotNull(orgUser); + Assert.Equal(ou.Id, orgUser.Id); + Assert.Equal(email, orgUser.Email); + Assert.Equal(OrganizationUserType.User, orgUser.Type); + Assert.Equal(externalId, orgUser.ExternalId); + Assert.Equal(OrganizationUserStatusType.Confirmed, orgUser.Status); + Assert.Equal(_organization.Id, orgUser.OrganizationId); + + } + + [Fact] + public async Task Import_New_Organization_User_Succeeds() + { + var email = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(email); + + var externalId = Guid.NewGuid().ToString(); + var request = new OrganizationImportRequestModel(); + request.LargeImport = false; + request.OverwriteExisting = false; + request.Groups = []; + request.Members = [ + new OrganizationImportRequestModel.OrganizationImportMemberRequestModel + { + Email = email, + ExternalId = externalId, + Deleted = false + } + ]; + + var response = await _client.PostAsync($"/public/organization/import", JsonContent.Create(request)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Assert against the database values + var organizationUserRepository = _factory.GetService(); + var orgUser = await organizationUserRepository.GetByOrganizationEmailAsync(_organization.Id, email); + + Assert.NotNull(orgUser); + Assert.Equal(email, orgUser.Email); + Assert.Equal(OrganizationUserType.User, orgUser.Type); + Assert.Equal(externalId, orgUser.ExternalId); + Assert.Equal(OrganizationUserStatusType.Invited, orgUser.Status); + Assert.Equal(_organization.Id, orgUser.OrganizationId); + } + + [Fact] + public async Task Import_New_And_Existing_Organization_Users_Succeeds() + { + // Existing organization user + var (existingEmail, ou) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, + OrganizationUserType.User); + var existingExternalId = Guid.NewGuid().ToString(); + + // New organization user + var newEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(newEmail); + var newExternalId = Guid.NewGuid().ToString(); + + var request = new OrganizationImportRequestModel(); + request.LargeImport = false; + request.OverwriteExisting = false; + request.Groups = []; + request.Members = [ + new OrganizationImportRequestModel.OrganizationImportMemberRequestModel + { + Email = existingEmail, + ExternalId = existingExternalId, + Deleted = false + }, + new OrganizationImportRequestModel.OrganizationImportMemberRequestModel + { + Email = newEmail, + ExternalId = newExternalId, + Deleted = false + } + ]; + + var response = await _client.PostAsync($"/public/organization/import", JsonContent.Create(request)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Assert against the database values + var organizationUserRepository = _factory.GetService(); + + // Existing user + var existingOrgUser = await organizationUserRepository.GetByIdAsync(ou.Id); + Assert.NotNull(existingOrgUser); + Assert.Equal(existingEmail, existingOrgUser.Email); + Assert.Equal(OrganizationUserType.User, existingOrgUser.Type); + Assert.Equal(existingExternalId, existingOrgUser.ExternalId); + Assert.Equal(OrganizationUserStatusType.Confirmed, existingOrgUser.Status); + Assert.Equal(_organization.Id, existingOrgUser.OrganizationId); + + // New User + var newOrgUser = await organizationUserRepository.GetByOrganizationEmailAsync(_organization.Id, newEmail); + Assert.NotNull(newOrgUser); + Assert.Equal(newEmail, newOrgUser.Email); + Assert.Equal(OrganizationUserType.User, newOrgUser.Type); + Assert.Equal(newExternalId, newOrgUser.ExternalId); + Assert.Equal(OrganizationUserStatusType.Invited, newOrgUser.Status); + Assert.Equal(_organization.Id, newOrgUser.OrganizationId); + } + + [Fact] + public async Task Import_Existing_Groups_Succeeds() + { + var organizationUserRepository = _factory.GetService(); + var group = await OrganizationTestHelpers.CreateGroup(_factory, _organization.Id); + var request = new OrganizationImportRequestModel(); + var addedMember = new OrganizationImportRequestModel.OrganizationImportMemberRequestModel + { + Email = "test@test.com", + ExternalId = "bwtest-externalId", + Deleted = false + }; + + request.LargeImport = false; + request.OverwriteExisting = false; + request.Groups = [ + new OrganizationImportRequestModel.OrganizationImportGroupRequestModel + { + Name = "new-name", + ExternalId = "bwtest-externalId", + MemberExternalIds = [] + } + ]; + request.Members = [addedMember]; + + var response = await _client.PostAsync($"/public/organization/import", JsonContent.Create(request)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Assert against the database values + var groupRepository = _factory.GetService(); + var existingGroups = (await groupRepository.GetManyByOrganizationIdAsync(_organization.Id)).ToArray(); + + // Assert that we are actually updating the existing group, not adding a new one. + Assert.Single(existingGroups); + Assert.NotNull(existingGroups[0]); + Assert.Equal(group.Id, existingGroups[0].Id); + Assert.Equal("new-name", existingGroups[0].Name); + Assert.Equal(group.ExternalId, existingGroups[0].ExternalId); + + var addedOrgUser = await organizationUserRepository.GetByOrganizationEmailAsync(_organization.Id, addedMember.Email); + Assert.NotNull(addedOrgUser); + } + + [Fact] + public async Task Import_New_Groups_Succeeds() + { + var group = new Group + { + OrganizationId = _organization.Id, + ExternalId = new Guid().ToString(), + Name = "bwtest1" + }; + + var request = new OrganizationImportRequestModel(); + request.LargeImport = false; + request.OverwriteExisting = false; + request.Groups = [ + new OrganizationImportRequestModel.OrganizationImportGroupRequestModel + { + Name = group.Name, + ExternalId = group.ExternalId, + MemberExternalIds = [] + } + ]; + request.Members = []; + + var response = await _client.PostAsync($"/public/organization/import", JsonContent.Create(request)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Assert against the database values + var groupRepository = _factory.GetService(); + var existingGroups = await groupRepository.GetManyByOrganizationIdAsync(_organization.Id); + var existingGroup = existingGroups.Where(g => g.ExternalId == group.ExternalId).FirstOrDefault(); + + Assert.NotNull(existingGroup); + Assert.Equal(existingGroup.Name, group.Name); + Assert.Equal(existingGroup.ExternalId, group.ExternalId); + } + + [Fact] + public async Task Import_New_And_Existing_Groups_Succeeds() + { + var existingGroup = await OrganizationTestHelpers.CreateGroup(_factory, _organization.Id); + + var newGroup = new Group + { + OrganizationId = _organization.Id, + ExternalId = "test", + Name = "bwtest1" + }; + + var request = new OrganizationImportRequestModel(); + request.LargeImport = false; + request.OverwriteExisting = false; + request.Groups = [ + new OrganizationImportRequestModel.OrganizationImportGroupRequestModel + { + Name = "new-name", + ExternalId = existingGroup.ExternalId, + MemberExternalIds = [] + }, + new OrganizationImportRequestModel.OrganizationImportGroupRequestModel + { + Name = newGroup.Name, + ExternalId = newGroup.ExternalId, + MemberExternalIds = [] + } + ]; + request.Members = []; + + var response = await _client.PostAsync($"/public/organization/import", JsonContent.Create(request)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Assert against the database values + var groupRepository = _factory.GetService(); + var groups = await groupRepository.GetManyByOrganizationIdAsync(_organization.Id); + + var newGroupInDb = groups.Where(g => g.ExternalId == newGroup.ExternalId).FirstOrDefault(); + Assert.NotNull(newGroupInDb); + Assert.Equal(newGroupInDb.Name, newGroup.Name); + Assert.Equal(newGroupInDb.ExternalId, newGroup.ExternalId); + + var existingGroupInDb = groups.Where(g => g.ExternalId == existingGroup.ExternalId).FirstOrDefault(); + Assert.NotNull(existingGroupInDb); + Assert.Equal(existingGroup.Id, existingGroupInDb.Id); + Assert.Equal("new-name", existingGroupInDb.Name); + Assert.Equal(existingGroup.ExternalId, existingGroupInDb.ExternalId); + } +} diff --git a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs index f2bc9f4bac..ae4e27267d 100644 --- a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs +++ b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs @@ -2,6 +2,7 @@ using Bit.Api.IntegrationTest.Factories; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; @@ -78,6 +79,7 @@ public static class OrganizationTestHelpers Status = userStatusType, ExternalId = null, AccessSecretsManager = accessSecretsManager, + Email = userEmail }; if (permissions != null) @@ -130,4 +132,20 @@ public static class OrganizationTestHelpers await organizationDomainRepository.CreateAsync(verifiedDomain); } + + public static async Task CreateGroup(ApiApplicationFactory factory, Guid organizationId) + { + + var groupRepository = factory.GetService(); + var group = new Group + { + OrganizationId = organizationId, + Id = new Guid(), + ExternalId = "bwtest-externalId", + Name = "bwtest" + }; + + await groupRepository.CreateAsync(group, new List()); + return group; + } } diff --git a/test/Core.Test/AdminConsole/Models/InviteOrganizationUsersRequestTests.cs b/test/Core.Test/AdminConsole/Models/InviteOrganizationUsersRequestTests.cs index 71b2b9766c..a9d1836a9f 100644 --- a/test/Core.Test/AdminConsole/Models/InviteOrganizationUsersRequestTests.cs +++ b/test/Core.Test/AdminConsole/Models/InviteOrganizationUsersRequestTests.cs @@ -16,7 +16,7 @@ public class InviteOrganizationUsersRequestTests public void Constructor_WhenPassedInvalidEmail_ThrowsException(string email, OrganizationUserType type, Permissions permissions, string externalId) { var exception = Assert.Throws(() => - new OrganizationUserInvite(email, [], [], type, permissions, externalId, false)); + new OrganizationUserInviteCommandModel(email, [], [], type, permissions, externalId, false)); Assert.Contains(InvalidEmailErrorMessage, exception.Message); } @@ -33,7 +33,7 @@ public class InviteOrganizationUsersRequestTests }; var exception = Assert.Throws(() => - new OrganizationUserInvite( + new OrganizationUserInviteCommandModel( email: validEmail, assignedCollections: [invalidCollectionConfiguration], groups: [], @@ -51,7 +51,7 @@ public class InviteOrganizationUsersRequestTests const string validEmail = "test@email.com"; var validCollectionConfiguration = new CollectionAccessSelection { Id = Guid.NewGuid(), Manage = true }; - var invite = new OrganizationUserInvite( + var invite = new OrganizationUserInviteCommandModel( email: validEmail, assignedCollections: [validCollectionConfiguration], groups: [], diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommandTests.cs new file mode 100644 index 0000000000..da02fbcf4d --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommandTests.cs @@ -0,0 +1,215 @@ +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; +using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Business; +using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Test.AutoFixture.OrganizationFixtures; +using Bit.Core.Tokens; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.Fakes; +using NSubstitute; +using Xunit; +using Organization = Bit.Core.AdminConsole.Entities.Organization; + +namespace Bit.Core.Test.OrganizationFeatures.OrganizationUsers; + +public class ImportOrganizationUsersAndGroupsCommandTests +{ + + private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory = new FakeDataProtectorTokenFactory(); + + [Theory, PaidOrganizationCustomize, BitAutoData] + public async Task OrgImportCallsInviteOrgUserCommand( + SutProvider sutProvider, + Organization org, + List existingUsers, + List importedUsers, + List newGroups) + { + SetupOrganizationConfigForImport(sutProvider, org, existingUsers, importedUsers); + + var orgUsers = new List(); + + // fix mocked email format, mock OrganizationUsers. + foreach (var u in importedUsers) + { + u.Email += "@bitwardentest.com"; + orgUsers.Add(new OrganizationUser { Email = u.Email, ExternalId = u.ExternalId }); + } + + importedUsers.Add(new ImportedOrganizationUser + { + Email = existingUsers.First().Email, + ExternalId = existingUsers.First().ExternalId + }); + + + existingUsers.First().Type = OrganizationUserType.Owner; + + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + + var organizationUserRepository = sutProvider.GetDependency(); + SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); + + sutProvider.GetDependency().HasSecretsManagerStandalone(org).Returns(true); + sutProvider.GetDependency().GetManyDetailsByOrganizationAsync(org.Id).Returns(existingUsers); + sutProvider.GetDependency().GetOccupiedSeatCountByOrganizationIdAsync(org.Id).Returns( + new OrganizationSeatCounts + { + Users = existingUsers.Count, + Sponsored = 0 + }); + sutProvider.GetDependency().ManageUsers(org.Id).Returns(true); + sutProvider.GetDependency().InviteUsersAsync(org.Id, Guid.Empty, EventSystemUser.PublicApi, + Arg.Any>()) + .Returns(orgUsers); + + await sutProvider.Sut.ImportAsync(org.Id, newGroups, importedUsers, new List(), false); + + var expectedNewUsersCount = importedUsers.Count - 1; + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .UpsertAsync(default); + await sutProvider.GetDependency().Received(1) + .UpsertManyAsync(Arg.Is>(users => !users.Any())); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAsync(default); + + // Send Invites + await sutProvider.GetDependency().Received(1). + InviteUsersAsync(org.Id, Guid.Empty, EventSystemUser.PublicApi, + Arg.Is>(invites => invites.Count() == expectedNewUsersCount)); + + // Send events + await sutProvider.GetDependency().Received(1) + .LogOrganizationUserEventsAsync(Arg.Any>()); + } + + [Theory, PaidOrganizationCustomize, BitAutoData] + public async Task OrgImportCreateNewUsersAndMarryExistingUser( + SutProvider sutProvider, + Organization org, + List existingUsers, + List importedUsers, + List newGroups) + { + SetupOrganizationConfigForImport(sutProvider, org, existingUsers, importedUsers); + + var orgUsers = new List(); + var reInvitedUser = existingUsers.First(); + // Existing user has no external ID. This will make the SUT call UpsertManyAsync + reInvitedUser.ExternalId = ""; + + // Mock an existing org user for this "existing" user + var reInvitedOrgUser = new OrganizationUser { Email = reInvitedUser.Email, Id = reInvitedUser.Id }; + + // fix email formatting, mock orgUsers to be returned + foreach (var u in existingUsers) + { + u.Email += "@bitwardentest.com"; + orgUsers.Add(new OrganizationUser { Email = u.Email, ExternalId = u.ExternalId }); + } + foreach (var u in importedUsers) + { + u.Email += "@bitwardentest.com"; + orgUsers.Add(new OrganizationUser { Email = u.Email, ExternalId = u.ExternalId }); + } + + // add the existing user to be re-imported + importedUsers.Add(new ImportedOrganizationUser + { + Email = reInvitedUser.Email, + ExternalId = reInvitedUser.Email, + }); + + var expectedNewUsersCount = importedUsers.Count - 1; + + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + + var organizationUserRepository = sutProvider.GetDependency(); + SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); + + sutProvider.GetDependency().GetManyAsync(Arg.Any>()) + .Returns(new List([reInvitedOrgUser])); + sutProvider.GetDependency().GetManyDetailsByOrganizationAsync(org.Id).Returns(existingUsers); + sutProvider.GetDependency().GetOccupiedSeatCountByOrganizationIdAsync(org.Id).Returns( + new OrganizationSeatCounts + { + Users = existingUsers.Count, + Sponsored = 0 + }); + + sutProvider.GetDependency().InviteUsersAsync(org.Id, Guid.Empty, EventSystemUser.PublicApi, + Arg.Any>()) + .Returns(orgUsers); + + await sutProvider.Sut.ImportAsync(org.Id, newGroups, importedUsers, new List(), false); + + 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 && users.First() == reInvitedOrgUser)); + + // Send Invites + await sutProvider.GetDependency().Received(1). + InviteUsersAsync(org.Id, Guid.Empty, EventSystemUser.PublicApi, + Arg.Is>(invites => invites.Count() == expectedNewUsersCount)); + + // Send events + await sutProvider.GetDependency().Received(1) + .LogOrganizationUserEventsAsync(Arg.Any>()); + } + + private void SetupOrganizationConfigForImport( + SutProvider sutProvider, + Organization org, + List existingUsers, + List importedUsers) + { + // 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 = importedUsers.Count + existingUsers.Count + 1; + } + + // Must set real guids in order for dictionary of guids to not throw aggregate exceptions + private void SetupOrgUserRepositoryCreateManyAsyncMock(IOrganizationUserRepository organizationUserRepository) + { + organizationUserRepository.CreateManyAsync(Arg.Any>()).Returns( + info => + { + var orgUsers = info.Arg>(); + foreach (var orgUser in orgUsers) + { + orgUser.Id = Guid.NewGuid(); + } + + return Task.FromResult>(orgUsers.Select(u => u.Id).ToList()); + } + ); + + organizationUserRepository.CreateAsync(Arg.Any(), Arg.Any>()).Returns( + info => + { + var orgUser = info.Arg(); + orgUser.Id = Guid.NewGuid(); + return Task.FromResult(orgUser.Id); + } + ); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs index cee801d190..aa803bd0c9 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs @@ -30,7 +30,6 @@ using NSubstitute; using NSubstitute.ExceptionExtensions; using Xunit; using static Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Helpers.InviteUserOrganizationValidationRequestHelpers; -using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite; namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; @@ -54,7 +53,7 @@ public class InviteOrganizationUserCommandTests var request = new InviteOrganizationUsersRequest( invites: [ - new OrganizationUserInvite( + new OrganizationUserInviteCommandModel( email: user.Email, assignedCollections: [], groups: [], @@ -112,7 +111,7 @@ public class InviteOrganizationUserCommandTests var request = new InviteOrganizationUsersRequest( invites: [ - new OrganizationUserInvite( + new OrganizationUserInviteCommandModel( email: orgUser.Email, assignedCollections: [], groups: [], @@ -182,7 +181,7 @@ public class InviteOrganizationUserCommandTests var request = new InviteOrganizationUsersRequest( invites: [ - new OrganizationUserInvite( + new OrganizationUserInviteCommandModel( email: user.Email, assignedCollections: [], groups: [], @@ -257,7 +256,7 @@ public class InviteOrganizationUserCommandTests var request = new InviteOrganizationUsersRequest( invites: [ - new OrganizationUserInvite( + new OrganizationUserInviteCommandModel( email: user.Email, assignedCollections: [], groups: [], @@ -334,7 +333,7 @@ public class InviteOrganizationUserCommandTests var request = new InviteOrganizationUsersRequest( invites: [ - new OrganizationUserInvite( + new OrganizationUserInviteCommandModel( email: user.Email, assignedCollections: [], groups: [], @@ -411,7 +410,7 @@ public class InviteOrganizationUserCommandTests var request = new InviteOrganizationUsersRequest( invites: [ - new OrganizationUserInvite( + new OrganizationUserInviteCommandModel( email: user.Email, assignedCollections: [], groups: [], @@ -492,7 +491,7 @@ public class InviteOrganizationUserCommandTests var request = new InviteOrganizationUsersRequest( invites: [ - new OrganizationUserInvite( + new OrganizationUserInviteCommandModel( email: user.Email, assignedCollections: [], groups: [], @@ -566,7 +565,7 @@ public class InviteOrganizationUserCommandTests var request = new InviteOrganizationUsersRequest( invites: [ - new OrganizationUserInvite( + new OrganizationUserInviteCommandModel( email: user.Email, assignedCollections: [], groups: [], @@ -669,7 +668,7 @@ public class InviteOrganizationUserCommandTests var request = new InviteOrganizationUsersRequest( invites: [ - new OrganizationUserInvite( + new OrganizationUserInviteCommandModel( email: user.Email, assignedCollections: [], groups: [], @@ -768,7 +767,7 @@ public class InviteOrganizationUserCommandTests var request = new InviteOrganizationUsersRequest( invites: [ - new OrganizationUserInvite( + new OrganizationUserInviteCommandModel( email: user.Email, assignedCollections: [], groups: [], @@ -863,7 +862,7 @@ public class InviteOrganizationUserCommandTests var request = new InviteOrganizationUsersRequest( invites: [ - new OrganizationUserInvite( + new OrganizationUserInviteCommandModel( email: user.Email, assignedCollections: [], groups: [], @@ -942,7 +941,7 @@ public class InviteOrganizationUserCommandTests var request = new InviteOrganizationUsersRequest( invites: [ - new OrganizationUserInvite( + new OrganizationUserInviteCommandModel( email: user.Email, assignedCollections: [], groups: [], diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUsersValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUsersValidatorTests.cs index 7c06e04256..a5b220b94a 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUsersValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUsersValidatorTests.cs @@ -14,7 +14,6 @@ using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using NSubstitute.ExceptionExtensions; using Xunit; -using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite; namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; @@ -36,13 +35,13 @@ public class InviteOrganizationUsersValidatorTests { Invites = [ - new OrganizationUserInvite( + new OrganizationUserInviteCommandModel( email: "test@email.com", externalId: "test-external-id"), - new OrganizationUserInvite( + new OrganizationUserInviteCommandModel( email: "test2@email.com", externalId: "test-external-id2"), - new OrganizationUserInvite( + new OrganizationUserInviteCommandModel( email: "test3@email.com", externalId: "test-external-id3") ], @@ -82,13 +81,13 @@ public class InviteOrganizationUsersValidatorTests { Invites = [ - new OrganizationUserInvite( + new OrganizationUserInviteCommandModel( email: "test@email.com", externalId: "test-external-id"), - new OrganizationUserInvite( + new OrganizationUserInviteCommandModel( email: "test2@email.com", externalId: "test-external-id2"), - new OrganizationUserInvite( + new OrganizationUserInviteCommandModel( email: "test3@email.com", externalId: "test-external-id3") ], @@ -126,13 +125,13 @@ public class InviteOrganizationUsersValidatorTests { Invites = [ - new OrganizationUserInvite( + new OrganizationUserInviteCommandModel( email: "test@email.com", externalId: "test-external-id"), - new OrganizationUserInvite( + new OrganizationUserInviteCommandModel( email: "test2@email.com", externalId: "test-external-id2"), - new OrganizationUserInvite( + new OrganizationUserInviteCommandModel( email: "test3@email.com", externalId: "test-external-id3") ],