1
0
mirror of https://github.com/bitwarden/server synced 2025-12-19 17:53:44 +00:00

[AC 1410] Secrets Manager subscription adjustment back-end changes (#3036)

* Create UpgradeSecretsManagerSubscription command

---------

Co-authored-by: Thomas Rittson <trittson@bitwarden.com>
This commit is contained in:
cyprain-okeke
2023-07-10 08:46:32 +01:00
committed by GitHub
parent 692c7ff843
commit a1f8ca85cb
27 changed files with 1467 additions and 13 deletions

View File

@@ -9,6 +9,8 @@ using Stripe;
using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
using Bit.Admin.Services; using Bit.Admin.Services;
using Bit.Core.Repositories.Noop;
using Bit.Core.SecretsManager.Repositories;
#if !OSS #if !OSS
using Bit.Commercial.Core.Utilities; using Bit.Commercial.Core.Utilities;
@@ -86,6 +88,7 @@ public class Startup
services.AddBaseServices(globalSettings); services.AddBaseServices(globalSettings);
services.AddDefaultServices(globalSettings); services.AddDefaultServices(globalSettings);
services.AddScoped<IAccessControlService, AccessControlService>(); services.AddScoped<IAccessControlService, AccessControlService>();
services.AddScoped<IServiceAccountRepository, NoopServiceAccountRepository>();
#if OSS #if OSS
services.AddOosServices(); services.AddOosServices();

View File

@@ -18,6 +18,7 @@ using Bit.Core.Models.Business;
using Bit.Core.Models.Data.Organizations.Policies; using Bit.Core.Models.Data.Organizations.Policies;
using Bit.Core.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationApiKeys.Interfaces;
using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptionUpdate.Interface;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
@@ -50,6 +51,7 @@ public class OrganizationsController : Controller
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly GlobalSettings _globalSettings; private readonly GlobalSettings _globalSettings;
private readonly ILicensingService _licensingService; private readonly ILicensingService _licensingService;
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
public OrganizationsController( public OrganizationsController(
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@@ -70,7 +72,8 @@ public class OrganizationsController : Controller
ICloudGetOrganizationLicenseQuery cloudGetOrganizationLicenseQuery, ICloudGetOrganizationLicenseQuery cloudGetOrganizationLicenseQuery,
IFeatureService featureService, IFeatureService featureService,
GlobalSettings globalSettings, GlobalSettings globalSettings,
ILicensingService licensingService) ILicensingService licensingService,
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
@@ -91,6 +94,7 @@ public class OrganizationsController : Controller
_featureService = featureService; _featureService = featureService;
_globalSettings = globalSettings; _globalSettings = globalSettings;
_licensingService = licensingService; _licensingService = licensingService;
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
} }
[HttpGet("{id}")] [HttpGet("{id}")]
@@ -319,10 +323,34 @@ public class OrganizationsController : Controller
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
await _organizationService.UpdateSubscription(orgIdGuid, model.SeatAdjustment, model.MaxAutoscaleSeats); await _organizationService.UpdateSubscription(orgIdGuid, model.SeatAdjustment, model.MaxAutoscaleSeats);
} }
[HttpPost("{id}/sm-subscription")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostSmSubscription(Guid id, [FromBody] SecretsManagerSubscriptionUpdateRequestModel model)
{
var organization = await _organizationRepository.GetByIdAsync(id);
if (organization == null)
{
throw new NotFoundException();
}
if (!await _currentContext.EditSubscription(id))
{
throw new NotFoundException();
}
var secretsManagerPlan = StaticStore.GetSecretsManagerPlan(organization.PlanType);
if (secretsManagerPlan == null)
{
throw new NotFoundException("Invalid Secrets Manager plan.");
}
var organizationUpdate = model.ToSecretsManagerSubscriptionUpdate(organization, secretsManagerPlan);
await _updateSecretsManagerSubscriptionCommand.UpdateSecretsManagerSubscription(organizationUpdate);
}
[HttpPost("{id}/seat")] [HttpPost("{id}/seat")]
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
public async Task<PaymentResponseModel> PostSeat(string id, [FromBody] OrganizationSeatRequestModel model) public async Task<PaymentResponseModel> PostSeat(string id, [FromBody] OrganizationSeatRequestModel model)

View File

@@ -0,0 +1,48 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Entities;
using Bit.Core.Models.Business;
using Bit.Core.Models.StaticStore;
namespace Bit.Api.Models.Request.Organizations;
public class SecretsManagerSubscriptionUpdateRequestModel
{
[Required]
public int SeatAdjustment { get; set; }
public int? MaxAutoscaleSeats { get; set; }
public int ServiceAccountAdjustment { get; set; }
public int? MaxAutoscaleServiceAccounts { get; set; }
public virtual SecretsManagerSubscriptionUpdate ToSecretsManagerSubscriptionUpdate(Organization organization, Plan plan)
{
var newTotalSeats = organization.SmSeats.GetValueOrDefault() + SeatAdjustment;
var newTotalServiceAccounts = organization.SmServiceAccounts.GetValueOrDefault() + ServiceAccountAdjustment;
var autoscaleSeats = MaxAutoscaleSeats.HasValue && MaxAutoscaleSeats !=
organization.MaxAutoscaleSmSeats.GetValueOrDefault();
var autoscaleServiceAccounts = MaxAutoscaleServiceAccounts.HasValue &&
MaxAutoscaleServiceAccounts !=
organization.MaxAutoscaleSmServiceAccounts.GetValueOrDefault();
var orgUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organization.Id,
SmSeatsAdjustment = SeatAdjustment,
SmSeats = newTotalSeats,
SmSeatsExcludingBase = newTotalSeats - plan.BaseSeats,
MaxAutoscaleSmSeats = MaxAutoscaleSeats,
SmServiceAccountsAdjustment = ServiceAccountAdjustment,
SmServiceAccounts = newTotalServiceAccounts,
SmServiceAccountsExcludingBase = newTotalServiceAccounts - plan.BaseServiceAccount.GetValueOrDefault(),
MaxAutoscaleSmServiceAccounts = MaxAutoscaleServiceAccounts,
MaxAutoscaleSmSeatsChanged = autoscaleSeats,
MaxAutoscaleSmServiceAccountsChanged = autoscaleServiceAccounts
};
return orgUpdate;
}
}

View File

@@ -55,10 +55,12 @@ public class PlanResponseModel : ResponseModel
AdditionalPricePerServiceAccount = plan.AdditionalPricePerServiceAccount; AdditionalPricePerServiceAccount = plan.AdditionalPricePerServiceAccount;
BaseServiceAccount = plan.BaseServiceAccount; BaseServiceAccount = plan.BaseServiceAccount;
MaxServiceAccount = plan.MaxServiceAccount; MaxServiceAccounts = plan.MaxServiceAccounts;
MaxAdditionalServiceAccounts = plan.MaxAdditionalServiceAccount;
HasAdditionalServiceAccountOption = plan.HasAdditionalServiceAccountOption; HasAdditionalServiceAccountOption = plan.HasAdditionalServiceAccountOption;
MaxProjects = plan.MaxProjects; MaxProjects = plan.MaxProjects;
BitwardenProduct = plan.BitwardenProduct; BitwardenProduct = plan.BitwardenProduct;
StripeServiceAccountPlanId = plan.StripeServiceAccountPlanId;
} }
public PlanType Type { get; set; } public PlanType Type { get; set; }
@@ -105,10 +107,11 @@ public class PlanResponseModel : ResponseModel
public decimal SeatPrice { get; set; } public decimal SeatPrice { get; set; }
public decimal AdditionalStoragePricePerGb { get; set; } public decimal AdditionalStoragePricePerGb { get; set; }
public decimal PremiumAccessOptionPrice { get; set; } public decimal PremiumAccessOptionPrice { get; set; }
public string StripeServiceAccountPlanId { get; set; }
public decimal? AdditionalPricePerServiceAccount { get; set; } public decimal? AdditionalPricePerServiceAccount { get; set; }
public short? BaseServiceAccount { get; set; } public short? BaseServiceAccount { get; set; }
public short? MaxServiceAccount { get; set; } public short? MaxServiceAccounts { get; set; }
public short? MaxAdditionalServiceAccounts { get; set; }
public bool HasAdditionalServiceAccountOption { get; set; } public bool HasAdditionalServiceAccountOption { get; set; }
public short? MaxProjects { get; set; } public short? MaxProjects { get; set; }
public BitwardenProductType BitwardenProduct { get; set; } public BitwardenProductType BitwardenProduct { get; set; }

View File

@@ -0,0 +1,34 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0"
style="margin: 0; box-sizing: border-box; ">
<tr
style="margin: 0; box-sizing: border-box; ">
<td class="content-block"
style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;"
valign="top">
Your organization has reached the Secrets Manager seat limit of {{MaxSeatCount}} and new members cannot be invited.
</td>
</tr>
<tr
style="margin: 0; box-sizing: border-box; ">
<td class="content-block last"
style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;"
valign="top">
For more information, please refer to the following help article:
<a href="https://bitwarden.com/help/managing-users" class="inline-link">
Member management
</a>
<br class="line-break" />
<br class="line-break" />
</td>
</tr>
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
<a href="https://vault.bitwarden.com/#/organizations/{{{OrganizationId}}}/billing/subscription" clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #175DDC; border-color: #175DDC; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
Manage subscription
</a>
<br class="line-break" />
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

@@ -0,0 +1,5 @@
{{#>BasicTextLayout}}
Your organization has reached the Secrets Manager seat limit of {{MaxSeatCount}} and new members cannot be invited.
For more information, please refer to the following help article: https://bitwarden.com/help/managing-users
{{/BasicTextLayout}}

View File

@@ -0,0 +1,34 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0"
style="margin: 0; box-sizing: border-box; ">
<tr
style="margin: 0; box-sizing: border-box; ">
<td class="content-block"
style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;"
valign="top">
Your organization has reached the Secrets Manager service accounts limit of {{MaxServiceAccountsCount}}. New service accounts cannot be created
</td>
</tr>
<tr
style="margin: 0; box-sizing: border-box; ">
<td class="content-block last"
style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;"
valign="top">
For more information, please refer to the following help article:
<a href="https://bitwarden.com/help/managing-users" class="inline-link">
Member management
</a>
<br class="line-break" />
<br class="line-break" />
</td>
</tr>
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
<a href="https://vault.bitwarden.com/#/organizations/{{{OrganizationId}}}/billing/subscription" clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #175DDC; border-color: #175DDC; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
Manage subscription
</a>
<br class="line-break" />
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

@@ -0,0 +1,5 @@
{{#>BasicTextLayout}}
Your organization has reached the Secrets Manager service accounts limit of {{MaxServiceAccountsCount}}. New service accounts cannot be created
For more information, please refer to the following help article: https://bitwarden.com/help/managing-users
{{/BasicTextLayout}}

View File

@@ -0,0 +1,56 @@
namespace Bit.Core.Models.Business;
public class SecretsManagerSubscriptionUpdate
{
public Guid OrganizationId { get; set; }
/// <summary>
/// The seats to be added or removed from the organization
/// </summary>
public int SmSeatsAdjustment { get; set; }
/// <summary>
/// The total seats the organization will have after the update, including any base seats included in the plan
/// </summary>
public int SmSeats { get; set; }
/// <summary>
/// The seats the organization will have after the update, excluding the base seats included in the plan
/// Usually this is what the organization is billed for
/// </summary>
public int SmSeatsExcludingBase { get; set; }
/// <summary>
/// The new autoscale limit for seats, expressed as a total (not an adjustment).
/// This may or may not be the same as the current autoscale limit.
/// </summary>
public int? MaxAutoscaleSmSeats { get; set; }
/// <summary>
/// The service accounts to be added or removed from the organization
/// </summary>
public int SmServiceAccountsAdjustment { get; set; }
/// <summary>
/// The total service accounts the organization will have after the update, including the base service accounts
/// included in the plan
/// </summary>
public int SmServiceAccounts { get; set; }
/// <summary>
/// The seats the organization will have after the update, excluding the base seats included in the plan
/// Usually this is what the organization is billed for
/// </summary>
public int SmServiceAccountsExcludingBase { get; set; }
/// <summary>
/// The new autoscale limit for service accounts, expressed as a total (not an adjustment).
/// This may or may not be the same as the current autoscale limit.
/// </summary>
public int? MaxAutoscaleSmServiceAccounts { get; set; }
public bool SmSeatsChanged => SmSeatsAdjustment != 0;
public bool SmServiceAccountsChanged => SmServiceAccountsAdjustment != 0;
public bool MaxAutoscaleSmSeatsChanged { get; set; }
public bool MaxAutoscaleSmServiceAccountsChanged { get; set; }
}

View File

@@ -1,4 +1,5 @@
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums;
using Stripe; using Stripe;
namespace Bit.Core.Models.Business; namespace Bit.Core.Models.Business;
@@ -42,7 +43,15 @@ public class SeatSubscriptionUpdate : SubscriptionUpdate
{ {
_plan = plan; _plan = plan;
_additionalSeats = additionalSeats; _additionalSeats = additionalSeats;
_previousSeats = organization.Seats ?? 0; switch (plan.BitwardenProduct)
{
case BitwardenProductType.PasswordManager:
_previousSeats = organization.Seats.GetValueOrDefault();
break;
case BitwardenProductType.SecretsManager:
_previousSeats = organization.SmSeats.GetValueOrDefault();
break;
}
} }
public override List<SubscriptionItemOptions> UpgradeItemsOptions(Subscription subscription) public override List<SubscriptionItemOptions> UpgradeItemsOptions(Subscription subscription)
@@ -77,6 +86,52 @@ public class SeatSubscriptionUpdate : SubscriptionUpdate
} }
} }
public class ServiceAccountSubscriptionUpdate : SubscriptionUpdate
{
private long? _prevServiceAccounts;
private readonly StaticStore.Plan _plan;
private readonly long? _additionalServiceAccounts;
protected override List<string> PlanIds => new() { _plan.StripeServiceAccountPlanId };
public ServiceAccountSubscriptionUpdate(Organization organization, StaticStore.Plan plan, long? additionalServiceAccounts)
{
_plan = plan;
_additionalServiceAccounts = additionalServiceAccounts;
_prevServiceAccounts = organization.SmServiceAccounts ?? 0;
}
public override List<SubscriptionItemOptions> UpgradeItemsOptions(Subscription subscription)
{
var item = SubscriptionItem(subscription, PlanIds.Single());
_prevServiceAccounts = item?.Quantity ?? 0;
return new()
{
new SubscriptionItemOptions
{
Id = item?.Id,
Plan = PlanIds.Single(),
Quantity = _additionalServiceAccounts,
Deleted = (item?.Id != null && _additionalServiceAccounts == 0) ? true : (bool?)null,
}
};
}
public override List<SubscriptionItemOptions> RevertItemsOptions(Subscription subscription)
{
var item = SubscriptionItem(subscription, PlanIds.Single());
return new()
{
new SubscriptionItemOptions
{
Id = item?.Id,
Plan = PlanIds.Single(),
Quantity = _prevServiceAccounts,
Deleted = _prevServiceAccounts == 0 ? true : (bool?)null,
}
};
}
}
public class StorageSubscriptionUpdate : SubscriptionUpdate public class StorageSubscriptionUpdate : SubscriptionUpdate
{ {
private long? _prevStorage; private long? _prevStorage;

View File

@@ -0,0 +1,7 @@
namespace Bit.Core.Models.Mail;
public class OrganizationServiceAccountsMaxReachedViewModel
{
public Guid OrganizationId { get; set; }
public int MaxServiceAccountsCount { get; set; }
}

View File

@@ -15,8 +15,11 @@ public class Plan
public short? BaseStorageGb { get; set; } public short? BaseStorageGb { get; set; }
public short? MaxCollections { get; set; } public short? MaxCollections { get; set; }
public short? MaxUsers { get; set; } public short? MaxUsers { get; set; }
public short? MaxServiceAccounts { get; set; }
public bool AllowSeatAutoscale { get; set; } public bool AllowSeatAutoscale { get; set; }
public bool AllowServiceAccountsAutoscale { get; set; }
public bool HasAdditionalSeatsOption { get; set; } public bool HasAdditionalSeatsOption { get; set; }
public int? MaxAdditionalSeats { get; set; } public int? MaxAdditionalSeats { get; set; }
public bool HasAdditionalStorageOption { get; set; } public bool HasAdditionalStorageOption { get; set; }
@@ -55,7 +58,7 @@ public class Plan
public decimal PremiumAccessOptionPrice { get; set; } public decimal PremiumAccessOptionPrice { get; set; }
public decimal? AdditionalPricePerServiceAccount { get; set; } public decimal? AdditionalPricePerServiceAccount { get; set; }
public short? BaseServiceAccount { get; set; } public short? BaseServiceAccount { get; set; }
public short? MaxServiceAccount { get; set; } public short? MaxAdditionalServiceAccount { get; set; }
public bool HasAdditionalServiceAccountOption { get; set; } public bool HasAdditionalServiceAccountOption { get; set; }
public short? MaxProjects { get; set; } public short? MaxProjects { get; set; }
public BitwardenProductType BitwardenProduct { get; set; } public BitwardenProductType BitwardenProduct { get; set; }

View File

@@ -15,6 +15,8 @@ using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterpri
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Cloud; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Cloud;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.SelfHosted; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.SelfHosted;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptionUpdate;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptionUpdate.Interface;
using Bit.Core.SecretsManager.Commands.EnableAccessSecretsManager; using Bit.Core.SecretsManager.Commands.EnableAccessSecretsManager;
using Bit.Core.SecretsManager.Commands.EnableAccessSecretsManager.Interfaces; using Bit.Core.SecretsManager.Commands.EnableAccessSecretsManager.Interfaces;
using Bit.Core.Services; using Bit.Core.Services;
@@ -41,6 +43,7 @@ public static class OrganizationServiceCollectionExtensions
services.AddOrganizationGroupCommands(); services.AddOrganizationGroupCommands();
services.AddOrganizationLicenseCommandsQueries(); services.AddOrganizationLicenseCommandsQueries();
services.AddOrganizationDomainCommandsQueries(); services.AddOrganizationDomainCommandsQueries();
services.AddOrganizationSubscriptionUpdateCommandsQueries();
} }
private static void AddOrganizationConnectionCommands(this IServiceCollection services) private static void AddOrganizationConnectionCommands(this IServiceCollection services)
@@ -110,6 +113,11 @@ public static class OrganizationServiceCollectionExtensions
services.AddScoped<IDeleteOrganizationDomainCommand, DeleteOrganizationDomainCommand>(); services.AddScoped<IDeleteOrganizationDomainCommand, DeleteOrganizationDomainCommand>();
} }
private static void AddOrganizationSubscriptionUpdateCommandsQueries(this IServiceCollection services)
{
services.AddScoped<IUpdateSecretsManagerSubscriptionCommand, UpdateSecretsManagerSubscriptionCommand>();
}
private static void AddTokenizers(this IServiceCollection services) private static void AddTokenizers(this IServiceCollection services)
{ {
services.AddSingleton<IDataProtectorTokenFactory<OrganizationSponsorshipOfferTokenable>>(serviceProvider => services.AddSingleton<IDataProtectorTokenFactory<OrganizationSponsorshipOfferTokenable>>(serviceProvider =>

View File

@@ -0,0 +1,8 @@
using Bit.Core.Models.Business;
namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptionUpdate.Interface;
public interface IUpdateSecretsManagerSubscriptionCommand
{
Task UpdateSecretsManagerSubscription(SecretsManagerSubscriptionUpdate update);
}

View File

@@ -0,0 +1,344 @@
#nullable enable
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Models.StaticStore;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptionUpdate.Interface;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.Extensions.Logging;
namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptionUpdate;
public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubscriptionCommand
{
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IPaymentService _paymentService;
private readonly IOrganizationService _organizationService;
private readonly IMailService _mailService;
private readonly ILogger<UpdateSecretsManagerSubscriptionCommand> _logger;
private readonly IServiceAccountRepository _serviceAccountRepository;
public UpdateSecretsManagerSubscriptionCommand(
IOrganizationRepository organizationRepository,
IOrganizationService organizationService,
IOrganizationUserRepository organizationUserRepository,
IPaymentService paymentService,
IMailService mailService,
ILogger<UpdateSecretsManagerSubscriptionCommand> logger,
IServiceAccountRepository serviceAccountRepository)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
_paymentService = paymentService;
_organizationService = organizationService;
_mailService = mailService;
_logger = logger;
_serviceAccountRepository = serviceAccountRepository;
}
public async Task UpdateSecretsManagerSubscription(SecretsManagerSubscriptionUpdate update)
{
var organization = await _organizationRepository.GetByIdAsync(update.OrganizationId);
ValidateOrganization(organization);
var plan = GetPlanForOrganization(organization);
if (update.SmSeatsChanged)
{
await ValidateSmSeatsUpdateAsync(organization, update, plan);
}
if (update.SmServiceAccountsChanged)
{
await ValidateSmServiceAccountsUpdateAsync(organization, update, plan);
}
if (update.MaxAutoscaleSmSeatsChanged)
{
ValidateMaxAutoscaleSmSeatsUpdateAsync(organization, update.MaxAutoscaleSmSeats.GetValueOrDefault(), plan);
}
if (update.MaxAutoscaleSmServiceAccountsChanged)
{
ValidateMaxAutoscaleSmServiceAccountUpdate(organization, update.MaxAutoscaleSmServiceAccounts.GetValueOrDefault(), plan);
}
await FinalizeSubscriptionAdjustmentAsync(organization, plan, update);
await SendEmailIfAutoscaleLimitReached(organization);
}
private Plan GetPlanForOrganization(Organization organization)
{
var plan = StaticStore.SecretManagerPlans.FirstOrDefault(p => p.Type == organization.PlanType);
if (plan == null)
{
throw new BadRequestException("Existing plan not found.");
}
return plan;
}
private static void ValidateOrganization(Organization organization)
{
if (organization == null)
{
throw new NotFoundException("Organization is not found");
}
if (!organization.UseSecretsManager)
{
throw new BadRequestException("Organization has no access to Secrets Manager.");
}
}
private async Task FinalizeSubscriptionAdjustmentAsync(Organization organization,
Plan plan, SecretsManagerSubscriptionUpdate update)
{
if (update.SmSeatsChanged)
{
await ProcessChargesAndRaiseEventsForAdjustSeatsAsync(organization, plan, update);
organization.SmSeats = update.SmSeats;
}
if (update.SmServiceAccountsChanged)
{
await ProcessChargesAndRaiseEventsForAdjustServiceAccountsAsync(organization, plan, update);
organization.SmServiceAccounts = update.SmServiceAccounts;
}
if (update.MaxAutoscaleSmSeatsChanged)
{
organization.MaxAutoscaleSmSeats = update.MaxAutoscaleSmSeats;
}
if (update.MaxAutoscaleSmServiceAccountsChanged)
{
organization.MaxAutoscaleSmServiceAccounts = update.MaxAutoscaleSmServiceAccounts;
}
await _organizationService.ReplaceAndUpdateCacheAsync(organization);
}
private async Task ProcessChargesAndRaiseEventsForAdjustSeatsAsync(Organization organization, Plan plan,
SecretsManagerSubscriptionUpdate update)
{
await _paymentService.AdjustSeatsAsync(organization, plan, update.SmSeatsExcludingBase);
// TODO: call ReferenceEventService - see AC-1481
}
private async Task ProcessChargesAndRaiseEventsForAdjustServiceAccountsAsync(Organization organization, Plan plan,
SecretsManagerSubscriptionUpdate update)
{
await _paymentService.AdjustServiceAccountsAsync(organization, plan,
update.SmServiceAccountsExcludingBase);
// TODO: call ReferenceEventService - see AC-1481
}
private async Task SendEmailIfAutoscaleLimitReached(Organization organization)
{
if (organization.SmSeats.HasValue && organization.MaxAutoscaleSmSeats.HasValue && organization.SmSeats == organization.MaxAutoscaleSmSeats)
{
await SendSeatLimitEmailAsync(organization, organization.MaxAutoscaleSmSeats.Value);
}
if (organization.SmServiceAccounts.HasValue && organization.MaxAutoscaleSmServiceAccounts.HasValue && organization.SmServiceAccounts == organization.MaxAutoscaleSmServiceAccounts)
{
await SendServiceAccountLimitEmailAsync(organization, organization.MaxAutoscaleSmServiceAccounts.Value);
}
}
private async Task SendSeatLimitEmailAsync(Organization organization, int MaxAutoscaleValue)
{
try
{
var ownerEmails = (await _organizationUserRepository.GetManyByMinimumRoleAsync(organization.Id,
OrganizationUserType.Owner))
.Select(u => u.Email).Distinct();
await _mailService.SendSecretsManagerMaxSeatLimitReachedEmailAsync(organization, MaxAutoscaleValue, ownerEmails);
}
catch (Exception e)
{
_logger.LogError(e, $"Error encountered notifying organization owners of Seats limit reached.");
}
}
private async Task SendServiceAccountLimitEmailAsync(Organization organization, int MaxAutoscaleValue)
{
try
{
var ownerEmails = (await _organizationUserRepository.GetManyByMinimumRoleAsync(organization.Id,
OrganizationUserType.Owner))
.Select(u => u.Email).Distinct();
await _mailService.SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(organization, MaxAutoscaleValue, ownerEmails);
}
catch (Exception e)
{
_logger.LogError(e, $"Error encountered notifying organization owners of Service Accounts limit reached.");
}
}
private async Task ValidateSmSeatsUpdateAsync(Organization organization, SecretsManagerSubscriptionUpdate update, Plan plan)
{
if (organization.SmSeats == null)
{
throw new BadRequestException("Organization has no Secrets Manager seat limit, no need to adjust seats");
}
if (update.MaxAutoscaleSmSeats.HasValue && update.SmSeats > update.MaxAutoscaleSmSeats.Value)
{
throw new BadRequestException("Cannot set max seat autoscaling below seat count.");
}
if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId))
{
throw new BadRequestException("No payment method found.");
}
if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
{
throw new BadRequestException("No subscription found.");
}
if (!plan.HasAdditionalSeatsOption)
{
throw new BadRequestException("Plan does not allow additional Secrets Manager seats.");
}
if (plan.BaseSeats > update.SmSeats)
{
throw new BadRequestException($"Plan has a minimum of {plan.BaseSeats} Secrets Manager seats.");
}
if (update.SmSeats <= 0)
{
throw new BadRequestException("You must have at least 1 Secrets Manager seat.");
}
if (plan.MaxAdditionalSeats.HasValue && update.SmSeatsExcludingBase > plan.MaxAdditionalSeats.Value)
{
throw new BadRequestException($"Organization plan allows a maximum of " +
$"{plan.MaxAdditionalSeats.Value} additional Secrets Manager seats.");
}
if (organization.SmSeats.Value > update.SmSeats)
{
var currentSeats = await _organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id);
if (currentSeats > update.SmSeats)
{
throw new BadRequestException($"Your organization currently has {currentSeats} Secrets Manager seats. " +
$"Your plan only allows ({update.SmSeats}) Secrets Manager seats. Remove some Secrets Manager users.");
}
}
}
private async Task ValidateSmServiceAccountsUpdateAsync(Organization organization, SecretsManagerSubscriptionUpdate update, Plan plan)
{
if (organization.SmServiceAccounts == null)
{
throw new BadRequestException("Organization has no Service Accounts limit, no need to adjust Service Accounts");
}
if (update.MaxAutoscaleSmServiceAccounts.HasValue && update.SmServiceAccounts > update.MaxAutoscaleSmServiceAccounts.Value)
{
throw new BadRequestException("Cannot set max Service Accounts autoscaling below Service Accounts count.");
}
if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId))
{
throw new BadRequestException("No payment method found.");
}
if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
{
throw new BadRequestException("No subscription found.");
}
if (!plan.HasAdditionalServiceAccountOption)
{
throw new BadRequestException("Plan does not allow additional Service Accounts.");
}
if (plan.BaseServiceAccount > update.SmServiceAccounts)
{
throw new BadRequestException($"Plan has a minimum of {plan.BaseServiceAccount} Service Accounts.");
}
if (update.SmServiceAccounts <= 0)
{
throw new BadRequestException("You must have at least 1 Service Account.");
}
if (plan.MaxAdditionalServiceAccount.HasValue && update.SmServiceAccountsExcludingBase > plan.MaxAdditionalServiceAccount.Value)
{
throw new BadRequestException($"Organization plan allows a maximum of " +
$"{plan.MaxAdditionalServiceAccount.Value} additional Service Accounts.");
}
if (!organization.SmServiceAccounts.HasValue || organization.SmServiceAccounts.Value > update.SmServiceAccounts)
{
var currentServiceAccounts = await _serviceAccountRepository.GetServiceAccountCountByOrganizationIdAsync(organization.Id);
if (currentServiceAccounts > update.SmServiceAccounts)
{
throw new BadRequestException($"Your organization currently has {currentServiceAccounts} Service Accounts. " +
$"Your plan only allows ({update.SmServiceAccounts}) Service Accounts. Remove some Service Accounts.");
}
}
}
private void ValidateMaxAutoscaleSmSeatsUpdateAsync(Organization organization, int maxAutoscaleSeats, Plan plan)
{
if (organization.SmSeats.HasValue && maxAutoscaleSeats < organization.SmSeats.Value)
{
throw new BadRequestException($"Cannot set max Secrets Manager seat autoscaling below current Secrets Manager seat count.");
}
if (plan.MaxUsers.HasValue && maxAutoscaleSeats > plan.MaxUsers)
{
throw new BadRequestException(string.Concat(
$"Your plan has a Secrets Manager seat limit of {plan.MaxUsers}, ",
$"but you have specified a max autoscale count of {maxAutoscaleSeats}.",
"Reduce your max autoscale count."));
}
if (!plan.AllowSeatAutoscale)
{
throw new BadRequestException("Your plan does not allow Secrets Manager seat autoscaling.");
}
}
private void ValidateMaxAutoscaleSmServiceAccountUpdate(Organization organization, int maxAutoscaleServiceAccounts, Plan plan)
{
if (organization.SmServiceAccounts.HasValue && maxAutoscaleServiceAccounts < organization.SmServiceAccounts.Value)
{
throw new BadRequestException(
$"Cannot set max Service Accounts autoscaling below current Service Accounts count.");
}
if (!plan.AllowServiceAccountsAutoscale)
{
throw new BadRequestException("Your plan does not allow Service Accounts autoscaling.");
}
if (plan.MaxServiceAccounts.HasValue && maxAutoscaleServiceAccounts > plan.MaxServiceAccounts)
{
throw new BadRequestException(string.Concat(
$"Your plan has a Service Accounts limit of {plan.MaxServiceAccounts}, ",
$"but you have specified a max autoscale count of {maxAutoscaleServiceAccounts}.",
"Reduce your max autoscale count."));
}
}
}

View File

@@ -55,4 +55,6 @@ public interface IMailService
Task SendFailedLoginAttemptsEmailAsync(string email, DateTime utcNow, string ip); Task SendFailedLoginAttemptsEmailAsync(string email, DateTime utcNow, string ip);
Task SendFailedTwoFactorAttemptsEmailAsync(string email, DateTime utcNow, string ip); Task SendFailedTwoFactorAttemptsEmailAsync(string email, DateTime utcNow, string ip);
Task SendUnverifiedOrganizationDomainEmailAsync(IEnumerable<string> adminEmails, string organizationId, string domainName); Task SendUnverifiedOrganizationDomainEmailAsync(IEnumerable<string> adminEmails, string organizationId, string domainName);
Task SendSecretsManagerMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable<string> ownerEmails);
Task SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable<string> ownerEmails);
} }

View File

@@ -21,6 +21,9 @@ public interface IPaymentService
short additionalStorageGb, TaxInfo taxInfo); short additionalStorageGb, TaxInfo taxInfo);
Task<string> AdjustSeatsAsync(Organization organization, Plan plan, int additionalSeats, DateTime? prorationDate = null); Task<string> AdjustSeatsAsync(Organization organization, Plan plan, int additionalSeats, DateTime? prorationDate = null);
Task<string> AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId, DateTime? prorationDate = null); Task<string> AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId, DateTime? prorationDate = null);
Task<string> AdjustServiceAccountsAsync(Organization organization, Plan plan, int additionalServiceAccounts,
DateTime? prorationDate = null);
Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false, Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false,
bool skipInAppPurchaseCheck = false); bool skipInAppPurchaseCheck = false);
Task ReinstateSubscriptionAsync(ISubscriber subscriber); Task ReinstateSubscriptionAsync(ISubscriber subscriber);

View File

@@ -896,6 +896,36 @@ public class HandlebarsMailService : IMailService
await _mailDeliveryService.SendEmailAsync(message); await _mailDeliveryService.SendEmailAsync(message);
} }
public async Task SendSecretsManagerMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount,
IEnumerable<string> ownerEmails)
{
var message = CreateDefaultMessage($"{organization.Name} Secrets Manager Seat Limit Reached", ownerEmails);
var model = new OrganizationSeatsMaxReachedViewModel
{
OrganizationId = organization.Id,
MaxSeatCount = maxSeatCount,
};
await AddMessageContentAsync(message, "OrganizationSmSeatsMaxReached", model);
message.Category = "OrganizationSmSeatsMaxReached";
await _mailDeliveryService.SendEmailAsync(message);
}
public async Task SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(Organization organization, int maxSeatCount,
IEnumerable<string> ownerEmails)
{
var message = CreateDefaultMessage($"{organization.Name} Secrets Manager Service Accounts Limit Reached", ownerEmails);
var model = new OrganizationServiceAccountsMaxReachedViewModel
{
OrganizationId = organization.Id,
MaxServiceAccountsCount = maxSeatCount,
};
await AddMessageContentAsync(message, "OrganizationSmServiceAccountsMaxReached", model);
message.Category = "OrganizationSmServiceAccountsMaxReached";
await _mailDeliveryService.SendEmailAsync(message);
}
private static string GetUserIdentifier(string email, string userName) private static string GetUserIdentifier(string email, string userName)
{ {
return string.IsNullOrEmpty(userName) ? email : CoreHelpers.SanitizeForEmail(userName, false); return string.IsNullOrEmpty(userName) ? email : CoreHelpers.SanitizeForEmail(userName, false);

View File

@@ -427,15 +427,15 @@ public class OrganizationService : IOrganizationService
if (newSecretsManagerPlan.BaseServiceAccount != null) if (newSecretsManagerPlan.BaseServiceAccount != null)
{ {
if (!organization.SmServiceAccounts.HasValue || if (!organization.SmServiceAccounts.HasValue ||
organization.SmServiceAccounts.Value > newSecretsManagerPlan.MaxServiceAccount) organization.SmServiceAccounts.Value > newSecretsManagerPlan.MaxServiceAccounts)
{ {
var currentServiceAccounts = var currentServiceAccounts =
await _serviceAccountRepository.GetServiceAccountCountByOrganizationIdAsync(organization.Id); await _serviceAccountRepository.GetServiceAccountCountByOrganizationIdAsync(organization.Id);
if (currentServiceAccounts > newSecretsManagerPlan.MaxServiceAccount) if (currentServiceAccounts > newSecretsManagerPlan.MaxServiceAccounts)
{ {
throw new BadRequestException( throw new BadRequestException(
$"Your organization currently has {currentServiceAccounts} service account seats filled. " + $"Your organization currently has {currentServiceAccounts} service account seats filled. " +
$"Your new plan only has ({newSecretsManagerPlan.MaxServiceAccount}) service accounts. Remove some service accounts."); $"Your new plan only has ({newSecretsManagerPlan.MaxServiceAccounts}) service accounts. Remove some service accounts.");
} }
} }
} }

View File

@@ -853,6 +853,11 @@ public class StripePaymentService : IPaymentService
return FinalizeSubscriptionChangeAsync(organization, new SeatSubscriptionUpdate(organization, plan, additionalSeats), prorationDate); return FinalizeSubscriptionChangeAsync(organization, new SeatSubscriptionUpdate(organization, plan, additionalSeats), prorationDate);
} }
public Task<string> AdjustServiceAccountsAsync(Organization organization, StaticStore.Plan plan, int additionalServiceAccounts, DateTime? prorationDate = null)
{
return FinalizeSubscriptionChangeAsync(organization, new ServiceAccountSubscriptionUpdate(organization, plan, additionalServiceAccounts), prorationDate);
}
public Task<string> AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, public Task<string> AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage,
string storagePlanId, DateTime? prorationDate = null) string storagePlanId, DateTime? prorationDate = null)
{ {

View File

@@ -238,4 +238,17 @@ public class NoopMailService : IMailService
{ {
return Task.FromResult(0); return Task.FromResult(0);
} }
public Task SendSecretsManagerMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount,
IEnumerable<string> ownerEmails)
{
return Task.FromResult(0);
}
public Task SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(Organization organization,
int maxSeatCount,
IEnumerable<string> ownerEmails)
{
return Task.FromResult(0);
}
} }

View File

@@ -44,4 +44,8 @@ public enum ReferenceEventType
OrganizationCreatedByAdmin, OrganizationCreatedByAdmin,
[EnumMember(Value = "sm-service-account-accessed-secret")] [EnumMember(Value = "sm-service-account-accessed-secret")]
SmServiceAccountAccessedSecret, SmServiceAccountAccessedSecret,
[EnumMember(Value = "adjust-service-accounts")]
AdjustServiceAccounts,
[EnumMember(Value = "adjust-sm-seats")]
AdjustSmSeats,
} }

View File

@@ -52,6 +52,7 @@ public class ReferenceEvent
public int? Seats { get; set; } public int? Seats { get; set; }
public int? PreviousSeats { get; set; } public int? PreviousSeats { get; set; }
public int? PreviousServiceAccounts { get; set; }
public short? Storage { get; set; } public short? Storage { get; set; }

View File

@@ -45,6 +45,7 @@ public static class SecretsManagerPlanStore
SeatPrice = 13, SeatPrice = 13,
AdditionalPricePerServiceAccount = 0.5M, AdditionalPricePerServiceAccount = 0.5M,
AllowSeatAutoscale = true, AllowSeatAutoscale = true,
AllowServiceAccountsAutoscale = true
}, },
new Plan new Plan
{ {
@@ -83,6 +84,7 @@ public static class SecretsManagerPlanStore
SeatPrice = 144, SeatPrice = 144,
AdditionalPricePerServiceAccount = 6, AdditionalPricePerServiceAccount = 6,
AllowSeatAutoscale = true, AllowSeatAutoscale = true,
AllowServiceAccountsAutoscale = true
}, },
new Plan new Plan
{ {
@@ -113,6 +115,7 @@ public static class SecretsManagerPlanStore
SeatPrice = 7, SeatPrice = 7,
AdditionalPricePerServiceAccount = 0.5M, AdditionalPricePerServiceAccount = 0.5M,
AllowSeatAutoscale = true, AllowSeatAutoscale = true,
AllowServiceAccountsAutoscale = true
}, },
new Plan new Plan
{ {
@@ -145,6 +148,7 @@ public static class SecretsManagerPlanStore
SeatPrice = 72, SeatPrice = 72,
AdditionalPricePerServiceAccount = 6, AdditionalPricePerServiceAccount = 6,
AllowSeatAutoscale = true, AllowSeatAutoscale = true,
AllowServiceAccountsAutoscale = true
}, },
new Plan new Plan
{ {
@@ -158,7 +162,7 @@ public static class SecretsManagerPlanStore
BaseServiceAccount = 3, BaseServiceAccount = 3,
MaxProjects = 3, MaxProjects = 3,
MaxUsers = 2, MaxUsers = 2,
MaxServiceAccount = 3, MaxServiceAccounts = 3,
UpgradeSortOrder = -1, // Always the lowest plan, cannot be upgraded to UpgradeSortOrder = -1, // Always the lowest plan, cannot be upgraded to
DisplaySortOrder = -1, DisplaySortOrder = -1,
AllowSeatAutoscale = false, AllowSeatAutoscale = false,

View File

@@ -4,6 +4,8 @@ using AspNetCoreRateLimit;
using Bit.Core; using Bit.Core;
using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Repositories.Noop;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Identity.Utilities; using Bit.Identity.Utilities;
@@ -46,7 +48,7 @@ public class Startup
// Repositories // Repositories
services.AddDatabaseRepositories(globalSettings); services.AddDatabaseRepositories(globalSettings);
services.AddOosServices(); services.AddScoped<IServiceAccountRepository, NoopServiceAccountRepository>();
// Context // Context
services.AddScoped<ICurrentContext, CurrentContext>(); services.AddScoped<ICurrentContext, CurrentContext>();

View File

@@ -11,6 +11,7 @@ using Bit.Core.Entities;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationApiKeys.Interfaces;
using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptionUpdate.Interface;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
@@ -40,6 +41,7 @@ public class OrganizationsControllerTests : IDisposable
private readonly IUpdateOrganizationLicenseCommand _updateOrganizationLicenseCommand; private readonly IUpdateOrganizationLicenseCommand _updateOrganizationLicenseCommand;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly ILicensingService _licensingService; private readonly ILicensingService _licensingService;
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
private readonly OrganizationsController _sut; private readonly OrganizationsController _sut;
@@ -64,12 +66,14 @@ public class OrganizationsControllerTests : IDisposable
_updateOrganizationLicenseCommand = Substitute.For<IUpdateOrganizationLicenseCommand>(); _updateOrganizationLicenseCommand = Substitute.For<IUpdateOrganizationLicenseCommand>();
_featureService = Substitute.For<IFeatureService>(); _featureService = Substitute.For<IFeatureService>();
_licensingService = Substitute.For<ILicensingService>(); _licensingService = Substitute.For<ILicensingService>();
_updateSecretsManagerSubscriptionCommand = Substitute.For<IUpdateSecretsManagerSubscriptionCommand>();
_sut = new OrganizationsController(_organizationRepository, _organizationUserRepository, _sut = new OrganizationsController(_organizationRepository, _organizationUserRepository,
_policyRepository, _providerRepository, _organizationService, _userService, _paymentService, _currentContext, _policyRepository, _providerRepository, _organizationService, _userService, _paymentService, _currentContext,
_ssoConfigRepository, _ssoConfigService, _getOrganizationApiKeyQuery, _rotateOrganizationApiKeyCommand, _ssoConfigRepository, _ssoConfigService, _getOrganizationApiKeyQuery, _rotateOrganizationApiKeyCommand,
_createOrganizationApiKeyCommand, _organizationApiKeyRepository, _updateOrganizationLicenseCommand, _createOrganizationApiKeyCommand, _organizationApiKeyRepository, _updateOrganizationLicenseCommand,
_cloudGetOrganizationLicenseQuery, _featureService, _globalSettings, _licensingService); _cloudGetOrganizationLicenseQuery, _featureService, _globalSettings, _licensingService,
_updateSecretsManagerSubscriptionCommand);
} }
public void Dispose() public void Dispose()

View File

@@ -0,0 +1,745 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Models.StaticStore;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptionUpdate;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.OrganizationFeatures.OrganizationSubscriptionUpdate;
[SutProviderCustomize]
public class UpdateSecretsManagerSubscriptionCommandTests
{
[Theory]
[BitAutoData]
public async Task UpdateSecretsManagerSubscription_NoOrganization_Throws(
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organizationId)
.Returns((Organization)null);
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
MaxAutoscaleSmSeats = null,
SmSeatsAdjustment = 0
};
var exception = await Assert.ThrowsAsync<NotFoundException>(
() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("Organization is not found", exception.Message);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData]
public async Task UpdateSecretsManagerSubscription_NoSecretsManagerAccess_ThrowsException(
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
SmSeats = 10,
SmServiceAccounts = 5,
UseSecretsManager = false,
MaxAutoscaleSmSeats = 20,
MaxAutoscaleSmServiceAccounts = 10
};
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organizationId)
.Returns(organization);
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
SmSeatsAdjustment = 1,
MaxAutoscaleSmSeats = 1
};
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("Organization has no access to Secrets Manager.", exception.Message);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData]
public async Task UpdateSecretsManagerSubscription_SeatsAdustmentGreaterThanMaxAutoscaleSeats_ThrowsException(
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
SmSeats = 10,
UseSecretsManager = true,
SmServiceAccounts = 5,
MaxAutoscaleSmSeats = 20,
MaxAutoscaleSmServiceAccounts = 10,
PlanType = PlanType.EnterpriseAnnually,
};
var plan = StaticStore.SecretManagerPlans.FirstOrDefault(x => x.Type == organization.PlanType);
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
MaxAutoscaleSmSeats = 10,
SmSeatsAdjustment = 15,
SmSeats = organization.SmSeats.GetValueOrDefault() + 10,
SmSeatsExcludingBase = (organization.SmSeats.GetValueOrDefault() + 10) - plan.BaseSeats,
SmServiceAccounts = organization.SmServiceAccounts.GetValueOrDefault() + 5,
SmServiceAccountsExcludingBase = (organization.SmServiceAccounts.GetValueOrDefault() + 5) - (int)plan.BaseServiceAccount
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("Cannot set max seat autoscaling below seat count.", exception.Message);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData]
public async Task UpdateSecretsManagerSubscription_ServiceAccountsGreaterThanMaxAutoscaleSeats_ThrowsException(
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
SmSeats = 10,
UseSecretsManager = true,
SmServiceAccounts = 5,
MaxAutoscaleSmSeats = 20,
MaxAutoscaleSmServiceAccounts = 10,
PlanType = PlanType.EnterpriseAnnually,
GatewayCustomerId = "1",
GatewaySubscriptionId = "9"
};
var plan = StaticStore.SecretManagerPlans.FirstOrDefault(x => x.Type == organization.PlanType);
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
MaxAutoscaleSmSeats = 15,
SmSeatsAdjustment = 1,
MaxAutoscaleSmServiceAccounts = 10,
SmServiceAccountsAdjustment = 11,
SmSeats = organization.SmSeats.GetValueOrDefault() + 1,
SmSeatsExcludingBase = (organization.SmSeats.GetValueOrDefault() + 1) - plan.BaseSeats,
SmServiceAccounts = organization.SmServiceAccounts.GetValueOrDefault() + 11,
SmServiceAccountsExcludingBase = (organization.SmServiceAccounts.GetValueOrDefault() + 11) - (int)plan.BaseServiceAccount
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("Cannot set max Service Accounts autoscaling below Service Accounts count", exception.Message);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData]
public async Task UpdateSecretsManagerSubscription_NullGatewayCustomerId_ThrowsException(
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
SmSeats = 10,
SmServiceAccounts = 5,
UseSecretsManager = true,
MaxAutoscaleSmSeats = 20,
MaxAutoscaleSmServiceAccounts = 15,
PlanType = PlanType.EnterpriseAnnually
};
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
MaxAutoscaleSmSeats = 15,
SmSeatsAdjustment = 1,
MaxAutoscaleSmServiceAccounts = 15,
SmServiceAccountsAdjustment = 1
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("No payment method found.", exception.Message);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData]
public async Task UpdateSecretsManagerSubscription_NullGatewaySubscriptionId_ThrowsException(
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
SmSeats = 10,
UseSecretsManager = true,
SmServiceAccounts = 5,
MaxAutoscaleSmSeats = 20,
MaxAutoscaleSmServiceAccounts = 15,
PlanType = PlanType.EnterpriseAnnually,
GatewayCustomerId = "1"
};
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
MaxAutoscaleSmSeats = 15,
SmSeatsAdjustment = 1,
MaxAutoscaleSmServiceAccounts = 15,
SmServiceAccountsAdjustment = 1
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("No subscription found.", exception.Message);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData]
public async Task UpdateSecretsManagerSubscription_OrgWithNullSmSeatOnSeatsAdjustment_ThrowsException(
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
SmSeats = null,
UseSecretsManager = true,
SmServiceAccounts = 5,
MaxAutoscaleSmSeats = 20,
MaxAutoscaleSmServiceAccounts = 15,
PlanType = PlanType.EnterpriseAnnually,
GatewayCustomerId = "1"
};
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
MaxAutoscaleSmSeats = 15,
SmSeatsAdjustment = 1,
MaxAutoscaleSmServiceAccounts = 15,
SmServiceAccountsAdjustment = 1
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("Organization has no Secrets Manager seat limit, no need to adjust seats", exception.Message);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData(PlanType.Custom)]
[BitAutoData(PlanType.FamiliesAnnually)]
[BitAutoData(PlanType.FamiliesAnnually2019)]
[BitAutoData(PlanType.EnterpriseMonthly2019)]
[BitAutoData(PlanType.EnterpriseAnnually2019)]
[BitAutoData(PlanType.TeamsMonthly2019)]
[BitAutoData(PlanType.TeamsAnnually2019)]
public async Task UpdateSecretsManagerSubscription_WithNonSecretsManagerPlanType_ThrowsBadRequestException(
PlanType planType,
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
SmSeats = 10,
UseSecretsManager = true,
SmServiceAccounts = 200,
MaxAutoscaleSmSeats = 20,
MaxAutoscaleSmServiceAccounts = 300,
PlanType = planType,
GatewayCustomerId = "1",
GatewaySubscriptionId = "2"
};
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organization.Id,
MaxAutoscaleSmSeats = 15,
SmSeatsAdjustment = 1,
MaxAutoscaleSmServiceAccounts = 300,
SmServiceAccountsAdjustment = 1
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("Existing plan not found", exception.Message, StringComparison.InvariantCultureIgnoreCase);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData(PlanType.Free)]
public async Task UpdateSecretsManagerSubscription_WithHasAdditionalSeatsOptionfalse_ThrowsBadRequestException(
PlanType planType,
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
UseSecretsManager = true,
SmSeats = 10,
SmServiceAccounts = 200,
MaxAutoscaleSmSeats = 20,
MaxAutoscaleSmServiceAccounts = 300,
PlanType = planType,
GatewayCustomerId = "1",
GatewaySubscriptionId = "2"
};
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organization.Id,
MaxAutoscaleSmSeats = 15,
SmSeatsAdjustment = 1,
MaxAutoscaleSmServiceAccounts = 300,
SmServiceAccountsAdjustment = 1
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("Plan does not allow additional Secrets Manager seats.", exception.Message, StringComparison.InvariantCultureIgnoreCase);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData(PlanType.Free)]
public async Task UpdateSecretsManagerSubscription_WithHasAdditionalServiceAccountOptionFalse_ThrowsBadRequestException(
PlanType planType,
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
UseSecretsManager = true,
SmSeats = 10,
SmServiceAccounts = 200,
MaxAutoscaleSmSeats = 20,
MaxAutoscaleSmServiceAccounts = 300,
PlanType = planType,
GatewayCustomerId = "1",
GatewaySubscriptionId = "2"
};
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organization.Id,
MaxAutoscaleSmSeats = 15,
SmSeatsAdjustment = 0,
MaxAutoscaleSmServiceAccounts = 300,
SmServiceAccountsAdjustment = 1
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("Plan does not allow additional Service Accounts", exception.Message, StringComparison.InvariantCultureIgnoreCase);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData(PlanType.EnterpriseAnnually)]
[BitAutoData(PlanType.EnterpriseMonthly)]
[BitAutoData(PlanType.TeamsMonthly)]
[BitAutoData(PlanType.TeamsAnnually)]
public async Task UpdateSecretsManagerSubscription_ValidInput_Passes(
PlanType planType,
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
UseSecretsManager = true,
SmSeats = 10,
SmServiceAccounts = 200,
MaxAutoscaleSmSeats = 20,
MaxAutoscaleSmServiceAccounts = 350,
PlanType = planType,
GatewayCustomerId = "1",
GatewaySubscriptionId = "2"
};
var plan = StaticStore.SecretManagerPlans.FirstOrDefault(x => x.Type == organization.PlanType);
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
MaxAutoscaleSmSeats = 15,
SmSeatsAdjustment = 5,
MaxAutoscaleSmServiceAccounts = 300,
SmServiceAccountsAdjustment = 100,
SmSeats = organization.SmSeats.GetValueOrDefault() + 5,
SmSeatsExcludingBase = (organization.SmSeats.GetValueOrDefault() + 5) - plan.BaseSeats,
SmServiceAccounts = organization.SmServiceAccounts.GetValueOrDefault() + 100,
SmServiceAccountsExcludingBase = (organization.SmServiceAccounts.GetValueOrDefault() + 100) - (int)plan.BaseServiceAccount,
MaxAutoscaleSmSeatsChanged = 15 != organization.MaxAutoscaleSeats.GetValueOrDefault(),
MaxAutoscaleSmServiceAccountsChanged = 200 != organization.MaxAutoscaleSmServiceAccounts.GetValueOrDefault()
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
await sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate);
if (organizationUpdate.SmServiceAccountsAdjustment != 0)
{
await sutProvider.GetDependency<IPaymentService>().Received(1)
.AdjustSeatsAsync(organization, plan, organizationUpdate.SmSeatsExcludingBase);
// TODO: call ReferenceEventService - see AC-1481
}
if (organizationUpdate.SmSeatsAdjustment != 0)
{
await sutProvider.GetDependency<IPaymentService>().Received(1)
.AdjustServiceAccountsAsync(organization, plan, organizationUpdate.SmServiceAccountsExcludingBase);
// TODO: call ReferenceEventService - see AC-1481
}
if (organizationUpdate.SmSeatsAdjustment != 0)
{
await sutProvider.GetDependency<IOrganizationService>().Received(1).ReplaceAndUpdateCacheAsync(
Arg.Is<Organization>(org => org.SmSeats == organizationUpdate.SmSeats));
}
if (organizationUpdate.SmServiceAccountsAdjustment != 0)
{
await sutProvider.GetDependency<IOrganizationService>().Received(1).ReplaceAndUpdateCacheAsync(
Arg.Is<Organization>(org =>
org.SmServiceAccounts == organizationUpdate.SmServiceAccounts));
}
if (organizationUpdate.MaxAutoscaleSmSeats != organization.MaxAutoscaleSmSeats)
{
await sutProvider.GetDependency<IOrganizationService>().Received(1).ReplaceAndUpdateCacheAsync(
Arg.Is<Organization>(org =>
org.MaxAutoscaleSmSeats == organizationUpdate.MaxAutoscaleSmServiceAccounts));
}
if (organizationUpdate.MaxAutoscaleSmServiceAccounts != organization.MaxAutoscaleSmServiceAccounts)
{
await sutProvider.GetDependency<IOrganizationService>().Received(1).ReplaceAndUpdateCacheAsync(
Arg.Is<Organization>(org =>
org.MaxAutoscaleSmServiceAccounts == organizationUpdate.MaxAutoscaleSmServiceAccounts));
}
await sutProvider.GetDependency<IMailService>().Received(1).SendSecretsManagerMaxSeatLimitReachedEmailAsync(organization, organization.MaxAutoscaleSmSeats.Value, Arg.Any<IEnumerable<string>>());
await sutProvider.GetDependency<IMailService>().Received(1).SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(organization, organization.MaxAutoscaleSmServiceAccounts.Value, Arg.Any<IEnumerable<string>>());
}
[Theory]
[BitAutoData]
public async Task UpdateSecretsManagerSubscription_ThrowsBadRequestException_WhenMaxAutoscaleSeatsBelowSeatCount(
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
UseSecretsManager = true,
SmSeats = 5,
SmServiceAccounts = 200,
MaxAutoscaleSmSeats = 4,
MaxAutoscaleSmServiceAccounts = 300,
PlanType = PlanType.EnterpriseAnnually,
GatewayCustomerId = "1",
GatewaySubscriptionId = "2"
};
var plan = StaticStore.SecretManagerPlans.FirstOrDefault(x => x.Type == organization.PlanType);
var update = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
MaxAutoscaleSmSeats = 4,
SmSeatsAdjustment = 1,
MaxAutoscaleSmServiceAccounts = 300,
SmServiceAccountsAdjustment = 5,
SmSeats = organization.SmSeats.GetValueOrDefault() + 1,
SmSeatsExcludingBase = (organization.SmSeats.GetValueOrDefault() + 1) - plan.BaseSeats,
SmServiceAccounts = organization.SmServiceAccounts.GetValueOrDefault() + 5,
SmServiceAccountsExcludingBase = (organization.SmServiceAccounts.GetValueOrDefault() + 5) - (int)plan.BaseServiceAccount
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(update));
Assert.Contains("Cannot set max seat autoscaling below seat count.", exception.Message);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData]
public async Task UpdateSecretsManagerSubscription_ThrowsBadRequestException_WhenOccupiedSeatsExceedNewSeatTotal(
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
UseSecretsManager = true,
SmSeats = 10,
GatewayCustomerId = "1",
GatewaySubscriptionId = "2",
PlanType = PlanType.EnterpriseAnnually
};
var plan = StaticStore.SecretManagerPlans.FirstOrDefault(x => x.Type == organization.PlanType);
var update = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
MaxAutoscaleSmSeats = 7,
SmSeatsAdjustment = -3,
MaxAutoscaleSmServiceAccounts = 300,
SmServiceAccountsAdjustment = 5,
SmSeats = organization.SmSeats.GetValueOrDefault() - 3,
SmSeatsExcludingBase = (organization.SmSeats.GetValueOrDefault() - 3) - plan.BaseSeats,
SmServiceAccounts = organization.SmServiceAccounts.GetValueOrDefault() + 5,
SmServiceAccountsExcludingBase = (organization.SmServiceAccounts.GetValueOrDefault() + 5) - (int)plan.BaseServiceAccount
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
sutProvider.GetDependency<IOrganizationUserRepository>().GetOccupiedSmSeatCountByOrganizationIdAsync(organizationId).Returns(8);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(update));
Assert.Contains("Your organization currently has 8 Secrets Manager seats. Your plan only allows (7) Secrets Manager seats. Remove some Secrets Manager users", exception.Message);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData]
public async Task AdjustServiceAccountsAsync_ThrowsBadRequestException_WhenSmServiceAccountsIsNull(
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
SmSeats = 10,
UseSecretsManager = true,
GatewayCustomerId = "1",
GatewaySubscriptionId = "2",
SmServiceAccounts = null,
PlanType = PlanType.EnterpriseAnnually
};
var plan = StaticStore.SecretManagerPlans.FirstOrDefault(x => x.Type == organization.PlanType);
var update = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
MaxAutoscaleSmSeats = 21,
SmSeatsAdjustment = 10,
MaxAutoscaleSmServiceAccounts = 250,
SmServiceAccountsAdjustment = 1,
SmSeats = organization.SmSeats.GetValueOrDefault() + 10,
SmSeatsExcludingBase = (organization.SmSeats.GetValueOrDefault() + 10) - plan.BaseSeats,
SmServiceAccounts = organization.SmServiceAccounts.GetValueOrDefault() + 1,
SmServiceAccountsExcludingBase = (organization.SmServiceAccounts.GetValueOrDefault() + 1) - (int)plan.BaseServiceAccount
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(update));
Assert.Contains("Organization has no Service Accounts limit, no need to adjust Service Accounts", exception.Message);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData]
public async Task AutoscaleSeatsAsync_ThrowsBadRequestException_WhenMaxAutoscaleSeatsExceedPlanMaxUsers(
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
SmSeats = 3,
UseSecretsManager = true,
SmServiceAccounts = 100,
PlanType = PlanType.Free,
GatewayCustomerId = "1",
GatewaySubscriptionId = "2",
};
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
MaxAutoscaleSmSeats = 15,
SmSeatsAdjustment = 0,
MaxAutoscaleSmServiceAccounts = 200,
SmServiceAccountsAdjustment = 0,
MaxAutoscaleSmSeatsChanged = 15 != organization.MaxAutoscaleSeats.GetValueOrDefault(),
MaxAutoscaleSmServiceAccountsChanged = 200 != organization.MaxAutoscaleSmServiceAccounts.GetValueOrDefault()
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("Your plan has a Secrets Manager seat limit of 2, but you have specified a max autoscale count of 15.Reduce your max autoscale count.", exception.Message);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData(PlanType.Free)]
public async Task AutoscaleSeatsAsync_ThrowsBadRequestException_WhenPlanDoesNotAllowSeatAutoscale(
PlanType planType,
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
UseSecretsManager = true,
SmSeats = 1,
SmServiceAccounts = 200,
MaxAutoscaleSmSeats = 20,
MaxAutoscaleSmServiceAccounts = 350,
PlanType = planType,
GatewayCustomerId = "1",
GatewaySubscriptionId = "2"
};
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
MaxAutoscaleSmSeats = 1,
SmSeatsAdjustment = 0,
MaxAutoscaleSmServiceAccounts = 300,
SmServiceAccountsAdjustment = 0,
MaxAutoscaleSmSeatsChanged = 1 != organization.MaxAutoscaleSeats.GetValueOrDefault(),
MaxAutoscaleSmServiceAccountsChanged = 200 != organization.MaxAutoscaleSmServiceAccounts.GetValueOrDefault()
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("Your plan does not allow Secrets Manager seat autoscaling", exception.Message);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData(PlanType.Free)]
public async Task UpdateServiceAccountAutoscaling_ThrowsBadRequestException_WhenPlanDoesNotAllowServiceAccountAutoscale(
PlanType planType,
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
UseSecretsManager = true,
SmSeats = 10,
SmServiceAccounts = 200,
MaxAutoscaleSmSeats = 20,
MaxAutoscaleSmServiceAccounts = 350,
PlanType = planType,
GatewayCustomerId = "1",
GatewaySubscriptionId = "2"
};
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
MaxAutoscaleSmSeats = null,
SmSeatsAdjustment = 0,
MaxAutoscaleSmServiceAccounts = 300,
SmServiceAccountsAdjustment = 0,
MaxAutoscaleSmSeatsChanged = false,
MaxAutoscaleSmServiceAccountsChanged = 300 != organization.MaxAutoscaleSmServiceAccounts.GetValueOrDefault()
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("Your plan does not allow Service Accounts autoscaling.", exception.Message);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData(PlanType.EnterpriseAnnually)]
[BitAutoData(PlanType.EnterpriseMonthly)]
[BitAutoData(PlanType.TeamsMonthly)]
[BitAutoData(PlanType.TeamsAnnually)]
public async Task UpdateServiceAccountAutoscaling_WhenCurrentServiceAccountsIsGreaterThanNew_ThrowsBadRequestException(
PlanType planType,
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
UseSecretsManager = true,
SmSeats = 10,
SmServiceAccounts = 301,
MaxAutoscaleSmSeats = 20,
MaxAutoscaleSmServiceAccounts = 350,
PlanType = planType,
GatewayCustomerId = "1",
GatewaySubscriptionId = "2"
};
var plan = StaticStore.SecretManagerPlans.FirstOrDefault(x => x.Type == organization.PlanType);
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
MaxAutoscaleSmSeats = 15,
SmSeatsAdjustment = 5,
MaxAutoscaleSmServiceAccounts = 300,
SmServiceAccountsAdjustment = 100,
SmSeats = organization.SmSeats.GetValueOrDefault() + 5,
SmSeatsExcludingBase = (organization.SmSeats.GetValueOrDefault() + 5) - plan.BaseSeats,
SmServiceAccounts = 300,
SmServiceAccountsExcludingBase = (organization.SmServiceAccounts.GetValueOrDefault() + 100) - (int)plan.BaseServiceAccount,
MaxAutoscaleSmSeatsChanged = 15 != organization.MaxAutoscaleSeats.GetValueOrDefault(),
MaxAutoscaleSmServiceAccountsChanged = 200 != organization.MaxAutoscaleSmServiceAccounts.GetValueOrDefault()
};
var currentServiceAccounts = 301;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
sutProvider.GetDependency<IServiceAccountRepository>()
.GetServiceAccountCountByOrganizationIdAsync(organization.Id)
.Returns(currentServiceAccounts);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("Your organization currently has 301 Service Accounts. Your plan only allows (300) Service Accounts. Remove some Service Accounts", exception.Message);
await sutProvider.GetDependency<IServiceAccountRepository>().Received(1).GetServiceAccountCountByOrganizationIdAsync(organization.Id);
await VerifyDependencyNotCalledAsync(sutProvider);
}
private static async Task VerifyDependencyNotCalledAsync(SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
await sutProvider.GetDependency<IPaymentService>().DidNotReceive()
.AdjustSeatsAsync(Arg.Any<Organization>(), Arg.Any<Plan>(), Arg.Any<int>());
await sutProvider.GetDependency<IPaymentService>().DidNotReceive()
.AdjustServiceAccountsAsync(Arg.Any<Organization>(), Arg.Any<Plan>(), Arg.Any<int>());
// TODO: call ReferenceEventService - see AC-1481
await sutProvider.GetDependency<IOrganizationService>().DidNotReceive()
.ReplaceAndUpdateCacheAsync(Arg.Any<Organization>());
await sutProvider.GetDependency<IMailService>().DidNotReceive()
.SendOrganizationMaxSeatLimitReachedEmailAsync(Arg.Any<Organization>(), Arg.Any<int>(),
Arg.Any<IEnumerable<string>>());
}
}