1
0
mirror of https://github.com/bitwarden/server.git synced 2025-05-20 19:14:32 -05:00

[AC-2461] Scale provider seats on client organization deletion (#3996)

* Scaled provider seats on client organization deletion

* Thomas' feedback
This commit is contained in:
Alex Morask 2024-04-19 10:09:18 -04:00 committed by GitHub
parent e6bd8779a6
commit 821f7620b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 118 additions and 9 deletions

View File

@ -3,10 +3,12 @@ using Bit.Admin.AdminConsole.Models;
using Bit.Admin.Enums; using Bit.Admin.Enums;
using Bit.Admin.Services; using Bit.Admin.Services;
using Bit.Admin.Utilities; using Bit.Admin.Utilities;
using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Commands; using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Extensions;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@ -53,6 +55,8 @@ public class OrganizationsController : Controller
private readonly IProviderOrganizationRepository _providerOrganizationRepository; private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand; private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand;
private readonly IRemovePaymentMethodCommand _removePaymentMethodCommand; private readonly IRemovePaymentMethodCommand _removePaymentMethodCommand;
private readonly IFeatureService _featureService;
private readonly IScaleSeatsCommand _scaleSeatsCommand;
public OrganizationsController( public OrganizationsController(
IOrganizationService organizationService, IOrganizationService organizationService,
@ -78,7 +82,9 @@ public class OrganizationsController : Controller
IServiceAccountRepository serviceAccountRepository, IServiceAccountRepository serviceAccountRepository,
IProviderOrganizationRepository providerOrganizationRepository, IProviderOrganizationRepository providerOrganizationRepository,
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand, IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand,
IRemovePaymentMethodCommand removePaymentMethodCommand) IRemovePaymentMethodCommand removePaymentMethodCommand,
IFeatureService featureService,
IScaleSeatsCommand scaleSeatsCommand)
{ {
_organizationService = organizationService; _organizationService = organizationService;
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
@ -104,6 +110,8 @@ public class OrganizationsController : Controller
_providerOrganizationRepository = providerOrganizationRepository; _providerOrganizationRepository = providerOrganizationRepository;
_removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand; _removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand;
_removePaymentMethodCommand = removePaymentMethodCommand; _removePaymentMethodCommand = removePaymentMethodCommand;
_featureService = featureService;
_scaleSeatsCommand = scaleSeatsCommand;
} }
[RequirePermission(Permission.Org_List_View)] [RequirePermission(Permission.Org_List_View)]
@ -234,12 +242,30 @@ public class OrganizationsController : Controller
public async Task<IActionResult> Delete(Guid id) public async Task<IActionResult> Delete(Guid id)
{ {
var organization = await _organizationRepository.GetByIdAsync(id); var organization = await _organizationRepository.GetByIdAsync(id);
if (organization != null)
if (organization == null)
{ {
await _organizationRepository.DeleteAsync(organization); return RedirectToAction("Index");
await _applicationCacheService.DeleteOrganizationAbilityAsync(organization.Id);
} }
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
if (consolidatedBillingEnabled && organization.IsValidClient())
{
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
if (provider.IsBillable())
{
await _scaleSeatsCommand.ScalePasswordManagerSeats(
provider,
organization.PlanType,
-organization.Seats ?? 0);
}
}
await _organizationRepository.DeleteAsync(organization);
await _applicationCacheService.DeleteOrganizationAbilityAsync(organization.Id);
return RedirectToAction("Index"); return RedirectToAction("Index");
} }

View File

@ -20,6 +20,7 @@ using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.Services; using Bit.Core.Auth.Services;
using Bit.Core.Billing.Commands; using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Billing.Queries; using Bit.Core.Billing.Queries;
using Bit.Core.Context; using Bit.Core.Context;
@ -69,6 +70,8 @@ public class OrganizationsController : Controller
private readonly ISubscriberQueries _subscriberQueries; private readonly ISubscriberQueries _subscriberQueries;
private readonly IReferenceEventService _referenceEventService; private readonly IReferenceEventService _referenceEventService;
private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand; private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand;
private readonly IProviderRepository _providerRepository;
private readonly IScaleSeatsCommand _scaleSeatsCommand;
public OrganizationsController( public OrganizationsController(
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -95,7 +98,9 @@ public class OrganizationsController : Controller
ICancelSubscriptionCommand cancelSubscriptionCommand, ICancelSubscriptionCommand cancelSubscriptionCommand,
ISubscriberQueries subscriberQueries, ISubscriberQueries subscriberQueries,
IReferenceEventService referenceEventService, IReferenceEventService referenceEventService,
IOrganizationEnableCollectionEnhancementsCommand organizationEnableCollectionEnhancementsCommand) IOrganizationEnableCollectionEnhancementsCommand organizationEnableCollectionEnhancementsCommand,
IProviderRepository providerRepository,
IScaleSeatsCommand scaleSeatsCommand)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
@ -122,6 +127,8 @@ public class OrganizationsController : Controller
_subscriberQueries = subscriberQueries; _subscriberQueries = subscriberQueries;
_referenceEventService = referenceEventService; _referenceEventService = referenceEventService;
_organizationEnableCollectionEnhancementsCommand = organizationEnableCollectionEnhancementsCommand; _organizationEnableCollectionEnhancementsCommand = organizationEnableCollectionEnhancementsCommand;
_providerRepository = providerRepository;
_scaleSeatsCommand = scaleSeatsCommand;
} }
[HttpGet("{id}")] [HttpGet("{id}")]
@ -560,10 +567,23 @@ public class OrganizationsController : Controller
await Task.Delay(2000); await Task.Delay(2000);
throw new BadRequestException(string.Empty, "User verification failed."); throw new BadRequestException(string.Empty, "User verification failed.");
} }
else
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
if (consolidatedBillingEnabled && organization.IsValidClient())
{ {
await _organizationService.DeleteAsync(organization); var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
if (provider.IsBillable())
{
await _scaleSeatsCommand.ScalePasswordManagerSeats(
provider,
organization.PlanType,
-organization.Seats ?? 0);
}
} }
await _organizationService.DeleteAsync(organization);
} }
[HttpPost("{id}/import")] [HttpPost("{id}/import")]

View File

@ -1,9 +1,27 @@
using Bit.Core.Enums; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Enums;
namespace Bit.Core.Billing.Extensions; namespace Bit.Core.Billing.Extensions;
public static class BillingExtensions public static class BillingExtensions
{ {
public static bool IsBillable(this Provider provider) =>
provider is
{
Type: ProviderType.Msp,
Status: ProviderStatusType.Billable
};
public static bool IsValidClient(this Organization organization)
=> organization is
{
Seats: not null,
Status: OrganizationStatusType.Managed,
PlanType: PlanType.TeamsMonthly or PlanType.EnterpriseMonthly
};
public static bool SupportsConsolidatedBilling(this PlanType planType) public static bool SupportsConsolidatedBilling(this PlanType planType)
=> planType is PlanType.TeamsMonthly or PlanType.EnterpriseMonthly; => planType is PlanType.TeamsMonthly or PlanType.EnterpriseMonthly;
} }

View File

@ -2,8 +2,11 @@
using AutoFixture.Xunit2; using AutoFixture.Xunit2;
using Bit.Api.AdminConsole.Controllers; using Bit.Api.AdminConsole.Controllers;
using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Request.Organizations;
using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
@ -25,6 +28,7 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Tools.Services; using Bit.Core.Tools.Services;
using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider;
using NSubstitute; using NSubstitute;
using NSubstitute.ReturnsExtensions; using NSubstitute.ReturnsExtensions;
using Xunit; using Xunit;
@ -59,6 +63,8 @@ public class OrganizationsControllerTests : IDisposable
private readonly ISubscriberQueries _subscriberQueries; private readonly ISubscriberQueries _subscriberQueries;
private readonly IReferenceEventService _referenceEventService; private readonly IReferenceEventService _referenceEventService;
private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand; private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand;
private readonly IProviderRepository _providerRepository;
private readonly IScaleSeatsCommand _scaleSeatsCommand;
private readonly OrganizationsController _sut; private readonly OrganizationsController _sut;
@ -89,6 +95,8 @@ public class OrganizationsControllerTests : IDisposable
_subscriberQueries = Substitute.For<ISubscriberQueries>(); _subscriberQueries = Substitute.For<ISubscriberQueries>();
_referenceEventService = Substitute.For<IReferenceEventService>(); _referenceEventService = Substitute.For<IReferenceEventService>();
_organizationEnableCollectionEnhancementsCommand = Substitute.For<IOrganizationEnableCollectionEnhancementsCommand>(); _organizationEnableCollectionEnhancementsCommand = Substitute.For<IOrganizationEnableCollectionEnhancementsCommand>();
_providerRepository = Substitute.For<IProviderRepository>();
_scaleSeatsCommand = Substitute.For<IScaleSeatsCommand>();
_sut = new OrganizationsController( _sut = new OrganizationsController(
_organizationRepository, _organizationRepository,
@ -115,7 +123,9 @@ public class OrganizationsControllerTests : IDisposable
_cancelSubscriptionCommand, _cancelSubscriptionCommand,
_subscriberQueries, _subscriberQueries,
_referenceEventService, _referenceEventService,
_organizationEnableCollectionEnhancementsCommand); _organizationEnableCollectionEnhancementsCommand,
_providerRepository,
_scaleSeatsCommand);
} }
public void Dispose() public void Dispose()
@ -414,4 +424,39 @@ public class OrganizationsControllerTests : IDisposable
await _organizationEnableCollectionEnhancementsCommand.DidNotReceiveWithAnyArgs().EnableCollectionEnhancements(Arg.Any<Organization>()); await _organizationEnableCollectionEnhancementsCommand.DidNotReceiveWithAnyArgs().EnableCollectionEnhancements(Arg.Any<Organization>());
await _pushNotificationService.DidNotReceiveWithAnyArgs().PushSyncOrganizationsAsync(Arg.Any<Guid>()); await _pushNotificationService.DidNotReceiveWithAnyArgs().PushSyncOrganizationsAsync(Arg.Any<Guid>());
} }
[Theory, AutoData]
public async Task Delete_OrganizationIsConsolidatedBillingClient_ScalesProvidersSeats(
Provider provider,
Organization organization,
User user,
Guid organizationId,
SecretVerificationRequestModel requestModel)
{
organization.Status = OrganizationStatusType.Managed;
organization.PlanType = PlanType.TeamsMonthly;
organization.Seats = 10;
provider.Type = ProviderType.Msp;
provider.Status = ProviderStatusType.Billable;
_currentContext.OrganizationOwner(organizationId).Returns(true);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_userService.VerifySecretAsync(user, requestModel.Secret).Returns(true);
_featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
_providerRepository.GetByOrganizationIdAsync(organization.Id).Returns(provider);
await _sut.Delete(organizationId.ToString(), requestModel);
await _scaleSeatsCommand.Received(1)
.ScalePasswordManagerSeats(provider, organization.PlanType, -organization.Seats.Value);
await _organizationService.Received(1).DeleteAsync(organization);
}
} }