From 4d6ffb037fb10c2226fa3886d6c1a983131149d1 Mon Sep 17 00:00:00 2001 From: Sven Date: Mon, 6 Jul 2026 08:30:21 -0500 Subject: [PATCH 1/2] Adds API access for SCIM/BWDC to provision Staged users behind the feature flag --- .../src/Scim/Users/PostUserCommand.cs | 52 ++++ .../Controllers/v2/UsersControllerTests.cs | 117 ++++++++ .../Scim.Test/Users/PostUserCommandTests.cs | 264 +++++++++++++++++- .../Controllers/OrganizationController.cs | 3 +- .../Request/OrganizationImportRequestModel.cs | 5 + .../ScimConfig.cs | 7 + ...IImportOrganizationUserAndGroupsCommand.cs | 3 +- ...ImportOrganizationUsersAndGroupsCommand.cs | 73 ++++- .../OrganizationControllerTests.cs | 123 ++++++++ .../packages.lock.json | 101 ++++--- ...tOrganizationUsersAndGroupsCommandTests.cs | 140 +++++++++- 11 files changed, 827 insertions(+), 61 deletions(-) create mode 100644 test/Api.IntegrationTest/AdminConsole/Public/Controllers/OrganizationControllerTests.cs diff --git a/bitwarden_license/src/Scim/Users/PostUserCommand.cs b/bitwarden_license/src/Scim/Users/PostUserCommand.cs index f2df04da337d..a16a52c22047 100644 --- a/bitwarden_license/src/Scim/Users/PostUserCommand.cs +++ b/bitwarden_license/src/Scim/Users/PostUserCommand.cs @@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.StagedUsers; using Bit.Core.AdminConsole.Utilities.Commands; using Bit.Core.Billing.Services; using Bit.Core.Enums; @@ -27,11 +28,18 @@ public class PostUserCommand( IScimContext scimContext, IFeatureService featureService, IInviteOrganizationUsersCommand inviteOrganizationUsersCommand, + ICreateStagedOrganizationUsersCommand createStagedOrganizationUsersCommand, TimeProvider timeProvider) : IPostUserCommand { public async Task PostUserAsync(Guid organizationId, ScimUserRequestModel model) { + var inviteUsersAfterProvisioning = scimContext.ScimConfiguration?.InviteUsersAfterProvisioning ?? true; + if (!inviteUsersAfterProvisioning && featureService.IsEnabled(FeatureFlagKeys.PM34423StagedStatus)) + { + return await StageScimOrganizationUserAsync(model, organizationId, scimContext.RequestScimProvider); + } + if (featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization) is false) { return await InviteScimOrganizationUserAsync(model, organizationId, scimContext.RequestScimProvider); @@ -40,6 +48,50 @@ public class PostUserCommand( return await InviteScimOrganizationUserAsync_vNext(model, organizationId, scimContext.RequestScimProvider); } + private async Task StageScimOrganizationUserAsync( + ScimUserRequestModel model, + Guid organizationId, + ScimProviderType scimProvider) + { + var email = model.ToOrganizationUserInvite(scimProvider).Emails.Single(); + var externalId = model.ExternalIdForInvite(); + + if (string.IsNullOrWhiteSpace(email) || !model.Active) + { + throw new BadRequestException(); + } + + var orgUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); + if (orgUsers.Any(ou => ou.Email?.ToLowerInvariant() == email || ou.ExternalId == externalId)) + { + throw new ConflictException(); + } + + var organization = await organizationRepository.GetByIdAsync(organizationId); + if (organization == null) + { + throw new NotFoundException(); + } + + var result = await createStagedOrganizationUsersCommand.RunAsync(new CreateStagedOrganizationUsersRequest + { + Organization = organization, + Users = [new StagedOrganizationUserRequest { Email = email, ExternalId = externalId }], + EventSystemUser = EventSystemUser.SCIM + }); + + if (result.IsError) + { + throw new BadRequestException(result.AsError.Message); + } + + // The command skips emails already present in the organization, so an empty result means the user + // was added by a concurrent request after the conflict check above. + var stagedUser = result.AsSuccess.FirstOrDefault() ?? throw new ConflictException(); + + return await organizationUserRepository.GetDetailsByIdAsync(stagedUser.Id); + } + private async Task InviteScimOrganizationUserAsync_vNext( ScimUserRequestModel model, Guid organizationId, diff --git a/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/UsersControllerTests.cs b/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/UsersControllerTests.cs index 1a275c8066c1..b280ea54f31d 100644 --- a/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/UsersControllerTests.cs +++ b/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/UsersControllerTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Bit.Core; +using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs; using Bit.Core.Enums; using Bit.Core.Services; using Bit.Scim.IntegrationTest.Factories; @@ -394,6 +395,122 @@ public async Task Post_ExistingData_Conflict(string email, string externalId) Assert.Equal(_initialUserCount, databaseContext.OrganizationUsers.Count()); } + [Fact] + public async Task Post_InviteUsersAfterProvisioningDisabled_CreatesStagedUser() + { + var localFactory = new ScimApplicationFactory(); + localFactory.SubstituteService((IFeatureService featureService) + => featureService.IsEnabled(FeatureFlagKeys.PM34423StagedStatus) + .Returns(true)); + + localFactory.ReinitializeDbForTests(localFactory.GetDatabaseContext()); + SeedScimConnection(localFactory, inviteUsersAfterProvisioning: false); + + var email = "user5@example.com"; + var externalId = "UE"; + var inputModel = new ScimUserRequestModel + { + DisplayName = "Test User 5", + Emails = new List { new BaseScimUserModel.EmailModel(email) }, + ExternalId = externalId, + Active = true, + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + var context = await localFactory.UsersPostAsync(ScimApplicationFactory.TestOrganizationId1, inputModel); + + Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode); + Assert.Contains(context.Response.Headers, h => h.Key == "Location"); + + var databaseContext = localFactory.GetDatabaseContext(); + Assert.Equal(_initialUserCount + 1, databaseContext.OrganizationUsers.Count()); + + var stagedUser = databaseContext.OrganizationUsers.Single(ou => ou.Email == email); + Assert.Equal(OrganizationUserStatusType.Staged, stagedUser.Status); + Assert.Equal(externalId, stagedUser.ExternalId); + Assert.Null(stagedUser.UserId); + } + + [Fact] + public async Task Post_InviteUsersAfterProvisioningDisabled_WithoutFeatureFlag_InvitesUser() + { + var localFactory = new ScimApplicationFactory(); + localFactory.SubstituteService((IFeatureService featureService) + => featureService.IsEnabled(FeatureFlagKeys.PM34423StagedStatus) + .Returns(false)); + + localFactory.ReinitializeDbForTests(localFactory.GetDatabaseContext()); + SeedScimConnection(localFactory, inviteUsersAfterProvisioning: false); + + var email = "user5@example.com"; + var inputModel = new ScimUserRequestModel + { + DisplayName = "Test User 5", + Emails = new List { new BaseScimUserModel.EmailModel(email) }, + ExternalId = "UE", + Active = true, + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + var context = await localFactory.UsersPostAsync(ScimApplicationFactory.TestOrganizationId1, inputModel); + + Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode); + + var databaseContext = localFactory.GetDatabaseContext(); + var newUser = databaseContext.OrganizationUsers.Single(ou => ou.Email == email); + Assert.Equal(OrganizationUserStatusType.Invited, newUser.Status); + } + + [Fact] + public async Task Post_InviteUsersAfterProvisioningEnabled_InvitesUser() + { + var localFactory = new ScimApplicationFactory(); + localFactory.SubstituteService((IFeatureService featureService) + => featureService.IsEnabled(FeatureFlagKeys.PM34423StagedStatus) + .Returns(true)); + + localFactory.ReinitializeDbForTests(localFactory.GetDatabaseContext()); + SeedScimConnection(localFactory, inviteUsersAfterProvisioning: true); + + var email = "user5@example.com"; + var inputModel = new ScimUserRequestModel + { + DisplayName = "Test User 5", + Emails = new List { new BaseScimUserModel.EmailModel(email) }, + ExternalId = "UE", + Active = true, + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + var context = await localFactory.UsersPostAsync(ScimApplicationFactory.TestOrganizationId1, inputModel); + + Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode); + + var databaseContext = localFactory.GetDatabaseContext(); + var newUser = databaseContext.OrganizationUsers.Single(ou => ou.Email == email); + Assert.Equal(OrganizationUserStatusType.Invited, newUser.Status); + } + + private static void SeedScimConnection(ScimApplicationFactory factory, bool inviteUsersAfterProvisioning) + { + var connection = new Infrastructure.EntityFramework.Models.OrganizationConnection + { + Id = Guid.NewGuid(), + OrganizationId = ScimApplicationFactory.TestOrganizationId1, + Type = OrganizationConnectionType.Scim, + Enabled = true + }; + connection.SetConfig(new ScimConfig + { + Enabled = true, + InviteUsersAfterProvisioning = inviteUsersAfterProvisioning + }); + + var databaseContext = factory.GetDatabaseContext(); + databaseContext.OrganizationConnections.Add(connection); + databaseContext.SaveChanges(); + } + [Fact] public async Task Put_RevokeUser_Success() { diff --git a/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs b/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs index 05264a295c5b..3228b36fa0eb 100644 --- a/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs +++ b/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs @@ -1,4 +1,9 @@ -using Bit.Core.AdminConsole.Entities; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.StagedUsers; +using Bit.Core.AdminConsole.Utilities.v2.Results; using Bit.Core.Billing.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -6,6 +11,7 @@ using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Scim.Context; using Bit.Scim.Models; using Bit.Scim.Users; using Bit.Scim.Utilities; @@ -179,4 +185,260 @@ public async Task PostUser_DuplicateUserName_Throws(SutProvider await Assert.ThrowsAsync(async () => await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel)); } + + [Theory] + [BitAutoData] + public async Task PostUser_InviteUsersAfterProvisioningDisabled_StagesUser( + SutProvider sutProvider, + string email, + string externalId, + Guid organizationId, + ICollection organizationUsers, + Core.Entities.OrganizationUser stagedUser, + OrganizationUserUserDetails stagedUserDetails, + Organization organization) + { + var scimUserRequestModel = new ScimUserRequestModel + { + ExternalId = externalId, + Emails = [new BaseScimUserModel.EmailModel(email)], + Active = true, + Schemas = [ScimConstants.Scim2SchemaUser] + }; + var expectedEmail = email.ToLowerInvariant(); + + sutProvider.GetDependency().ScimConfiguration + .Returns(new ScimConfig { Enabled = true, InviteUsersAfterProvisioning = false }); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM34423StagedStatus) + .Returns(true); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(organizationId) + .Returns(organizationUsers); + + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + + CommandResult> commandResult = + new List { stagedUser }; + sutProvider.GetDependency() + .RunAsync(Arg.Any()) + .Returns(commandResult); + + sutProvider.GetDependency() + .GetDetailsByIdAsync(stagedUser.Id) + .Returns(stagedUserDetails); + + var user = await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel); + + Assert.Equal(stagedUserDetails, user); + await sutProvider.GetDependency().Received(1) + .RunAsync(Arg.Is(r => + r.Organization == organization && + r.EventSystemUser == EventSystemUser.SCIM && + r.Users.Count() == 1 && + r.Users.Single().Email == expectedEmail && + r.Users.Single().ExternalId == externalId)); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .InviteUserAsync(default, default, default, default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .InviteScimOrganizationUserAsync(default); + } + + [Theory] + [BitAutoData] + public async Task PostUser_InviteUsersAfterProvisioningDisabled_FeatureFlagDisabled_InvitesUser( + SutProvider sutProvider, + string email, + string externalId, + Guid organizationId, + ICollection organizationUsers, + Core.Entities.OrganizationUser newUser, + Organization organization) + { + var scimUserRequestModel = new ScimUserRequestModel + { + ExternalId = externalId, + Emails = [new BaseScimUserModel.EmailModel(email)], + Active = true, + Schemas = [ScimConstants.Scim2SchemaUser] + }; + + sutProvider.GetDependency().ScimConfiguration + .Returns(new ScimConfig { Enabled = true, InviteUsersAfterProvisioning = false }); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM34423StagedStatus) + .Returns(false); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(organizationId) + .Returns(organizationUsers); + + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + + sutProvider.GetDependency() + .InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM, + Arg.Any(), externalId) + .Returns(newUser); + + await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel); + + await sutProvider.GetDependency().Received(1) + .InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM, + Arg.Any(), externalId); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .RunAsync(default); + } + + [Theory] + [BitAutoData] + public async Task PostUser_InviteUsersAfterProvisioningEnabled_InvitesUser( + SutProvider sutProvider, + string email, + string externalId, + Guid organizationId, + ICollection organizationUsers, + Core.Entities.OrganizationUser newUser, + Organization organization) + { + var scimUserRequestModel = new ScimUserRequestModel + { + ExternalId = externalId, + Emails = [new BaseScimUserModel.EmailModel(email)], + Active = true, + Schemas = [ScimConstants.Scim2SchemaUser] + }; + + sutProvider.GetDependency().ScimConfiguration + .Returns(new ScimConfig { Enabled = true, InviteUsersAfterProvisioning = true }); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM34423StagedStatus) + .Returns(true); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(organizationId) + .Returns(organizationUsers); + + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + + sutProvider.GetDependency() + .InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM, + Arg.Any(), externalId) + .Returns(newUser); + + await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel); + + await sutProvider.GetDependency().Received(1) + .InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM, + Arg.Any(), externalId); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .RunAsync(default); + } + + [Theory] + [BitAutoData] + public async Task PostUser_NoScimConfig_FeatureFlagEnabled_InvitesUser( + SutProvider sutProvider, + string email, + string externalId, + Guid organizationId, + ICollection organizationUsers, + Core.Entities.OrganizationUser newUser, + Organization organization) + { + var scimUserRequestModel = new ScimUserRequestModel + { + ExternalId = externalId, + Emails = [new BaseScimUserModel.EmailModel(email)], + Active = true, + Schemas = [ScimConstants.Scim2SchemaUser] + }; + + // No SCIM configuration stored on the connection - missing InviteUsersAfterProvisioning reads as true + sutProvider.GetDependency().ScimConfiguration.Returns((ScimConfig)null); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM34423StagedStatus) + .Returns(true); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(organizationId) + .Returns(organizationUsers); + + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + + sutProvider.GetDependency() + .InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM, + Arg.Any(), externalId) + .Returns(newUser); + + await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel); + + await sutProvider.GetDependency().Received(1) + .InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM, + Arg.Any(), externalId); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .RunAsync(default); + } + + [Theory] + [BitAutoData] + public async Task PostUser_InviteUsersAfterProvisioningDisabled_ExistingEmail_Throws( + SutProvider sutProvider, + string externalId, + Guid organizationId, + ICollection organizationUsers) + { + var scimUserRequestModel = new ScimUserRequestModel + { + ExternalId = externalId, + Emails = [new BaseScimUserModel.EmailModel(organizationUsers.First().Email)], + Active = true, + Schemas = [ScimConstants.Scim2SchemaUser] + }; + + sutProvider.GetDependency().ScimConfiguration + .Returns(new ScimConfig { Enabled = true, InviteUsersAfterProvisioning = false }); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM34423StagedStatus) + .Returns(true); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(organizationId) + .Returns(organizationUsers); + + await Assert.ThrowsAsync( + async () => await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .RunAsync(default); + } + + [Theory] + [BitAutoData] + public async Task PostUser_InviteUsersAfterProvisioningDisabled_Inactive_Throws( + SutProvider sutProvider, + string email, + string externalId, + Guid organizationId) + { + var scimUserRequestModel = new ScimUserRequestModel + { + ExternalId = externalId, + Emails = [new BaseScimUserModel.EmailModel(email)], + Active = false, + Schemas = [ScimConstants.Scim2SchemaUser] + }; + + sutProvider.GetDependency().ScimConfiguration + .Returns(new ScimConfig { Enabled = true, InviteUsersAfterProvisioning = false }); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM34423StagedStatus) + .Returns(true); + + await Assert.ThrowsAsync( + async () => await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .RunAsync(default); + } } diff --git a/src/Api/AdminConsole/Public/Controllers/OrganizationController.cs b/src/Api/AdminConsole/Public/Controllers/OrganizationController.cs index 553120403363..c264cf5710a0 100644 --- a/src/Api/AdminConsole/Public/Controllers/OrganizationController.cs +++ b/src/Api/AdminConsole/Public/Controllers/OrganizationController.cs @@ -61,7 +61,8 @@ await _importOrganizationUsersAndGroupsCommand.ImportAsync( 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() + model.OverwriteExisting.GetValueOrDefault(), + model.InviteUsersAfterProvisioning ); return new OkResult(); diff --git a/src/Api/AdminConsole/Public/Models/Request/OrganizationImportRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/OrganizationImportRequestModel.cs index 6122d5dfd05f..e28264dd1d16 100644 --- a/src/Api/AdminConsole/Public/Models/Request/OrganizationImportRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/OrganizationImportRequestModel.cs @@ -29,6 +29,11 @@ public class OrganizationImportRequestModel /// Indicates an import of over 2000 users and/or groups is expected /// public bool LargeImport { get; set; } = false; + /// + /// Determines whether newly provisioned members are sent an invitation email. When false, new members are + /// created in the Staged status without an invitation. Defaults to true. + /// + public bool InviteUsersAfterProvisioning { get; set; } = true; public class OrganizationImportGroupRequestModel { diff --git a/src/Core/AdminConsole/Models/OrganizationConnectionConfigs/ScimConfig.cs b/src/Core/AdminConsole/Models/OrganizationConnectionConfigs/ScimConfig.cs index 8aba3b57937c..7924e40bdbe4 100644 --- a/src/Core/AdminConsole/Models/OrganizationConnectionConfigs/ScimConfig.cs +++ b/src/Core/AdminConsole/Models/OrganizationConnectionConfigs/ScimConfig.cs @@ -10,6 +10,13 @@ public class ScimConfig : IConnectionConfig [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ScimProviderType? ScimProvider { get; set; } + /// + /// Determines whether newly provisioned users are sent an invitation email. When false, users are created in + /// the Staged status without an invitation. Null (or missing from the stored config) is treated as true. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? InviteUsersAfterProvisioning { get; set; } + public bool Validate(out string exception) { if (!Enabled) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Import/IImportOrganizationUserAndGroupsCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Import/IImportOrganizationUserAndGroupsCommand.cs index b74da0a2e890..b3ca50c6fc7b 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Import/IImportOrganizationUserAndGroupsCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Import/IImportOrganizationUserAndGroupsCommand.cs @@ -11,6 +11,7 @@ Task ImportAsync(Guid organizationId, IEnumerable groups, IEnumerable newUsers, IEnumerable removeUserExternalIds, - bool overwriteExisting + bool overwriteExisting, + bool inviteUsersAfterProvisioning ); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs index 2fe1fef05b1a..a769e2d5ed2e 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.StagedUsers; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Services; using Bit.Core.Entities; @@ -23,6 +24,8 @@ public class ImportOrganizationUsersAndGroupsCommand : IImportOrganizationUsersA private readonly IGroupRepository _groupRepository; private readonly IEventService _eventService; private readonly IOrganizationService _organizationService; + private readonly IFeatureService _featureService; + private readonly ICreateStagedOrganizationUsersCommand _createStagedOrganizationUsersCommand; private readonly EventSystemUser _EventSystemUser = EventSystemUser.PublicApi; @@ -31,7 +34,9 @@ public ImportOrganizationUsersAndGroupsCommand(IOrganizationRepository organizat IStripePaymentService paymentService, IGroupRepository groupRepository, IEventService eventService, - IOrganizationService organizationService) + IOrganizationService organizationService, + IFeatureService featureService, + ICreateStagedOrganizationUsersCommand createStagedOrganizationUsersCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -39,6 +44,8 @@ public ImportOrganizationUsersAndGroupsCommand(IOrganizationRepository organizat _groupRepository = groupRepository; _eventService = eventService; _organizationService = organizationService; + _featureService = featureService; + _createStagedOrganizationUsersCommand = createStagedOrganizationUsersCommand; } /// @@ -50,13 +57,16 @@ public ImportOrganizationUsersAndGroupsCommand(IOrganizationRepository organizat /// 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. + /// Indicates whether new users are sent an invitation email. + /// When false, new users are created in the Staged status without an invitation. /// 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) + bool overwriteExisting, + bool inviteUsersAfterProvisioning) { var organization = await GetOrgById(organizationId); if (organization is null) @@ -82,7 +92,7 @@ public async Task ImportAsync(Guid organizationId, await UpdateExistingUsers(importedUsers, importUserData); - await AddNewUsers(organization, importedUsers, importUserData); + await AddNewUsers(organization, importedUsers, importUserData, inviteUsersAfterProvisioning); await ImportGroups(organization, importedGroups, importUserData); @@ -172,28 +182,36 @@ private async Task UpdateExistingUsers(IEnumerable imp /// /// 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. + /// and throws exceptions on failure. When is false, new + /// users are created in the Staged status instead of being invited. /// /// 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. + /// Indicates whether new users are sent an invitation email. private async Task AddNewUsers(Organization organization, IEnumerable importedUsers, - OrganizationUserImportData importUserData) + OrganizationUserImportData importUserData, + bool inviteUsersAfterProvisioning) { // Determine which users are already in the organization var existingUsersSet = new HashSet(importUserData.ExistingExternalUsersIdDict.Keys); var usersToAdd = importUserData.ImportedExternalIds.Except(existingUsersSet).ToList(); + var newUsers = importedUsers + .Where(u => usersToAdd.Contains(u.ExternalId) && !string.IsNullOrWhiteSpace(u.Email)) + .ToList(); + + if (!inviteUsersAfterProvisioning && _featureService.IsEnabled(FeatureFlagKeys.PM34423StagedStatus)) + { + await StageNewUsers(organization, newUsers, importUserData); + return; + } + var userInvites = new List<(OrganizationUserInvite, string)>(); var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization); - foreach (var user in importedUsers) + foreach (var user in newUsers) { - if (!usersToAdd.Contains(user.ExternalId) || string.IsNullOrWhiteSpace(user.Email)) - { - continue; - } - try { var invite = new OrganizationUserInvite @@ -219,6 +237,39 @@ private async Task AddNewUsers(Organization organization, } } + /// + /// Creates new external users in the Staged status without sending invitations, and updates the user Id + /// mapping so staged users can be associated with imported groups. + /// + /// The target organization to which users are being added. + /// The imported users that are not already part of the organization. + /// Data containing imported user info and existing user mappings. + private async Task StageNewUsers(Organization organization, + IEnumerable newUsers, + OrganizationUserImportData importUserData) + { + var result = await _createStagedOrganizationUsersCommand.RunAsync(new CreateStagedOrganizationUsersRequest + { + Organization = organization, + Users = newUsers.Select(u => new StagedOrganizationUserRequest + { + Email = u.Email, + ExternalId = u.ExternalId + }), + EventSystemUser = _EventSystemUser + }); + + if (result.IsError) + { + throw new BadRequestException(result.AsError.Message); + } + + foreach (var stagedUser in result.AsSuccess) + { + importUserData.ExistingExternalUsersIdDict.TryAdd(stagedUser.ExternalId!, stagedUser.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. diff --git a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/OrganizationControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/OrganizationControllerTests.cs new file mode 100644 index 000000000000..a569a03fb873 --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/OrganizationControllerTests.cs @@ -0,0 +1,123 @@ +using System.Net; +using System.Net.Http.Json; +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.Billing.Enums; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using NSubstitute; +using Xunit; + +namespace Bit.Api.IntegrationTest.AdminConsole.Public.Controllers; + +public class OrganizationControllerTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + private readonly LoginHelper _loginHelper; + + // These will get set in `InitializeAsync` which is ran before all tests + private Organization _organization = null!; + private string _ownerEmail = null!; + + public OrganizationControllerTests(ApiApplicationFactory factory) + { + _factory = factory; + // The substitution must be registered before the host is built; each test sets the flag values it needs + _factory.SubstituteService(_ => { }); + _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.EnterpriseAnnually, + 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_InviteUsersAfterProvisioningDisabled_CreatesStagedUsers() + { + _factory.GetService() + .IsEnabled(FeatureFlagKeys.PM34423StagedStatus) + .Returns(true); + + var email1 = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + var email2 = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + var request = new OrganizationImportRequestModel + { + Groups = [], + Members = + [ + new OrganizationImportRequestModel.OrganizationImportMemberRequestModel { Email = email1, ExternalId = "external-1" }, + new OrganizationImportRequestModel.OrganizationImportMemberRequestModel { Email = email2, ExternalId = "external-2" } + ], + OverwriteExisting = false, + InviteUsersAfterProvisioning = false + }; + + var response = await _client.PostAsJsonAsync("/public/organization/import", request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var organizationUserRepository = _factory.GetService(); + var organizationUsers = await organizationUserRepository.GetManyByOrganizationAsync(_organization.Id, null); + + var stagedUser1 = Assert.Single(organizationUsers, ou => ou.Email == email1); + Assert.Equal(OrganizationUserStatusType.Staged, stagedUser1.Status); + Assert.Equal("external-1", stagedUser1.ExternalId); + Assert.Null(stagedUser1.UserId); + + var stagedUser2 = Assert.Single(organizationUsers, ou => ou.Email == email2); + Assert.Equal(OrganizationUserStatusType.Staged, stagedUser2.Status); + Assert.Equal("external-2", stagedUser2.ExternalId); + Assert.Null(stagedUser2.UserId); + } + + [Fact] + public async Task Import_InviteUsersAfterProvisioningOmitted_InvitesUsers() + { + _factory.GetService() + .IsEnabled(FeatureFlagKeys.PM34423StagedStatus) + .Returns(true); + + var email = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + + // Omit inviteUsersAfterProvisioning entirely - a missing value must default to the invite flow + var request = new + { + Groups = Array.Empty(), + Members = new[] { new { Email = email, ExternalId = "external-3" } }, + OverwriteExisting = false + }; + + var response = await _client.PostAsJsonAsync("/public/organization/import", request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var organizationUserRepository = _factory.GetService(); + var organizationUsers = await organizationUserRepository.GetManyByOrganizationAsync(_organization.Id, null); + + var invitedUser = Assert.Single(organizationUsers, ou => ou.Email == email); + Assert.Equal(OrganizationUserStatusType.Invited, invitedUser.Status); + Assert.Equal("external-3", invitedUser.ExternalId); + } +} diff --git a/test/Billing.IntegrationTest/packages.lock.json b/test/Billing.IntegrationTest/packages.lock.json index 3add696ca043..e10e8dfa4e4b 100644 --- a/test/Billing.IntegrationTest/packages.lock.json +++ b/test/Billing.IntegrationTest/packages.lock.json @@ -1824,11 +1824,11 @@ "admin": { "type": "Project", "dependencies": { - "Commercial.Core": "[2026.6.0, )", - "Commercial.Infrastructure.EntityFramework": "[2026.6.0, )", - "Core": "[2026.6.0, )", - "Migrator": "[2026.6.0, )", - "MySqlMigrations": "[2026.6.0, )", + "Commercial.Core": "[2026.6.2, )", + "Commercial.Infrastructure.EntityFramework": "[2026.6.2, )", + "Core": "[2026.6.2, )", + "Migrator": "[2026.6.2, )", + "MySqlMigrations": "[2026.6.2, )", "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )", "OpenTelemetry.Extensions.Hosting": "[1.15.3, )", "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.0, )", @@ -1836,9 +1836,9 @@ "OpenTelemetry.Instrumentation.Http": "[1.15.0, )", "OpenTelemetry.Instrumentation.Runtime": "[1.15.0, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.12.0-beta.3, )", - "PostgresMigrations": "[2026.6.0, )", - "SharedWeb": "[2026.6.0, )", - "SqliteMigrations": "[2026.6.0, )" + "PostgresMigrations": "[2026.6.2, )", + "SharedWeb": "[2026.6.2, )", + "SqliteMigrations": "[2026.6.2, )" } }, "api": { @@ -1847,9 +1847,10 @@ "AspNetCore.HealthChecks.SqlServer": "[8.0.2, 8.0.2]", "AspNetCore.HealthChecks.Uris": "[8.0.1, 8.0.1]", "Azure.Messaging.EventGrid": "[5.0.0, 5.0.0]", - "Commercial.Core": "[2026.6.0, )", - "Commercial.Infrastructure.EntityFramework": "[2026.6.0, )", - "Core": "[2026.6.0, )", + "Commercial.Core": "[2026.6.2, )", + "Commercial.Infrastructure.EntityFramework": "[2026.6.2, )", + "Core": "[2026.6.2, )", + "HttpExtensions": "[2026.6.2, )", "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )", "OpenTelemetry.Extensions.Hosting": "[1.15.3, )", "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.0, )", @@ -1857,26 +1858,27 @@ "OpenTelemetry.Instrumentation.Http": "[1.15.0, )", "OpenTelemetry.Instrumentation.Runtime": "[1.15.0, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.12.0-beta.3, )", - "SharedWeb": "[2026.6.0, )", + "Pam": "[2026.6.2, )", + "SharedWeb": "[2026.6.2, )", "Swashbuckle.AspNetCore": "[10.1.7, 10.1.7]" } }, "api.integrationtest": { "type": "Project", "dependencies": { - "Api": "[2026.6.0, )", - "IntegrationTestCommon": "[2026.6.0, )", + "Api": "[2026.6.2, )", + "IntegrationTestCommon": "[2026.6.2, )", "Microsoft.Extensions.Diagnostics.Testing": "[9.10.0, 9.10.0]", "Microsoft.NET.Test.Sdk": "[18.0.1, )", - "Seeder": "[2026.6.0, )", + "Seeder": "[2026.6.2, )", "xunit": "[2.6.6, )" } }, "billing": { "type": "Project", "dependencies": { - "Commercial.Core": "[2026.6.0, )", - "Core": "[2026.6.0, )", + "Commercial.Core": "[2026.6.2, )", + "Core": "[2026.6.2, )", "MarkDig": "[1.1.0, 1.1.0]", "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )", "OpenTelemetry.Extensions.Hosting": "[1.15.3, )", @@ -1885,14 +1887,14 @@ "OpenTelemetry.Instrumentation.Http": "[1.15.0, )", "OpenTelemetry.Instrumentation.Runtime": "[1.15.0, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.12.0-beta.3, )", - "SharedWeb": "[2026.6.0, )", + "SharedWeb": "[2026.6.2, )", "Swashbuckle.AspNetCore": "[10.1.7, 10.1.7]" } }, "commercial.core": { "type": "Project", "dependencies": { - "Core": "[2026.6.0, )", + "Core": "[2026.6.2, )", "CsvHelper": "[33.1.0, 33.1.0]" } }, @@ -1900,8 +1902,8 @@ "type": "Project", "dependencies": { "AutoMapper": "[14.0.0, 14.0.0]", - "Core": "[2026.6.0, )", - "Infrastructure.EntityFramework": "[2026.6.0, )" + "Core": "[2026.6.2, )", + "Infrastructure.EntityFramework": "[2026.6.2, )" } }, "common": { @@ -1909,7 +1911,7 @@ "dependencies": { "AutoFixture.AutoNSubstitute": "[4.18.1, )", "AutoFixture.Xunit2": "[4.18.1, )", - "Core": "[2026.6.0, )", + "Core": "[2026.6.2, )", "Kralizek.AutoFixture.Extensions.MockHttp": "[2.2.1, 2.2.1]", "Microsoft.Extensions.TimeProvider.Testing": "[10.6.0, 10.6.0]", "Microsoft.NET.Test.Sdk": "[18.0.1, )", @@ -1932,6 +1934,7 @@ "Azure.Storage.Queues": "[12.24.0, 12.24.0]", "BitPay.Light": "[1.0.1907, 1.0.1907]", "Braintree": "[5.36.0, 5.36.0]", + "CsvHelper": "[33.1.0, 33.1.0]", "DnsClient": "[1.8.0, 1.8.0]", "Duende.IdentityServer": "[7.4.6, 7.4.6]", "DuoUniversal": "[1.3.1, 1.3.1]", @@ -1969,10 +1972,13 @@ "ZiggyCreatures.FusionCache.Serialization.SystemTextJson": "[2.0.2, 2.0.2]" } }, + "httpextensions": { + "type": "Project" + }, "identity": { "type": "Project", "dependencies": { - "Core": "[2026.6.0, )", + "Core": "[2026.6.2, )", "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )", "OpenTelemetry.Extensions.Hosting": "[1.15.3, )", "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.0, )", @@ -1980,13 +1986,13 @@ "OpenTelemetry.Instrumentation.Http": "[1.15.0, )", "OpenTelemetry.Instrumentation.Runtime": "[1.15.0, )", "OpenTelemetry.Instrumentation.SqlClient": "[1.12.0-beta.3, )", - "SharedWeb": "[2026.6.0, )" + "SharedWeb": "[2026.6.2, )" } }, "infrastructure.dapper": { "type": "Project", "dependencies": { - "Core": "[2026.6.0, )", + "Core": "[2026.6.2, )", "Dapper": "[2.1.66, 2.1.66]" } }, @@ -1994,7 +2000,7 @@ "type": "Project", "dependencies": { "AutoMapper": "[14.0.0, 14.0.0]", - "Core": "[2026.6.0, )", + "Core": "[2026.6.2, )", "Microsoft.EntityFrameworkCore.Relational": "[8.0.8, 8.0.8]", "Microsoft.EntityFrameworkCore.SqlServer": "[8.0.8, 8.0.8]", "Microsoft.EntityFrameworkCore.Sqlite": "[8.0.8, 8.0.8]", @@ -2007,17 +2013,17 @@ "integrationtestcommon": { "type": "Project", "dependencies": { - "Common": "[2026.6.0, )", - "Identity": "[2026.6.0, )", + "Common": "[2026.6.2, )", + "Identity": "[2026.6.2, )", "Microsoft.AspNetCore.Mvc.Testing": "[10.0.8, 10.0.8]", - "Migrator": "[2026.6.0, )", - "Seeder": "[2026.6.0, )" + "Migrator": "[2026.6.2, )", + "Seeder": "[2026.6.2, )" } }, "migrator": { "type": "Project", "dependencies": { - "Core": "[2026.6.0, )", + "Core": "[2026.6.2, )", "Microsoft.Extensions.Logging": "[10.0.8, 10.0.8]", "dbup-sqlserver": "[7.2.0, 7.2.0]" } @@ -2025,15 +2031,22 @@ "mysqlmigrations": { "type": "Project", "dependencies": { - "Core": "[2026.6.0, )", - "Infrastructure.EntityFramework": "[2026.6.0, )" + "Core": "[2026.6.2, )", + "Infrastructure.EntityFramework": "[2026.6.2, )" + } + }, + "pam": { + "type": "Project", + "dependencies": { + "Core": "[2026.6.2, )", + "HttpExtensions": "[2026.6.2, )" } }, "postgresmigrations": { "type": "Project", "dependencies": { - "Core": "[2026.6.0, )", - "Infrastructure.EntityFramework": "[2026.6.0, )" + "Core": "[2026.6.2, )", + "Infrastructure.EntityFramework": "[2026.6.2, )" } }, "rustsdk": { @@ -2043,18 +2056,18 @@ "type": "Project", "dependencies": { "Bogus": "[35.6.5, 35.6.5]", - "Core": "[2026.6.0, )", - "Infrastructure.EntityFramework": "[2026.6.0, )", - "RustSdk": "[2026.6.0, )", - "SharedWeb": "[2026.6.0, )" + "Core": "[2026.6.2, )", + "Infrastructure.EntityFramework": "[2026.6.2, )", + "RustSdk": "[2026.6.2, )", + "SharedWeb": "[2026.6.2, )" } }, "sharedweb": { "type": "Project", "dependencies": { - "Core": "[2026.6.0, )", - "Infrastructure.Dapper": "[2026.6.0, )", - "Infrastructure.EntityFramework": "[2026.6.0, )", + "Core": "[2026.6.2, )", + "Infrastructure.Dapper": "[2026.6.2, )", + "Infrastructure.EntityFramework": "[2026.6.2, )", "Microsoft.Bot.Builder.Integration.AspNet.Core": "[4.23.0, 4.23.0]", "Swashbuckle.AspNetCore.SwaggerGen": "[10.1.7, 10.1.7]" } @@ -2062,8 +2075,8 @@ "sqlitemigrations": { "type": "Project", "dependencies": { - "Core": "[2026.6.0, )", - "Infrastructure.EntityFramework": "[2026.6.0, )" + "Core": "[2026.6.2, )", + "Infrastructure.EntityFramework": "[2026.6.2, )" } } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommandTests.cs index efcd57b6ad7a..481b1d7d6fda 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommandTests.cs @@ -1,5 +1,7 @@ using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.OrganizationFeatures.Import; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.StagedUsers; +using Bit.Core.AdminConsole.Utilities.v2.Results; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Billing.Services; using Bit.Core.Entities; @@ -70,7 +72,7 @@ public async Task OrgImportCallsInviteOrgUserCommand( Arg.Any>()) .Returns(orgUsers); - await sutProvider.Sut.ImportAsync(org.Id, newGroups, importedUsers, new List(), false); + await sutProvider.Sut.ImportAsync(org.Id, newGroups, importedUsers, new List(), false, true); var expectedNewUsersCount = importedUsers.Count - 1; @@ -105,7 +107,7 @@ public async Task OverwriteExistingUsers_WhenRemovingUserWithoutMasterPassword_T sutProvider.GetDependency().GetManyDetailsByOrganizationAsync(org.Id).Returns(existingUsers); var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.ImportAsync(org.Id, [], [], [], true)); + sutProvider.Sut.ImportAsync(org.Id, [], [], [], true, true)); Assert.Contains("Sync failed. To proceed, disable the 'Remove and re-add users during next sync' setting and try again.", exception.Message); @@ -179,7 +181,7 @@ public async Task OrgImportCreateNewUsersAndMarryExistingUser( Arg.Any>()) .Returns(orgUsers); - await sutProvider.Sut.ImportAsync(org.Id, newGroups, importedUsers, new List(), false); + await sutProvider.Sut.ImportAsync(org.Id, newGroups, importedUsers, new List(), false, true); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .UpsertAsync(default); @@ -202,6 +204,138 @@ await sutProvider.GetDependency().Received(1) .LogOrganizationUserEventsAsync(Arg.Any>()); } + [Theory, PaidOrganizationCustomize, BitAutoData] + public async Task ImportAsync_InviteUsersAfterProvisioningDisabled_WithFeatureFlag_StagesNewUsers( + SutProvider sutProvider, + Organization org, + List existingUsers, + List importedUsers, + List newGroups) + { + SetupOrganizationConfigForImport(sutProvider, org, existingUsers, importedUsers); + + var stagedUsers = new List(); + + // fix mocked email format, mock the staged OrganizationUsers the command returns. + foreach (var u in importedUsers) + { + u.Email += "@bitwardentest.com"; + stagedUsers.Add(new OrganizationUser + { + Id = Guid.NewGuid(), + Email = u.Email, + ExternalId = u.ExternalId, + Status = OrganizationUserStatusType.Staged + }); + } + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM34423StagedStatus) + .Returns(true); + + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + sutProvider.GetDependency().GetManyDetailsByOrganizationAsync(org.Id).Returns(existingUsers); + + CommandResult> commandResult = stagedUsers; + sutProvider.GetDependency() + .RunAsync(Arg.Any()) + .Returns(commandResult); + + await sutProvider.Sut.ImportAsync(org.Id, newGroups, importedUsers, new List(), false, false); + + // Stage new users instead of inviting them + await sutProvider.GetDependency().Received(1) + .RunAsync(Arg.Is(r => + r.Organization == org && + r.EventSystemUser == EventSystemUser.PublicApi && + r.Users.Count() == importedUsers.Count && + r.Users.All(u => importedUsers.Any(iu => iu.Email == u.Email && iu.ExternalId == u.ExternalId)))); + + // No invites are sent + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .InviteUsersAsync(default, default, default, default); + } + + [Theory, PaidOrganizationCustomize, BitAutoData] + public async Task ImportAsync_InviteUsersAfterProvisioningDisabled_WithoutFeatureFlag_InvitesNewUsers( + 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 }); + } + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM34423StagedStatus) + .Returns(false); + + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + sutProvider.GetDependency().GetManyDetailsByOrganizationAsync(org.Id).Returns(existingUsers); + sutProvider.GetDependency().HasSecretsManagerStandalone(org).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, false); + + // The feature flag is off, so the existing invite flow runs unchanged + await sutProvider.GetDependency().Received(1) + .InviteUsersAsync(org.Id, Guid.Empty, EventSystemUser.PublicApi, + Arg.Is>(invites => invites.Count() == importedUsers.Count)); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .RunAsync(default); + } + + [Theory, PaidOrganizationCustomize, BitAutoData] + public async Task ImportAsync_InviteUsersAfterProvisioningEnabled_WithFeatureFlag_InvitesNewUsers( + 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 }); + } + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM34423StagedStatus) + .Returns(true); + + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + sutProvider.GetDependency().GetManyDetailsByOrganizationAsync(org.Id).Returns(existingUsers); + sutProvider.GetDependency().HasSecretsManagerStandalone(org).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, true); + + // Invitations are requested, so the existing invite flow runs unchanged + await sutProvider.GetDependency().Received(1) + .InviteUsersAsync(org.Id, Guid.Empty, EventSystemUser.PublicApi, + Arg.Is>(invites => invites.Count() == importedUsers.Count)); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .RunAsync(default); + } + private void SetupOrganizationConfigForImport( SutProvider sutProvider, Organization org, From 5b94151385e547a1a3efa49decb57414f88b6f55 Mon Sep 17 00:00:00 2001 From: Sven Date: Tue, 7 Jul 2026 09:22:49 -0500 Subject: [PATCH 2/2] format --- .../Public/Controllers/OrganizationControllerTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/OrganizationControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/OrganizationControllerTests.cs index a569a03fb873..f7b740b49057 100644 --- a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/OrganizationControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/OrganizationControllerTests.cs @@ -1,5 +1,4 @@ -using System.Net; -using System.Net.Http.Json; +using System.Net; using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers;