diff --git a/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.html.hbs b/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.html.hbs index 1f4300c23e..efeab22b9b 100644 --- a/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.html.hbs +++ b/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.html.hbs @@ -6,7 +6,7 @@ - Your organization has reached the Secrets Manager machine accounts limit of {{MaxServiceAccountsCount}}. New machine accounts cannot be created + Your organization has reached the Secrets Manager machine accounts limit of {{MaxServiceAccountsCount}}. New machine accounts cannot be created. BasicTextLayout}} -Your organization has reached the Secrets Manager machine accounts limit of {{MaxServiceAccountsCount}}. New machine accounts cannot be created +Your organization has reached the Secrets Manager machine accounts limit of {{MaxServiceAccountsCount}}. New machine 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/SecretsManagerSubscriptionUpdate.cs b/src/Core/Models/Business/SecretsManagerSubscriptionUpdate.cs index d85925db34..c813fd5b45 100644 --- a/src/Core/Models/Business/SecretsManagerSubscriptionUpdate.cs +++ b/src/Core/Models/Business/SecretsManagerSubscriptionUpdate.cs @@ -50,11 +50,7 @@ public class SecretsManagerSubscriptionUpdate public bool MaxAutoscaleSmSeatsChanged => MaxAutoscaleSmSeats != Organization.MaxAutoscaleSmSeats; public bool MaxAutoscaleSmServiceAccountsChanged => MaxAutoscaleSmServiceAccounts != Organization.MaxAutoscaleSmServiceAccounts; - public bool SmSeatAutoscaleLimitReached => SmSeats.HasValue && MaxAutoscaleSmSeats.HasValue && SmSeats == MaxAutoscaleSmSeats; - public bool SmServiceAccountAutoscaleLimitReached => SmServiceAccounts.HasValue && - MaxAutoscaleSmServiceAccounts.HasValue && - SmServiceAccounts == MaxAutoscaleSmServiceAccounts; public SecretsManagerSubscriptionUpdate(Organization organization, Plan plan, bool autoscaling) { diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs index 88b995be64..f7d6f0e5a2 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs @@ -55,15 +55,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs await FinalizeSubscriptionAdjustmentAsync(update); - if (update.SmSeatAutoscaleLimitReached) - { - await SendSeatLimitEmailAsync(update.Organization); - } - - if (update.SmServiceAccountAutoscaleLimitReached) - { - await SendServiceAccountLimitEmailAsync(update.Organization); - } + await ValidateAutoScaleLimitsAsync(update); } private async Task FinalizeSubscriptionAdjustmentAsync(SecretsManagerSubscriptionUpdate update) @@ -100,7 +92,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs OrganizationUserType.Owner)) .Select(u => u.Email).Distinct(); - await _mailService.SendSecretsManagerMaxSeatLimitReachedEmailAsync(organization, organization.MaxAutoscaleSmSeats.Value, ownerEmails); + await _mailService.SendSecretsManagerMaxSeatLimitReachedEmailAsync(organization, organization.MaxAutoscaleSmSeats!.Value, ownerEmails); } catch (Exception e) @@ -117,7 +109,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs OrganizationUserType.Owner)) .Select(u => u.Email).Distinct(); - await _mailService.SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(organization, organization.MaxAutoscaleSmServiceAccounts.Value, ownerEmails); + await _mailService.SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(organization, organization.MaxAutoscaleSmServiceAccounts!.Value, ownerEmails); } catch (Exception e) @@ -197,7 +189,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs throw new BadRequestException("Organization has no Secrets Manager seat limit, no need to adjust seats"); } - if (update.Autoscaling && update.SmSeats.Value < organization.SmSeats.Value) + if (update.Autoscaling && update.SmSeats!.Value < organization.SmSeats.Value) { throw new BadRequestException("Cannot use autoscaling to subtract seats."); } @@ -211,7 +203,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs } // Check autoscale maximum seats - if (update.MaxAutoscaleSmSeats.HasValue && update.SmSeats.Value > update.MaxAutoscaleSmSeats.Value) + if (update.MaxAutoscaleSmSeats.HasValue && update.SmSeats!.Value > update.MaxAutoscaleSmSeats.Value) { var message = update.Autoscaling ? "Secrets Manager seat limit has been reached." @@ -220,7 +212,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs } // Check minimum seats included with plan - if (plan.SecretsManager.BaseSeats > update.SmSeats.Value) + if (plan.SecretsManager.BaseSeats > update.SmSeats!.Value) { throw new BadRequestException($"Plan has a minimum of {plan.SecretsManager.BaseSeats} Secrets Manager seats."); } @@ -260,7 +252,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs throw new BadRequestException("Organization has no machine accounts limit, no need to adjust machine accounts"); } - if (update.Autoscaling && update.SmServiceAccounts.Value < organization.SmServiceAccounts.Value) + if (update.Autoscaling && update.SmServiceAccounts!.Value < organization.SmServiceAccounts.Value) { throw new BadRequestException("Cannot use autoscaling to subtract machine accounts."); } @@ -276,7 +268,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs // Check autoscale maximum service accounts if (update.MaxAutoscaleSmServiceAccounts.HasValue && - update.SmServiceAccounts.Value > update.MaxAutoscaleSmServiceAccounts.Value) + update.SmServiceAccounts!.Value > update.MaxAutoscaleSmServiceAccounts.Value) { var message = update.Autoscaling ? "Secrets Manager machine account limit has been reached." @@ -285,7 +277,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs } // Check minimum service accounts included with plan - if (plan.SecretsManager.BaseServiceAccount > update.SmServiceAccounts.Value) + if (plan.SecretsManager.BaseServiceAccount > update.SmServiceAccounts!.Value) { throw new BadRequestException($"Plan has a minimum of {plan.SecretsManager.BaseServiceAccount} machine accounts."); } @@ -379,4 +371,55 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs await _eventService.LogOrganizationEventAsync(org, orgEvent.Value); } } + + private async Task ValidateAutoScaleLimitsAsync(SecretsManagerSubscriptionUpdate update) + { + var (smSeatAutoScaleLimitReached, smServiceAccountsLimitReached) = await AreAutoscaleLimitsReachedAsync(update); + + if (smSeatAutoScaleLimitReached) + { + await SendSeatLimitEmailAsync(update.Organization); + } + + if (smServiceAccountsLimitReached) + { + await SendServiceAccountLimitEmailAsync(update.Organization); + } + } + + private async Task<(bool, bool)> AreAutoscaleLimitsReachedAsync(SecretsManagerSubscriptionUpdate update) + { + var smSeatAutoScaleLimitReached = false; + var smServiceAccountsLimitReached = false; + + var (occupiedSmSeats, occupiedSmServiceAccounts) = await GetOccupiedSmSeatsAndServiceAccountsAsync(update.Organization.Id); + + if (occupiedSmSeats > 0 + && update.MaxAutoscaleSmSeats is not null + && occupiedSmSeats == update.MaxAutoscaleSmSeats!.Value) + { + smSeatAutoScaleLimitReached = true; + } + + if (occupiedSmServiceAccounts > 0 + && update.MaxAutoscaleSmServiceAccounts is not null + && occupiedSmServiceAccounts == update.MaxAutoscaleSmServiceAccounts!.Value) + { + smServiceAccountsLimitReached = true; + } + + return (smSeatAutoScaleLimitReached, smServiceAccountsLimitReached); + } + + /// + /// Requests the number of Secret Manager seats and service accounts are currently used by the organization + /// + /// The id of the organization + /// A tuple containing the occupied seats and the occupied service account counts + private async Task<(int, int)> GetOccupiedSmSeatsAndServiceAccountsAsync(Guid organizationId) + { + var occupiedSmSeatsTask = _organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organizationId); + var occupiedServiceAccountsTask = _serviceAccountRepository.GetServiceAccountCountByOrganizationIdAsync(organizationId); + return (await occupiedSmSeatsTask, await occupiedServiceAccountsTask); + } } diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs index 50f51da7d0..8b00741215 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs @@ -1,7 +1,9 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.StaticStore; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions; using Bit.Core.Repositories; @@ -99,8 +101,13 @@ public class UpdateSecretsManagerSubscriptionCommandTests org.MaxAutoscaleSmServiceAccounts == updateMaxAutoscaleSmServiceAccounts), sutProvider); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().SendSecretsManagerMaxSeatLimitReachedEmailAsync(default, default, default); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(default, default, default); + await sutProvider + .GetDependency() + .DidNotReceiveWithAnyArgs() + .SendSecretsManagerMaxSeatLimitReachedEmailAsync(default, default, default); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(default, default, default); } [Theory] @@ -266,11 +273,13 @@ public class UpdateSecretsManagerSubscriptionCommandTests [Theory] [BitAutoData] - public async Task UpdateSubscriptionAsync_UpdateSeatsToAutoscaleLimit_EmailsOwners( + public async Task UpdateSubscriptionAsync_UpdateSeatCount_AndExistingSeatsDoNotReachAutoscaleLimit_NoEmailSent( Organization organization, SutProvider sutProvider) { + // Arrange const int seatCount = 10; + var existingSeatCount = 9; // Make sure Password Manager seats is greater or equal to Secrets Manager seats organization.Seats = seatCount; @@ -281,11 +290,66 @@ public class UpdateSecretsManagerSubscriptionCommandTests SmSeats = seatCount, MaxAutoscaleSmSeats = seatCount }; + sutProvider.GetDependency() + .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id) + .Returns(existingSeatCount); + // Act await sutProvider.Sut.UpdateSubscriptionAsync(update); - await sutProvider.GetDependency().Received(1).SendSecretsManagerMaxSeatLimitReachedEmailAsync( - organization, organization.MaxAutoscaleSmSeats.Value, Arg.Any>()); + // Assert + + // Currently being called once each for different validation methods + await sutProvider.GetDependency() + .Received(2) + .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SendSecretsManagerMaxSeatLimitReachedEmailAsync(Arg.Any(), Arg.Any(), Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task UpdateSubscriptionAsync_ExistingSeatsReachAutoscaleLimit_EmailOwners( + Organization organization, + SutProvider sutProvider) + { + // Arrange + const int seatCount = 10; + const int existingSeatCount = 10; + var ownerDetailsList = new List { new() { Email = "owner@example.com" } }; + + // The amount of seats for users in an organization + var plan = StaticStore.GetPlan(organization.PlanType); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false) + { + SmSeats = seatCount, + MaxAutoscaleSmSeats = seatCount + }; + + sutProvider.GetDependency() + .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id) + .Returns(existingSeatCount); + sutProvider.GetDependency() + .GetManyByMinimumRoleAsync(organization.Id, OrganizationUserType.Owner) + .Returns(ownerDetailsList); + + // Act + await sutProvider.Sut.UpdateSubscriptionAsync(update); + + // Assert + + // Currently being called once each for different validation methods + await sutProvider.GetDependency() + .Received(2) + .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id); + + await sutProvider.GetDependency() + .Received(1) + .SendSecretsManagerMaxSeatLimitReachedEmailAsync(Arg.Is(organization), + Arg.Is(seatCount), + Arg.Is>(emails => emails.Contains(ownerDetailsList[0].Email))); } [Theory] @@ -413,21 +477,78 @@ public class UpdateSecretsManagerSubscriptionCommandTests [Theory] [BitAutoData] - public async Task UpdateSubscriptionAsync_UpdateServiceAccountsToAutoscaleLimit_EmailsOwners( + public async Task UpdateSubscriptionAsync_UpdateServiceAccounts_AndExistingServiceAccountsCountDoesNotReachAutoscaleLimit_NoEmailSent( Organization organization, SutProvider sutProvider) { + // Arrange + var smServiceAccounts = 300; + var existingServiceAccountCount = 299; + var plan = StaticStore.GetPlan(organization.PlanType); var update = new SecretsManagerSubscriptionUpdate(organization, plan, false) { - SmServiceAccounts = 300, - MaxAutoscaleSmServiceAccounts = 300 + SmServiceAccounts = smServiceAccounts, + MaxAutoscaleSmServiceAccounts = smServiceAccounts }; + sutProvider.GetDependency() + .GetServiceAccountCountByOrganizationIdAsync(organization.Id) + .Returns(existingServiceAccountCount); + // Act await sutProvider.Sut.UpdateSubscriptionAsync(update); - await sutProvider.GetDependency().Received(1).SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync( - organization, organization.MaxAutoscaleSmServiceAccounts.Value, Arg.Any>()); + // Assert + await sutProvider.GetDependency() + .Received(1) + .GetServiceAccountCountByOrganizationIdAsync(organization.Id); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync( + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task UpdateSubscriptionAsync_ExistingServiceAccountsReachAutoscaleLimit_EmailOwners( + Organization organization, + SutProvider sutProvider) + { + var smServiceAccounts = 300; + var plan = StaticStore.GetPlan(organization.PlanType); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false) + { + SmServiceAccounts = smServiceAccounts, + MaxAutoscaleSmServiceAccounts = smServiceAccounts + }; + var ownerDetailsList = new List { new() { Email = "owner@example.com" } }; + + + sutProvider.GetDependency() + .GetServiceAccountCountByOrganizationIdAsync(organization.Id) + .Returns(smServiceAccounts); + sutProvider.GetDependency() + .GetManyByMinimumRoleAsync(organization.Id, OrganizationUserType.Owner) + .Returns(ownerDetailsList); + + + // Act + await sutProvider.Sut.UpdateSubscriptionAsync(update); + + // Assert + + await sutProvider.GetDependency() + .Received(1) + .GetServiceAccountCountByOrganizationIdAsync(organization.Id); + + await sutProvider.GetDependency() + .Received(1) + .SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(Arg.Is(organization), + Arg.Is(smServiceAccounts), + Arg.Is>(emails => emails.Contains(ownerDetailsList[0].Email))); } [Theory]