From 494c41e3b1073769ceb42e3935f660dfd5085891 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rui=20Tom=C3=A9?=
<108268980+r-tome@users.noreply.github.com>
Date: Tue, 24 Jun 2025 13:33:09 +0100
Subject: [PATCH] [PM-15160] Create ResellerClientOrganizationSignUpCommand
(#5981)
* Implement ResellerClientOrganizationSignUpCommand for signing up reseller client organizations with email invitations and error handling
* Refactor ProvidersController to replace IOrganizationService with IResellerClientOrganizationSignUpCommand for organization sign-up process
* Remove CreatePendingOrganization method from IOrganizationService and its implementation in OrganizationService
* Add IResellerClientOrganizationSignUpCommand to service collection for organization sign-up
* Add comment to clarify organization deletion process in ResellerClientOrganizationSignUpCommand
---
.../Controllers/ProvidersController.cs | 12 +-
...ResellerClientOrganizationSignUpCommand.cs | 130 ++++++++++++
.../Services/IOrganizationService.cs | 4 +-
.../Implementations/OrganizationService.cs | 26 +--
...OrganizationServiceCollectionExtensions.cs | 1 +
...lerClientOrganizationSignUpCommandTests.cs | 185 ++++++++++++++++++
6 files changed, 323 insertions(+), 35 deletions(-)
create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Organizations/ResellerClientOrganizationSignUpCommand.cs
create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/ResellerClientOrganizationSignUpCommandTests.cs
diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs
index b4abf81ee2..7f11b65d9e 100644
--- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs
+++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs
@@ -6,6 +6,7 @@ using Bit.Admin.Utilities;
using Bit.Core;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
+using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
@@ -34,14 +35,13 @@ namespace Bit.Admin.AdminConsole.Controllers;
public class ProvidersController : Controller
{
private readonly IOrganizationRepository _organizationRepository;
- private readonly IOrganizationService _organizationService;
+ private readonly IResellerClientOrganizationSignUpCommand _resellerClientOrganizationSignUpCommand;
private readonly IProviderRepository _providerRepository;
private readonly IProviderUserRepository _providerUserRepository;
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly GlobalSettings _globalSettings;
private readonly IApplicationCacheService _applicationCacheService;
private readonly IProviderService _providerService;
- private readonly IUserService _userService;
private readonly ICreateProviderCommand _createProviderCommand;
private readonly IFeatureService _featureService;
private readonly IProviderPlanRepository _providerPlanRepository;
@@ -54,14 +54,13 @@ public class ProvidersController : Controller
public ProvidersController(
IOrganizationRepository organizationRepository,
- IOrganizationService organizationService,
+ IResellerClientOrganizationSignUpCommand resellerClientOrganizationSignUpCommand,
IProviderRepository providerRepository,
IProviderUserRepository providerUserRepository,
IProviderOrganizationRepository providerOrganizationRepository,
IProviderService providerService,
GlobalSettings globalSettings,
IApplicationCacheService applicationCacheService,
- IUserService userService,
ICreateProviderCommand createProviderCommand,
IFeatureService featureService,
IProviderPlanRepository providerPlanRepository,
@@ -71,14 +70,13 @@ public class ProvidersController : Controller
IStripeAdapter stripeAdapter)
{
_organizationRepository = organizationRepository;
- _organizationService = organizationService;
+ _resellerClientOrganizationSignUpCommand = resellerClientOrganizationSignUpCommand;
_providerRepository = providerRepository;
_providerUserRepository = providerUserRepository;
_providerOrganizationRepository = providerOrganizationRepository;
_providerService = providerService;
_globalSettings = globalSettings;
_applicationCacheService = applicationCacheService;
- _userService = userService;
_createProviderCommand = createProviderCommand;
_featureService = featureService;
_providerPlanRepository = providerPlanRepository;
@@ -459,7 +457,7 @@ public class ProvidersController : Controller
}
var organization = model.CreateOrganization(provider);
- await _organizationService.CreatePendingOrganization(organization, model.Owners, User, _userService, model.SalesAssistedTrialStarted);
+ await _resellerClientOrganizationSignUpCommand.SignUpResellerClientAsync(organization, model.Owners);
await _providerService.AddOrganization(providerId, organization.Id, null);
return RedirectToAction("Edit", "Providers", new { id = providerId });
diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/ResellerClientOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ResellerClientOrganizationSignUpCommand.cs
new file mode 100644
index 0000000000..446d7339ca
--- /dev/null
+++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ResellerClientOrganizationSignUpCommand.cs
@@ -0,0 +1,130 @@
+using Bit.Core.AdminConsole.Entities;
+using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
+using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
+using Bit.Core.Entities;
+using Bit.Core.Enums;
+using Bit.Core.Repositories;
+using Bit.Core.Services;
+using Bit.Core.Utilities;
+
+namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
+
+public record ResellerClientOrganizationSignUpResponse(
+ Organization Organization,
+ OrganizationUser OwnerOrganizationUser);
+
+///
+/// Command for signing up reseller client organizations in a pending state.
+///
+public interface IResellerClientOrganizationSignUpCommand
+{
+ ///
+ /// Sign up a reseller client organization. The organization will be created in a pending state
+ /// (disabled and with Pending status) and the owner will be invited via email. The organization
+ /// will become active once the owner accepts the invitation.
+ ///
+ /// The organization to create.
+ /// The email of the organization owner who will be invited.
+ /// A response containing the created pending organization and invited owner user.
+ Task SignUpResellerClientAsync(
+ Organization organization,
+ string ownerEmail);
+}
+
+public class ResellerClientOrganizationSignUpCommand : IResellerClientOrganizationSignUpCommand
+{
+ private readonly IOrganizationRepository _organizationRepository;
+ private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository;
+ private readonly IApplicationCacheService _applicationCacheService;
+ private readonly IOrganizationUserRepository _organizationUserRepository;
+ private readonly IEventService _eventService;
+ private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand;
+ private readonly IPaymentService _paymentService;
+
+ public ResellerClientOrganizationSignUpCommand(
+ IOrganizationRepository organizationRepository,
+ IOrganizationApiKeyRepository organizationApiKeyRepository,
+ IApplicationCacheService applicationCacheService,
+ IOrganizationUserRepository organizationUserRepository,
+ IEventService eventService,
+ ISendOrganizationInvitesCommand sendOrganizationInvitesCommand,
+ IPaymentService paymentService)
+ {
+ _organizationRepository = organizationRepository;
+ _organizationApiKeyRepository = organizationApiKeyRepository;
+ _applicationCacheService = applicationCacheService;
+ _organizationUserRepository = organizationUserRepository;
+ _eventService = eventService;
+ _sendOrganizationInvitesCommand = sendOrganizationInvitesCommand;
+ _paymentService = paymentService;
+ }
+
+ public async Task SignUpResellerClientAsync(
+ Organization organization,
+ string ownerEmail)
+ {
+ try
+ {
+ var createdOrganization = await CreateOrganizationAsync(organization);
+ var ownerOrganizationUser = await CreateAndInviteOwnerAsync(createdOrganization, ownerEmail);
+
+ await _eventService.LogOrganizationUserEventAsync(ownerOrganizationUser, EventType.OrganizationUser_Invited);
+
+ return new ResellerClientOrganizationSignUpResponse(organization, ownerOrganizationUser);
+ }
+ catch
+ {
+ await _paymentService.CancelAndRecoverChargesAsync(organization);
+
+ if (organization.Id != default)
+ {
+ // Deletes the organization and all related data, including its owner user
+ await _organizationRepository.DeleteAsync(organization);
+ await _applicationCacheService.DeleteOrganizationAbilityAsync(organization.Id);
+ }
+
+ throw;
+ }
+ }
+
+ private async Task CreateOrganizationAsync(Organization organization)
+ {
+ organization.Id = CoreHelpers.GenerateComb();
+ organization.Enabled = false;
+ organization.Status = OrganizationStatusType.Pending;
+
+ await _organizationRepository.CreateAsync(organization);
+ await _organizationApiKeyRepository.CreateAsync(new OrganizationApiKey
+ {
+ OrganizationId = organization.Id,
+ ApiKey = CoreHelpers.SecureRandomString(30),
+ Type = OrganizationApiKeyType.Default,
+ RevisionDate = DateTime.UtcNow,
+ });
+ await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
+
+ return organization;
+ }
+
+ private async Task CreateAndInviteOwnerAsync(Organization organization, string ownerEmail)
+ {
+ var ownerOrganizationUser = new OrganizationUser
+ {
+ OrganizationId = organization.Id,
+ UserId = null,
+ Email = ownerEmail,
+ Key = null,
+ Type = OrganizationUserType.Owner,
+ Status = OrganizationUserStatusType.Invited,
+ };
+
+ await _organizationUserRepository.CreateAsync(ownerOrganizationUser);
+
+ await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(
+ users: [ownerOrganizationUser],
+ organization: organization,
+ initOrganization: true));
+
+ return ownerOrganizationUser;
+ }
+}
diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs
index 5fe68bd22e..feae561a19 100644
--- a/src/Core/AdminConsole/Services/IOrganizationService.cs
+++ b/src/Core/AdminConsole/Services/IOrganizationService.cs
@@ -1,5 +1,4 @@
-using System.Security.Claims;
-using Bit.Core.AdminConsole.Entities;
+using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.Auth.Enums;
using Bit.Core.Entities;
@@ -42,7 +41,6 @@ public interface IOrganizationService
Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser);
Task>> RevokeUsersAsync(Guid organizationId,
IEnumerable organizationUserIds, Guid? revokingUserId);
- Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted);
Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null);
Task<(bool canScale, string failureReason)> CanScaleAsync(Organization organization, int seatsToAdd);
diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs
index 4d709bb7cf..d5320b5110 100644
--- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs
+++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs
@@ -1,5 +1,4 @@
-using System.Security.Claims;
-using System.Text.Json;
+using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Enums.Provider;
@@ -1705,27 +1704,4 @@ public class OrganizationService : IOrganizationService
return status;
}
-
- public async Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted)
- {
- organization.Id = CoreHelpers.GenerateComb();
- organization.Enabled = false;
- organization.Status = OrganizationStatusType.Pending;
-
- await SignUpAsync(organization, default, null, null, true);
-
- var ownerOrganizationUser = new OrganizationUser
- {
- OrganizationId = organization.Id,
- UserId = null,
- Email = ownerEmail,
- Key = null,
- Type = OrganizationUserType.Owner,
- Status = OrganizationUserStatusType.Invited,
- };
- await _organizationUserRepository.CreateAsync(ownerOrganizationUser);
-
- await SendInviteAsync(ownerOrganizationUser, organization, true);
- await _eventService.LogOrganizationUserEventAsync(ownerOrganizationUser, EventType.OrganizationUser_Invited);
- }
}
diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs
index 8a02ee68d8..ef78e966f6 100644
--- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs
+++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs
@@ -73,6 +73,7 @@ public static class OrganizationServiceCollectionExtensions
{
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
}
private static void AddOrganizationDeleteCommands(this IServiceCollection services)
diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/ResellerClientOrganizationSignUpCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/ResellerClientOrganizationSignUpCommandTests.cs
new file mode 100644
index 0000000000..55e5698ad4
--- /dev/null
+++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/ResellerClientOrganizationSignUpCommandTests.cs
@@ -0,0 +1,185 @@
+using Bit.Core.AdminConsole.Entities;
+using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
+using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
+using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
+using Bit.Core.Entities;
+using Bit.Core.Enums;
+using Bit.Core.Repositories;
+using Bit.Core.Services;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Organizations.OrganizationSignUp;
+
+[SutProviderCustomize]
+public class ResellerClientOrganizationSignUpCommandTests
+{
+ [Theory]
+ [BitAutoData]
+ public async Task SignUpResellerClientAsync_WithValidParameters_CreatesOrganizationSuccessfully(
+ Organization organization,
+ string ownerEmail,
+ SutProvider sutProvider)
+ {
+ var result = await sutProvider.Sut.SignUpResellerClientAsync(organization, ownerEmail);
+
+ Assert.NotNull(result.Organization);
+ Assert.False(result.Organization.Enabled);
+ Assert.Equal(OrganizationStatusType.Pending, result.Organization.Status);
+ Assert.NotNull(result.OwnerOrganizationUser);
+ Assert.Equal(ownerEmail, result.OwnerOrganizationUser.Email);
+ Assert.Equal(OrganizationUserType.Owner, result.OwnerOrganizationUser.Type);
+ Assert.Equal(OrganizationUserStatusType.Invited, result.OwnerOrganizationUser.Status);
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .CreateAsync(
+ Arg.Is(o =>
+ o.Id != default &&
+ o.Name == organization.Name &&
+ o.Enabled == false &&
+ o.Status == OrganizationStatusType.Pending
+ )
+ );
+ await sutProvider.GetDependency()
+ .Received(1)
+ .CreateAsync(
+ Arg.Is(k =>
+ k.OrganizationId == result.Organization.Id &&
+ k.Type == OrganizationApiKeyType.Default &&
+ !string.IsNullOrEmpty(k.ApiKey)
+ )
+ );
+ await sutProvider.GetDependency()
+ .Received(1)
+ .UpsertOrganizationAbilityAsync(Arg.Is(o => o.Id == result.Organization.Id));
+ await sutProvider.GetDependency()
+ .Received(1)
+ .CreateAsync(
+ Arg.Is(u =>
+ u.OrganizationId == result.Organization.Id &&
+ u.Email == ownerEmail &&
+ u.Type == OrganizationUserType.Owner &&
+ u.Status == OrganizationUserStatusType.Invited &&
+ u.UserId == null
+ )
+ );
+ await sutProvider.GetDependency()
+ .Received(1)
+ .SendInvitesAsync(
+ Arg.Is(r =>
+ r.Users.Count() == 1 &&
+ r.Users.First().Email == ownerEmail &&
+ r.Organization.Id == result.Organization.Id &&
+ r.InitOrganization == true
+ )
+ );
+ await sutProvider.GetDependency()
+ .Received(1)
+ .LogOrganizationUserEventAsync(
+ Arg.Is(u => u.Email == ownerEmail),
+ EventType.OrganizationUser_Invited
+ );
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task SignUpResellerClientAsync_WhenOrganizationRepositoryThrows_PerformsCleanup(
+ Organization organization,
+ string ownerEmail,
+ SutProvider sutProvider)
+ {
+ sutProvider.GetDependency()
+ .When(x => x.CreateAsync(Arg.Any()))
+ .Do(_ => throw new Exception());
+
+ await Assert.ThrowsAsync(
+ () => sutProvider.Sut.SignUpResellerClientAsync(organization, ownerEmail));
+
+ await AssertCleanupIsPerformed(sutProvider);
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task SignUpResellerClientAsync_WhenOrganizationUserCreationFails_PerformsCleanup(
+ Organization organization,
+ string ownerEmail,
+ SutProvider sutProvider)
+ {
+ sutProvider.GetDependency()
+ .When(x => x.CreateAsync(Arg.Any()))
+ .Do(_ => throw new Exception());
+
+ await Assert.ThrowsAsync(
+ () => sutProvider.Sut.SignUpResellerClientAsync(organization, ownerEmail));
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .CreateAsync(Arg.Any());
+ await AssertCleanupIsPerformed(sutProvider);
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task SignUpResellerClientAsync_WhenInvitationSendingFails_PerformsCleanup(
+ Organization organization,
+ string ownerEmail,
+ SutProvider sutProvider)
+ {
+ sutProvider.GetDependency()
+ .When(x => x.SendInvitesAsync(Arg.Any()))
+ .Do(_ => throw new Exception());
+
+ await Assert.ThrowsAsync(
+ () => sutProvider.Sut.SignUpResellerClientAsync(organization, ownerEmail));
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .CreateAsync(Arg.Any());
+ await sutProvider.GetDependency()
+ .Received(1)
+ .CreateAsync(Arg.Any());
+ await AssertCleanupIsPerformed(sutProvider);
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task SignUpResellerClientAsync_WhenEventLoggingFails_PerformsCleanup(
+ Organization organization,
+ string ownerEmail,
+ SutProvider sutProvider)
+ {
+ sutProvider.GetDependency()
+ .When(x => x.LogOrganizationUserEventAsync(Arg.Any(), Arg.Any()))
+ .Do(_ => throw new Exception());
+
+ await Assert.ThrowsAsync(
+ () => sutProvider.Sut.SignUpResellerClientAsync(organization, ownerEmail));
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .CreateAsync(Arg.Any());
+ await sutProvider.GetDependency()
+ .Received(1)
+ .CreateAsync(Arg.Any());
+ await sutProvider.GetDependency()
+ .Received(1)
+ .SendInvitesAsync(Arg.Any());
+ await AssertCleanupIsPerformed(sutProvider);
+ }
+
+ private static async Task AssertCleanupIsPerformed(SutProvider sutProvider)
+ {
+ await sutProvider.GetDependency()
+ .Received(1)
+ .CancelAndRecoverChargesAsync(Arg.Any());
+ await sutProvider.GetDependency()
+ .Received(1)
+ .DeleteAsync(Arg.Any());
+ await sutProvider.GetDependency()
+ .Received(1)
+ .DeleteOrganizationAbilityAsync(Arg.Any());
+ }
+}