From cb2860c0c1beeb12d35c5166442d419535dd871f Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Fri, 25 Apr 2025 05:54:54 -0500 Subject: [PATCH 01/27] chore: update public api members delete xmldoc, refs PM-20520 (#5708) --- src/Api/AdminConsole/Public/Controllers/MembersController.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Api/AdminConsole/Public/Controllers/MembersController.cs b/src/Api/AdminConsole/Public/Controllers/MembersController.cs index 76bd29d38e..92e5071801 100644 --- a/src/Api/AdminConsole/Public/Controllers/MembersController.cs +++ b/src/Api/AdminConsole/Public/Controllers/MembersController.cs @@ -221,8 +221,7 @@ public class MembersController : Controller /// Remove a member. /// /// - /// Permanently removes a member from the organization. This cannot be undone. - /// The user account will still remain. The user is only removed from the organization. + /// Removes a member from the organization. This cannot be undone. The user account will still remain. /// /// The identifier of the member to be removed. [HttpDelete("{id}")] From 5184d109954d9cd66df1e91b8ec1765df678396c Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Fri, 25 Apr 2025 13:06:06 -0400 Subject: [PATCH 02/27] Create customer for client organization that was converted to BU upon unlinking (#5706) --- .../Providers/RemoveOrganizationFromProviderCommand.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs index 2c34e57a92..9a62be8dd5 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs @@ -110,9 +110,14 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv IEnumerable organizationOwnerEmails) { if (provider.IsBillable() && - organization.IsValidClient() && - !string.IsNullOrEmpty(organization.GatewayCustomerId)) + organization.IsValidClient()) { + // An organization converted to a business unit will not have a Customer since it was given to the business unit. + if (string.IsNullOrEmpty(organization.GatewayCustomerId)) + { + await _providerBillingService.CreateCustomerForClientOrganization(provider, organization); + } + var customer = await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions { Description = string.Empty, From 9a7fddd77c38c1013ae9e58e23127544afe92f3d Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Fri, 25 Apr 2025 13:15:26 -0400 Subject: [PATCH 03/27] Removed feature flag (#5707) --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index e4a8465bcb..b6cc079600 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -195,7 +195,6 @@ public static class FeatureFlagKeys public const string PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form"; public const string NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss"; public const string NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss"; - public const string VaultBulkManagementAction = "vault-bulk-management-action"; public const string RestrictProviderAccess = "restrict-provider-access"; public const string SecurityTasks = "security-tasks"; public const string CipherKeyEncryption = "cipher-key-encryption"; From ad19d3d3ade994e6a167b2b17d2c065f820f8696 Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Mon, 28 Apr 2025 08:20:47 -0400 Subject: [PATCH 04/27] [PM-17562] Add feature flag for event-based organization integrations (#5710) * Added EventBasedOrganizationIntegrations feature flag; Added enforcement of flag at the API layer * [PM-17562] Use EventBasedOrganizationIntegrations feature flag to turn on/off event queue * Optimization that removes the need for EventRouteService (from @justindbaur) --- ...ationIntegrationConfigurationController.cs | 3 +++ .../OrganizationIntegrationController.cs | 3 +++ .../Controllers/SlackIntegrationController.cs | 3 +++ src/Core/Constants.cs | 1 + src/Events/Startup.cs | 23 +++++++++++++++---- .../Utilities/ServiceCollectionExtensions.cs | 23 +++++++++++++++---- 6 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs b/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs index da0151067b..848098ef00 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs @@ -1,13 +1,16 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Core; using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Repositories; +using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Bit.Api.AdminConsole.Controllers; +[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)] [Route("organizations/{organizationId:guid}/integrations/{integrationId:guid}/configurations")] [Authorize("Application")] public class OrganizationIntegrationConfigurationController( diff --git a/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs b/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs index cb96be97c7..3b52e7a8da 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs @@ -1,8 +1,10 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Core; using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Repositories; +using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -10,6 +12,7 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Api.AdminConsole.Controllers; +[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)] [Route("organizations/{organizationId:guid}/integrations")] [Authorize("Application")] public class OrganizationIntegrationController( diff --git a/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs b/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs index ed6971911b..a8bef10dc6 100644 --- a/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs +++ b/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.Context; using Bit.Core.Enums; @@ -7,11 +8,13 @@ using Bit.Core.Exceptions; using Bit.Core.Models.Data.Integrations; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Bit.Api.AdminConsole.Controllers; +[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)] [Route("organizations/{organizationId:guid}/integrations/slack")] [Authorize("Application")] public class SlackIntegrationController( diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index b6cc079600..5a7ed13843 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -108,6 +108,7 @@ public static class FeatureFlagKeys public const string PolicyRequirements = "pm-14439-policy-requirements"; public const string SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility"; public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast"; + public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations"; /* Auth Team */ public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence"; diff --git a/src/Events/Startup.cs b/src/Events/Startup.cs index 34ffed4ee6..bb37e240c8 100644 --- a/src/Events/Startup.cs +++ b/src/Events/Startup.cs @@ -1,4 +1,5 @@ using System.Globalization; +using Bit.Core; using Bit.Core.AdminConsole.Services.Implementations; using Bit.Core.AdminConsole.Services.NoopImplementations; using Bit.Core.Context; @@ -62,33 +63,45 @@ public class Startup { services.AddSingleton(); } - services.AddScoped(); + if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString)) { + services.AddKeyedSingleton("storage"); + if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.ConnectionString) && CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.TopicName)) { - services.AddSingleton(); + services.AddKeyedSingleton("broadcast"); } else { - services.AddSingleton(); + services.AddKeyedSingleton("broadcast"); } } else { + services.AddKeyedSingleton("storage"); + if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.HostName) && CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Username) && CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Password) && CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.ExchangeName)) { - services.AddSingleton(); + services.AddKeyedSingleton("broadcast"); } else { - services.AddSingleton(); + services.AddKeyedSingleton("broadcast"); } } + services.AddScoped(sp => + { + var featureService = sp.GetRequiredService(); + var key = featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations) + ? "broadcast" : "storage"; + return sp.GetRequiredKeyedService(key); + }); + services.AddScoped(); services.AddOptionality(); diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 8d48fc86ef..9883e6db47 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using System.Security.Claims; using System.Security.Cryptography.X509Certificates; using AspNetCoreRateLimit; using Azure.Storage.Queues; +using Bit.Core; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.Services; @@ -332,34 +333,46 @@ public static class ServiceCollectionExtensions if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString)) { + services.AddKeyedSingleton("storage"); + if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.ConnectionString) && CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.TopicName)) { - services.AddSingleton(); + services.AddKeyedSingleton("broadcast"); } else { - services.AddSingleton(); + services.AddKeyedSingleton("broadcast"); } } else if (globalSettings.SelfHosted) { + services.AddKeyedSingleton("storage"); + if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.HostName) && CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Username) && CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Password) && CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.ExchangeName)) { - services.AddSingleton(); + services.AddKeyedSingleton("broadcast"); } else { - services.AddSingleton(); + services.AddKeyedSingleton("broadcast"); } } else { - services.AddSingleton(); + services.AddKeyedSingleton("storage"); + services.AddKeyedSingleton("broadcast"); } + services.AddScoped(sp => + { + var featureService = sp.GetRequiredService(); + var key = featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations) + ? "broadcast" : "storage"; + return sp.GetRequiredKeyedService(key); + }); if (CoreHelpers.SettingHasValue(globalSettings.Attachment.ConnectionString)) { From 60f61893140434f61e34f4aa4d8e240e699d4cdc Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Mon, 28 Apr 2025 16:41:49 +0200 Subject: [PATCH 05/27] Move feature flags owned by Data Insights and Reporting team into their own section (#5691) Co-authored-by: Daniel James Smith --- src/Core/Constants.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 5a7ed13843..36c054b55f 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -151,6 +151,10 @@ public static class FeatureFlagKeys public const string PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion"; public const string PM199566_UpdateMSPToChargeAutomatically = "pm-199566-update-msp-to-charge-automatically"; + /* Data Insights and Reporting Team */ + public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application"; + public const string EnableRiskInsightsNotifications = "enable-risk-insights-notifications"; + /* Key Management Team */ public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; public const string PM4154BulkEncryptionService = "PM-4154-bulk-encryption-service"; @@ -187,8 +191,6 @@ public static class FeatureFlagKeys /* Tools Team */ public const string ItemShare = "item-share"; - public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application"; - public const string EnableRiskInsightsNotifications = "enable-risk-insights-notifications"; public const string DesktopSendUIRefresh = "desktop-send-ui-refresh"; /* Vault Team */ From 12fc9dffd4c6ed7e035a310836d599663c41d888 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Mon, 28 Apr 2025 09:55:55 -0500 Subject: [PATCH 06/27] [PM-20586] - Fixing allowing seats to increase to limit. (#5705) --- .../PasswordManager/InviteUsersPasswordManagerValidator.cs | 2 +- .../PasswordManager/PasswordManagerSubscriptionUpdate.cs | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs index 1867a2808e..6a8ec8e6d3 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs @@ -44,7 +44,7 @@ public class InviteUsersPasswordManagerValidator( return new Invalid(new PasswordManagerMustHaveSeatsError(subscriptionUpdate)); } - if (subscriptionUpdate.MaxSeatsReached) + if (subscriptionUpdate.MaxSeatsExceeded) { return new Invalid( new PasswordManagerSeatLimitHasBeenReachedError(subscriptionUpdate)); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/PasswordManagerSubscriptionUpdate.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/PasswordManagerSubscriptionUpdate.cs index f6126fd8f5..d7c61c6c81 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/PasswordManagerSubscriptionUpdate.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/PasswordManagerSubscriptionUpdate.cs @@ -48,6 +48,11 @@ public class PasswordManagerSubscriptionUpdate /// public bool MaxSeatsReached => UpdatedSeatTotal.HasValue && MaxAutoScaleSeats.HasValue && UpdatedSeatTotal.Value >= MaxAutoScaleSeats.Value; + /// + /// If the new seat total exceeds the organization's auto-scale seat limit + /// + public bool MaxSeatsExceeded => UpdatedSeatTotal.HasValue && MaxAutoScaleSeats.HasValue && UpdatedSeatTotal.Value > MaxAutoScaleSeats.Value; + public Plan.PasswordManagerPlanFeatures PasswordManagerPlan { get; } public InviteOrganization InviteOrganization { get; } From 07a2c0e9d25e86524ce6bc10139a4f07bea9cb5f Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Mon, 28 Apr 2025 19:21:52 +0100 Subject: [PATCH 07/27] [PM-18569]Add admin sponsored families to organization license (#5569) * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * Add `Notes` column to `OrganizationSponsorships` table * Add feature flag to `CreateAdminInitiatedSponsorshipHandler` * Unit tests for `CreateSponsorshipHandler` * More tests for `CreateSponsorshipHandler` * Forgot to add `Notes` column to `OrganizationSponsorships` table in the migration script * `CreateAdminInitiatedSponsorshipHandler` unit tests * Fix `CreateSponsorshipCommandTests` * Encrypt the notes field * Wrong business logic checking for invalid permissions. * Wrong business logic checking for invalid permissions. * Remove design patterns * duplicate definition in Constants.cs * initial commit * Merge Change with pm-17830 and use the property * Add the new property to download licence * Add the new property Signed-off-by: Cy Okeke * Remove the unsed failing test Signed-off-by: Cy Okeke * Remove unused method Signed-off-by: Cy Okeke --------- Signed-off-by: Cy Okeke Co-authored-by: Jonas Hendrickx --- src/Core/Billing/Licenses/LicenseConstants.cs | 1 + .../OrganizationLicenseClaimsFactory.cs | 2 + .../Models/Business/OrganizationLicense.cs | 69 +++++++++++++++++-- .../Business/OrganizationLicenseTests.cs | 37 ---------- 4 files changed, 65 insertions(+), 44 deletions(-) diff --git a/src/Core/Billing/Licenses/LicenseConstants.cs b/src/Core/Billing/Licenses/LicenseConstants.cs index 50510914a5..513578f43e 100644 --- a/src/Core/Billing/Licenses/LicenseConstants.cs +++ b/src/Core/Billing/Licenses/LicenseConstants.cs @@ -41,6 +41,7 @@ public static class OrganizationLicenseConstants public const string Refresh = nameof(Refresh); public const string ExpirationWithoutGracePeriod = nameof(ExpirationWithoutGracePeriod); public const string Trial = nameof(Trial); + public const string UseAdminSponsoredFamilies = nameof(UseAdminSponsoredFamilies); } public static class UserLicenseConstants diff --git a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs index 62e1889564..6819d3cc0b 100644 --- a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs +++ b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs @@ -53,6 +53,7 @@ public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory + /// Initializes a new instance of the class. + /// + /// + /// + /// ⚠️ DEPRECATED: This constructor and the entire property-based licensing system is deprecated. + /// Do not add new properties to this constructor or extend its functionality. + /// + /// + /// This implementation has been replaced by a new claims-based licensing system that provides better security + /// and flexibility. The new system uses JWT claims to store and validate license information, making it more + /// secure and easier to extend without requiring changes to the license format. + /// + /// + /// For new license-related features or modifications: + /// 1. Use the claims-based system instead of adding properties here + /// 2. Add new claims to the license token + /// 3. Validate claims in the and methods + /// + /// + /// This constructor is maintained only for backward compatibility with existing licenses. + /// + /// + /// The organization to create the license for. + /// Information about the organization's subscription. + /// The ID of the current installation. + /// The service used to sign the license. + /// Optional version number for the license format. public OrganizationLicense(Organization org, SubscriptionInfo subscriptionInfo, Guid installationId, ILicensingService licenseService, int? version = null) { @@ -105,6 +133,7 @@ public class OrganizationLicense : ILicense Trial = false; } + UseAdminSponsoredFamilies = org.UseAdminSponsoredFamilies; Hash = Convert.ToBase64String(ComputeHash()); Signature = Convert.ToBase64String(licenseService.SignLicense(this)); } @@ -153,6 +182,7 @@ public class OrganizationLicense : ILicense public bool Trial { get; set; } public LicenseType? LicenseType { get; set; } + public bool UseAdminSponsoredFamilies { get; set; } public string Hash { get; set; } public string Signature { get; set; } public string Token { get; set; } @@ -292,13 +322,35 @@ public class OrganizationLicense : ILicense } /// - /// Do not extend this method. It is only here for backwards compatibility with old licenses. - /// Instead, extend the CanUse method using the ClaimsPrincipal. + /// Validates an obsolete license format using property-based validation. /// - /// - /// - /// - /// + /// + /// + /// ⚠️ DEPRECATED: This method is deprecated and should not be extended or modified. + /// It is maintained only for backward compatibility with old license formats. + /// + /// + /// This method has been replaced by a new claims-based validation system that provides: + /// - Better security through JWT claims + /// - More flexible validation rules + /// - Easier extensibility without changing the license format + /// - Better separation of concerns + /// + /// + /// To add new license validation rules: + /// 1. Add new claims to the license token in the claims-based system + /// 2. Extend the method + /// 3. Validate the new claims using the ClaimsPrincipal parameter + /// + /// + /// This method will be removed in a future version once all old licenses have been migrated + /// to the new claims-based system. + /// + /// + /// The global settings containing installation information. + /// The service used to verify the license signature. + /// When the method returns false, contains the error message explaining why the license is invalid. + /// True if the license is valid, false otherwise. private bool ObsoleteCanUse(IGlobalSettings globalSettings, ILicensingService licensingService, out string exception) { // Do not extend this method. It is only here for backwards compatibility with old licenses. @@ -392,6 +444,7 @@ public class OrganizationLicense : ILicense var usePasswordManager = claimsPrincipal.GetValue(nameof(UsePasswordManager)); var smSeats = claimsPrincipal.GetValue(nameof(SmSeats)); var smServiceAccounts = claimsPrincipal.GetValue(nameof(SmServiceAccounts)); + var useAdminSponsoredFamilies = claimsPrincipal.GetValue(nameof(UseAdminSponsoredFamilies)); return issued <= DateTime.UtcNow && expires >= DateTime.UtcNow && @@ -419,7 +472,9 @@ public class OrganizationLicense : ILicense useSecretsManager == organization.UseSecretsManager && usePasswordManager == organization.UsePasswordManager && smSeats == organization.SmSeats && - smServiceAccounts == organization.SmServiceAccounts; + smServiceAccounts == organization.SmServiceAccounts && + useAdminSponsoredFamilies == organization.UseAdminSponsoredFamilies; + } /// diff --git a/test/Core.Test/Models/Business/OrganizationLicenseTests.cs b/test/Core.Test/Models/Business/OrganizationLicenseTests.cs index 26945f533e..836df59dd8 100644 --- a/test/Core.Test/Models/Business/OrganizationLicenseTests.cs +++ b/test/Core.Test/Models/Business/OrganizationLicenseTests.cs @@ -1,9 +1,6 @@ using System.Security.Claims; -using System.Text.Json; using Bit.Core.Models.Business; -using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; @@ -12,22 +9,6 @@ namespace Bit.Core.Test.Models.Business; public class OrganizationLicenseTests { - /// - /// Verifies that when the license file is loaded from disk using the current OrganizationLicense class, - /// its hash does not change. - /// This guards against the risk that properties added in later versions are accidentally included in the hash, - /// or that a property is added without incrementing the version number. - /// - [Theory] - [BitAutoData(OrganizationLicense.CurrentLicenseFileVersion)] // Previous version (this property is 1 behind) - [BitAutoData(OrganizationLicense.CurrentLicenseFileVersion + 1)] // Current version - public void OrganizationLicense_LoadFromDisk_HashDoesNotChange(int licenseVersion) - { - var license = OrganizationLicenseFileFixtures.GetVersion(licenseVersion); - - // Compare the hash loaded from the json to the hash generated by the current class - Assert.Equal(Convert.FromBase64String(license.Hash), license.ComputeHash()); - } /// /// Verifies that when the license file is loaded from disk using the current OrganizationLicense class, @@ -52,22 +33,4 @@ public class OrganizationLicenseTests }); Assert.True(license.VerifyData(organization, claimsPrincipal, globalSettings)); } - - /// - /// Helper used to generate a new json string to be added in OrganizationLicenseFileFixtures. - /// Uncomment [Fact], run the test and copy the value of the `result` variable into OrganizationLicenseFileFixtures, - /// following the instructions in that class. - /// - // [Fact] - private void GenerateLicenseFileJsonString() - { - var organization = OrganizationLicenseFileFixtures.OrganizationFactory(); - var licensingService = Substitute.For(); - var installationId = new Guid(OrganizationLicenseFileFixtures.InstallationId); - - var license = new OrganizationLicense(organization, null, installationId, licensingService); - - var result = JsonSerializer.Serialize(license, JsonHelpers.Indented).Replace("\"", "'"); - // Put a break after this line, then copy and paste the value of `result` into OrganizationLicenseFileFixtures - } } From 00b9ba2392840e6a7a1d4502a76b526010f04656 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Mon, 28 Apr 2025 15:50:40 -0400 Subject: [PATCH 08/27] Allow for deletion of pending providers (#5679) --- .../Controllers/ProvidersController.cs | 20 +++++++---- .../AdminConsole/Views/Providers/Edit.cshtml | 34 +++++++++++++------ 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index 264e9df069..dd4332358c 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -470,6 +470,19 @@ public class ProvidersController : Controller [RequirePermission(Permission.Provider_Edit)] public async Task Delete(Guid id, string providerName) { + var provider = await _providerRepository.GetByIdAsync(id); + + if (provider is null) + { + return BadRequest("Provider does not exist"); + } + + if (provider.Status == ProviderStatusType.Pending) + { + await _providerService.DeleteAsync(provider); + return NoContent(); + } + if (string.IsNullOrWhiteSpace(providerName)) { return BadRequest("Invalid provider name"); @@ -482,13 +495,6 @@ public class ProvidersController : Controller return BadRequest("You must unlink all clients before you can delete a provider"); } - var provider = await _providerRepository.GetByIdAsync(id); - - if (provider is null) - { - return BadRequest("Provider does not exist"); - } - if (!string.Equals(providerName.Trim(), provider.DisplayName(), StringComparison.OrdinalIgnoreCase)) { return BadRequest("Invalid provider name"); diff --git a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml index ce215e1575..d2a9ed1f62 100644 --- a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml +++ b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml @@ -183,17 +183,29 @@

Delete provider

- + + @if (Model.Provider.Status == ProviderStatusType.Pending) + { + + } + else + { + + }
/// The to create a Stripe customer for. /// The to use for calculating the customer's automatic tax. + /// The (ex. Credit Card) to attach to the customer. /// The newly created for the . Task SetupCustomer( Provider provider, - TaxInfo taxInfo); + TaxInfo taxInfo, + TokenizedPaymentSource tokenizedPaymentSource = null); /// /// For use during the provider setup process, this method starts a Stripe for the given . diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index a05b89a94f..0d4c105b2d 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -149,6 +149,7 @@ public static class FeatureFlagKeys public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates"; public const string PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion"; public const string PM199566_UpdateMSPToChargeAutomatically = "pm-199566-update-msp-to-charge-automatically"; + public const string PM19956_RequireProviderPaymentMethodDuringSetup = "pm-19956-require-provider-payment-method-during-setup"; /* Data Insights and Reporting Team */ public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application"; From 706d7a5768be5ba40dec5c9bc64f2030a4f0a20e Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Thu, 1 May 2025 10:08:39 -0700 Subject: [PATCH 20/27] Migrate to new LD Action for code references (#5759) --- .github/workflows/code-references.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/code-references.yml b/.github/workflows/code-references.yml index ce8cb8e467..676a747017 100644 --- a/.github/workflows/code-references.yml +++ b/.github/workflows/code-references.yml @@ -37,12 +37,10 @@ jobs: - name: Collect id: collect - uses: launchdarkly/find-code-references-in-pull-request@30f4c4ab2949bbf258b797ced2fbf6dea34df9ce # v2.1.0 + uses: launchdarkly/find-code-references@e3e9da201b87ada54eb4c550c14fb783385c5c8a # v2.13.0 with: - project-key: default - environment-key: dev - access-token: ${{ secrets.LD_ACCESS_TOKEN }} - repo-token: ${{ secrets.GITHUB_TOKEN }} + accessToken: ${{ secrets.LD_ACCESS_TOKEN }} + projKey: default - name: Add label if: steps.collect.outputs.any-changed == 'true' From 0fa6962d1784a39734bc41264761eaf75708ae94 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Thu, 1 May 2025 13:39:04 -0400 Subject: [PATCH 21/27] Register EF OrganizationInstallationRepository (#5751) --- .../EntityFrameworkServiceCollectionExtensions.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs index ad6c7cf369..c9f0406a58 100644 --- a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs +++ b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs @@ -103,6 +103,7 @@ public static class EntityFrameworkServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); if (selfHosted) { From 011298c9ff0a873ba696e73ad5bfe0cbc6d32c65 Mon Sep 17 00:00:00 2001 From: Jonas Hendrickx Date: Thu, 1 May 2025 19:53:03 +0200 Subject: [PATCH 22/27] PM-16517: Create personal use plan for additional storage (#5205) * PM-16517: Create personal use plan for additional storage * f * f * f * fix * f --------- Co-authored-by: Jonas Hendrickx Co-authored-by: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> --- src/Core/Billing/Constants/StripeConstants.cs | 4 ++++ .../Billing/Models/StaticStore/Plans/Families2019Plan.cs | 2 +- src/Core/Billing/Models/StaticStore/Plans/FamiliesPlan.cs | 2 +- .../Services/Implementations/PremiumUserBillingService.cs | 2 +- src/Core/Services/Implementations/UserService.cs | 6 +++--- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 8a4303e378..b5c2794d22 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -2,6 +2,10 @@ public static class StripeConstants { + public static class Prices + { + public const string StoragePlanPersonal = "personal-storage-gb-annually"; + } public static class AutomaticTaxStatus { public const string Failed = "failed"; diff --git a/src/Core/Billing/Models/StaticStore/Plans/Families2019Plan.cs b/src/Core/Billing/Models/StaticStore/Plans/Families2019Plan.cs index b0ca8feeb0..93ab2c39a1 100644 --- a/src/Core/Billing/Models/StaticStore/Plans/Families2019Plan.cs +++ b/src/Core/Billing/Models/StaticStore/Plans/Families2019Plan.cs @@ -38,7 +38,7 @@ public record Families2019Plan : Plan HasPremiumAccessOption = true; StripePlanId = "personal-org-annually"; - StripeStoragePlanId = "storage-gb-annually"; + StripeStoragePlanId = "personal-storage-gb-annually"; StripePremiumAccessPlanId = "personal-org-premium-access-annually"; BasePrice = 12; AdditionalStoragePricePerGb = 4; diff --git a/src/Core/Billing/Models/StaticStore/Plans/FamiliesPlan.cs b/src/Core/Billing/Models/StaticStore/Plans/FamiliesPlan.cs index e2f51ec913..8c71e50fa4 100644 --- a/src/Core/Billing/Models/StaticStore/Plans/FamiliesPlan.cs +++ b/src/Core/Billing/Models/StaticStore/Plans/FamiliesPlan.cs @@ -37,7 +37,7 @@ public record FamiliesPlan : Plan HasAdditionalStorageOption = true; StripePlanId = "2020-families-org-annually"; - StripeStoragePlanId = "storage-gb-annually"; + StripeStoragePlanId = "personal-storage-gb-annually"; BasePrice = 40; AdditionalStoragePricePerGb = 4; diff --git a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs index 6746a8cc98..cbd4dbbdff 100644 --- a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs +++ b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs @@ -313,7 +313,7 @@ public class PremiumUserBillingService( { subscriptionItemOptionsList.Add(new SubscriptionItemOptions { - Price = "storage-gb-annually", + Price = StripeConstants.Prices.StoragePlanPersonal, Quantity = storage }); } diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index de0fa427ba..95ee4544fa 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -11,6 +11,7 @@ using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Services; @@ -45,7 +46,6 @@ namespace Bit.Core.Services; public class UserService : UserManager, IUserService, IDisposable { private const string PremiumPlanId = "premium-annually"; - private const string StoragePlanId = "storage-gb-annually"; private readonly IUserRepository _userRepository; private readonly ICipherRepository _cipherRepository; @@ -1106,12 +1106,12 @@ public class UserService : UserManager, IUserService, IDisposable } var secret = await BillingHelpers.AdjustStorageAsync(_paymentService, user, storageAdjustmentGb, - StoragePlanId); + StripeConstants.Prices.StoragePlanPersonal); await _referenceEventService.RaiseEventAsync( new ReferenceEvent(ReferenceEventType.AdjustStorage, user, _currentContext) { Storage = storageAdjustmentGb, - PlanName = StoragePlanId, + PlanName = StripeConstants.Prices.StoragePlanPersonal, }); await SaveUserAsync(user); return secret; From 9da98d8e974b9e57468e1514b5c7820b22c755ed Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Thu, 1 May 2025 12:25:52 -0700 Subject: [PATCH 23/27] Run LD reference check on all pushes (#5760) * Run LD reference check on all pushes * Fix syntax of code-references.yml --------- Co-authored-by: Matt Andreko --- .github/workflows/code-references.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/code-references.yml b/.github/workflows/code-references.yml index 676a747017..30fbff32ed 100644 --- a/.github/workflows/code-references.yml +++ b/.github/workflows/code-references.yml @@ -1,7 +1,10 @@ name: Collect code references -on: - pull_request: +on: + push: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: check-ld-secret: From 41001fefaeacaaf9a63740711f36fee27445fa0d Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Fri, 2 May 2025 07:00:48 +1000 Subject: [PATCH 24/27] Support use of organizationId parameter in authorization (#5758) --- .../Authorization/HttpContextExtensions.cs | 20 ++++++--- .../HttpContextExtensionsTests.cs | 42 ++++++++++++++++++- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/Api/AdminConsole/Authorization/HttpContextExtensions.cs b/src/Api/AdminConsole/Authorization/HttpContextExtensions.cs index ba00ea6c18..accb9539fa 100644 --- a/src/Api/AdminConsole/Authorization/HttpContextExtensions.cs +++ b/src/Api/AdminConsole/Authorization/HttpContextExtensions.cs @@ -9,7 +9,7 @@ namespace Bit.Api.AdminConsole.Authorization; public static class HttpContextExtensions { public const string NoOrgIdError = - "A route decorated with with '[Authorize]' must include a route value named 'orgId' either through the [Controller] attribute or through a '[Http*]' attribute."; + "A route decorated with with '[Authorize]' must include a route value named 'orgId' or 'organizationId' either through the [Controller] attribute or through a '[Http*]' attribute."; /// /// Returns the result of the callback, caching it in HttpContext.Features for the lifetime of the request. @@ -61,19 +61,27 @@ public static class HttpContextExtensions /// - /// Parses the {orgId} route parameter into a Guid, or throws if the {orgId} is not present or not a valid guid. + /// Parses the {orgId} or {organizationId} route parameter into a Guid, or throws if neither are present or are not valid guids. /// /// /// /// public static Guid GetOrganizationId(this HttpContext httpContext) { - httpContext.GetRouteData().Values.TryGetValue("orgId", out var orgIdParam); - if (orgIdParam == null || !Guid.TryParse(orgIdParam.ToString(), out var orgId)) + var routeValues = httpContext.GetRouteData().Values; + + routeValues.TryGetValue("orgId", out var orgIdParam); + if (orgIdParam != null && Guid.TryParse(orgIdParam.ToString(), out var orgId)) { - throw new InvalidOperationException(NoOrgIdError); + return orgId; } - return orgId; + routeValues.TryGetValue("organizationId", out var organizationIdParam); + if (organizationIdParam != null && Guid.TryParse(organizationIdParam.ToString(), out var organizationId)) + { + return organizationId; + } + + throw new InvalidOperationException(NoOrgIdError); } } diff --git a/test/Api.Test/AdminConsole/Authorization/HttpContextExtensionsTests.cs b/test/Api.Test/AdminConsole/Authorization/HttpContextExtensionsTests.cs index 1901742777..428726aaac 100644 --- a/test/Api.Test/AdminConsole/Authorization/HttpContextExtensionsTests.cs +++ b/test/Api.Test/AdminConsole/Authorization/HttpContextExtensionsTests.cs @@ -1,5 +1,7 @@ -using Bit.Api.AdminConsole.Authorization; +using AutoFixture.Xunit2; +using Bit.Api.AdminConsole.Authorization; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; using NSubstitute; using Xunit; @@ -25,4 +27,42 @@ public class HttpContextExtensionsTests await callback.ReceivedWithAnyArgs(1).Invoke(); } + [Theory] + [InlineAutoData("orgId")] + [InlineAutoData("organizationId")] + public void GetOrganizationId_GivenValidParameter_ReturnsOrganizationId(string paramName, Guid orgId) + { + var httpContext = new DefaultHttpContext + { + Request = { RouteValues = new RouteValueDictionary + { + { "userId", "someGuid" }, + { paramName, orgId.ToString() } + } + } + }; + + var result = httpContext.GetOrganizationId(); + Assert.Equal(orgId, result); + } + + [Theory] + [InlineAutoData("orgId")] + [InlineAutoData("organizationId")] + [InlineAutoData("missingParameter")] + public void GetOrganizationId_GivenMissingOrInvalidGuid_Throws(string paramName) + { + var httpContext = new DefaultHttpContext + { + Request = { RouteValues = new RouteValueDictionary + { + { "userId", "someGuid" }, + { paramName, "invalidGuid" } + } + } + }; + + var exception = Assert.Throws(() => httpContext.GetOrganizationId()); + Assert.Equal(HttpContextExtensions.NoOrgIdError, exception.Message); + } } From 2d4ec530c5c3638cbc3c7ddb286ba3442cd03014 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Thu, 1 May 2025 17:13:10 -0400 Subject: [PATCH 25/27] [PM-18955] Implement `OrganizationWarningsQuery` (#5713) * Add GetWarnings endpoint to OrganizationBillingController * Add OrganizationWarningsQueryTests --- .../OrganizationBillingController.cs | 26 ++ .../OrganizationWarningsResponse.cs | 43 +++ .../OrganizationWarningsQuery.cs | 214 ++++++++++++ src/Api/Billing/Registrations.cs | 11 + src/Api/Startup.cs | 3 + src/Core/Billing/Constants/StripeConstants.cs | 2 + src/Core/Constants.cs | 1 + .../OrganizationWarningsQueryTests.cs | 315 ++++++++++++++++++ 8 files changed, 615 insertions(+) create mode 100644 src/Api/Billing/Models/Responses/Organizations/OrganizationWarningsResponse.cs create mode 100644 src/Api/Billing/Queries/Organizations/OrganizationWarningsQuery.cs create mode 100644 src/Api/Billing/Registrations.cs create mode 100644 test/Api.Test/Billing/Queries/Organizations/OrganizationWarningsQueryTests.cs diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index 1d4ebc1511..2f0a4ef48b 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -2,6 +2,7 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; +using Bit.Api.Billing.Queries.Organizations; using Bit.Core; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; @@ -24,6 +25,7 @@ public class OrganizationBillingController( IFeatureService featureService, IOrganizationBillingService organizationBillingService, IOrganizationRepository organizationRepository, + IOrganizationWarningsQuery organizationWarningsQuery, IPaymentService paymentService, IPricingClient pricingClient, ISubscriberService subscriberService, @@ -335,4 +337,28 @@ public class OrganizationBillingController( return TypedResults.Ok(providerId); } + + [HttpGet("warnings")] + public async Task GetWarningsAsync([FromRoute] Guid organizationId) + { + /* + * We'll keep these available at the User level, because we're hiding any pertinent information and + * we want to throw as few errors as possible since these are not core features. + */ + if (!await currentContext.OrganizationUser(organizationId)) + { + return Error.Unauthorized(); + } + + var organization = await organizationRepository.GetByIdAsync(organizationId); + + if (organization == null) + { + return Error.NotFound(); + } + + var response = await organizationWarningsQuery.Run(organization); + + return TypedResults.Ok(response); + } } diff --git a/src/Api/Billing/Models/Responses/Organizations/OrganizationWarningsResponse.cs b/src/Api/Billing/Models/Responses/Organizations/OrganizationWarningsResponse.cs new file mode 100644 index 0000000000..e124bdc318 --- /dev/null +++ b/src/Api/Billing/Models/Responses/Organizations/OrganizationWarningsResponse.cs @@ -0,0 +1,43 @@ +#nullable enable +namespace Bit.Api.Billing.Models.Responses.Organizations; + +public record OrganizationWarningsResponse +{ + public FreeTrialWarning? FreeTrial { get; set; } + public InactiveSubscriptionWarning? InactiveSubscription { get; set; } + public ResellerRenewalWarning? ResellerRenewal { get; set; } + + public record FreeTrialWarning + { + public int RemainingTrialDays { get; set; } + } + + public record InactiveSubscriptionWarning + { + public required string Resolution { get; set; } + } + + public record ResellerRenewalWarning + { + public required string Type { get; set; } + public UpcomingRenewal? Upcoming { get; set; } + public IssuedRenewal? Issued { get; set; } + public PastDueRenewal? PastDue { get; set; } + + public record UpcomingRenewal + { + public required DateTime RenewalDate { get; set; } + } + + public record IssuedRenewal + { + public required DateTime IssuedDate { get; set; } + public required DateTime DueDate { get; set; } + } + + public record PastDueRenewal + { + public required DateTime SuspensionDate { get; set; } + } + } +} diff --git a/src/Api/Billing/Queries/Organizations/OrganizationWarningsQuery.cs b/src/Api/Billing/Queries/Organizations/OrganizationWarningsQuery.cs new file mode 100644 index 0000000000..f6a0e5b1e6 --- /dev/null +++ b/src/Api/Billing/Queries/Organizations/OrganizationWarningsQuery.cs @@ -0,0 +1,214 @@ +// ReSharper disable InconsistentNaming + +#nullable enable + +using Bit.Api.Billing.Models.Responses.Organizations; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Services; +using Bit.Core.Context; +using Bit.Core.Services; +using Stripe; +using FreeTrialWarning = Bit.Api.Billing.Models.Responses.Organizations.OrganizationWarningsResponse.FreeTrialWarning; +using InactiveSubscriptionWarning = + Bit.Api.Billing.Models.Responses.Organizations.OrganizationWarningsResponse.InactiveSubscriptionWarning; +using ResellerRenewalWarning = + Bit.Api.Billing.Models.Responses.Organizations.OrganizationWarningsResponse.ResellerRenewalWarning; + +namespace Bit.Api.Billing.Queries.Organizations; + +public interface IOrganizationWarningsQuery +{ + Task Run( + Organization organization); +} + +public class OrganizationWarningsQuery( + ICurrentContext currentContext, + IProviderRepository providerRepository, + IStripeAdapter stripeAdapter, + ISubscriberService subscriberService) : IOrganizationWarningsQuery +{ + public async Task Run( + Organization organization) + { + var response = new OrganizationWarningsResponse(); + + var subscription = + await subscriberService.GetSubscription(organization, + new SubscriptionGetOptions { Expand = ["customer", "latest_invoice", "test_clock"] }); + + if (subscription == null) + { + return response; + } + + response.FreeTrial = await GetFreeTrialWarning(organization, subscription); + + var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id); + + response.InactiveSubscription = await GetInactiveSubscriptionWarning(organization, provider, subscription); + + response.ResellerRenewal = await GetResellerRenewalWarning(provider, subscription); + + return response; + } + + private async Task GetFreeTrialWarning( + Organization organization, + Subscription subscription) + { + if (!await currentContext.EditSubscription(organization.Id)) + { + return null; + } + + if (subscription is not + { + Status: StripeConstants.SubscriptionStatus.Trialing, + TrialEnd: not null, + Customer: not null + }) + { + return null; + } + + var customer = subscription.Customer; + + var hasPaymentMethod = + !string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) || + !string.IsNullOrEmpty(customer.DefaultSourceId) || + customer.Metadata.ContainsKey(StripeConstants.MetadataKeys.BraintreeCustomerId); + + if (hasPaymentMethod) + { + return null; + } + + var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow; + + var remainingTrialDays = (int)Math.Ceiling((subscription.TrialEnd.Value - now).TotalDays); + + return new FreeTrialWarning { RemainingTrialDays = remainingTrialDays }; + } + + private async Task GetInactiveSubscriptionWarning( + Organization organization, + Provider? provider, + Subscription subscription) + { + if (organization.Enabled || + subscription.Status is not StripeConstants.SubscriptionStatus.Unpaid + and not StripeConstants.SubscriptionStatus.Canceled) + { + return null; + } + + if (provider != null) + { + return new InactiveSubscriptionWarning { Resolution = "contact_provider" }; + } + + if (await currentContext.OrganizationOwner(organization.Id)) + { + return subscription.Status switch + { + StripeConstants.SubscriptionStatus.Unpaid => new InactiveSubscriptionWarning + { + Resolution = "add_payment_method" + }, + StripeConstants.SubscriptionStatus.Canceled => new InactiveSubscriptionWarning + { + Resolution = "resubscribe" + }, + _ => null + }; + } + + return new InactiveSubscriptionWarning { Resolution = "contact_owner" }; + } + + private async Task GetResellerRenewalWarning( + Provider? provider, + Subscription subscription) + { + if (provider is not + { + Type: ProviderType.Reseller + }) + { + return null; + } + + if (subscription.CollectionMethod != StripeConstants.CollectionMethod.SendInvoice) + { + return null; + } + + var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow; + + // ReSharper disable once ConvertIfStatementToSwitchStatement + if (subscription is + { + Status: StripeConstants.SubscriptionStatus.Trialing or StripeConstants.SubscriptionStatus.Active, + LatestInvoice: null or { Status: StripeConstants.InvoiceStatus.Paid } + } && (subscription.CurrentPeriodEnd - now).TotalDays <= 14) + { + return new ResellerRenewalWarning + { + Type = "upcoming", + Upcoming = new ResellerRenewalWarning.UpcomingRenewal + { + RenewalDate = subscription.CurrentPeriodEnd + } + }; + } + + if (subscription is + { + Status: StripeConstants.SubscriptionStatus.Active, + LatestInvoice: { Status: StripeConstants.InvoiceStatus.Open, DueDate: not null } + } && subscription.LatestInvoice.DueDate > now) + { + return new ResellerRenewalWarning + { + Type = "issued", + Issued = new ResellerRenewalWarning.IssuedRenewal + { + IssuedDate = subscription.LatestInvoice.Created, + DueDate = subscription.LatestInvoice.DueDate.Value + } + }; + } + + // ReSharper disable once InvertIf + if (subscription.Status == StripeConstants.SubscriptionStatus.PastDue) + { + var openInvoices = await stripeAdapter.InvoiceSearchAsync(new InvoiceSearchOptions + { + Query = $"subscription:'{subscription.Id}' status:'open'" + }); + + var earliestOverdueInvoice = openInvoices + .Where(invoice => invoice.DueDate != null && invoice.DueDate < now) + .MinBy(invoice => invoice.Created); + + if (earliestOverdueInvoice != null) + { + return new ResellerRenewalWarning + { + Type = "past_due", + PastDue = new ResellerRenewalWarning.PastDueRenewal + { + SuspensionDate = earliestOverdueInvoice.DueDate!.Value.AddDays(30) + } + }; + } + } + + return null; + } +} diff --git a/src/Api/Billing/Registrations.cs b/src/Api/Billing/Registrations.cs new file mode 100644 index 0000000000..cb92098333 --- /dev/null +++ b/src/Api/Billing/Registrations.cs @@ -0,0 +1,11 @@ +using Bit.Api.Billing.Queries.Organizations; + +namespace Bit.Api.Billing; + +public static class Registrations +{ + public static void AddBillingQueries(this IServiceCollection services) + { + services.AddTransient(); + } +} diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 40448f722d..1cc371ae1b 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -27,6 +27,7 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions; using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; using Bit.Api.Auth.Models.Request.WebAuthn; +using Bit.Api.Billing; using Bit.Core.AdminConsole.Services.NoopImplementations; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Identity.TokenProviders; @@ -184,6 +185,8 @@ public class Startup services.AddImportServices(); services.AddPhishingDomainServices(globalSettings); + services.AddBillingQueries(); + // Authorization Handlers services.AddAuthorizationHandlers(); diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index b5c2794d22..c3e3ec6c30 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -46,10 +46,12 @@ public static class StripeConstants { public const string Draft = "draft"; public const string Open = "open"; + public const string Paid = "paid"; } public static class MetadataKeys { + public const string BraintreeCustomerId = "btCustomerId"; public const string InvoiceApproved = "invoice_approved"; public const string OrganizationId = "organizationId"; public const string ProviderId = "providerId"; diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 0d4c105b2d..13d0bad495 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -150,6 +150,7 @@ public static class FeatureFlagKeys public const string PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion"; public const string PM199566_UpdateMSPToChargeAutomatically = "pm-199566-update-msp-to-charge-automatically"; public const string PM19956_RequireProviderPaymentMethodDuringSetup = "pm-19956-require-provider-payment-method-during-setup"; + public const string UseOrganizationWarningsService = "use-organization-warnings-service"; /* Data Insights and Reporting Team */ public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application"; diff --git a/test/Api.Test/Billing/Queries/Organizations/OrganizationWarningsQueryTests.cs b/test/Api.Test/Billing/Queries/Organizations/OrganizationWarningsQueryTests.cs new file mode 100644 index 0000000000..67979f506e --- /dev/null +++ b/test/Api.Test/Billing/Queries/Organizations/OrganizationWarningsQueryTests.cs @@ -0,0 +1,315 @@ +using Bit.Api.Billing.Queries.Organizations; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Services; +using Bit.Core.Context; +using Bit.Core.Services; +using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Stripe; +using Stripe.TestHelpers; +using Xunit; + +namespace Bit.Api.Test.Billing.Queries.Organizations; + +[SutProviderCustomize] +public class OrganizationWarningsQueryTests +{ + private static readonly string[] _requiredExpansions = ["customer", "latest_invoice", "test_clock"]; + + [Theory, BitAutoData] + public async Task Run_NoSubscription_NoWarnings( + Organization organization, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .ReturnsNull(); + + var response = await sutProvider.Sut.Run(organization); + + Assert.True(response is + { + FreeTrial: null, + InactiveSubscription: null, + ResellerRenewal: null + }); + } + + [Theory, BitAutoData] + public async Task Run_Has_FreeTrialWarning( + Organization organization, + SutProvider sutProvider) + { + var now = DateTime.UtcNow; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = StripeConstants.SubscriptionStatus.Trialing, + TrialEnd = now.AddDays(7), + Customer = new Customer + { + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + }, + TestClock = new TestClock + { + FrozenTime = now + } + }); + + sutProvider.GetDependency().EditSubscription(organization.Id).Returns(true); + + var response = await sutProvider.Sut.Run(organization); + + Assert.True(response is + { + FreeTrial.RemainingTrialDays: 7 + }); + } + + [Theory, BitAutoData] + public async Task Run_Has_InactiveSubscriptionWarning_ContactProvider( + Organization organization, + SutProvider sutProvider) + { + organization.Enabled = false; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = StripeConstants.SubscriptionStatus.Unpaid + }); + + sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id) + .Returns(new Provider()); + + var response = await sutProvider.Sut.Run(organization); + + Assert.True(response is + { + InactiveSubscription.Resolution: "contact_provider" + }); + } + + [Theory, BitAutoData] + public async Task Run_Has_InactiveSubscriptionWarning_AddPaymentMethod( + Organization organization, + SutProvider sutProvider) + { + organization.Enabled = false; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = StripeConstants.SubscriptionStatus.Unpaid + }); + + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(true); + + var response = await sutProvider.Sut.Run(organization); + + Assert.True(response is + { + InactiveSubscription.Resolution: "add_payment_method" + }); + } + + [Theory, BitAutoData] + public async Task Run_Has_InactiveSubscriptionWarning_Resubscribe( + Organization organization, + SutProvider sutProvider) + { + organization.Enabled = false; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = StripeConstants.SubscriptionStatus.Canceled + }); + + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(true); + + var response = await sutProvider.Sut.Run(organization); + + Assert.True(response is + { + InactiveSubscription.Resolution: "resubscribe" + }); + } + + [Theory, BitAutoData] + public async Task Run_Has_InactiveSubscriptionWarning_ContactOwner( + Organization organization, + SutProvider sutProvider) + { + organization.Enabled = false; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = StripeConstants.SubscriptionStatus.Unpaid + }); + + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(false); + + var response = await sutProvider.Sut.Run(organization); + + Assert.True(response is + { + InactiveSubscription.Resolution: "contact_owner" + }); + } + + [Theory, BitAutoData] + public async Task Run_Has_ResellerRenewalWarning_Upcoming( + Organization organization, + SutProvider sutProvider) + { + var now = DateTime.UtcNow; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + CollectionMethod = StripeConstants.CollectionMethod.SendInvoice, + Status = StripeConstants.SubscriptionStatus.Active, + CurrentPeriodEnd = now.AddDays(10), + TestClock = new TestClock + { + FrozenTime = now + } + }); + + sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id) + .Returns(new Provider + { + Type = ProviderType.Reseller + }); + + var response = await sutProvider.Sut.Run(organization); + + Assert.True(response is + { + ResellerRenewal.Type: "upcoming" + }); + + Assert.Equal(now.AddDays(10), response.ResellerRenewal.Upcoming!.RenewalDate); + } + + [Theory, BitAutoData] + public async Task Run_Has_ResellerRenewalWarning_Issued( + Organization organization, + SutProvider sutProvider) + { + var now = DateTime.UtcNow; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + CollectionMethod = StripeConstants.CollectionMethod.SendInvoice, + Status = StripeConstants.SubscriptionStatus.Active, + LatestInvoice = new Invoice + { + Status = StripeConstants.InvoiceStatus.Open, + DueDate = now.AddDays(30), + Created = now + }, + TestClock = new TestClock + { + FrozenTime = now + } + }); + + sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id) + .Returns(new Provider + { + Type = ProviderType.Reseller + }); + + var response = await sutProvider.Sut.Run(organization); + + Assert.True(response is + { + ResellerRenewal.Type: "issued" + }); + + Assert.Equal(now, response.ResellerRenewal.Issued!.IssuedDate); + Assert.Equal(now.AddDays(30), response.ResellerRenewal.Issued!.DueDate); + } + + [Theory, BitAutoData] + public async Task Run_Has_ResellerRenewalWarning_PastDue( + Organization organization, + SutProvider sutProvider) + { + var now = DateTime.UtcNow; + + const string subscriptionId = "subscription_id"; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Id = subscriptionId, + CollectionMethod = StripeConstants.CollectionMethod.SendInvoice, + Status = StripeConstants.SubscriptionStatus.PastDue, + TestClock = new TestClock + { + FrozenTime = now + } + }); + + sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id) + .Returns(new Provider + { + Type = ProviderType.Reseller + }); + + var dueDate = now.AddDays(-10); + + sutProvider.GetDependency().InvoiceSearchAsync(Arg.Is(options => + options.Query == $"subscription:'{subscriptionId}' status:'open'")).Returns([ + new Invoice { DueDate = dueDate, Created = dueDate.AddDays(-30) } + ]); + + var response = await sutProvider.Sut.Run(organization); + + Assert.True(response is + { + ResellerRenewal.Type: "past_due" + }); + + Assert.Equal(dueDate.AddDays(30), response.ResellerRenewal.PastDue!.SuspensionDate); + } +} From cd3f16948b31367cfbc02f2a9e457cb45581d6cc Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Fri, 2 May 2025 08:25:52 -0400 Subject: [PATCH 26/27] Resolved the ambiguous build error (#5762) --- .../PhishingDomainFeatures/AzurePhishingDomainStorageService.cs | 2 +- .../PhishingDomainFeatures/CloudPhishingDomainDirectQuery.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Core/PhishingDomainFeatures/AzurePhishingDomainStorageService.cs b/src/Core/PhishingDomainFeatures/AzurePhishingDomainStorageService.cs index 9af9c94e1d..0d287a2229 100644 --- a/src/Core/PhishingDomainFeatures/AzurePhishingDomainStorageService.cs +++ b/src/Core/PhishingDomainFeatures/AzurePhishingDomainStorageService.cs @@ -39,7 +39,7 @@ public class AzurePhishingDomainStorageService var content = await streamReader.ReadToEndAsync(); return [.. content - .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) .Select(line => line.Trim()) .Where(line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith('#'))]; } diff --git a/src/Core/PhishingDomainFeatures/CloudPhishingDomainDirectQuery.cs b/src/Core/PhishingDomainFeatures/CloudPhishingDomainDirectQuery.cs index b059eac0e8..420948e310 100644 --- a/src/Core/PhishingDomainFeatures/CloudPhishingDomainDirectQuery.cs +++ b/src/Core/PhishingDomainFeatures/CloudPhishingDomainDirectQuery.cs @@ -92,7 +92,7 @@ public class CloudPhishingDomainDirectQuery : ICloudPhishingDomainQuery } return content - .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) .Select(line => line.Trim()) .Where(line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith("#")) .ToList(); From 077d0fa6d7268a402dcbe88e574d2c4242cd6c78 Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Fri, 2 May 2025 12:53:06 -0400 Subject: [PATCH 27/27] Resolved an issue where autoscaling always happened (#5765) --- .../Services/IOrganizationService.cs | 1 + .../Implementations/OrganizationService.cs | 2 +- .../CreateSponsorshipCommand.cs | 19 ++- .../CreateSponsorshipCommandTests.cs | 127 ++++++++++++++++++ 4 files changed, 145 insertions(+), 4 deletions(-) diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index 9c9e311a02..1e53be734e 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -49,6 +49,7 @@ public interface IOrganizationService IEnumerable organizationUserIds, Guid? revokingUserId); Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted); Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null); + Task<(bool canScale, string failureReason)> CanScaleAsync(Organization organization, int seatsToAdd); void ValidatePasswordManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade); void ValidateSecretsManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade); diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 532aebf5e0..5c7e5e29ed 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -1058,7 +1058,7 @@ public class OrganizationService : IOrganizationService organization: organization, initOrganization: initOrganization)); - internal async Task<(bool canScale, string failureReason)> CanScaleAsync( + public async Task<(bool canScale, string failureReason)> CanScaleAsync( Organization organization, int seatsToAdd) { diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs index 3b74baf6f9..b15cbea240 100644 --- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs @@ -15,7 +15,8 @@ public class CreateSponsorshipCommand( ICurrentContext currentContext, IOrganizationSponsorshipRepository organizationSponsorshipRepository, IUserService userService, - IOrganizationService organizationService) : ICreateSponsorshipCommand + IOrganizationService organizationService, + IOrganizationUserRepository organizationUserRepository) : ICreateSponsorshipCommand { public async Task CreateSponsorshipAsync( Organization sponsoringOrganization, @@ -82,14 +83,26 @@ public class CreateSponsorshipCommand( if (existingOrgSponsorship != null) { - // Replace existing invalid offer with our new sponsorship offer sponsorship.Id = existingOrgSponsorship.Id; } } if (isAdminInitiated && sponsoringOrganization.Seats.HasValue) { - await organizationService.AutoAddSeatsAsync(sponsoringOrganization, 1); + var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrganization.Id); + var availableSeats = sponsoringOrganization.Seats.Value - occupiedSeats; + + if (availableSeats <= 0) + { + var newSeatsRequired = 1; + var (canScale, failureReason) = await organizationService.CanScaleAsync(sponsoringOrganization, newSeatsRequired); + if (!canScale) + { + throw new BadRequestException(failureReason); + } + + await organizationService.AutoAddSeatsAsync(sponsoringOrganization, newSeatsRequired); + } } try diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs index f6b6721bd2..7dc6b7360d 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs @@ -168,6 +168,11 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase }); sutProvider.GetDependency().UserId.Returns(sponsoringOrgUser.UserId.Value); + // Setup for checking available seats + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id) + .Returns(0); + await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, false, null); @@ -293,6 +298,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase { sponsoringOrg.PlanType = PlanType.EnterpriseAnnually; sponsoringOrg.UseAdminSponsoredFamilies = true; + sponsoringOrg.Seats = 10; sponsoringOrgUser.Status = OrganizationUserStatusType.Confirmed; sutProvider.GetDependency().GetUserByIdAsync(sponsoringOrgUser.UserId!.Value).Returns(user); @@ -311,6 +317,11 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase } ]); + // Setup for checking available seats - organization has plenty of seats + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id) + .Returns(5); + var actual = await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, notes); @@ -331,5 +342,121 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase await sutProvider.GetDependency().Received(1) .CreateAsync(Arg.Is(s => SponsorshipValidator(s, expectedSponsorship))); + + // Verify we didn't need to add seats + await sutProvider.GetDependency().DidNotReceive() + .AutoAddSeatsAsync(Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.Owner)] + public async Task CreateSponsorship_CreatesAdminInitiatedSponsorship_AutoscalesWhenNeeded( + OrganizationUserType organizationUserType, + Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, User user, string sponsoredEmail, + string friendlyName, Guid sponsorshipId, Guid currentUserId, string notes, SutProvider sutProvider) + { + sponsoringOrg.PlanType = PlanType.EnterpriseAnnually; + sponsoringOrg.UseAdminSponsoredFamilies = true; + sponsoringOrg.Seats = 10; + sponsoringOrgUser.Status = OrganizationUserStatusType.Confirmed; + + sutProvider.GetDependency().GetUserByIdAsync(sponsoringOrgUser.UserId!.Value).Returns(user); + sutProvider.GetDependency().WhenForAnyArgs(x => x.UpsertAsync(null!)).Do(callInfo => + { + var sponsorship = callInfo.Arg(); + sponsorship.Id = sponsorshipId; + }); + sutProvider.GetDependency().UserId.Returns(currentUserId); + sutProvider.GetDependency().Organizations.Returns([ + new() + { + Id = sponsoringOrg.Id, + Permissions = new Permissions { ManageUsers = true }, + Type = organizationUserType + } + ]); + + // Setup for checking available seats - organization has no available seats + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id) + .Returns(10); + + // Setup for checking if can scale + sutProvider.GetDependency() + .CanScaleAsync(sponsoringOrg, 1) + .Returns((true, "")); + + var actual = await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, + PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, notes); + + + var expectedSponsorship = new OrganizationSponsorship + { + Id = sponsorshipId, + SponsoringOrganizationId = sponsoringOrg.Id, + SponsoringOrganizationUserId = sponsoringOrgUser.Id, + FriendlyName = friendlyName, + OfferedToEmail = sponsoredEmail, + PlanSponsorshipType = PlanSponsorshipType.FamiliesForEnterprise, + IsAdminInitiated = true, + Notes = notes + }; + + Assert.True(SponsorshipValidator(expectedSponsorship, actual)); + + await sutProvider.GetDependency().Received(1) + .CreateAsync(Arg.Is(s => SponsorshipValidator(s, expectedSponsorship))); + + // Verify we needed to add seats + await sutProvider.GetDependency().Received(1) + .AutoAddSeatsAsync(sponsoringOrg, 1); + } + + [Theory] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.Owner)] + public async Task CreateSponsorship_CreatesAdminInitiatedSponsorship_ThrowsWhenCannotAutoscale( + OrganizationUserType organizationUserType, + Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, User user, string sponsoredEmail, + string friendlyName, Guid sponsorshipId, Guid currentUserId, string notes, SutProvider sutProvider) + { + sponsoringOrg.PlanType = PlanType.EnterpriseAnnually; + sponsoringOrg.UseAdminSponsoredFamilies = true; + sponsoringOrg.Seats = 10; + sponsoringOrgUser.Status = OrganizationUserStatusType.Confirmed; + + sutProvider.GetDependency().GetUserByIdAsync(sponsoringOrgUser.UserId!.Value).Returns(user); + sutProvider.GetDependency().WhenForAnyArgs(x => x.UpsertAsync(null!)).Do(callInfo => + { + var sponsorship = callInfo.Arg(); + sponsorship.Id = sponsorshipId; + }); + sutProvider.GetDependency().UserId.Returns(currentUserId); + sutProvider.GetDependency().Organizations.Returns([ + new() + { + Id = sponsoringOrg.Id, + Permissions = new Permissions { ManageUsers = true }, + Type = organizationUserType + } + ]); + + // Setup for checking available seats - organization has no available seats + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id) + .Returns(10); + + // Setup for checking if can scale - cannot scale + var failureReason = "Seat limit has been reached."; + sutProvider.GetDependency() + .CanScaleAsync(sponsoringOrg, 1) + .Returns((false, failureReason)); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, + PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, notes)); + + Assert.Equal(failureReason, exception.Message); } }