From 35111382e5db60c18438f87195db66f589328db2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lison=20Fernandes?= Date: Mon, 24 Jul 2023 23:05:05 +0100 Subject: [PATCH 1/2] [AC-1486] Feature: SM Billing (#3073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [AC-1423] Add AddonProduct and BitwardenProduct properties to BillingSubscriptionItem (#3037) * [AC-1423] Add AddonProduct and BitwardenProduct properties to BillingSubscriptionItem * [AC-1423] Add helper to StaticStore.cs to find a Plan by StripePlanId * [AC-1423] Use the helper method to set SubscriptionInfo.BitwardenProduct * Add SecretsManagerBilling feature flag to Constants * [AC 1409] Secrets Manager Subscription Stripe Integration (#3019) * [AC-1418] Add missing SecretsManagerPlan property to OrganizationResponseModel (#3055) * [AC 1460] Update Stripe Configuration (#3070) * [AC 1410] Secrets Manager subscription adjustment back-end changes (#3036) * Create UpgradeSecretsManagerSubscription command * [AC-1495] Extract UpgradePlanAsync into a command (#3081) * This is a pure lift & shift with no refactors * [AC-1503] Fix Stripe integration on organization upgrade (#3084) * Fix SM parameters not being passed to Stripe * [AC-1504] Allow SM max autoscale limits to be disabled (#3085) * [AC-1488] Changed SM Signup and Upgrade paths to set SmServiceAccounts to include the plan BaseServiceAccount (#3086) * [AC-1510] Enable access to Secrets Manager to Organization owner for new Subscription (#3089) * Revert changes to ReferenceEvent code (#3091) This will be done in AC-1481 * Add UsePasswordManager to sync data (#3114) * [AC-1522] Fix service account check on upgrading (#3111) * [AC-1521] Address checkmarx security feedback (#3124) * Reinstate target attribute but add noopener noreferrer * Update date on migration script --------- Co-authored-by: Shane Melton Co-authored-by: Thomas Rittson Co-authored-by: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Co-authored-by: cyprain-okeke Co-authored-by: Rui Tomé <108268980+r-tome@users.noreply.github.com> Co-authored-by: Conner Turnbull Co-authored-by: Rui Tome --- .../Repositories/ServiceAccountRepository.cs | 10 + .../Controllers/OrganizationsController.cs | 37 +- .../OrganizationCreateRequestModel.cs | 9 + .../OrganizationUpgradeRequestModel.cs | 9 + ...tsManagerSubscriptionUpdateRequestModel.cs | 45 ++ .../OrganizationResponseModel.cs | 2 + src/Api/Models/Response/PlanResponseModel.cs | 9 +- .../ProfileOrganizationResponseModel.cs | 2 + .../Response/SubscriptionResponseModel.cs | 5 + src/Api/Startup.cs | 2 + src/Core/Constants.cs | 1 + .../OrganizationSmSeatsMaxReached.html.hbs | 34 + .../OrganizationSmSeatsMaxReached.text.hbs | 5 + ...zationSmServiceAccountsMaxReached.html.hbs | 34 + ...zationSmServiceAccountsMaxReached.text.hbs | 5 + .../Models/Business/OrganizationUpgrade.cs | 3 + .../SecretsManagerSubscriptionUpdate.cs | 56 ++ .../Business/SubscriptionCreateOptions.cs | 88 ++- src/Core/Models/Business/SubscriptionInfo.cs | 11 +- .../Models/Business/SubscriptionUpdate.cs | 57 +- ...ationServiceAccountsMaxReachedViewModel.cs | 7 + src/Core/Models/StaticStore/Plan.cs | 5 +- ...UpdateSecretsManagerSubscriptionCommand.cs | 8 + .../IUpgradeOrganizationPlanCommand.cs | 6 + ...SubscriptionServiceCollectionExtensions.cs | 13 + ...UpdateSecretsManagerSubscriptionCommand.cs | 356 +++++++++ .../UpgradeOrganizationPlanCommand.cs | 337 ++++++++ .../IOrganizationUserRepository.cs | 1 + .../Repositories/IServiceAccountRepository.cs | 1 + .../Noop/NoopServiceAccountRepository.cs | 59 ++ src/Core/Services/IMailService.cs | 2 + src/Core/Services/IOrganizationService.cs | 4 +- src/Core/Services/IPaymentService.cs | 11 +- .../Implementations/HandlebarsMailService.cs | 30 + .../Implementations/OrganizationService.cs | 365 +++------ .../Implementations/StripePaymentService.cs | 20 +- .../NoopImplementations/NoopMailService.cs | 13 + src/Core/Utilities/SecretsManagerPlanStore.cs | 22 +- src/Core/Utilities/StaticStore.cs | 49 ++ .../OrganizationUserRepository.cs | 13 + .../OrganizationUserRepository.cs | 7 + ...ccupiedSmSeatCountByOrganizationIdQuery.cs | 22 + .../Utilities/ServiceCollectionExtensions.cs | 3 + ...eadOccupiedSmSeatCountByOrganizationId.sql | 16 + .../OrganizationsControllerTests.cs | 8 +- ...eSecretsManagerSubscriptionCommandTests.cs | 748 ++++++++++++++++++ .../UpgradeOrganizationPlanCommandTests.cs | 185 +++++ .../Services/OrganizationServiceTests.cs | 137 +++- .../Services/StripePaymentServiceTests.cs | 337 +++++++- ..._OrgUserReadOccupiedSmSeatCountByOrgId.sql | 32 + 50 files changed, 2870 insertions(+), 371 deletions(-) create mode 100644 src/Api/Models/Request/Organizations/SecretsManagerSubscriptionUpdateRequestModel.cs create mode 100644 src/Core/MailTemplates/Handlebars/OrganizationSmSeatsMaxReached.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/OrganizationSmSeatsMaxReached.text.hbs create mode 100644 src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.text.hbs create mode 100644 src/Core/Models/Business/SecretsManagerSubscriptionUpdate.cs create mode 100644 src/Core/Models/Mail/OrganizationServiceAccountsMaxReachedViewModel.cs create mode 100644 src/Core/OrganizationFeatures/OrganizationSubscriptions/Interface/IUpdateSecretsManagerSubscriptionCommand.cs create mode 100644 src/Core/OrganizationFeatures/OrganizationSubscriptions/Interface/IUpgradeOrganizationPlanCommand.cs create mode 100644 src/Core/OrganizationFeatures/OrganizationSubscriptions/OrganizationSubscriptionServiceCollectionExtensions.cs create mode 100644 src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs create mode 100644 src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs create mode 100644 src/Core/SecretsManager/Repositories/Noop/NoopServiceAccountRepository.cs create mode 100644 src/Infrastructure.EntityFramework/Repositories/Queries/OrganizationUserReadOccupiedSmSeatCountByOrganizationIdQuery.cs create mode 100644 src/Sql/dbo/Stored Procedures/OrganizationUser_ReadOccupiedSmSeatCountByOrganizationId.sql create mode 100644 test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs create mode 100644 test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs create mode 100644 util/Migrator/DbScripts/2023-07-24_00_OrgUserReadOccupiedSmSeatCountByOrgId.sql diff --git a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/ServiceAccountRepository.cs b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/ServiceAccountRepository.cs index 492c0c0d89e9..d880f5d02434 100644 --- a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/ServiceAccountRepository.cs +++ b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/ServiceAccountRepository.cs @@ -130,6 +130,16 @@ public async Task DeleteManyByIdAsync(IEnumerable ids) return policy == null ? (false, false) : (policy.Read, policy.Write); } + public async Task GetServiceAccountCountByOrganizationIdAsync(Guid organizationId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + return await dbContext.ServiceAccount + .CountAsync(ou => ou.OrganizationId == organizationId); + } + } + private static Expression> UserHasReadAccessToServiceAccount(Guid userId) => sa => sa.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Read) || sa.GroupAccessPolicies.Any(ap => ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Read)); diff --git a/src/Api/Controllers/OrganizationsController.cs b/src/Api/Controllers/OrganizationsController.cs index 5a1569c98ed1..8ceb1f038b53 100644 --- a/src/Api/Controllers/OrganizationsController.cs +++ b/src/Api/Controllers/OrganizationsController.cs @@ -18,6 +18,7 @@ using Bit.Core.Models.Data.Organizations.Policies; using Bit.Core.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; +using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -50,6 +51,8 @@ public class OrganizationsController : Controller private readonly IFeatureService _featureService; private readonly GlobalSettings _globalSettings; private readonly ILicensingService _licensingService; + private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; + private readonly IUpgradeOrganizationPlanCommand _upgradeOrganizationPlanCommand; public OrganizationsController( IOrganizationRepository organizationRepository, @@ -70,7 +73,9 @@ public OrganizationsController( ICloudGetOrganizationLicenseQuery cloudGetOrganizationLicenseQuery, IFeatureService featureService, GlobalSettings globalSettings, - ILicensingService licensingService) + ILicensingService licensingService, + IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, + IUpgradeOrganizationPlanCommand upgradeOrganizationPlanCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -91,6 +96,8 @@ public OrganizationsController( _featureService = featureService; _globalSettings = globalSettings; _licensingService = licensingService; + _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; + _upgradeOrganizationPlanCommand = upgradeOrganizationPlanCommand; } [HttpGet("{id}")] @@ -306,7 +313,7 @@ public async Task PostUpgrade(string id, [FromBody] Organi throw new NotFoundException(); } - var result = await _organizationService.UpgradePlanAsync(orgIdGuid, model.ToOrganizationUpgrade()); + var result = await _upgradeOrganizationPlanCommand.UpgradePlanAsync(orgIdGuid, model.ToOrganizationUpgrade()); return new PaymentResponseModel { Success = result.Item1, PaymentIntentClientSecret = result.Item2 }; } @@ -319,10 +326,34 @@ public async Task PostSubscription(string id, [FromBody] OrganizationSubscriptio { throw new NotFoundException(); } - 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")] [SelfHosted(NotSelfHostedOnly = true)] public async Task PostSeat(string id, [FromBody] OrganizationSeatRequestModel model) diff --git a/src/Api/Models/Request/Organizations/OrganizationCreateRequestModel.cs b/src/Api/Models/Request/Organizations/OrganizationCreateRequestModel.cs index 3e4602179503..35151dc4f625 100644 --- a/src/Api/Models/Request/Organizations/OrganizationCreateRequestModel.cs +++ b/src/Api/Models/Request/Organizations/OrganizationCreateRequestModel.cs @@ -40,6 +40,12 @@ public class OrganizationCreateRequestModel : IValidatableObject [StringLength(2)] public string BillingAddressCountry { get; set; } public int? MaxAutoscaleSeats { get; set; } + [Range(0, int.MaxValue)] + public int? AdditionalSmSeats { get; set; } + [Range(0, int.MaxValue)] + public int? AdditionalServiceAccounts { get; set; } + [Required] + public bool UseSecretsManager { get; set; } public virtual OrganizationSignup ToOrganizationSignup(User user) { @@ -58,6 +64,9 @@ public virtual OrganizationSignup ToOrganizationSignup(User user) BillingEmail = BillingEmail, BusinessName = BusinessName, CollectionName = CollectionName, + AdditionalSmSeats = AdditionalSmSeats.GetValueOrDefault(), + AdditionalServiceAccounts = AdditionalServiceAccounts.GetValueOrDefault(), + UseSecretsManager = UseSecretsManager, TaxInfo = new TaxInfo { TaxIdNumber = TaxIdNumber, diff --git a/src/Api/Models/Request/Organizations/OrganizationUpgradeRequestModel.cs b/src/Api/Models/Request/Organizations/OrganizationUpgradeRequestModel.cs index fb2666cc1ed0..0c5277ec6e2f 100644 --- a/src/Api/Models/Request/Organizations/OrganizationUpgradeRequestModel.cs +++ b/src/Api/Models/Request/Organizations/OrganizationUpgradeRequestModel.cs @@ -13,6 +13,12 @@ public class OrganizationUpgradeRequestModel public int AdditionalSeats { get; set; } [Range(0, 99)] public short? AdditionalStorageGb { get; set; } + [Range(0, int.MaxValue)] + public int? AdditionalSmSeats { get; set; } + [Range(0, int.MaxValue)] + public int? AdditionalServiceAccounts { get; set; } + [Required] + public bool UseSecretsManager { get; set; } public bool PremiumAccessAddon { get; set; } public string BillingAddressCountry { get; set; } public string BillingAddressPostalCode { get; set; } @@ -24,6 +30,9 @@ public OrganizationUpgrade ToOrganizationUpgrade() { AdditionalSeats = AdditionalSeats, AdditionalStorageGb = AdditionalStorageGb.GetValueOrDefault(), + AdditionalServiceAccounts = AdditionalServiceAccounts.GetValueOrDefault(0), + AdditionalSmSeats = AdditionalSmSeats.GetValueOrDefault(0), + UseSecretsManager = UseSecretsManager, BusinessName = BusinessName, Plan = PlanType, PremiumAccessAddon = PremiumAccessAddon, diff --git a/src/Api/Models/Request/Organizations/SecretsManagerSubscriptionUpdateRequestModel.cs b/src/Api/Models/Request/Organizations/SecretsManagerSubscriptionUpdateRequestModel.cs new file mode 100644 index 000000000000..31f071953a92 --- /dev/null +++ b/src/Api/Models/Request/Organizations/SecretsManagerSubscriptionUpdateRequestModel.cs @@ -0,0 +1,45 @@ +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 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 = + MaxAutoscaleSeats.GetValueOrDefault() != organization.MaxAutoscaleSmSeats.GetValueOrDefault(), + MaxAutoscaleSmServiceAccountsChanged = + MaxAutoscaleServiceAccounts.GetValueOrDefault() != organization.MaxAutoscaleSmServiceAccounts.GetValueOrDefault() + }; + + return orgUpdate; + } +} diff --git a/src/Api/Models/Response/Organizations/OrganizationResponseModel.cs b/src/Api/Models/Response/Organizations/OrganizationResponseModel.cs index f88c6609dfba..c3d89836c5a0 100644 --- a/src/Api/Models/Response/Organizations/OrganizationResponseModel.cs +++ b/src/Api/Models/Response/Organizations/OrganizationResponseModel.cs @@ -27,6 +27,7 @@ public OrganizationResponseModel(Organization organization, string obj = "organi BusinessTaxNumber = organization.BusinessTaxNumber; BillingEmail = organization.BillingEmail; Plan = new PlanResponseModel(StaticStore.PasswordManagerPlans.FirstOrDefault(plan => plan.Type == organization.PlanType)); + SecretsManagerPlan = new PlanResponseModel(StaticStore.SecretManagerPlans.FirstOrDefault(plan => plan.Type == organization.PlanType)); PlanType = organization.PlanType; Seats = organization.Seats; MaxAutoscaleSeats = organization.MaxAutoscaleSeats; @@ -65,6 +66,7 @@ public OrganizationResponseModel(Organization organization, string obj = "organi public string BusinessTaxNumber { get; set; } public string BillingEmail { get; set; } public PlanResponseModel Plan { get; set; } + public PlanResponseModel SecretsManagerPlan { get; set; } public PlanType PlanType { get; set; } public int? Seats { get; set; } public int? MaxAutoscaleSeats { get; set; } = null; diff --git a/src/Api/Models/Response/PlanResponseModel.cs b/src/Api/Models/Response/PlanResponseModel.cs index ee86dde59e50..f3b39f113373 100644 --- a/src/Api/Models/Response/PlanResponseModel.cs +++ b/src/Api/Models/Response/PlanResponseModel.cs @@ -55,10 +55,12 @@ public PlanResponseModel(Plan plan, string obj = "plan") AdditionalPricePerServiceAccount = plan.AdditionalPricePerServiceAccount; BaseServiceAccount = plan.BaseServiceAccount; - MaxServiceAccount = plan.MaxServiceAccount; + MaxServiceAccounts = plan.MaxServiceAccounts; + MaxAdditionalServiceAccounts = plan.MaxAdditionalServiceAccount; HasAdditionalServiceAccountOption = plan.HasAdditionalServiceAccountOption; MaxProjects = plan.MaxProjects; BitwardenProduct = plan.BitwardenProduct; + StripeServiceAccountPlanId = plan.StripeServiceAccountPlanId; } public PlanType Type { get; set; } @@ -105,10 +107,11 @@ public PlanResponseModel(Plan plan, string obj = "plan") public decimal SeatPrice { get; set; } public decimal AdditionalStoragePricePerGb { get; set; } public decimal PremiumAccessOptionPrice { get; set; } - + public string StripeServiceAccountPlanId { get; set; } public decimal? AdditionalPricePerServiceAccount { 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 short? MaxProjects { get; set; } public BitwardenProductType BitwardenProduct { get; set; } diff --git a/src/Api/Models/Response/ProfileOrganizationResponseModel.cs b/src/Api/Models/Response/ProfileOrganizationResponseModel.cs index 4cfe368397b3..a0ededa0b6b1 100644 --- a/src/Api/Models/Response/ProfileOrganizationResponseModel.cs +++ b/src/Api/Models/Response/ProfileOrganizationResponseModel.cs @@ -29,6 +29,7 @@ public ProfileOrganizationResponseModel(OrganizationUserOrganizationDetails orga UseApi = organization.UseApi; UseResetPassword = organization.UseResetPassword; UseSecretsManager = organization.UseSecretsManager; + UsePasswordManager = organization.UsePasswordManager; UsersGetPremium = organization.UsersGetPremium; UseCustomPermissions = organization.UseCustomPermissions; UseActivateAutofillPolicy = organization.PlanType == PlanType.EnterpriseAnnually || @@ -82,6 +83,7 @@ public ProfileOrganizationResponseModel(OrganizationUserOrganizationDetails orga public bool UseApi { get; set; } public bool UseResetPassword { get; set; } public bool UseSecretsManager { get; set; } + public bool UsePasswordManager { get; set; } public bool UsersGetPremium { get; set; } public bool UseCustomPermissions { get; set; } public bool UseActivateAutofillPolicy { get; set; } diff --git a/src/Api/Models/Response/SubscriptionResponseModel.cs b/src/Api/Models/Response/SubscriptionResponseModel.cs index 3ec1b975e3b7..4c0ee9338b34 100644 --- a/src/Api/Models/Response/SubscriptionResponseModel.cs +++ b/src/Api/Models/Response/SubscriptionResponseModel.cs @@ -1,4 +1,5 @@ using Bit.Core.Entities; +using Bit.Core.Enums; using Bit.Core.Models.Api; using Bit.Core.Models.Business; using Bit.Core.Utilities; @@ -82,6 +83,8 @@ public BillingSubscriptionItem(SubscriptionInfo.BillingSubscription.BillingSubsc Interval = item.Interval; Quantity = item.Quantity; SponsoredSubscriptionItem = item.SponsoredSubscriptionItem; + AddonSubscriptionItem = item.AddonSubscriptionItem; + BitwardenProduct = item.BitwardenProduct; } public string Name { get; set; } @@ -89,6 +92,8 @@ public BillingSubscriptionItem(SubscriptionInfo.BillingSubscription.BillingSubsc public int Quantity { get; set; } public string Interval { get; set; } public bool SponsoredSubscriptionItem { get; set; } + public bool AddonSubscriptionItem { get; set; } + public BitwardenProductType BitwardenProduct { get; set; } } } diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index de4779493214..a642dcb10f98 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -15,6 +15,7 @@ using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.Extensions.DependencyInjection.Extensions; using Bit.Core.Auth.Identity; +using Bit.Core.OrganizationFeatures.OrganizationSubscriptions; #if !OSS using Bit.Commercial.Core.SecretsManager; @@ -133,6 +134,7 @@ public void ConfigureServices(IServiceCollection services) // Services services.AddBaseServices(globalSettings); services.AddDefaultServices(globalSettings); + services.AddOrganizationSubscriptionServices(); services.AddCoreLocalizationServices(); //health check diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 8ff378d00cb7..5cd0349291b4 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -36,6 +36,7 @@ public static class FeatureFlagKeys public const string DisplayEuEnvironment = "display-eu-environment"; public const string DisplayLowKdfIterationWarning = "display-kdf-iteration-warning"; public const string TrustedDeviceEncryption = "trusted-device-encryption"; + public const string SecretsManagerBilling = "sm-ga-billing"; public static List GetAllKeys() { diff --git a/src/Core/MailTemplates/Handlebars/OrganizationSmSeatsMaxReached.html.hbs b/src/Core/MailTemplates/Handlebars/OrganizationSmSeatsMaxReached.html.hbs new file mode 100644 index 000000000000..a6db21effc93 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/OrganizationSmSeatsMaxReached.html.hbs @@ -0,0 +1,34 @@ +{{#>FullHtmlLayout}} + + + + + + + + + + +
+ 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: + + Member management + +
+
+
+ + Manage subscription + +
+
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/OrganizationSmSeatsMaxReached.text.hbs b/src/Core/MailTemplates/Handlebars/OrganizationSmSeatsMaxReached.text.hbs new file mode 100644 index 000000000000..325d4c256195 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/OrganizationSmSeatsMaxReached.text.hbs @@ -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}} diff --git a/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.html.hbs b/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.html.hbs new file mode 100644 index 000000000000..6376d72826a3 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.html.hbs @@ -0,0 +1,34 @@ +{{#>FullHtmlLayout}} + + + + + + + + + + +
+ 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: + + Member management + +
+
+
+ + Manage subscription + +
+
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.text.hbs b/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.text.hbs new file mode 100644 index 000000000000..377c8699bff3 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.text.hbs @@ -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}} diff --git a/src/Core/Models/Business/OrganizationUpgrade.cs b/src/Core/Models/Business/OrganizationUpgrade.cs index b77a9d012c2e..173502a24fa1 100644 --- a/src/Core/Models/Business/OrganizationUpgrade.cs +++ b/src/Core/Models/Business/OrganizationUpgrade.cs @@ -12,4 +12,7 @@ public class OrganizationUpgrade public TaxInfo TaxInfo { get; set; } public string PublicKey { get; set; } public string PrivateKey { get; set; } + public int? AdditionalSmSeats { get; set; } + public int? AdditionalServiceAccounts { get; set; } + public bool UseSecretsManager { get; set; } } diff --git a/src/Core/Models/Business/SecretsManagerSubscriptionUpdate.cs b/src/Core/Models/Business/SecretsManagerSubscriptionUpdate.cs new file mode 100644 index 000000000000..051a7560dfac --- /dev/null +++ b/src/Core/Models/Business/SecretsManagerSubscriptionUpdate.cs @@ -0,0 +1,56 @@ +namespace Bit.Core.Models.Business; + +public class SecretsManagerSubscriptionUpdate +{ + public Guid OrganizationId { get; set; } + + /// + /// The seats to be added or removed from the organization + /// + public int SmSeatsAdjustment { get; set; } + + /// + /// The total seats the organization will have after the update, including any base seats included in the plan + /// + public int SmSeats { get; set; } + + /// + /// 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 + /// + public int SmSeatsExcludingBase { get; set; } + + /// + /// 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. + /// + public int? MaxAutoscaleSmSeats { get; set; } + + /// + /// The service accounts to be added or removed from the organization + /// + public int SmServiceAccountsAdjustment { get; set; } + + /// + /// The total service accounts the organization will have after the update, including the base service accounts + /// included in the plan + /// + public int SmServiceAccounts { get; set; } + + /// + /// 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 + /// + public int SmServiceAccountsExcludingBase { get; set; } + + /// + /// 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. + /// + 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; } +} diff --git a/src/Core/Models/Business/SubscriptionCreateOptions.cs b/src/Core/Models/Business/SubscriptionCreateOptions.cs index 4964a625c8c4..86524a55b6cb 100644 --- a/src/Core/Models/Business/SubscriptionCreateOptions.cs +++ b/src/Core/Models/Business/SubscriptionCreateOptions.cs @@ -1,36 +1,61 @@ using Bit.Core.Entities; +using Bit.Core.Enums; using Stripe; namespace Bit.Core.Models.Business; public class OrganizationSubscriptionOptionsBase : Stripe.SubscriptionCreateOptions { - public OrganizationSubscriptionOptionsBase(Organization org, StaticStore.Plan plan, TaxInfo taxInfo, int additionalSeats, int additionalStorageGb, bool premiumAccessAddon) + public OrganizationSubscriptionOptionsBase(Organization org, List plans, TaxInfo taxInfo, int additionalSeats, + int additionalStorageGb, bool premiumAccessAddon, int additionalSmSeats, int additionalServiceAccounts) { Items = new List(); Metadata = new Dictionary { [org.GatewayIdField()] = org.Id.ToString() }; - - if (plan.StripePlanId != null) + foreach (var plan in plans) { - Items.Add(new SubscriptionItemOptions + AddPlanIdToSubscription(plan); + + switch (plan.BitwardenProduct) { - Plan = plan.StripePlanId, - Quantity = 1 - }); + case BitwardenProductType.PasswordManager: + { + AddPremiumAccessAddon(premiumAccessAddon, plan); + AddAdditionalSeatToSubscription(additionalSeats, plan); + AddAdditionalStorage(additionalStorageGb, plan); + break; + } + case BitwardenProductType.SecretsManager: + { + AddAdditionalSeatToSubscription(additionalSmSeats, plan); + AddServiceAccount(additionalServiceAccounts, plan); + break; + } + } } - if (additionalSeats > 0 && plan.StripeSeatPlanId != null) + if (!string.IsNullOrWhiteSpace(taxInfo?.StripeTaxRateId)) + { + DefaultTaxRates = new List { taxInfo.StripeTaxRateId }; + } + } + + private void AddServiceAccount(int additionalServiceAccounts, StaticStore.Plan plan) + { + if (additionalServiceAccounts > 0 && plan.StripeServiceAccountPlanId != null) { Items.Add(new SubscriptionItemOptions { - Plan = plan.StripeSeatPlanId, - Quantity = additionalSeats + Plan = plan.StripeServiceAccountPlanId, + Quantity = additionalServiceAccounts }); } + } + private void AddAdditionalStorage(int additionalStorageGb, StaticStore.Plan plan) + { if (additionalStorageGb > 0) { Items.Add(new SubscriptionItemOptions @@ -39,19 +64,29 @@ public OrganizationSubscriptionOptionsBase(Organization org, StaticStore.Plan pl Quantity = additionalStorageGb }); } + } + private void AddPremiumAccessAddon(bool premiumAccessAddon, StaticStore.Plan plan) + { if (premiumAccessAddon && plan.StripePremiumAccessPlanId != null) { - Items.Add(new SubscriptionItemOptions - { - Plan = plan.StripePremiumAccessPlanId, - Quantity = 1 - }); + Items.Add(new SubscriptionItemOptions { Plan = plan.StripePremiumAccessPlanId, Quantity = 1 }); } + } - if (!string.IsNullOrWhiteSpace(taxInfo?.StripeTaxRateId)) + private void AddAdditionalSeatToSubscription(int additionalSeats, StaticStore.Plan plan) + { + if (additionalSeats > 0 && plan.StripeSeatPlanId != null) { - DefaultTaxRates = new List { taxInfo.StripeTaxRateId }; + Items.Add(new SubscriptionItemOptions { Plan = plan.StripeSeatPlanId, Quantity = additionalSeats }); + } + } + + private void AddPlanIdToSubscription(StaticStore.Plan plan) + { + if (plan.StripePlanId != null) + { + Items.Add(new SubscriptionItemOptions { Plan = plan.StripePlanId, Quantity = 1 }); } } } @@ -59,13 +94,14 @@ public OrganizationSubscriptionOptionsBase(Organization org, StaticStore.Plan pl public class OrganizationPurchaseSubscriptionOptions : OrganizationSubscriptionOptionsBase { public OrganizationPurchaseSubscriptionOptions( - Organization org, StaticStore.Plan plan, - TaxInfo taxInfo, int additionalSeats = 0, - int additionalStorageGb = 0, bool premiumAccessAddon = false) : - base(org, plan, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon) + Organization org, List plans, + TaxInfo taxInfo, int additionalSeats, + int additionalStorageGb, bool premiumAccessAddon, + int additionalSmSeats, int additionalServiceAccounts) : + base(org, plans, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon, additionalSmSeats, additionalServiceAccounts) { OffSession = true; - TrialPeriodDays = plan.TrialPeriodDays; + TrialPeriodDays = plans.FirstOrDefault(x => x.BitwardenProduct == BitwardenProductType.PasswordManager)!.TrialPeriodDays; } } @@ -73,10 +109,10 @@ public class OrganizationUpgradeSubscriptionOptions : OrganizationSubscriptionOp { public OrganizationUpgradeSubscriptionOptions( string customerId, Organization org, - StaticStore.Plan plan, TaxInfo taxInfo, - int additionalSeats = 0, int additionalStorageGb = 0, - bool premiumAccessAddon = false) : - base(org, plan, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon) + List plans, OrganizationUpgrade upgrade) : + base(org, plans, upgrade.TaxInfo, upgrade.AdditionalSeats, upgrade.AdditionalStorageGb, + upgrade.PremiumAccessAddon, upgrade.AdditionalSmSeats.GetValueOrDefault(), + upgrade.AdditionalServiceAccounts.GetValueOrDefault()) { Customer = customerId; } diff --git a/src/Core/Models/Business/SubscriptionInfo.cs b/src/Core/Models/Business/SubscriptionInfo.cs index 61aa060cd443..c72e291de0d6 100644 --- a/src/Core/Models/Business/SubscriptionInfo.cs +++ b/src/Core/Models/Business/SubscriptionInfo.cs @@ -1,4 +1,5 @@ -using Stripe; +using Bit.Core.Enums; +using Stripe; namespace Bit.Core.Models.Business; @@ -46,12 +47,20 @@ public BillingSubscriptionItem(SubscriptionItem item) Name = item.Plan.Nickname; Amount = item.Plan.Amount.GetValueOrDefault() / 100M; Interval = item.Plan.Interval; + AddonSubscriptionItem = + Utilities.StaticStore.IsAddonSubscriptionItem(item.Plan.Id); + BitwardenProduct = + Utilities.StaticStore.GetPlanByStripeId(item.Plan.Id)?.BitwardenProduct ?? BitwardenProductType.PasswordManager; } Quantity = (int)item.Quantity; SponsoredSubscriptionItem = Utilities.StaticStore.SponsoredPlans.Any(p => p.StripePlanId == item.Plan.Id); } + public BitwardenProductType BitwardenProduct { get; set; } + + public bool AddonSubscriptionItem { get; set; } + public string Name { get; set; } public decimal Amount { get; set; } public int Quantity { get; set; } diff --git a/src/Core/Models/Business/SubscriptionUpdate.cs b/src/Core/Models/Business/SubscriptionUpdate.cs index 64b43a8de299..1a93bbfa3cbf 100644 --- a/src/Core/Models/Business/SubscriptionUpdate.cs +++ b/src/Core/Models/Business/SubscriptionUpdate.cs @@ -1,4 +1,5 @@ using Bit.Core.Entities; +using Bit.Core.Enums; using Stripe; namespace Bit.Core.Models.Business; @@ -42,7 +43,15 @@ public SeatSubscriptionUpdate(Organization organization, StaticStore.Plan plan, { _plan = plan; _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 UpgradeItemsOptions(Subscription subscription) @@ -77,6 +86,52 @@ public override List RevertItemsOptions(Subscription su } } +public class ServiceAccountSubscriptionUpdate : SubscriptionUpdate +{ + private long? _prevServiceAccounts; + private readonly StaticStore.Plan _plan; + private readonly long? _additionalServiceAccounts; + protected override List PlanIds => new() { _plan.StripeServiceAccountPlanId }; + + public ServiceAccountSubscriptionUpdate(Organization organization, StaticStore.Plan plan, long? additionalServiceAccounts) + { + _plan = plan; + _additionalServiceAccounts = additionalServiceAccounts; + _prevServiceAccounts = organization.SmServiceAccounts ?? 0; + } + + public override List 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 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 { private long? _prevStorage; diff --git a/src/Core/Models/Mail/OrganizationServiceAccountsMaxReachedViewModel.cs b/src/Core/Models/Mail/OrganizationServiceAccountsMaxReachedViewModel.cs new file mode 100644 index 000000000000..1b9c9257207a --- /dev/null +++ b/src/Core/Models/Mail/OrganizationServiceAccountsMaxReachedViewModel.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Models.Mail; + +public class OrganizationServiceAccountsMaxReachedViewModel +{ + public Guid OrganizationId { get; set; } + public int MaxServiceAccountsCount { get; set; } +} diff --git a/src/Core/Models/StaticStore/Plan.cs b/src/Core/Models/StaticStore/Plan.cs index cc781a411cb1..f542e5e21480 100644 --- a/src/Core/Models/StaticStore/Plan.cs +++ b/src/Core/Models/StaticStore/Plan.cs @@ -15,8 +15,11 @@ public class Plan public short? BaseStorageGb { get; set; } public short? MaxCollections { get; set; } public short? MaxUsers { get; set; } + public short? MaxServiceAccounts { get; set; } public bool AllowSeatAutoscale { get; set; } + public bool AllowServiceAccountsAutoscale { get; set; } + public bool HasAdditionalSeatsOption { get; set; } public int? MaxAdditionalSeats { get; set; } public bool HasAdditionalStorageOption { get; set; } @@ -55,7 +58,7 @@ public class Plan public decimal PremiumAccessOptionPrice { get; set; } public decimal? AdditionalPricePerServiceAccount { get; set; } public short? BaseServiceAccount { get; set; } - public short? MaxServiceAccount { get; set; } + public short? MaxAdditionalServiceAccount { get; set; } public bool HasAdditionalServiceAccountOption { get; set; } public short? MaxProjects { get; set; } public BitwardenProductType BitwardenProduct { get; set; } diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/Interface/IUpdateSecretsManagerSubscriptionCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/Interface/IUpdateSecretsManagerSubscriptionCommand.cs new file mode 100644 index 000000000000..78244d8e3ddc --- /dev/null +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/Interface/IUpdateSecretsManagerSubscriptionCommand.cs @@ -0,0 +1,8 @@ +using Bit.Core.Models.Business; + +namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; + +public interface IUpdateSecretsManagerSubscriptionCommand +{ + Task UpdateSecretsManagerSubscription(SecretsManagerSubscriptionUpdate update); +} diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/Interface/IUpgradeOrganizationPlanCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/Interface/IUpgradeOrganizationPlanCommand.cs new file mode 100644 index 000000000000..59525242bb83 --- /dev/null +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/Interface/IUpgradeOrganizationPlanCommand.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; + +public interface IUpgradeOrganizationPlanCommand +{ + Task> UpgradePlanAsync(Guid organizationId, Models.Business.OrganizationUpgrade upgrade); +} diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/OrganizationSubscriptionServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/OrganizationSubscriptionServiceCollectionExtensions.cs new file mode 100644 index 000000000000..1b8d67211bdf --- /dev/null +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/OrganizationSubscriptionServiceCollectionExtensions.cs @@ -0,0 +1,13 @@ +using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptions; + +public static class OrganizationSubscriptionServiceCollectionExtensions +{ + public static void AddOrganizationSubscriptionServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + } +} diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs new file mode 100644 index 000000000000..87db6c83d834 --- /dev/null +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs @@ -0,0 +1,356 @@ +#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.OrganizationSubscriptions.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.OrganizationSubscriptions; + +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 _logger; + private readonly IServiceAccountRepository _serviceAccountRepository; + + public UpdateSecretsManagerSubscriptionCommand( + IOrganizationRepository organizationRepository, + IOrganizationService organizationService, + IOrganizationUserRepository organizationUserRepository, + IPaymentService paymentService, + IMailService mailService, + ILogger 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, plan); + } + + if (update.MaxAutoscaleSmServiceAccountsChanged) + { + ValidateMaxAutoscaleSmServiceAccountUpdate(organization, update.MaxAutoscaleSmServiceAccounts, 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 (!maxAutoscaleSeats.HasValue) + { + // autoscale limit has been turned off, no validation required + return; + } + + if (organization.SmSeats.HasValue && maxAutoscaleSeats.Value < organization.SmSeats.Value) + { + throw new BadRequestException($"Cannot set max Secrets Manager seat autoscaling below current Secrets Manager seat count."); + } + + if (plan.MaxUsers.HasValue && maxAutoscaleSeats.Value > 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 (!maxAutoscaleServiceAccounts.HasValue) + { + // autoscale limit has been turned off, no validation required + return; + } + + if (organization.SmServiceAccounts.HasValue && maxAutoscaleServiceAccounts.Value < 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.Value > 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.")); + } + } +} diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs new file mode 100644 index 000000000000..ee0d0bbc2adf --- /dev/null +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs @@ -0,0 +1,337 @@ +using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Repositories; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; +using Bit.Core.Repositories; +using Bit.Core.SecretsManager.Repositories; +using Bit.Core.Services; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Business; +using Bit.Core.Tools.Services; +using Bit.Core.Utilities; + +namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptions; + +public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand +{ + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly ICollectionRepository _collectionRepository; + private readonly IGroupRepository _groupRepository; + private readonly IPaymentService _paymentService; + private readonly IPolicyRepository _policyRepository; + private readonly ISsoConfigRepository _ssoConfigRepository; + private readonly IReferenceEventService _referenceEventService; + private readonly IOrganizationConnectionRepository _organizationConnectionRepository; + private readonly ICurrentContext _currentContext; + private readonly IServiceAccountRepository _serviceAccountRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationService _organizationService; + + public UpgradeOrganizationPlanCommand( + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository, + IGroupRepository groupRepository, + IPaymentService paymentService, + IPolicyRepository policyRepository, + ISsoConfigRepository ssoConfigRepository, + IReferenceEventService referenceEventService, + IOrganizationConnectionRepository organizationConnectionRepository, + ICurrentContext currentContext, + IServiceAccountRepository serviceAccountRepository, + IOrganizationRepository organizationRepository, + IOrganizationService organizationService) + { + _organizationUserRepository = organizationUserRepository; + _collectionRepository = collectionRepository; + _groupRepository = groupRepository; + _paymentService = paymentService; + _policyRepository = policyRepository; + _ssoConfigRepository = ssoConfigRepository; + _referenceEventService = referenceEventService; + _organizationConnectionRepository = organizationConnectionRepository; + _currentContext = currentContext; + _serviceAccountRepository = serviceAccountRepository; + _organizationRepository = organizationRepository; + _organizationService = organizationService; + } + + public async Task> UpgradePlanAsync(Guid organizationId, OrganizationUpgrade upgrade) + { + var organization = await GetOrgById(organizationId); + if (organization == null) + { + throw new NotFoundException(); + } + + if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) + { + throw new BadRequestException("Your account has no payment method available."); + } + + var existingPasswordManagerPlan = StaticStore.PasswordManagerPlans.FirstOrDefault(p => p.Type == organization.PlanType); + if (existingPasswordManagerPlan == null) + { + throw new BadRequestException("Existing plan not found."); + } + + var newPasswordManagerPlan = + StaticStore.PasswordManagerPlans.FirstOrDefault(p => p.Type == upgrade.Plan && !p.Disabled); + if (newPasswordManagerPlan == null) + { + throw new BadRequestException("Plan not found."); + } + + if (existingPasswordManagerPlan.Type == newPasswordManagerPlan.Type) + { + throw new BadRequestException("Organization is already on this plan."); + } + + if (existingPasswordManagerPlan.UpgradeSortOrder >= newPasswordManagerPlan.UpgradeSortOrder) + { + throw new BadRequestException("You cannot upgrade to this plan."); + } + + if (existingPasswordManagerPlan.Type != PlanType.Free) + { + throw new BadRequestException("You can only upgrade from the free plan. Contact support."); + } + + _organizationService.ValidatePasswordManagerPlan(newPasswordManagerPlan, upgrade); + var newSecretsManagerPlan = + StaticStore.SecretManagerPlans.FirstOrDefault(p => p.Type == upgrade.Plan && !p.Disabled); + if (upgrade.UseSecretsManager) + { + _organizationService.ValidateSecretsManagerPlan(newSecretsManagerPlan, upgrade); + } + + var newPasswordManagerPlanSeats = (short)(newPasswordManagerPlan.BaseSeats + + (newPasswordManagerPlan.HasAdditionalSeatsOption ? upgrade.AdditionalSeats : 0)); + if (!organization.Seats.HasValue || organization.Seats.Value > newPasswordManagerPlanSeats) + { + var occupiedSeats = + await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + if (occupiedSeats > newPasswordManagerPlanSeats) + { + throw new BadRequestException($"Your organization currently has {occupiedSeats} seats filled. " + + $"Your new plan only has ({newPasswordManagerPlanSeats}) seats. Remove some users."); + } + } + + if (newPasswordManagerPlan.MaxCollections.HasValue && (!organization.MaxCollections.HasValue || + organization.MaxCollections.Value > + newPasswordManagerPlan.MaxCollections.Value)) + { + var collectionCount = await _collectionRepository.GetCountByOrganizationIdAsync(organization.Id); + if (collectionCount > newPasswordManagerPlan.MaxCollections.Value) + { + throw new BadRequestException($"Your organization currently has {collectionCount} collections. " + + $"Your new plan allows for a maximum of ({newPasswordManagerPlan.MaxCollections.Value}) collections. " + + "Remove some collections."); + } + } + + if (!newPasswordManagerPlan.HasGroups && organization.UseGroups) + { + var groups = await _groupRepository.GetManyByOrganizationIdAsync(organization.Id); + if (groups.Any()) + { + throw new BadRequestException($"Your new plan does not allow the groups feature. " + + $"Remove your groups."); + } + } + + if (!newPasswordManagerPlan.HasPolicies && organization.UsePolicies) + { + var policies = await _policyRepository.GetManyByOrganizationIdAsync(organization.Id); + if (policies.Any(p => p.Enabled)) + { + throw new BadRequestException($"Your new plan does not allow the policies feature. " + + $"Disable your policies."); + } + } + + if (!newPasswordManagerPlan.HasSso && organization.UseSso) + { + var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(organization.Id); + if (ssoConfig != null && ssoConfig.Enabled) + { + throw new BadRequestException($"Your new plan does not allow the SSO feature. " + + $"Disable your SSO configuration."); + } + } + + if (!newPasswordManagerPlan.HasKeyConnector && organization.UseKeyConnector) + { + var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(organization.Id); + if (ssoConfig != null && ssoConfig.GetData().MemberDecryptionType == MemberDecryptionType.KeyConnector) + { + throw new BadRequestException("Your new plan does not allow the Key Connector feature. " + + "Disable your Key Connector."); + } + } + + if (!newPasswordManagerPlan.HasResetPassword && organization.UseResetPassword) + { + var resetPasswordPolicy = + await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword); + if (resetPasswordPolicy != null && resetPasswordPolicy.Enabled) + { + throw new BadRequestException("Your new plan does not allow the Password Reset feature. " + + "Disable your Password Reset policy."); + } + } + + if (!newPasswordManagerPlan.HasScim && organization.UseScim) + { + var scimConnections = await _organizationConnectionRepository.GetByOrganizationIdTypeAsync(organization.Id, + OrganizationConnectionType.Scim); + if (scimConnections != null && scimConnections.Any(c => c.GetConfig()?.Enabled == true)) + { + throw new BadRequestException("Your new plan does not allow the SCIM feature. " + + "Disable your SCIM configuration."); + } + } + + if (!newPasswordManagerPlan.HasCustomPermissions && organization.UseCustomPermissions) + { + var organizationCustomUsers = + await _organizationUserRepository.GetManyByOrganizationAsync(organization.Id, + OrganizationUserType.Custom); + if (organizationCustomUsers.Any()) + { + throw new BadRequestException("Your new plan does not allow the Custom Permissions feature. " + + "Disable your Custom Permissions configuration."); + } + } + + if (upgrade.UseSecretsManager && newSecretsManagerPlan != null) + { + await ValidateSecretsManagerSeatsAndServiceAccountAsync(upgrade, organization, newSecretsManagerPlan); + } + + // TODO: Check storage? + string paymentIntentClientSecret = null; + var success = true; + + if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) + { + var organizationUpgradePlan = upgrade.UseSecretsManager + ? StaticStore.Plans.Where(p => p.Type == upgrade.Plan).ToList() + : StaticStore.Plans.Where(p => p.Type == upgrade.Plan && p.BitwardenProduct == BitwardenProductType.PasswordManager).ToList(); + + paymentIntentClientSecret = await _paymentService.UpgradeFreeOrganizationAsync(organization, + organizationUpgradePlan, upgrade); + success = string.IsNullOrWhiteSpace(paymentIntentClientSecret); + } + else + { + // TODO: Update existing sub + throw new BadRequestException("You can only upgrade from the free plan. Contact support."); + } + + organization.BusinessName = upgrade.BusinessName; + organization.PlanType = newPasswordManagerPlan.Type; + organization.Seats = (short)(newPasswordManagerPlan.BaseSeats + upgrade.AdditionalSeats); + organization.MaxCollections = newPasswordManagerPlan.MaxCollections; + organization.UseGroups = newPasswordManagerPlan.HasGroups; + organization.UseDirectory = newPasswordManagerPlan.HasDirectory; + organization.UseEvents = newPasswordManagerPlan.HasEvents; + organization.UseTotp = newPasswordManagerPlan.HasTotp; + organization.Use2fa = newPasswordManagerPlan.Has2fa; + organization.UseApi = newPasswordManagerPlan.HasApi; + organization.SelfHost = newPasswordManagerPlan.HasSelfHost; + organization.UsePolicies = newPasswordManagerPlan.HasPolicies; + organization.MaxStorageGb = !newPasswordManagerPlan.BaseStorageGb.HasValue + ? (short?)null + : (short)(newPasswordManagerPlan.BaseStorageGb.Value + upgrade.AdditionalStorageGb); + organization.UseGroups = newPasswordManagerPlan.HasGroups; + organization.UseDirectory = newPasswordManagerPlan.HasDirectory; + organization.UseEvents = newPasswordManagerPlan.HasEvents; + organization.UseTotp = newPasswordManagerPlan.HasTotp; + organization.Use2fa = newPasswordManagerPlan.Has2fa; + organization.UseApi = newPasswordManagerPlan.HasApi; + organization.UseSso = newPasswordManagerPlan.HasSso; + organization.UseKeyConnector = newPasswordManagerPlan.HasKeyConnector; + organization.UseScim = newPasswordManagerPlan.HasScim; + organization.UseResetPassword = newPasswordManagerPlan.HasResetPassword; + organization.SelfHost = newPasswordManagerPlan.HasSelfHost; + organization.UsersGetPremium = newPasswordManagerPlan.UsersGetPremium || upgrade.PremiumAccessAddon; + organization.UseCustomPermissions = newPasswordManagerPlan.HasCustomPermissions; + organization.Plan = newPasswordManagerPlan.Name; + organization.Enabled = success; + organization.PublicKey = upgrade.PublicKey; + organization.PrivateKey = upgrade.PrivateKey; + organization.UsePasswordManager = true; + organization.SmSeats = (short)(newSecretsManagerPlan.BaseSeats + upgrade.AdditionalSmSeats.GetValueOrDefault()); + organization.SmServiceAccounts = newSecretsManagerPlan.BaseServiceAccount + upgrade.AdditionalServiceAccounts.GetValueOrDefault(); + organization.UseSecretsManager = upgrade.UseSecretsManager; + + await _organizationService.ReplaceAndUpdateCacheAsync(organization); + + if (success) + { + await _referenceEventService.RaiseEventAsync( + new ReferenceEvent(ReferenceEventType.UpgradePlan, organization, _currentContext) + { + PlanName = newPasswordManagerPlan.Name, + PlanType = newPasswordManagerPlan.Type, + OldPlanName = existingPasswordManagerPlan.Name, + OldPlanType = existingPasswordManagerPlan.Type, + Seats = organization.Seats, + Storage = organization.MaxStorageGb, + // TODO: add reference events for SmSeats and Service Accounts - see AC-1481 + }); + } + + return new Tuple(success, paymentIntentClientSecret); + } + + private async Task ValidateSecretsManagerSeatsAndServiceAccountAsync(OrganizationUpgrade upgrade, Organization organization, + Models.StaticStore.Plan newSecretsManagerPlan) + { + var newPlanSmSeats = (short)(newSecretsManagerPlan.BaseSeats + + (newSecretsManagerPlan.HasAdditionalSeatsOption + ? upgrade.AdditionalSmSeats + : 0)); + var occupiedSmSeats = + await _organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id); + + if (!organization.SmSeats.HasValue || organization.SmSeats.Value > newPlanSmSeats) + { + if (occupiedSmSeats > newPlanSmSeats) + { + throw new BadRequestException( + $"Your organization currently has {occupiedSmSeats} Secrets Manager seats filled. " + + $"Your new plan only has {newPlanSmSeats} seats. Remove some users or increase your subscription."); + } + } + + var additionalServiceAccounts = newSecretsManagerPlan.HasAdditionalServiceAccountOption + ? upgrade.AdditionalServiceAccounts + : 0; + var newPlanServiceAccounts = newSecretsManagerPlan.BaseServiceAccount + additionalServiceAccounts; + + if (!organization.SmServiceAccounts.HasValue || organization.SmServiceAccounts.Value > newPlanServiceAccounts) + { + var currentServiceAccounts = + await _serviceAccountRepository.GetServiceAccountCountByOrganizationIdAsync(organization.Id); + if (currentServiceAccounts > newPlanServiceAccounts) + { + throw new BadRequestException( + $"Your organization currently has {currentServiceAccounts} service accounts. " + + $"Your new plan only allows {newSecretsManagerPlan.MaxServiceAccounts} service accounts. " + + "Remove some service accounts or increase your subscription."); + } + } + } + + private async Task GetOrgById(Guid id) + { + return await _organizationRepository.GetByIdAsync(id); + } +} diff --git a/src/Core/Repositories/IOrganizationUserRepository.cs b/src/Core/Repositories/IOrganizationUserRepository.cs index 16d333f9e9fe..f9dfa12c2cbf 100644 --- a/src/Core/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/Repositories/IOrganizationUserRepository.cs @@ -40,4 +40,5 @@ Task GetDetailsByUserAsync(Guid userId, Gui Task RevokeAsync(Guid id); Task RestoreAsync(Guid id, OrganizationUserStatusType status); Task> GetByUserIdWithPolicyDetailsAsync(Guid userId, PolicyType policyType); + Task GetOccupiedSmSeatCountByOrganizationIdAsync(Guid organizationId); } diff --git a/src/Core/SecretsManager/Repositories/IServiceAccountRepository.cs b/src/Core/SecretsManager/Repositories/IServiceAccountRepository.cs index d79ed020a372..b362a5676b89 100644 --- a/src/Core/SecretsManager/Repositories/IServiceAccountRepository.cs +++ b/src/Core/SecretsManager/Repositories/IServiceAccountRepository.cs @@ -15,4 +15,5 @@ public interface IServiceAccountRepository Task UserHasWriteAccessToServiceAccount(Guid id, Guid userId); Task> GetManyByOrganizationIdWriteAccessAsync(Guid organizationId, Guid userId, AccessClientType accessType); Task<(bool Read, bool Write)> AccessToServiceAccountAsync(Guid id, Guid userId, AccessClientType accessType); + Task GetServiceAccountCountByOrganizationIdAsync(Guid organizationId); } diff --git a/src/Core/SecretsManager/Repositories/Noop/NoopServiceAccountRepository.cs b/src/Core/SecretsManager/Repositories/Noop/NoopServiceAccountRepository.cs new file mode 100644 index 000000000000..eab0b069b9ea --- /dev/null +++ b/src/Core/SecretsManager/Repositories/Noop/NoopServiceAccountRepository.cs @@ -0,0 +1,59 @@ +using Bit.Core.Enums; +using Bit.Core.SecretsManager.Entities; + +namespace Bit.Core.SecretsManager.Repositories.Noop; + +public class NoopServiceAccountRepository : IServiceAccountRepository +{ + public Task> GetManyByOrganizationIdAsync(Guid organizationId, Guid userId, AccessClientType accessType) + { + return Task.FromResult(null as IEnumerable); + } + + public Task GetByIdAsync(Guid id) + { + return Task.FromResult(null as ServiceAccount); + } + + public Task> GetManyByIds(IEnumerable ids) + { + return Task.FromResult(null as IEnumerable); + } + + public Task CreateAsync(ServiceAccount serviceAccount) + { + return Task.FromResult(null as ServiceAccount); + } + + public Task ReplaceAsync(ServiceAccount serviceAccount) + { + return Task.FromResult(0); + } + + public Task DeleteManyByIdAsync(IEnumerable ids) + { + return Task.FromResult(0); + } + + public Task UserHasReadAccessToServiceAccount(Guid id, Guid userId) + { + return Task.FromResult(false); + } + + public Task UserHasWriteAccessToServiceAccount(Guid id, Guid userId) + { + return Task.FromResult(false); + } + + public Task> GetManyByOrganizationIdWriteAccessAsync(Guid organizationId, Guid userId, AccessClientType accessType) => throw new NotImplementedException(); + + public Task<(bool Read, bool Write)> AccessToServiceAccountAsync(Guid id, Guid userId, AccessClientType accessType) + { + return Task.FromResult((false, false)); + } + + public Task GetServiceAccountCountByOrganizationIdAsync(Guid organizationId) + { + return Task.FromResult(0); + } +} diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 9d2bfabd5821..0e5831082f8a 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -55,6 +55,8 @@ Task SendInvoiceUpcomingAsync(string email, decimal amount, DateTime dueDate, Li Task SendFailedLoginAttemptsEmailAsync(string email, DateTime utcNow, string ip); Task SendFailedTwoFactorAttemptsEmailAsync(string email, DateTime utcNow, string ip); Task SendUnverifiedOrganizationDomainEmailAsync(IEnumerable adminEmails, string organizationId, string domainName); + Task SendSecretsManagerMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable ownerEmails); + Task SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable ownerEmails); Task SendTrustedDeviceAdminApprovalEmailAsync(string email, DateTime utcNow, string ip, string deviceTypeAndIdentifier); } diff --git a/src/Core/Services/IOrganizationService.cs b/src/Core/Services/IOrganizationService.cs index b912bd92149a..05fa0775a5c3 100644 --- a/src/Core/Services/IOrganizationService.cs +++ b/src/Core/Services/IOrganizationService.cs @@ -13,7 +13,6 @@ Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken, Payment TaxInfo taxInfo); Task CancelSubscriptionAsync(Guid organizationId, bool? endOfPeriod = null); Task ReinstateSubscriptionAsync(Guid organizationId); - Task> UpgradePlanAsync(Guid organizationId, OrganizationUpgrade upgrade); Task AdjustStorageAsync(Guid organizationId, short storageAdjustmentGb); Task UpdateSubscription(Guid organizationId, int seatAdjustment, int? maxAutoscaleSeats); Task AutoAddSeatsAsync(Organization organization, int seatsToAdd, DateTime? prorationDate = null); @@ -79,4 +78,7 @@ Task>> RestoreUsersAsync(Guid organizationI /// Task InitPendingOrganization(Guid userId, Guid organizationId, string publicKey, string privateKey, string collectionName); Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null); + + void ValidatePasswordManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade); + void ValidateSecretsManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade); } diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index 95c6f2590888..d636fa22653a 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -9,16 +9,19 @@ public interface IPaymentService { Task CancelAndRecoverChargesAsync(ISubscriber subscriber); Task PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType, - string paymentToken, Plan plan, short additionalStorageGb, int additionalSeats, - bool premiumAccessAddon, TaxInfo taxInfo, bool provider = false); + string paymentToken, List plans, short additionalStorageGb, int additionalSeats, + bool premiumAccessAddon, TaxInfo taxInfo, bool provider = false, int additionalSmSeats = 0, + int additionalServiceAccount = 0); Task SponsorOrganizationAsync(Organization org, OrganizationSponsorship sponsorship); Task RemoveOrganizationSponsorshipAsync(Organization org, OrganizationSponsorship sponsorship); - Task UpgradeFreeOrganizationAsync(Organization org, Plan plan, - short additionalStorageGb, int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo); + Task UpgradeFreeOrganizationAsync(Organization org, List plans, OrganizationUpgrade upgrade); Task PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, string paymentToken, short additionalStorageGb, TaxInfo taxInfo); Task AdjustSeatsAsync(Organization organization, Plan plan, int additionalSeats, DateTime? prorationDate = null); Task AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId, DateTime? prorationDate = null); + + Task AdjustServiceAccountsAsync(Organization organization, Plan plan, int additionalServiceAccounts, + DateTime? prorationDate = null); Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false, bool skipInAppPurchaseCheck = false); Task ReinstateSubscriptionAsync(ISubscriber subscriber); diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 03b309abd071..98ff7df07bd7 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -897,6 +897,36 @@ public async Task SendUnverifiedOrganizationDomainEmailAsync(IEnumerable await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendSecretsManagerMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, + IEnumerable 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 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); + } + public async Task SendTrustedDeviceAdminApprovalEmailAsync(string email, DateTime utcNow, string ip, string deviceTypeAndIdentifier) { diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index 94f03c89716c..3c3f5d709ca8 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -1,6 +1,5 @@ using System.Security.Claims; using System.Text.Json; -using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Business; using Bit.Core.Auth.Repositories; @@ -38,7 +37,6 @@ public class OrganizationService : IOrganizationService private readonly IDeviceRepository _deviceRepository; private readonly ILicensingService _licensingService; private readonly IEventService _eventService; - private readonly IInstallationRepository _installationRepository; private readonly IApplicationCacheService _applicationCacheService; private readonly IPaymentService _paymentService; private readonly IPolicyRepository _policyRepository; @@ -48,7 +46,6 @@ public class OrganizationService : IOrganizationService private readonly IReferenceEventService _referenceEventService; private readonly IGlobalSettings _globalSettings; private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; - private readonly IOrganizationConnectionRepository _organizationConnectionRepository; private readonly ICurrentContext _currentContext; private readonly ILogger _logger; private readonly IProviderOrganizationRepository _providerOrganizationRepository; @@ -67,7 +64,6 @@ public OrganizationService( IDeviceRepository deviceRepository, ILicensingService licensingService, IEventService eventService, - IInstallationRepository installationRepository, IApplicationCacheService applicationCacheService, IPaymentService paymentService, IPolicyRepository policyRepository, @@ -77,7 +73,6 @@ public OrganizationService( IReferenceEventService referenceEventService, IGlobalSettings globalSettings, IOrganizationApiKeyRepository organizationApiKeyRepository, - IOrganizationConnectionRepository organizationConnectionRepository, ICurrentContext currentContext, ILogger logger, IProviderOrganizationRepository providerOrganizationRepository, @@ -95,7 +90,6 @@ public OrganizationService( _deviceRepository = deviceRepository; _licensingService = licensingService; _eventService = eventService; - _installationRepository = installationRepository; _applicationCacheService = applicationCacheService; _paymentService = paymentService; _policyRepository = policyRepository; @@ -105,7 +99,6 @@ public OrganizationService( _referenceEventService = referenceEventService; _globalSettings = globalSettings; _organizationApiKeyRepository = organizationApiKeyRepository; - _organizationConnectionRepository = organizationConnectionRepository; _currentContext = currentContext; _logger = logger; _providerOrganizationRepository = providerOrganizationRepository; @@ -166,211 +159,6 @@ await _referenceEventService.RaiseEventAsync( new ReferenceEvent(ReferenceEventType.ReinstateSubscription, organization, _currentContext)); } - public async Task> UpgradePlanAsync(Guid organizationId, OrganizationUpgrade upgrade) - { - var organization = await GetOrgById(organizationId); - if (organization == null) - { - throw new NotFoundException(); - } - - if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) - { - throw new BadRequestException("Your account has no payment method available."); - } - - var existingPlan = StaticStore.PasswordManagerPlans.FirstOrDefault(p => p.Type == organization.PlanType); - if (existingPlan == null) - { - throw new BadRequestException("Existing plan not found."); - } - - var newPlan = StaticStore.PasswordManagerPlans.FirstOrDefault(p => p.Type == upgrade.Plan && !p.Disabled); - if (newPlan == null) - { - throw new BadRequestException("Plan not found."); - } - - if (existingPlan.Type == newPlan.Type) - { - throw new BadRequestException("Organization is already on this plan."); - } - - if (existingPlan.UpgradeSortOrder >= newPlan.UpgradeSortOrder) - { - throw new BadRequestException("You cannot upgrade to this plan."); - } - - if (existingPlan.Type != PlanType.Free) - { - throw new BadRequestException("You can only upgrade from the free plan. Contact support."); - } - - ValidateOrganizationUpgradeParameters(newPlan, upgrade); - - var newPlanSeats = (short)(newPlan.BaseSeats + - (newPlan.HasAdditionalSeatsOption ? upgrade.AdditionalSeats : 0)); - if (!organization.Seats.HasValue || organization.Seats.Value > newPlanSeats) - { - var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); - if (occupiedSeats > newPlanSeats) - { - throw new BadRequestException($"Your organization currently has {occupiedSeats} seats filled. " + - $"Your new plan only has ({newPlanSeats}) seats. Remove some users."); - } - } - - if (newPlan.MaxCollections.HasValue && (!organization.MaxCollections.HasValue || - organization.MaxCollections.Value > newPlan.MaxCollections.Value)) - { - var collectionCount = await _collectionRepository.GetCountByOrganizationIdAsync(organization.Id); - if (collectionCount > newPlan.MaxCollections.Value) - { - throw new BadRequestException($"Your organization currently has {collectionCount} collections. " + - $"Your new plan allows for a maximum of ({newPlan.MaxCollections.Value}) collections. " + - "Remove some collections."); - } - } - - if (!newPlan.HasGroups && organization.UseGroups) - { - var groups = await _groupRepository.GetManyByOrganizationIdAsync(organization.Id); - if (groups.Any()) - { - throw new BadRequestException($"Your new plan does not allow the groups feature. " + - $"Remove your groups."); - } - } - - if (!newPlan.HasPolicies && organization.UsePolicies) - { - var policies = await _policyRepository.GetManyByOrganizationIdAsync(organization.Id); - if (policies.Any(p => p.Enabled)) - { - throw new BadRequestException($"Your new plan does not allow the policies feature. " + - $"Disable your policies."); - } - } - - if (!newPlan.HasSso && organization.UseSso) - { - var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(organization.Id); - if (ssoConfig != null && ssoConfig.Enabled) - { - throw new BadRequestException($"Your new plan does not allow the SSO feature. " + - $"Disable your SSO configuration."); - } - } - - if (!newPlan.HasKeyConnector && organization.UseKeyConnector) - { - var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(organization.Id); - if (ssoConfig != null && ssoConfig.GetData().MemberDecryptionType == MemberDecryptionType.KeyConnector) - { - throw new BadRequestException("Your new plan does not allow the Key Connector feature. " + - "Disable your Key Connector."); - } - } - - if (!newPlan.HasResetPassword && organization.UseResetPassword) - { - var resetPasswordPolicy = - await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword); - if (resetPasswordPolicy != null && resetPasswordPolicy.Enabled) - { - throw new BadRequestException("Your new plan does not allow the Password Reset feature. " + - "Disable your Password Reset policy."); - } - } - - if (!newPlan.HasScim && organization.UseScim) - { - var scimConnections = await _organizationConnectionRepository.GetByOrganizationIdTypeAsync(organization.Id, - OrganizationConnectionType.Scim); - if (scimConnections != null && scimConnections.Any(c => c.GetConfig()?.Enabled == true)) - { - throw new BadRequestException("Your new plan does not allow the SCIM feature. " + - "Disable your SCIM configuration."); - } - } - - if (!newPlan.HasCustomPermissions && organization.UseCustomPermissions) - { - var organizationCustomUsers = - await _organizationUserRepository.GetManyByOrganizationAsync(organization.Id, - OrganizationUserType.Custom); - if (organizationCustomUsers.Any()) - { - throw new BadRequestException("Your new plan does not allow the Custom Permissions feature. " + - "Disable your Custom Permissions configuration."); - } - } - - // TODO: Check storage? - - string paymentIntentClientSecret = null; - var success = true; - if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) - { - paymentIntentClientSecret = await _paymentService.UpgradeFreeOrganizationAsync(organization, newPlan, - upgrade.AdditionalStorageGb, upgrade.AdditionalSeats, upgrade.PremiumAccessAddon, upgrade.TaxInfo); - success = string.IsNullOrWhiteSpace(paymentIntentClientSecret); - } - else - { - // TODO: Update existing sub - throw new BadRequestException("You can only upgrade from the free plan. Contact support."); - } - - organization.BusinessName = upgrade.BusinessName; - organization.PlanType = newPlan.Type; - organization.Seats = (short)(newPlan.BaseSeats + upgrade.AdditionalSeats); - organization.MaxCollections = newPlan.MaxCollections; - organization.UseGroups = newPlan.HasGroups; - organization.UseDirectory = newPlan.HasDirectory; - organization.UseEvents = newPlan.HasEvents; - organization.UseTotp = newPlan.HasTotp; - organization.Use2fa = newPlan.Has2fa; - organization.UseApi = newPlan.HasApi; - organization.SelfHost = newPlan.HasSelfHost; - organization.UsePolicies = newPlan.HasPolicies; - organization.MaxStorageGb = !newPlan.BaseStorageGb.HasValue ? - (short?)null : (short)(newPlan.BaseStorageGb.Value + upgrade.AdditionalStorageGb); - organization.UseGroups = newPlan.HasGroups; - organization.UseDirectory = newPlan.HasDirectory; - organization.UseEvents = newPlan.HasEvents; - organization.UseTotp = newPlan.HasTotp; - organization.Use2fa = newPlan.Has2fa; - organization.UseApi = newPlan.HasApi; - organization.UseSso = newPlan.HasSso; - organization.UseKeyConnector = newPlan.HasKeyConnector; - organization.UseScim = newPlan.HasScim; - organization.UseResetPassword = newPlan.HasResetPassword; - organization.SelfHost = newPlan.HasSelfHost; - organization.UsersGetPremium = newPlan.UsersGetPremium || upgrade.PremiumAccessAddon; - organization.UseCustomPermissions = newPlan.HasCustomPermissions; - organization.Plan = newPlan.Name; - organization.Enabled = success; - organization.PublicKey = upgrade.PublicKey; - organization.PrivateKey = upgrade.PrivateKey; - await ReplaceAndUpdateCacheAsync(organization); - if (success) - { - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.UpgradePlan, organization, _currentContext) - { - PlanName = newPlan.Name, - PlanType = newPlan.Type, - OldPlanName = existingPlan.Name, - OldPlanType = existingPlan.Type, - Seats = organization.Seats, - Storage = organization.MaxStorageGb, - }); - } - - return new Tuple(success, paymentIntentClientSecret); - } - public async Task AdjustStorageAsync(Guid organizationId, short storageAdjustmentGb) { var organization = await GetOrgById(organizationId); @@ -607,15 +395,14 @@ public async Task VerifyBankAsync(Guid organizationId, int amount1, int amount2) public async Task> SignUpAsync(OrganizationSignup signup, bool provider = false) { - var plan = StaticStore.PasswordManagerPlans.FirstOrDefault(p => p.Type == signup.Plan); - if (plan is not { LegacyYear: null }) - { - throw new BadRequestException("Invalid plan selected."); - } + var passwordManagerPlan = StaticStore.PasswordManagerPlans.FirstOrDefault(p => p.Type == signup.Plan); - if (plan.Disabled) + ValidatePasswordManagerPlan(passwordManagerPlan, signup); + + var secretsManagerPlan = StaticStore.SecretManagerPlans.FirstOrDefault(p => p.Type == signup.Plan); + if (signup.UseSecretsManager) { - throw new BadRequestException("Plan not found."); + ValidateSecretsManagerPlan(secretsManagerPlan, signup); } if (!provider) @@ -623,8 +410,6 @@ public async Task> SignUpAsync(Organizatio await ValidateSignUpPoliciesAsync(signup.Owner.Id); } - ValidateOrganizationUpgradeParameters(plan, signup); - var organization = new Organization { // Pre-generate the org id so that we can save it with the Stripe subscription.. @@ -632,25 +417,25 @@ public async Task> SignUpAsync(Organizatio Name = signup.Name, BillingEmail = signup.BillingEmail, BusinessName = signup.BusinessName, - PlanType = plan.Type, - Seats = (short)(plan.BaseSeats + signup.AdditionalSeats), - MaxCollections = plan.MaxCollections, - MaxStorageGb = !plan.BaseStorageGb.HasValue ? - (short?)null : (short)(plan.BaseStorageGb.Value + signup.AdditionalStorageGb), - UsePolicies = plan.HasPolicies, - UseSso = plan.HasSso, - UseGroups = plan.HasGroups, - UseEvents = plan.HasEvents, - UseDirectory = plan.HasDirectory, - UseTotp = plan.HasTotp, - Use2fa = plan.Has2fa, - UseApi = plan.HasApi, - UseResetPassword = plan.HasResetPassword, - SelfHost = plan.HasSelfHost, - UsersGetPremium = plan.UsersGetPremium || signup.PremiumAccessAddon, - UseCustomPermissions = plan.HasCustomPermissions, - UseScim = plan.HasScim, - Plan = plan.Name, + PlanType = passwordManagerPlan.Type, + Seats = (short)(passwordManagerPlan.BaseSeats + signup.AdditionalSeats), + MaxCollections = passwordManagerPlan.MaxCollections, + MaxStorageGb = !passwordManagerPlan.BaseStorageGb.HasValue ? + (short?)null : (short)(passwordManagerPlan.BaseStorageGb.Value + signup.AdditionalStorageGb), + UsePolicies = passwordManagerPlan.HasPolicies, + UseSso = passwordManagerPlan.HasSso, + UseGroups = passwordManagerPlan.HasGroups, + UseEvents = passwordManagerPlan.HasEvents, + UseDirectory = passwordManagerPlan.HasDirectory, + UseTotp = passwordManagerPlan.HasTotp, + Use2fa = passwordManagerPlan.Has2fa, + UseApi = passwordManagerPlan.HasApi, + UseResetPassword = passwordManagerPlan.HasResetPassword, + SelfHost = passwordManagerPlan.HasSelfHost, + UsersGetPremium = passwordManagerPlan.UsersGetPremium || signup.PremiumAccessAddon, + UseCustomPermissions = passwordManagerPlan.HasCustomPermissions, + UseScim = passwordManagerPlan.HasScim, + Plan = passwordManagerPlan.Name, Gateway = null, ReferenceData = signup.Owner.ReferenceData, Enabled = true, @@ -659,10 +444,14 @@ public async Task> SignUpAsync(Organizatio PrivateKey = signup.PrivateKey, CreationDate = DateTime.UtcNow, RevisionDate = DateTime.UtcNow, - Status = OrganizationStatusType.Created + Status = OrganizationStatusType.Created, + UsePasswordManager = true, + SmSeats = (short)(secretsManagerPlan.BaseSeats + signup.AdditionalSmSeats.GetValueOrDefault()), + SmServiceAccounts = secretsManagerPlan.BaseServiceAccount + signup.AdditionalServiceAccounts.GetValueOrDefault(), + UseSecretsManager = signup.UseSecretsManager }; - if (plan.Type == PlanType.Free && !provider) + if (passwordManagerPlan.Type == PlanType.Free && !provider) { var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(signup.Owner.Id); @@ -671,11 +460,16 @@ public async Task> SignUpAsync(Organizatio throw new BadRequestException("You can only be an admin of one free organization."); } } - else if (plan.Type != PlanType.Free) + else if (passwordManagerPlan.Type != PlanType.Free) { + var purchaseOrganizationPlan = signup.UseSecretsManager + ? StaticStore.Plans.Where(p => p.Type == signup.Plan).ToList() + : StaticStore.PasswordManagerPlans.Where(p => p.Type == signup.Plan).Take(1).ToList(); + await _paymentService.PurchaseOrganizationAsync(organization, signup.PaymentMethodType.Value, - signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats, - signup.PremiumAccessAddon, signup.TaxInfo, provider); + signup.PaymentToken, purchaseOrganizationPlan, signup.AdditionalStorageGb, signup.AdditionalSeats, + signup.PremiumAccessAddon, signup.TaxInfo, provider, signup.AdditionalSmSeats.GetValueOrDefault(), + signup.AdditionalServiceAccounts.GetValueOrDefault()); } var ownerId = provider ? default : signup.Owner.Id; @@ -683,10 +477,11 @@ await _paymentService.PurchaseOrganizationAsync(organization, signup.PaymentMeth await _referenceEventService.RaiseEventAsync( new ReferenceEvent(ReferenceEventType.Signup, organization, _currentContext) { - PlanName = plan.Name, - PlanType = plan.Type, + PlanName = passwordManagerPlan.Name, + PlanType = passwordManagerPlan.Type, Seats = returnValue.Item1.Seats, Storage = returnValue.Item1.MaxStorageGb, + // TODO: add reference events for SmSeats and Service Accounts - see AC-1481 }); return returnValue; } @@ -807,6 +602,7 @@ await _organizationApiKeyRepository.CreateAsync(new OrganizationApiKey OrganizationId = organization.Id, UserId = ownerId, Key = ownerKey, + AccessSecretsManager = organization.UseSecretsManager, Type = OrganizationUserType.Owner, Status = OrganizationUserStatusType.Confirmed, AccessAll = true, @@ -2060,31 +1856,56 @@ private async Task GetOrgById(Guid id) return await _organizationRepository.GetByIdAsync(id); } - private void ValidateOrganizationUpgradeParameters(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade) + private static void ValidatePlan(Models.StaticStore.Plan plan, int additionalSeats, string productType) { - if (!plan.HasAdditionalStorageOption && upgrade.AdditionalStorageGb > 0) + if (plan is not { LegacyYear: null }) { - throw new BadRequestException("Plan does not allow additional storage."); + throw new BadRequestException($"Invalid {productType} plan selected."); } - if (upgrade.AdditionalStorageGb < 0) + if (plan.Disabled) { - throw new BadRequestException("You can't subtract storage!"); + throw new BadRequestException($"{productType} Plan not found."); } - if (!plan.HasPremiumAccessOption && upgrade.PremiumAccessAddon) + if (plan.BaseSeats + additionalSeats <= 0) { - throw new BadRequestException("This plan does not allow you to buy the premium access addon."); + throw new BadRequestException($"You do not have any {productType} seats!"); } + if (additionalSeats < 0) + { + throw new BadRequestException($"You can't subtract {productType} seats!"); + } + } + + public void ValidatePasswordManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade) + { + ValidatePlan(plan, upgrade.AdditionalSeats, "Password Manager"); + if (plan.BaseSeats + upgrade.AdditionalSeats <= 0) { - throw new BadRequestException("You do not have any seats!"); + throw new BadRequestException($"You do not have any Password Manager seats!"); } if (upgrade.AdditionalSeats < 0) { - throw new BadRequestException("You can't subtract seats!"); + throw new BadRequestException($"You can't subtract Password Manager seats!"); + } + + if (!plan.HasAdditionalStorageOption && upgrade.AdditionalStorageGb > 0) + { + throw new BadRequestException("Plan does not allow additional storage."); + } + + if (upgrade.AdditionalStorageGb < 0) + { + throw new BadRequestException("You can't subtract storage!"); + } + + if (!plan.HasPremiumAccessOption && upgrade.PremiumAccessAddon) + { + throw new BadRequestException("This plan does not allow you to buy the premium access addon."); } if (!plan.HasAdditionalSeatsOption && upgrade.AdditionalSeats > 0) @@ -2096,7 +1917,37 @@ private void ValidateOrganizationUpgradeParameters(Models.StaticStore.Plan plan, upgrade.AdditionalSeats > plan.MaxAdditionalSeats.Value) { throw new BadRequestException($"Selected plan allows a maximum of " + - $"{plan.MaxAdditionalSeats.GetValueOrDefault(0)} additional users."); + $"{plan.MaxAdditionalSeats.GetValueOrDefault(0)} additional users."); + } + } + + public void ValidateSecretsManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade) + { + ValidatePlan(plan, upgrade.AdditionalSmSeats.GetValueOrDefault(), "Secrets Manager"); + + if (!plan.HasAdditionalServiceAccountOption && upgrade.AdditionalServiceAccounts > 0) + { + throw new BadRequestException("Plan does not allow additional Service Accounts."); + } + + if (upgrade.AdditionalSmSeats.GetValueOrDefault() > upgrade.AdditionalSeats) + { + throw new BadRequestException("You cannot have more Secrets Manager seats than Password Manager seats."); + } + + if (upgrade.AdditionalServiceAccounts.GetValueOrDefault() < 0) + { + throw new BadRequestException("You can't subtract Service Accounts!"); + } + + switch (plan.HasAdditionalSeatsOption) + { + case false when upgrade.AdditionalSmSeats > 0: + throw new BadRequestException("Plan does not allow additional users."); + case true when plan.MaxAdditionalSeats.HasValue && + upgrade.AdditionalSmSeats > plan.MaxAdditionalSeats.Value: + throw new BadRequestException($"Selected plan allows a maximum of " + + $"{plan.MaxAdditionalSeats.GetValueOrDefault(0)} additional users."); } } diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index ad0fd194310b..190666b1ea8c 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -49,8 +49,9 @@ public StripePaymentService( } public async Task PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType, - string paymentToken, StaticStore.Plan plan, short additionalStorageGb, - int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo, bool provider = false) + string paymentToken, List plans, short additionalStorageGb, + int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo, bool provider = false, + int additionalSmSeats = 0, int additionalServiceAccount = 0) { Braintree.Customer braintreeCustomer = null; string stipeCustomerSourceToken = null; @@ -118,7 +119,8 @@ public async Task PurchaseOrganizationAsync(Organization org, PaymentMet } } - var subCreateOptions = new OrganizationPurchaseSubscriptionOptions(org, plan, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon); + var subCreateOptions = new OrganizationPurchaseSubscriptionOptions(org, plans, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon + , additionalSmSeats, additionalServiceAccount); Stripe.Customer customer = null; Stripe.Subscription subscription; @@ -229,8 +231,8 @@ public Task SponsorOrganizationAsync(Organization org, OrganizationSponsorship s public Task RemoveOrganizationSponsorshipAsync(Organization org, OrganizationSponsorship sponsorship) => ChangeOrganizationSponsorship(org, sponsorship, false); - public async Task UpgradeFreeOrganizationAsync(Organization org, StaticStore.Plan plan, - short additionalStorageGb, int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo) + public async Task UpgradeFreeOrganizationAsync(Organization org, List plans, + OrganizationUpgrade upgrade) { if (!string.IsNullOrWhiteSpace(org.GatewaySubscriptionId)) { @@ -246,6 +248,7 @@ public async Task UpgradeFreeOrganizationAsync(Organization org, StaticS throw new GatewayException("Could not find customer payment profile."); } + var taxInfo = upgrade.TaxInfo; if (taxInfo != null && !string.IsNullOrWhiteSpace(taxInfo.BillingAddressCountry) && !string.IsNullOrWhiteSpace(taxInfo.BillingAddressPostalCode)) { var taxRateSearch = new TaxRate @@ -263,7 +266,7 @@ public async Task UpgradeFreeOrganizationAsync(Organization org, StaticS } } - var subCreateOptions = new OrganizationUpgradeSubscriptionOptions(customer.Id, org, plan, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon); + var subCreateOptions = new OrganizationUpgradeSubscriptionOptions(customer.Id, org, plans, upgrade); var (stripePaymentMethod, paymentMethodType) = IdentifyPaymentMethod(customer, subCreateOptions); var subscription = await ChargeForNewSubscriptionAsync(org, customer, false, @@ -860,6 +863,11 @@ public Task AdjustSeatsAsync(Organization organization, StaticStore.Plan return FinalizeSubscriptionChangeAsync(organization, new SeatSubscriptionUpdate(organization, plan, additionalSeats), prorationDate); } + public Task AdjustServiceAccountsAsync(Organization organization, StaticStore.Plan plan, int additionalServiceAccounts, DateTime? prorationDate = null) + { + return FinalizeSubscriptionChangeAsync(organization, new ServiceAccountSubscriptionUpdate(organization, plan, additionalServiceAccounts), prorationDate); + } + public Task AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId, DateTime? prorationDate = null) { diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index c26fee9738f2..97d69cfa48bf 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -239,6 +239,19 @@ public Task SendUnverifiedOrganizationDomainEmailAsync(IEnumerable admin return Task.FromResult(0); } + public Task SendSecretsManagerMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, + IEnumerable ownerEmails) + { + return Task.FromResult(0); + } + + public Task SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(Organization organization, + int maxSeatCount, + IEnumerable ownerEmails) + { + return Task.FromResult(0); + } + public Task SendTrustedDeviceAdminApprovalEmailAsync(string email, DateTime utcNow, string ip, string deviceTypeAndIdentifier) { return Task.FromResult(0); diff --git a/src/Core/Utilities/SecretsManagerPlanStore.cs b/src/Core/Utilities/SecretsManagerPlanStore.cs index 981acc3ab31b..c37c4244623d 100644 --- a/src/Core/Utilities/SecretsManagerPlanStore.cs +++ b/src/Core/Utilities/SecretsManagerPlanStore.cs @@ -39,12 +39,13 @@ public static IEnumerable CreatePlan() HasCustomPermissions = true, UpgradeSortOrder = 3, DisplaySortOrder = 3, - StripeSeatPlanId = "sm-enterprise-seat-monthly", - StripeServiceAccountPlanId = "service-account-monthly", + StripeSeatPlanId = "secrets-manager-enterprise-seat-monthly", + StripeServiceAccountPlanId = "secrets-manager-service-account-monthly", BasePrice = 0, SeatPrice = 13, AdditionalPricePerServiceAccount = 0.5M, AllowSeatAutoscale = true, + AllowServiceAccountsAutoscale = true }, new Plan { @@ -77,12 +78,13 @@ public static IEnumerable CreatePlan() HasCustomPermissions = true, UpgradeSortOrder = 3, DisplaySortOrder = 3, - StripeSeatPlanId = "sm-enterprise-seat-annually", - StripeServiceAccountPlanId = "service-account-annually", + StripeSeatPlanId = "secrets-manager-enterprise-seat-annually", + StripeServiceAccountPlanId = "secrets-manager-service-account-annually", BasePrice = 0, SeatPrice = 144, AdditionalPricePerServiceAccount = 6, AllowSeatAutoscale = true, + AllowServiceAccountsAutoscale = true }, new Plan { @@ -107,12 +109,13 @@ public static IEnumerable CreatePlan() UsersGetPremium = true, UpgradeSortOrder = 2, DisplaySortOrder = 2, - StripeSeatPlanId = "sm-teams-seat-monthly", - StripeServiceAccountPlanId = "service-account-monthly", + StripeSeatPlanId = "secrets-manager-teams-seat-monthly", + StripeServiceAccountPlanId = "secrets-manager-service-account-monthly", BasePrice = 0, SeatPrice = 7, AdditionalPricePerServiceAccount = 0.5M, AllowSeatAutoscale = true, + AllowServiceAccountsAutoscale = true }, new Plan { @@ -139,12 +142,13 @@ public static IEnumerable CreatePlan() UpgradeSortOrder = 2, DisplaySortOrder = 2, - StripeSeatPlanId = "sm-teams-seat-annually", - StripeServiceAccountPlanId = "service-account-annually", + StripeSeatPlanId = "secrets-manager-teams-seat-annually", + StripeServiceAccountPlanId = "secrets-manager-service-account-annually", BasePrice = 0, SeatPrice = 72, AdditionalPricePerServiceAccount = 6, AllowSeatAutoscale = true, + AllowServiceAccountsAutoscale = true }, new Plan { @@ -158,7 +162,7 @@ public static IEnumerable CreatePlan() BaseServiceAccount = 3, MaxProjects = 3, MaxUsers = 2, - MaxServiceAccount = 3, + MaxServiceAccounts = 3, UpgradeSortOrder = -1, // Always the lowest plan, cannot be upgraded to DisplaySortOrder = -1, AllowSeatAutoscale = false, diff --git a/src/Core/Utilities/StaticStore.cs b/src/Core/Utilities/StaticStore.cs index c5183296a9e6..ad11b2cfd092 100644 --- a/src/Core/Utilities/StaticStore.cs +++ b/src/Core/Utilities/StaticStore.cs @@ -139,4 +139,53 @@ public static Plan GetSecretsManagerPlan(PlanType planType) => public static SponsoredPlan GetSponsoredPlan(PlanSponsorshipType planSponsorshipType) => SponsoredPlans.FirstOrDefault(p => p.PlanSponsorshipType == planSponsorshipType); + + /// + /// Determines if the stripe plan id is an addon item by checking if the provided stripe plan id + /// matches either the or + /// in any . + /// + /// + /// + /// True if the stripePlanId is a addon product, false otherwise + /// + public static bool IsAddonSubscriptionItem(string stripePlanId) + { + if (PasswordManagerPlans.Select(p => p.StripeStoragePlanId).Contains(stripePlanId)) + { + return true; + } + + if (SecretManagerPlans.Select(p => p.StripeServiceAccountPlanId).Contains(stripePlanId)) + { + return true; + } + + return false; + } + + /// + /// Get a by comparing the provided stripeId to the various + /// Stripe plan ids within a . + /// The following properties are checked: + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// The plan if a matching stripeId was found, null otherwise + public static Plan GetPlanByStripeId(string stripeId) + { + return Plans.FirstOrDefault(p => + p.StripePlanId == stripeId || + p.StripeSeatPlanId == stripeId || + p.StripeStoragePlanId == stripeId || + p.StripeServiceAccountPlanId == stripeId || + p.StripePremiumAccessPlanId == stripeId + ); + } } diff --git a/src/Infrastructure.Dapper/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/Repositories/OrganizationUserRepository.cs index ca246949089d..008242c26c2b 100644 --- a/src/Infrastructure.Dapper/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/OrganizationUserRepository.cs @@ -99,6 +99,19 @@ public async Task GetOccupiedSeatCountByOrganizationIdAsync(Guid organizati } } + public async Task GetOccupiedSmSeatCountByOrganizationIdAsync(Guid organizationId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var result = await connection.ExecuteScalarAsync( + "[dbo].[OrganizationUser_ReadOccupiedSmSeatCountByOrganizationId]", + new { OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + + return result; + } + } + public async Task> SelectKnownEmailsAsync(Guid organizationId, IEnumerable emails, bool onlyRegisteredUsers) { diff --git a/src/Infrastructure.EntityFramework/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/OrganizationUserRepository.cs index 282304720fa8..8256696d9686 100644 --- a/src/Infrastructure.EntityFramework/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/OrganizationUserRepository.cs @@ -621,4 +621,11 @@ on p.OrganizationId equals ou.OrganizationId return await query.ToListAsync(); } } + + public async Task GetOccupiedSmSeatCountByOrganizationIdAsync(Guid organizationId) + { + var query = new OrganizationUserReadOccupiedSmSeatCountByOrganizationIdQuery(organizationId); + return await GetCountFromQuery(query); + } + } diff --git a/src/Infrastructure.EntityFramework/Repositories/Queries/OrganizationUserReadOccupiedSmSeatCountByOrganizationIdQuery.cs b/src/Infrastructure.EntityFramework/Repositories/Queries/OrganizationUserReadOccupiedSmSeatCountByOrganizationIdQuery.cs new file mode 100644 index 000000000000..0f21a80ba64f --- /dev/null +++ b/src/Infrastructure.EntityFramework/Repositories/Queries/OrganizationUserReadOccupiedSmSeatCountByOrganizationIdQuery.cs @@ -0,0 +1,22 @@ +using Bit.Core.Enums; +using Bit.Infrastructure.EntityFramework.Models; + +namespace Bit.Infrastructure.EntityFramework.Repositories.Queries; + +public class OrganizationUserReadOccupiedSmSeatCountByOrganizationIdQuery : IQuery +{ + private readonly Guid _organizationId; + + public OrganizationUserReadOccupiedSmSeatCountByOrganizationIdQuery(Guid organizationId) + { + _organizationId = organizationId; + } + + public IQueryable Run(DatabaseContext dbContext) + { + var query = from ou in dbContext.OrganizationUsers + where ou.OrganizationId == _organizationId && ou.Status >= OrganizationUserStatusType.Invited && ou.AccessSecretsManager == true + select ou; + return query; + } +} diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index c239be969a9c..97695d171f5a 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -18,6 +18,8 @@ using Bit.Core.OrganizationFeatures; using Bit.Core.Repositories; using Bit.Core.Resources; +using Bit.Core.SecretsManager.Repositories; +using Bit.Core.SecretsManager.Repositories.Noop; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tokens; @@ -329,6 +331,7 @@ public static void AddDefaultServices(this IServiceCollection services, GlobalSe public static void AddOosServices(this IServiceCollection services) { services.AddScoped(); + services.AddScoped(); } public static void AddNoopServices(this IServiceCollection services) diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadOccupiedSmSeatCountByOrganizationId.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadOccupiedSmSeatCountByOrganizationId.sql new file mode 100644 index 000000000000..3c3792effe59 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadOccupiedSmSeatCountByOrganizationId.sql @@ -0,0 +1,16 @@ +CREATE PROCEDURE [dbo].[OrganizationUser_ReadOccupiedSmSeatCountByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + COUNT(1) + FROM + [dbo].[OrganizationUserView] + WHERE + OrganizationId = @OrganizationId + AND Status >= 0 --Invited + AND AccessSecretsManager = 1 +END +GO diff --git a/test/Api.Test/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/Controllers/OrganizationsControllerTests.cs index 1d21e9b735a7..bfed60a9a462 100644 --- a/test/Api.Test/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/Controllers/OrganizationsControllerTests.cs @@ -11,6 +11,7 @@ using Bit.Core.Exceptions; using Bit.Core.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; +using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -40,6 +41,8 @@ public class OrganizationsControllerTests : IDisposable private readonly IUpdateOrganizationLicenseCommand _updateOrganizationLicenseCommand; private readonly IFeatureService _featureService; private readonly ILicensingService _licensingService; + private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; + private readonly IUpgradeOrganizationPlanCommand _upgradeOrganizationPlanCommand; private readonly OrganizationsController _sut; @@ -64,12 +67,15 @@ public OrganizationsControllerTests() _updateOrganizationLicenseCommand = Substitute.For(); _featureService = Substitute.For(); _licensingService = Substitute.For(); + _updateSecretsManagerSubscriptionCommand = Substitute.For(); + _upgradeOrganizationPlanCommand = Substitute.For(); _sut = new OrganizationsController(_organizationRepository, _organizationUserRepository, _policyRepository, _providerRepository, _organizationService, _userService, _paymentService, _currentContext, _ssoConfigRepository, _ssoConfigService, _getOrganizationApiKeyQuery, _rotateOrganizationApiKeyCommand, _createOrganizationApiKeyCommand, _organizationApiKeyRepository, _updateOrganizationLicenseCommand, - _cloudGetOrganizationLicenseQuery, _featureService, _globalSettings, _licensingService); + _cloudGetOrganizationLicenseQuery, _featureService, _globalSettings, _licensingService, + _updateSecretsManagerSubscriptionCommand, _upgradeOrganizationPlanCommand); } public void Dispose() diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs new file mode 100644 index 000000000000..11990c1733b0 --- /dev/null +++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs @@ -0,0 +1,748 @@ +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.OrganizationSubscriptions; +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 sutProvider) + { + sutProvider.GetDependency() + .GetByIdAsync(organizationId) + .Returns((Organization)null); + + var organizationUpdate = new SecretsManagerSubscriptionUpdate + { + OrganizationId = organizationId, + MaxAutoscaleSmSeats = null, + SmSeatsAdjustment = 0 + }; + + var exception = await Assert.ThrowsAsync( + () => 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 sutProvider) + { + var organization = new Organization + { + Id = organizationId, + SmSeats = 10, + SmServiceAccounts = 5, + UseSecretsManager = false, + MaxAutoscaleSmSeats = 20, + MaxAutoscaleSmServiceAccounts = 10 + }; + + sutProvider.GetDependency() + .GetByIdAsync(organizationId) + .Returns(organization); + + var organizationUpdate = new SecretsManagerSubscriptionUpdate + { + OrganizationId = organizationId, + SmSeatsAdjustment = 1, + MaxAutoscaleSmSeats = 1 + }; + + var exception = await Assert.ThrowsAsync( + () => 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 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().GetByIdAsync(organizationId).Returns(organization); + + var exception = await Assert.ThrowsAsync(() => 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 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().GetByIdAsync(organizationId).Returns(organization); + + var exception = await Assert.ThrowsAsync(() => 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 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().GetByIdAsync(organizationId).Returns(organization); + + var exception = await Assert.ThrowsAsync(() => 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 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().GetByIdAsync(organizationId).Returns(organization); + + var exception = await Assert.ThrowsAsync(() => 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 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().GetByIdAsync(organizationId).Returns(organization); + + var exception = await Assert.ThrowsAsync( + () => 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 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().GetByIdAsync(organizationId).Returns(organization); + + var exception = await Assert.ThrowsAsync(() => 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 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().GetByIdAsync(organizationId).Returns(organization); + + var exception = await Assert.ThrowsAsync(() => 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 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().GetByIdAsync(organizationId).Returns(organization); + + var exception = await Assert.ThrowsAsync(() => 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 sutProvider) + { + const int organizationServiceAccounts = 200; + const int seatAdjustment = 5; + const int maxAutoscaleSeats = 15; + const int serviceAccountAdjustment = 100; + const int maxAutoScaleServiceAccounts = 300; + + var organization = new Organization + { + Id = organizationId, + UseSecretsManager = true, + SmSeats = 10, + MaxAutoscaleSmSeats = 20, + SmServiceAccounts = organizationServiceAccounts, + MaxAutoscaleSmServiceAccounts = 350, + PlanType = planType, + GatewayCustomerId = "1", + GatewaySubscriptionId = "2" + }; + + var plan = StaticStore.SecretManagerPlans.FirstOrDefault(x => x.Type == organization.PlanType); + var organizationUpdate = new SecretsManagerSubscriptionUpdate + { + OrganizationId = organizationId, + SmSeatsAdjustment = seatAdjustment, + SmSeats = organization.SmSeats.GetValueOrDefault() + seatAdjustment, + SmSeatsExcludingBase = (organization.SmSeats.GetValueOrDefault() + seatAdjustment) - plan.BaseSeats, + MaxAutoscaleSmSeats = maxAutoscaleSeats, + + SmServiceAccountsAdjustment = serviceAccountAdjustment, + SmServiceAccounts = organization.SmServiceAccounts.GetValueOrDefault() + serviceAccountAdjustment, + SmServiceAccountsExcludingBase = (organization.SmServiceAccounts.GetValueOrDefault() + serviceAccountAdjustment) - (int)plan.BaseServiceAccount, + MaxAutoscaleSmServiceAccounts = maxAutoScaleServiceAccounts, + + MaxAutoscaleSmSeatsChanged = maxAutoscaleSeats != organization.MaxAutoscaleSeats.GetValueOrDefault(), + MaxAutoscaleSmServiceAccountsChanged = 200 != organization.MaxAutoscaleSmServiceAccounts.GetValueOrDefault() + }; + + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + + await sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate); + + if (organizationUpdate.SmSeatsAdjustment != 0) + { + await sutProvider.GetDependency().Received(1) + .AdjustServiceAccountsAsync(organization, plan, organizationUpdate.SmServiceAccountsExcludingBase); + + // TODO: call ReferenceEventService - see AC-1481 + + await sutProvider.GetDependency().Received(1).ReplaceAndUpdateCacheAsync( + Arg.Is(org => org.SmSeats == organizationUpdate.SmSeats)); + } + + if (organizationUpdate.SmServiceAccountsAdjustment != 0) + { + await sutProvider.GetDependency().Received(1) + .AdjustSeatsAsync(organization, plan, organizationUpdate.SmSeatsExcludingBase); + + // TODO: call ReferenceEventService - see AC-1481 + + await sutProvider.GetDependency().Received(1).ReplaceAndUpdateCacheAsync( + Arg.Is(org => + org.SmServiceAccounts == (organizationServiceAccounts + organizationUpdate.SmServiceAccountsAdjustment))); + } + + if (organizationUpdate.MaxAutoscaleSmSeats != organization.MaxAutoscaleSmSeats) + { + await sutProvider.GetDependency().Received(1).ReplaceAndUpdateCacheAsync( + Arg.Is(org => + org.MaxAutoscaleSmSeats == organizationUpdate.MaxAutoscaleSmServiceAccounts)); + } + + if (organizationUpdate.MaxAutoscaleSmServiceAccounts != organization.MaxAutoscaleSmServiceAccounts) + { + await sutProvider.GetDependency().Received(1).ReplaceAndUpdateCacheAsync( + Arg.Is(org => + org.MaxAutoscaleSmServiceAccounts == organizationUpdate.MaxAutoscaleSmServiceAccounts)); + } + + await sutProvider.GetDependency().Received(1).SendSecretsManagerMaxSeatLimitReachedEmailAsync(organization, organization.MaxAutoscaleSmSeats.Value, Arg.Any>()); + await sutProvider.GetDependency().Received(1).SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(organization, organization.MaxAutoscaleSmServiceAccounts.Value, Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task UpdateSecretsManagerSubscription_ThrowsBadRequestException_WhenMaxAutoscaleSeatsBelowSeatCount( + Guid organizationId, + SutProvider 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().GetByIdAsync(organizationId).Returns(organization); + + var exception = await Assert.ThrowsAsync(() => 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 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().GetByIdAsync(organizationId).Returns(organization); + sutProvider.GetDependency().GetOccupiedSmSeatCountByOrganizationIdAsync(organizationId).Returns(8); + + + var exception = await Assert.ThrowsAsync(() => 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 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().GetByIdAsync(organizationId).Returns(organization); + + var exception = await Assert.ThrowsAsync(() => 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 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().GetByIdAsync(organizationId).Returns(organization); + + var exception = await Assert.ThrowsAsync(() => 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 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().GetByIdAsync(organizationId).Returns(organization); + + var exception = await Assert.ThrowsAsync(() => 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 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().GetByIdAsync(organizationId).Returns(organization); + + var exception = await Assert.ThrowsAsync(() => 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 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().GetByIdAsync(organizationId).Returns(organization); + + sutProvider.GetDependency() + .GetServiceAccountCountByOrganizationIdAsync(organization.Id) + .Returns(currentServiceAccounts); + + var exception = await Assert.ThrowsAsync(() => 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().Received(1).GetServiceAccountCountByOrganizationIdAsync(organization.Id); + await VerifyDependencyNotCalledAsync(sutProvider); + } + + private static async Task VerifyDependencyNotCalledAsync(SutProvider sutProvider) + { + await sutProvider.GetDependency().DidNotReceive() + .AdjustSeatsAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .AdjustServiceAccountsAsync(Arg.Any(), Arg.Any(), Arg.Any()); + // TODO: call ReferenceEventService - see AC-1481 + await sutProvider.GetDependency().DidNotReceive() + .ReplaceAndUpdateCacheAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .SendOrganizationMaxSeatLimitReachedEmailAsync(Arg.Any(), Arg.Any(), + Arg.Any>()); + } +} diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs new file mode 100644 index 000000000000..f18a8b5de95c --- /dev/null +++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs @@ -0,0 +1,185 @@ +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.OrganizationFeatures.OrganizationSubscriptions; +using Bit.Core.Repositories; +using Bit.Core.SecretsManager.Repositories; +using Bit.Core.Services; +using Bit.Core.Test.AutoFixture.OrganizationFixtures; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; +using Organization = Bit.Core.Entities.Organization; + +namespace Bit.Core.Test.OrganizationFeatures.OrganizationSubscriptionUpdate; + +[SutProviderCustomize] +public class UpgradeOrganizationPlanCommandTests +{ + [Theory, BitAutoData] + public async Task UpgradePlan_OrganizationIsNull_Throws(Guid organizationId, OrganizationUpgrade upgrade, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(Task.FromResult(null)); + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpgradePlanAsync(organizationId, upgrade)); + } + + [Theory, BitAutoData] + public async Task UpgradePlan_GatewayCustomIdIsNull_Throws(Organization organization, OrganizationUpgrade upgrade, + SutProvider sutProvider) + { + organization.GatewayCustomerId = string.Empty; + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade)); + Assert.Contains("no payment method", exception.Message); + } + + [Theory, BitAutoData] + public async Task UpgradePlan_AlreadyInPlan_Throws(Organization organization, OrganizationUpgrade upgrade, + SutProvider sutProvider) + { + upgrade.Plan = organization.PlanType; + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade)); + Assert.Contains("already on this plan", exception.Message); + } + + [Theory, BitAutoData] + public async Task UpgradePlan_SM_AlreadyInPlan_Throws(Organization organization, OrganizationUpgrade upgrade, + SutProvider sutProvider) + { + upgrade.Plan = organization.PlanType; + upgrade.UseSecretsManager = true; + upgrade.AdditionalSmSeats = 10; + upgrade.AdditionalServiceAccounts = 10; + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade)); + Assert.Contains("already on this plan", exception.Message); + } + + [Theory, PaidOrganizationCustomize(CheckedPlanType = PlanType.Free), BitAutoData] + public async Task UpgradePlan_UpgradeFromPaidPlan_Throws(Organization organization, OrganizationUpgrade upgrade, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade)); + Assert.Contains("can only upgrade", exception.Message); + } + + [Theory, PaidOrganizationCustomize(CheckedPlanType = PlanType.Free), BitAutoData] + public async Task UpgradePlan_SM_UpgradeFromPaidPlan_Throws(Organization organization, OrganizationUpgrade upgrade, + SutProvider sutProvider) + { + upgrade.UseSecretsManager = true; + upgrade.AdditionalSmSeats = 10; + upgrade.AdditionalServiceAccounts = 10; + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade)); + Assert.Contains("can only upgrade", exception.Message); + } + + [Theory] + [FreeOrganizationUpgradeCustomize, BitAutoData] + public async Task UpgradePlan_Passes(Organization organization, OrganizationUpgrade upgrade, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + upgrade.AdditionalSmSeats = 10; + upgrade.AdditionalSeats = 10; + await sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade); + await sutProvider.GetDependency().Received(1).ReplaceAndUpdateCacheAsync(organization); + } + + [Theory, FreeOrganizationUpgradeCustomize] + [BitAutoData(PlanType.EnterpriseMonthly)] + [BitAutoData(PlanType.EnterpriseAnnually)] + [BitAutoData(PlanType.TeamsMonthly)] + [BitAutoData(PlanType.TeamsAnnually)] + public async Task UpgradePlan_SM_Passes(PlanType planType, Organization organization, OrganizationUpgrade upgrade, + SutProvider sutProvider) + { + upgrade.Plan = planType; + + var passwordManagerPlan = StaticStore.GetPasswordManagerPlan(upgrade.Plan); + var secretsManagerPlan = StaticStore.GetSecretsManagerPlan(upgrade.Plan); + + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + + upgrade.AdditionalSeats = 15; + upgrade.AdditionalSmSeats = 10; + upgrade.AdditionalServiceAccounts = 20; + + var result = await sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade); + + await sutProvider.GetDependency().Received(1).ReplaceAndUpdateCacheAsync( + Arg.Is(o => + o.Seats == passwordManagerPlan.BaseSeats + upgrade.AdditionalSeats + && o.SmSeats == secretsManagerPlan.BaseSeats + upgrade.AdditionalSmSeats + && o.SmServiceAccounts == secretsManagerPlan.BaseServiceAccount + upgrade.AdditionalServiceAccounts)); + + Assert.True(result.Item1); + Assert.NotNull(result.Item2); + } + + + [Theory, FreeOrganizationUpgradeCustomize] + [BitAutoData(PlanType.EnterpriseMonthly)] + [BitAutoData(PlanType.EnterpriseAnnually)] + [BitAutoData(PlanType.TeamsMonthly)] + [BitAutoData(PlanType.TeamsAnnually)] + public async Task UpgradePlan_SM_NotEnoughSmSeats_Throws(PlanType planType, Organization organization, OrganizationUpgrade upgrade, + SutProvider sutProvider) + { + upgrade.Plan = planType; + upgrade.AdditionalSeats = 15; + upgrade.AdditionalSmSeats = 1; + upgrade.AdditionalServiceAccounts = 0; + + organization.SmSeats = 2; + + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + sutProvider.GetDependency() + .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id).Returns(2); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade)); + Assert.Contains("Your organization currently has 2 Secrets Manager seats filled. Your new plan only has", exception.Message); + + sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAndUpdateCacheAsync(default); + } + + [Theory, FreeOrganizationUpgradeCustomize] + [BitAutoData(PlanType.EnterpriseMonthly, 201)] + [BitAutoData(PlanType.EnterpriseAnnually, 201)] + [BitAutoData(PlanType.TeamsMonthly, 51)] + [BitAutoData(PlanType.TeamsAnnually, 51)] + public async Task UpgradePlan_SM_NotEnoughServiceAccounts_Throws(PlanType planType, int currentServiceAccounts, + Organization organization, OrganizationUpgrade upgrade, SutProvider sutProvider) + { + upgrade.Plan = planType; + upgrade.AdditionalSeats = 15; + upgrade.AdditionalSmSeats = 1; + upgrade.AdditionalServiceAccounts = 0; + + organization.SmSeats = 1; + organization.SmServiceAccounts = currentServiceAccounts; + + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + sutProvider.GetDependency() + .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id).Returns(1); + sutProvider.GetDependency() + .GetServiceAccountCountByOrganizationIdAsync(organization.Id).Returns(currentServiceAccounts); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade)); + Assert.Contains($"Your organization currently has {currentServiceAccounts} service accounts. Your new plan only allows", exception.Message); + + sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAndUpdateCacheAsync(default); + } +} diff --git a/test/Core.Test/Services/OrganizationServiceTests.cs b/test/Core.Test/Services/OrganizationServiceTests.cs index b208ddecb0b4..b786fdb83e1f 100644 --- a/test/Core.Test/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/Services/OrganizationServiceTests.cs @@ -13,6 +13,7 @@ using Bit.Core.Models.Business; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Models.StaticStore; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -22,6 +23,7 @@ using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Business; using Bit.Core.Tools.Services; +using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -143,55 +145,117 @@ await sutProvider.GetDependency().Received(1) referenceEvent.Users == expectedNewUsersCount)); } - [Theory, BitAutoData] - public async Task UpgradePlan_OrganizationIsNull_Throws(Guid organizationId, OrganizationUpgrade upgrade, - SutProvider sutProvider) - { - sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(Task.FromResult(null)); - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.UpgradePlanAsync(organizationId, upgrade)); - } - [Theory, BitAutoData] - public async Task UpgradePlan_GatewayCustomIdIsNull_Throws(Organization organization, OrganizationUpgrade upgrade, - SutProvider sutProvider) + [Theory] + [BitAutoData(PlanType.EnterpriseAnnually)] + [BitAutoData(PlanType.EnterpriseMonthly)] + [BitAutoData(PlanType.TeamsAnnually)] + [BitAutoData(PlanType.TeamsMonthly)] + public async Task SignUp_SM_Passes(PlanType planType, OrganizationSignup signup, SutProvider sutProvider) { - organization.GatewayCustomerId = string.Empty; - sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade)); - Assert.Contains("no payment method", exception.Message); + signup.Plan = planType; + + var passwordManagerPlan = StaticStore.GetPasswordManagerPlan(signup.Plan); + var secretsManagerPlan = StaticStore.GetSecretsManagerPlan(signup.Plan); + + signup.UseSecretsManager = true; + signup.AdditionalSeats = 15; + signup.AdditionalSmSeats = 10; + signup.AdditionalServiceAccounts = 20; + signup.PaymentMethodType = PaymentMethodType.Card; + signup.PremiumAccessAddon = false; + + var purchaseOrganizationPlan = StaticStore.Plans.Where(x => x.Type == signup.Plan).ToList(); + + var result = await sutProvider.Sut.SignUpAsync(signup); + + await sutProvider.GetDependency().Received(1).CreateAsync( + Arg.Is(o => + o.Seats == passwordManagerPlan.BaseSeats + signup.AdditionalSeats + && o.SmSeats == secretsManagerPlan.BaseSeats + signup.AdditionalSmSeats + && o.SmServiceAccounts == secretsManagerPlan.BaseServiceAccount + signup.AdditionalServiceAccounts)); + await sutProvider.GetDependency().Received(1).CreateAsync( + Arg.Is(o => o.AccessSecretsManager == signup.UseSecretsManager)); + + await sutProvider.GetDependency().Received(1) + .RaiseEventAsync(Arg.Is(referenceEvent => + referenceEvent.Type == ReferenceEventType.Signup && + referenceEvent.PlanName == purchaseOrganizationPlan[0].Name && + referenceEvent.PlanType == purchaseOrganizationPlan[0].Type && + referenceEvent.Seats == result.Item1.Seats && + referenceEvent.Storage == result.Item1.MaxStorageGb)); + // TODO: add reference events for SmSeats and Service Accounts - see AC-1481 + + Assert.NotNull(result); + Assert.NotNull(result.Item1); + Assert.NotNull(result.Item2); + Assert.IsType>(result); + + await sutProvider.GetDependency().Received(1).PurchaseOrganizationAsync( + Arg.Any(), + signup.PaymentMethodType.Value, + signup.PaymentToken, + Arg.Is>(plan => plan.All(p => purchaseOrganizationPlan.Contains(p))), + signup.AdditionalStorageGb, + signup.AdditionalSeats, + signup.PremiumAccessAddon, + signup.TaxInfo, + false, + signup.AdditionalSmSeats.GetValueOrDefault(), + signup.AdditionalServiceAccounts.GetValueOrDefault() + ); } - [Theory, BitAutoData] - public async Task UpgradePlan_AlreadyInPlan_Throws(Organization organization, OrganizationUpgrade upgrade, - SutProvider sutProvider) - { - upgrade.Plan = organization.PlanType; - sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + [Theory] + [BitAutoData] + public async Task SignUpAsync_SecretManager_AdditionalServiceAccounts_NotAllowedByPlan_ShouldThrowException(OrganizationSignup signup, SutProvider sutProvider) + { + signup.AdditionalSmSeats = 0; + signup.AdditionalSeats = 0; + signup.Plan = PlanType.Free; + signup.UseSecretsManager = true; + signup.PaymentMethodType = PaymentMethodType.Card; + signup.PremiumAccessAddon = false; + signup.AdditionalServiceAccounts = 10; + signup.AdditionalStorageGb = 0; + var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade)); - Assert.Contains("already on this plan", exception.Message); + () => sutProvider.Sut.SignUpAsync(signup)); + Assert.Contains("Plan does not allow additional Service Accounts.", exception.Message); } - [Theory, PaidOrganizationCustomize(CheckedPlanType = PlanType.Free), BitAutoData] - public async Task UpgradePlan_UpgradeFromPaidPlan_Throws(Organization organization, OrganizationUpgrade upgrade, - SutProvider sutProvider) + [Theory] + [BitAutoData] + public async Task SignUpAsync_SMSeatsGreatThanPMSeat_ShouldThrowException(OrganizationSignup signup, SutProvider sutProvider) { - sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + signup.AdditionalSmSeats = 100; + signup.AdditionalSeats = 10; + signup.Plan = PlanType.EnterpriseAnnually; + signup.UseSecretsManager = true; + signup.PaymentMethodType = PaymentMethodType.Card; + signup.PremiumAccessAddon = false; + signup.AdditionalServiceAccounts = 10; + var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade)); - Assert.Contains("can only upgrade", exception.Message); + () => sutProvider.Sut.SignUpAsync(signup)); + Assert.Contains("You cannot have more Secrets Manager seats than Password Manager seats", exception.Message); } [Theory] - [FreeOrganizationUpgradeCustomize, BitAutoData] - public async Task UpgradePlan_Passes(Organization organization, OrganizationUpgrade upgrade, - SutProvider sutProvider) + [BitAutoData] + public async Task SignUpAsync_InvalidateServiceAccount_ShouldThrowException(OrganizationSignup signup, SutProvider sutProvider) { - sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); - await sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade); - await sutProvider.GetDependency().Received(1).ReplaceAsync(organization); + signup.AdditionalSmSeats = 10; + signup.AdditionalSeats = 10; + signup.Plan = PlanType.EnterpriseAnnually; + signup.UseSecretsManager = true; + signup.PaymentMethodType = PaymentMethodType.Card; + signup.PremiumAccessAddon = false; + signup.AdditionalServiceAccounts = -10; + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.SignUpAsync(signup)); + Assert.Contains("You can't subtract Service Accounts!", exception.Message); } [Theory] @@ -1469,4 +1533,5 @@ public async Task HasConfirmedOwnersExcept_WithConfirmedProviderUser_IncludeProv Assert.Equal(includeProvider, result); } + } diff --git a/test/Core.Test/Services/StripePaymentServiceTests.cs b/test/Core.Test/Services/StripePaymentServiceTests.cs index d8a67e815cab..cc32a93b800a 100644 --- a/test/Core.Test/Services/StripePaymentServiceTests.cs +++ b/test/Core.Test/Services/StripePaymentServiceTests.cs @@ -32,7 +32,7 @@ public class StripePaymentServiceTests public async void PurchaseOrganizationAsync_Invalid(PaymentMethodType paymentMethodType, SutProvider sutProvider) { var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.PurchaseOrganizationAsync(null, paymentMethodType, null, null, 0, 0, false, null)); + () => sutProvider.Sut.PurchaseOrganizationAsync(null, paymentMethodType, null, null, 0, 0, false, null, false, -1, -1)); Assert.Equal("Payment method is not supported at this time.", exception.Message); } @@ -40,7 +40,7 @@ public async void PurchaseOrganizationAsync_Invalid(PaymentMethodType paymentMet [Theory, BitAutoData] public async void PurchaseOrganizationAsync_Stripe_ProviderOrg_Coupon_Add(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo, bool provider = true) { - var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); var stripeAdapter = sutProvider.GetDependency(); stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer @@ -56,7 +56,7 @@ public async void PurchaseOrganizationAsync_Stripe_ProviderOrg_Coupon_Add(SutPro .BaseServiceUri.CloudRegion .Returns("US"); - var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan, 0, 0, false, taxInfo, provider); + var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plans, 0, 0, false, taxInfo, provider); Assert.Null(result); Assert.Equal(GatewayType.Stripe, organization.Gateway); @@ -91,10 +91,67 @@ await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is sutProvider, Organization organization, + string paymentToken, TaxInfo taxInfo, bool provider = true) + { + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer + { + Id = "C-1", + }); + stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription + { + Id = "S-1", + CurrentPeriodEnd = DateTime.Today.AddDays(10), + + }); + sutProvider.GetDependency() + .BaseServiceUri.CloudRegion + .Returns("US"); + + var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plans, 1, 1, + false, taxInfo, provider, 1, 1); + + Assert.Null(result); + Assert.Equal(GatewayType.Stripe, organization.Gateway); + Assert.Equal("C-1", organization.GatewayCustomerId); + Assert.Equal("S-1", organization.GatewaySubscriptionId); + Assert.True(organization.Enabled); + Assert.Equal(DateTime.Today.AddDays(10), organization.ExpirationDate); + + await stripeAdapter.Received().CustomerCreateAsync(Arg.Is(c => + c.Description == organization.BusinessName && + c.Email == organization.BillingEmail && + c.Source == paymentToken && + c.PaymentMethod == null && + c.Coupon == "msp-discount-35" && + c.Metadata.Count == 1 && + c.Metadata["region"] == "US" && + c.InvoiceSettings.DefaultPaymentMethod == null && + c.Address.Country == taxInfo.BillingAddressCountry && + c.Address.PostalCode == taxInfo.BillingAddressPostalCode && + c.Address.Line1 == taxInfo.BillingAddressLine1 && + c.Address.Line2 == taxInfo.BillingAddressLine2 && + c.Address.City == taxInfo.BillingAddressCity && + c.Address.State == taxInfo.BillingAddressState && + c.TaxIdData == null + )); + + await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is(s => + s.Customer == "C-1" && + s.Expand[0] == "latest_invoice.payment_intent" && + s.Metadata[organization.GatewayIdField()] == organization.Id.ToString() && + s.Items.Count == 4 + )); + } + [Theory, BitAutoData] public async void PurchaseOrganizationAsync_Stripe(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) { - var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); var stripeAdapter = sutProvider.GetDependency(); stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer @@ -110,7 +167,8 @@ public async void PurchaseOrganizationAsync_Stripe(SutProvider(c => c.Description == organization.BusinessName && c.Email == organization.BillingEmail && @@ -143,14 +200,14 @@ await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) { - var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); + var plans = StaticStore.PasswordManagerPlans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); paymentToken = "pm_" + paymentToken; var stripeAdapter = sutProvider.GetDependency(); @@ -167,7 +224,7 @@ public async void PurchaseOrganizationAsync_Stripe_PM(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) { - var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); var stripeAdapter = sutProvider.GetDependency(); stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer @@ -223,7 +280,37 @@ public async void PurchaseOrganizationAsync_Stripe_TaxRate(SutProvider { new() { Id = "T-1" } }); - var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan, 0, 0, false, taxInfo); + var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plans, 0, 0, false, taxInfo); + + Assert.Null(result); + + await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is(s => + s.DefaultTaxRates.Count == 1 && + s.DefaultTaxRates[0] == "T-1" + )); + } + + [Theory, BitAutoData] + public async void PurchaseOrganizationAsync_Stripe_TaxRate_SM(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) + { + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer + { + Id = "C-1", + }); + stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription + { + Id = "S-1", + CurrentPeriodEnd = DateTime.Today.AddDays(10), + }); + sutProvider.GetDependency().GetByLocationAsync(Arg.Is(t => + t.Country == taxInfo.BillingAddressCountry && t.PostalCode == taxInfo.BillingAddressPostalCode)) + .Returns(new List { new() { Id = "T-1" } }); + + var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plans, 2, 2, + false, taxInfo, false, 2, 2); Assert.Null(result); @@ -236,7 +323,7 @@ await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) { - var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); + var plan = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); paymentToken = "pm_" + paymentToken; var stripeAdapter = sutProvider.GetDependency(); @@ -266,10 +353,44 @@ public async void PurchaseOrganizationAsync_Stripe_Declined(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) + { + var plan = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); + paymentToken = "pm_" + paymentToken; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer + { + Id = "C-1", + }); + stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription + { + Id = "S-1", + CurrentPeriodEnd = DateTime.Today.AddDays(10), + Status = "incomplete", + LatestInvoice = new Stripe.Invoice + { + PaymentIntent = new Stripe.PaymentIntent + { + Status = "requires_payment_method", + }, + }, + }); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan, + 1, 12, false, taxInfo, false, 10, 10)); + + Assert.Equal("Payment method was declined.", exception.Message); + + await stripeAdapter.Received(1).CustomerDeleteAsync("C-1"); + } + [Theory, BitAutoData] public async void PurchaseOrganizationAsync_Stripe_RequiresAction(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) { - var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); var stripeAdapter = sutProvider.GetDependency(); stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer @@ -291,7 +412,39 @@ public async void PurchaseOrganizationAsync_Stripe_RequiresAction(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) + { + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer + { + Id = "C-1", + }); + stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription + { + Id = "S-1", + CurrentPeriodEnd = DateTime.Today.AddDays(10), + Status = "incomplete", + LatestInvoice = new Stripe.Invoice + { + PaymentIntent = new Stripe.PaymentIntent + { + Status = "requires_action", + ClientSecret = "clientSecret", + }, + }, + }); + + var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plans, + 10, 10, false, taxInfo, false, 10, 10); Assert.Equal("clientSecret", result); Assert.False(organization.Enabled); @@ -300,7 +453,7 @@ public async void PurchaseOrganizationAsync_Stripe_RequiresAction(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) { - var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); var stripeAdapter = sutProvider.GetDependency(); stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer @@ -327,7 +480,7 @@ public async void PurchaseOrganizationAsync_Paypal(SutProvider(); braintreeGateway.Customer.CreateAsync(default).ReturnsForAnyArgs(customerResult); - var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plan, 0, 0, false, taxInfo); + var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plans, 0, 0, false, taxInfo); Assert.Null(result); Assert.Equal(GatewayType.Stripe, organization.Gateway); @@ -361,10 +514,85 @@ await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) + { + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); + var passwordManagerPlan = plans.Single(p => p.BitwardenProduct == BitwardenProductType.PasswordManager); + var secretsManagerPlan = plans.Single(p => p.BitwardenProduct == BitwardenProductType.SecretsManager); + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer + { + Id = "C-1", + }); + stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription + { + Id = "S-1", + CurrentPeriodEnd = DateTime.Today.AddDays(10), + }); + + var customer = Substitute.For(); + customer.Id.ReturnsForAnyArgs("Braintree-Id"); + customer.PaymentMethods.ReturnsForAnyArgs(new[] { Substitute.For() }); + var customerResult = Substitute.For>(); + customerResult.IsSuccess().Returns(true); + customerResult.Target.ReturnsForAnyArgs(customer); + + var braintreeGateway = sutProvider.GetDependency(); + braintreeGateway.Customer.CreateAsync(default).ReturnsForAnyArgs(customerResult); + + sutProvider.GetDependency() + .BaseServiceUri.CloudRegion + .Returns("US"); + + var additionalStorage = (short)2; + var additionalSeats = 10; + var additionalSmSeats = 5; + var additionalServiceAccounts = 20; + var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plans, + additionalStorage, additionalSeats, false, taxInfo, false, additionalSmSeats, additionalServiceAccounts); + + Assert.Null(result); + Assert.Equal(GatewayType.Stripe, organization.Gateway); + Assert.Equal("C-1", organization.GatewayCustomerId); + Assert.Equal("S-1", organization.GatewaySubscriptionId); + Assert.True(organization.Enabled); + Assert.Equal(DateTime.Today.AddDays(10), organization.ExpirationDate); + + await stripeAdapter.Received().CustomerCreateAsync(Arg.Is(c => + c.Description == organization.BusinessName && + c.Email == organization.BillingEmail && + c.PaymentMethod == null && + c.Metadata.Count == 2 && + c.Metadata["region"] == "US" && + c.Metadata["btCustomerId"] == "Braintree-Id" && + c.InvoiceSettings.DefaultPaymentMethod == null && + c.Address.Country == taxInfo.BillingAddressCountry && + c.Address.PostalCode == taxInfo.BillingAddressPostalCode && + c.Address.Line1 == taxInfo.BillingAddressLine1 && + c.Address.Line2 == taxInfo.BillingAddressLine2 && + c.Address.City == taxInfo.BillingAddressCity && + c.Address.State == taxInfo.BillingAddressState && + c.TaxIdData == null + )); + + await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is(s => + s.Customer == "C-1" && + s.Expand[0] == "latest_invoice.payment_intent" && + s.Metadata[organization.GatewayIdField()] == organization.Id.ToString() && + s.Items.Count == 4 && + s.Items.Count(i => i.Plan == passwordManagerPlan.StripeSeatPlanId && i.Quantity == additionalSeats) == 1 && + s.Items.Count(i => i.Plan == passwordManagerPlan.StripeStoragePlanId && i.Quantity == additionalStorage) == 1 && + s.Items.Count(i => i.Plan == secretsManagerPlan.StripeSeatPlanId && i.Quantity == additionalSmSeats) == 1 && + s.Items.Count(i => i.Plan == secretsManagerPlan.StripeServiceAccountPlanId && i.Quantity == additionalServiceAccounts) == 1 + )); + } + [Theory, BitAutoData] public async void PurchaseOrganizationAsync_Paypal_FailedCreate(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) { - var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); var customerResult = Substitute.For>(); customerResult.IsSuccess().Returns(false); @@ -373,7 +601,25 @@ public async void PurchaseOrganizationAsync_Paypal_FailedCreate(SutProvider( - () => sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plan, 0, 0, false, taxInfo)); + () => sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plans, 0, 0, false, taxInfo)); + + Assert.Equal("Failed to create PayPal customer record.", exception.Message); + } + + [Theory, BitAutoData] + public async void PurchaseOrganizationAsync_SM_Paypal_FailedCreate(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) + { + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); + + var customerResult = Substitute.For>(); + customerResult.IsSuccess().Returns(false); + + var braintreeGateway = sutProvider.GetDependency(); + braintreeGateway.Customer.CreateAsync(default).ReturnsForAnyArgs(customerResult); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plans, + 1, 1, false, taxInfo, false, 8, 8)); Assert.Equal("Failed to create PayPal customer record.", exception.Message); } @@ -381,7 +627,7 @@ public async void PurchaseOrganizationAsync_Paypal_FailedCreate(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) { - var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually); + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); paymentToken = "pm_" + paymentToken; var stripeAdapter = sutProvider.GetDependency(); @@ -414,7 +660,7 @@ public async void PurchaseOrganizationAsync_PayPal_Declined(SutProvider( - () => sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plan, 0, 0, false, taxInfo)); + () => sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plans, 0, 0, false, taxInfo)); Assert.Equal("Payment method was declined.", exception.Message); @@ -443,8 +689,55 @@ public async void UpgradeFreeOrganizationAsync_Success(SutProvider p.Type == PlanType.EnterpriseAnnually); - var result = await sutProvider.Sut.UpgradeFreeOrganizationAsync(organization, plan, 0, 0, false, taxInfo); + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); + + var upgrade = new OrganizationUpgrade() + { + AdditionalStorageGb = 0, + AdditionalSeats = 0, + PremiumAccessAddon = false, + TaxInfo = taxInfo, + AdditionalSmSeats = 0, + AdditionalServiceAccounts = 0 + }; + var result = await sutProvider.Sut.UpgradeFreeOrganizationAsync(organization, plans, upgrade); + + Assert.Null(result); + } + + [Theory, BitAutoData] + public async void UpgradeFreeOrganizationAsync_SM_Success(SutProvider sutProvider, + Organization organization, TaxInfo taxInfo) + { + organization.GatewaySubscriptionId = null; + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.CustomerGetAsync(default).ReturnsForAnyArgs(new Stripe.Customer + { + Id = "C-1", + Metadata = new Dictionary + { + { "btCustomerId", "B-123" }, + } + }); + stripeAdapter.InvoiceUpcomingAsync(default).ReturnsForAnyArgs(new Stripe.Invoice + { + PaymentIntent = new Stripe.PaymentIntent { Status = "requires_payment_method", }, + AmountDue = 0 + }); + stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription { }); + + var upgrade = new OrganizationUpgrade() + { + AdditionalStorageGb = 1, + AdditionalSeats = 10, + PremiumAccessAddon = false, + TaxInfo = taxInfo, + AdditionalSmSeats = 5, + AdditionalServiceAccounts = 50 + }; + + var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList(); + var result = await sutProvider.Sut.UpgradeFreeOrganizationAsync(organization, plans, upgrade); Assert.Null(result); } diff --git a/util/Migrator/DbScripts/2023-07-24_00_OrgUserReadOccupiedSmSeatCountByOrgId.sql b/util/Migrator/DbScripts/2023-07-24_00_OrgUserReadOccupiedSmSeatCountByOrgId.sql new file mode 100644 index 000000000000..bb31b1e6007b --- /dev/null +++ b/util/Migrator/DbScripts/2023-07-24_00_OrgUserReadOccupiedSmSeatCountByOrgId.sql @@ -0,0 +1,32 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_ReadOccupiedSmSeatCountByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + COUNT(1) + FROM + [dbo].[OrganizationUserView] + WHERE + OrganizationId = @OrganizationId + AND Status >= 0 --Invited + AND AccessSecretsManager = 1 +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[ServiceAccount_ReadCountByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + COUNT(1) + FROM + [dbo].[ServiceAccount] + WHERE + OrganizationId = @OrganizationId +END +GO + From 764468adc104d61025bbdc1a070b3225e824d93d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 25 Jul 2023 19:09:05 +0000 Subject: [PATCH 2/2] Bumped version to 2023.7.1 (#3133) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> --- Directory.Build.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index beede21247f5..cbcc3a9ce233 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,7 +2,7 @@ net6.0 - 2023.7.0 + 2023.7.1 Bit.$(MSBuildProjectName) true enable @@ -63,4 +63,4 @@ - + \ No newline at end of file