1
0
mirror of https://github.com/bitwarden/server.git synced 2025-06-25 13:18:48 -05:00

[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
This commit is contained in:
Rui Tomé 2025-06-24 13:33:09 +01:00 committed by GitHub
parent cc07f13c7c
commit 494c41e3b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 323 additions and 35 deletions

View File

@ -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 });

View File

@ -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);
/// <summary>
/// Command for signing up reseller client organizations in a pending state.
/// </summary>
public interface IResellerClientOrganizationSignUpCommand
{
/// <summary>
/// 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.
/// </summary>
/// <param name="organization">The organization to create.</param>
/// <param name="ownerEmail">The email of the organization owner who will be invited.</param>
/// <returns>A response containing the created pending organization and invited owner user.</returns>
Task<ResellerClientOrganizationSignUpResponse> 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<ResellerClientOrganizationSignUpResponse> 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<Organization> 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<OrganizationUser> 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;
}
}

View File

@ -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<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId,
IEnumerable<Guid> 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);

View File

@ -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);
}
}

View File

@ -73,6 +73,7 @@ public static class OrganizationServiceCollectionExtensions
{
services.AddScoped<ICloudOrganizationSignUpCommand, CloudOrganizationSignUpCommand>();
services.AddScoped<IProviderClientOrganizationSignUpCommand, ProviderClientOrganizationSignUpCommand>();
services.AddScoped<IResellerClientOrganizationSignUpCommand, ResellerClientOrganizationSignUpCommand>();
}
private static void AddOrganizationDeleteCommands(this IServiceCollection services)

View File

@ -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<ResellerClientOrganizationSignUpCommand> 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<IOrganizationRepository>()
.Received(1)
.CreateAsync(
Arg.Is<Organization>(o =>
o.Id != default &&
o.Name == organization.Name &&
o.Enabled == false &&
o.Status == OrganizationStatusType.Pending
)
);
await sutProvider.GetDependency<IOrganizationApiKeyRepository>()
.Received(1)
.CreateAsync(
Arg.Is<OrganizationApiKey>(k =>
k.OrganizationId == result.Organization.Id &&
k.Type == OrganizationApiKeyType.Default &&
!string.IsNullOrEmpty(k.ApiKey)
)
);
await sutProvider.GetDependency<IApplicationCacheService>()
.Received(1)
.UpsertOrganizationAbilityAsync(Arg.Is<Organization>(o => o.Id == result.Organization.Id));
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.CreateAsync(
Arg.Is<OrganizationUser>(u =>
u.OrganizationId == result.Organization.Id &&
u.Email == ownerEmail &&
u.Type == OrganizationUserType.Owner &&
u.Status == OrganizationUserStatusType.Invited &&
u.UserId == null
)
);
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>()
.Received(1)
.SendInvitesAsync(
Arg.Is<SendInvitesRequest>(r =>
r.Users.Count() == 1 &&
r.Users.First().Email == ownerEmail &&
r.Organization.Id == result.Organization.Id &&
r.InitOrganization == true
)
);
await sutProvider.GetDependency<IEventService>()
.Received(1)
.LogOrganizationUserEventAsync(
Arg.Is<OrganizationUser>(u => u.Email == ownerEmail),
EventType.OrganizationUser_Invited
);
}
[Theory]
[BitAutoData]
public async Task SignUpResellerClientAsync_WhenOrganizationRepositoryThrows_PerformsCleanup(
Organization organization,
string ownerEmail,
SutProvider<ResellerClientOrganizationSignUpCommand> sutProvider)
{
sutProvider.GetDependency<IOrganizationRepository>()
.When(x => x.CreateAsync(Arg.Any<Organization>()))
.Do(_ => throw new Exception());
await Assert.ThrowsAsync<Exception>(
() => sutProvider.Sut.SignUpResellerClientAsync(organization, ownerEmail));
await AssertCleanupIsPerformed(sutProvider);
}
[Theory]
[BitAutoData]
public async Task SignUpResellerClientAsync_WhenOrganizationUserCreationFails_PerformsCleanup(
Organization organization,
string ownerEmail,
SutProvider<ResellerClientOrganizationSignUpCommand> sutProvider)
{
sutProvider.GetDependency<IOrganizationUserRepository>()
.When(x => x.CreateAsync(Arg.Any<OrganizationUser>()))
.Do(_ => throw new Exception());
await Assert.ThrowsAsync<Exception>(
() => sutProvider.Sut.SignUpResellerClientAsync(organization, ownerEmail));
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.CreateAsync(Arg.Any<Organization>());
await AssertCleanupIsPerformed(sutProvider);
}
[Theory]
[BitAutoData]
public async Task SignUpResellerClientAsync_WhenInvitationSendingFails_PerformsCleanup(
Organization organization,
string ownerEmail,
SutProvider<ResellerClientOrganizationSignUpCommand> sutProvider)
{
sutProvider.GetDependency<ISendOrganizationInvitesCommand>()
.When(x => x.SendInvitesAsync(Arg.Any<SendInvitesRequest>()))
.Do(_ => throw new Exception());
await Assert.ThrowsAsync<Exception>(
() => sutProvider.Sut.SignUpResellerClientAsync(organization, ownerEmail));
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.CreateAsync(Arg.Any<Organization>());
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.CreateAsync(Arg.Any<OrganizationUser>());
await AssertCleanupIsPerformed(sutProvider);
}
[Theory]
[BitAutoData]
public async Task SignUpResellerClientAsync_WhenEventLoggingFails_PerformsCleanup(
Organization organization,
string ownerEmail,
SutProvider<ResellerClientOrganizationSignUpCommand> sutProvider)
{
sutProvider.GetDependency<IEventService>()
.When(x => x.LogOrganizationUserEventAsync(Arg.Any<OrganizationUser>(), Arg.Any<EventType>()))
.Do(_ => throw new Exception());
await Assert.ThrowsAsync<Exception>(
() => sutProvider.Sut.SignUpResellerClientAsync(organization, ownerEmail));
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.CreateAsync(Arg.Any<Organization>());
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.CreateAsync(Arg.Any<OrganizationUser>());
await sutProvider.GetDependency<ISendOrganizationInvitesCommand>()
.Received(1)
.SendInvitesAsync(Arg.Any<SendInvitesRequest>());
await AssertCleanupIsPerformed(sutProvider);
}
private static async Task AssertCleanupIsPerformed(SutProvider<ResellerClientOrganizationSignUpCommand> sutProvider)
{
await sutProvider.GetDependency<IPaymentService>()
.Received(1)
.CancelAndRecoverChargesAsync(Arg.Any<Organization>());
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.DeleteAsync(Arg.Any<Organization>());
await sutProvider.GetDependency<IApplicationCacheService>()
.Received(1)
.DeleteOrganizationAbilityAsync(Arg.Any<Guid>());
}
}