From 3d59f5522dc0fd60879451a3352fd3d287f341fd Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Wed, 16 Apr 2025 10:33:00 -0700 Subject: [PATCH 01/11] [PM-19357] - [Defect] Unauthorised access allows limited access user to change custom hidden field of Items (#5572) * prevent hidden password users from modifying hidden fields * add tests * fix serialization issues * DRY up code * return newly created cipher * add sshKey data type * fix tests --- .../Vault/Controllers/CiphersController.cs | 7 +-- .../Services/Implementations/CipherService.cs | 53 +++++++++++++++--- .../Vault/Services/CipherServiceTests.cs | 56 ++++++++++++++++++- 3 files changed, 100 insertions(+), 16 deletions(-) diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index a9646acd1c..3bdb6c4bf0 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -177,12 +177,7 @@ public class CiphersController : Controller } await _cipherService.SaveDetailsAsync(cipher, user.Id, model.Cipher.LastKnownRevisionDate, model.CollectionIds, cipher.OrganizationId.HasValue); - var response = new CipherResponseModel( - cipher, - user, - await _applicationCacheService.GetOrganizationAbilitiesAsync(), - _globalSettings); - return response; + return await Get(cipher.Id); } [HttpPost("admin")] diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index 989fbf43b8..745d90b741 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -1003,7 +1003,7 @@ public class CipherService : ICipherService private async Task ValidateViewPasswordUserAsync(Cipher cipher) { - if (cipher.Type != CipherType.Login || cipher.Data == null || !cipher.OrganizationId.HasValue) + if (cipher.Data == null || !cipher.OrganizationId.HasValue) { return; } @@ -1014,21 +1014,58 @@ public class CipherService : ICipherService // Check if user is a "hidden password" user if (!cipherPermissions.TryGetValue(cipher.Id, out var permission) || !(permission.ViewPassword && permission.Edit)) { + var existingCipherData = DeserializeCipherData(existingCipher); + var newCipherData = DeserializeCipherData(cipher); + // "hidden password" users may not add cipher key encryption if (existingCipher.Key == null && cipher.Key != null) { throw new BadRequestException("You do not have permission to add cipher key encryption."); } - // "hidden password" users may not change passwords, TOTP codes, or passkeys, so we need to set them back to the original values - var existingCipherData = JsonSerializer.Deserialize(existingCipher.Data); - var newCipherData = JsonSerializer.Deserialize(cipher.Data); - newCipherData.Fido2Credentials = existingCipherData.Fido2Credentials; - newCipherData.Totp = existingCipherData.Totp; - newCipherData.Password = existingCipherData.Password; - cipher.Data = JsonSerializer.Serialize(newCipherData); + // Keep only non-hidden fileds from the new cipher + var nonHiddenFields = newCipherData.Fields?.Where(f => f.Type != FieldType.Hidden) ?? []; + // Get hidden fields from the existing cipher + var hiddenFields = existingCipherData.Fields?.Where(f => f.Type == FieldType.Hidden) ?? []; + // Replace the hidden fields in new cipher data with the existing ones + newCipherData.Fields = nonHiddenFields.Concat(hiddenFields); + cipher.Data = SerializeCipherData(newCipherData); + if (existingCipherData is CipherLoginData existingLoginData && newCipherData is CipherLoginData newLoginCipherData) + { + // "hidden password" users may not change passwords, TOTP codes, or passkeys, so we need to set them back to the original values + newLoginCipherData.Fido2Credentials = existingLoginData.Fido2Credentials; + newLoginCipherData.Totp = existingLoginData.Totp; + newLoginCipherData.Password = existingLoginData.Password; + cipher.Data = SerializeCipherData(newLoginCipherData); + } } } + private string SerializeCipherData(CipherData data) + { + return data switch + { + CipherLoginData loginData => JsonSerializer.Serialize(loginData), + CipherIdentityData identityData => JsonSerializer.Serialize(identityData), + CipherCardData cardData => JsonSerializer.Serialize(cardData), + CipherSecureNoteData noteData => JsonSerializer.Serialize(noteData), + CipherSSHKeyData sshKeyData => JsonSerializer.Serialize(sshKeyData), + _ => throw new ArgumentException("Unsupported cipher data type.", nameof(data)) + }; + } + + private CipherData DeserializeCipherData(Cipher cipher) + { + return cipher.Type switch + { + CipherType.Login => JsonSerializer.Deserialize(cipher.Data), + CipherType.Identity => JsonSerializer.Deserialize(cipher.Data), + CipherType.Card => JsonSerializer.Deserialize(cipher.Data), + CipherType.SecureNote => JsonSerializer.Deserialize(cipher.Data), + CipherType.SSHKey => JsonSerializer.Deserialize(cipher.Data), + _ => throw new ArgumentException("Unsupported cipher type.", nameof(cipher)) + }; + } + // This method is used to filter ciphers based on the user's permissions to delete them. // It supports both the old and new logic depending on the feature flag. private async Task> FilterCiphersByDeletePermission( diff --git a/test/Core.Test/Vault/Services/CipherServiceTests.cs b/test/Core.Test/Vault/Services/CipherServiceTests.cs index ed07799c93..061d90bcc3 100644 --- a/test/Core.Test/Vault/Services/CipherServiceTests.cs +++ b/test/Core.Test/Vault/Services/CipherServiceTests.cs @@ -1228,7 +1228,8 @@ public class CipherServiceTests bool editPermission, string? key = null, string? totp = null, - CipherLoginFido2CredentialData[]? passkeys = null + CipherLoginFido2CredentialData[]? passkeys = null, + CipherFieldData[]? fields = null ) { var cipherDetails = new CipherDetails @@ -1241,12 +1242,13 @@ public class CipherServiceTests Key = key, }; - var newLoginData = new CipherLoginData { Username = "user", Password = newPassword, Totp = totp, Fido2Credentials = passkeys }; + var newLoginData = new CipherLoginData { Username = "user", Password = newPassword, Totp = totp, Fido2Credentials = passkeys, Fields = fields }; cipherDetails.Data = JsonSerializer.Serialize(newLoginData); var existingCipher = new Cipher { Id = cipherDetails.Id, + Type = CipherType.Login, Data = JsonSerializer.Serialize( new CipherLoginData { @@ -1442,6 +1444,56 @@ public class CipherServiceTests Assert.Equal(passkeys.Length, updatedLoginData.Fido2Credentials.Length); } + [Theory] + [BitAutoData] + public async Task SaveDetailsAsync_HiddenFieldsChangedWithoutPermission(string _, SutProvider sutProvider) + { + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: false, editPermission: false, fields: + [ + new CipherFieldData + { + Name = "FieldName", + Value = "FieldValue", + Type = FieldType.Hidden, + } + ]); + + await deps.SutProvider.Sut.SaveDetailsAsync( + deps.CipherDetails, + deps.CipherDetails.UserId.Value, + deps.CipherDetails.RevisionDate, + null, + true); + + var updatedLoginData = JsonSerializer.Deserialize(deps.CipherDetails.Data); + Assert.Empty(updatedLoginData.Fields); + } + + [Theory] + [BitAutoData] + public async Task SaveDetailsAsync_HiddenFieldsChangedWithPermission(string _, SutProvider sutProvider) + { + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: true, fields: + [ + new CipherFieldData + { + Name = "FieldName", + Value = "FieldValue", + Type = FieldType.Hidden, + } + ]); + + await deps.SutProvider.Sut.SaveDetailsAsync( + deps.CipherDetails, + deps.CipherDetails.UserId.Value, + deps.CipherDetails.RevisionDate, + null, + true); + + var updatedLoginData = JsonSerializer.Deserialize(deps.CipherDetails.Data); + Assert.Single(updatedLoginData.Fields.ToArray()); + } + [Theory] [BitAutoData] public async Task DeleteAsync_WithPersonalCipherOwner_DeletesCipher( From 01a08c581476c56289bbc7fc1013f35be2a67d3d Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 16 Apr 2025 13:36:04 -0400 Subject: [PATCH 02/11] [PM-19566] Update MSPs to "charge_automatically" with Admin-based opt-out (#5650) * Update provider to charge automatically with Admin Portal-based opt-out * Design feedback * Run dotnet format --- .../Controllers/ProvidersController.cs | 33 +++++- .../AdminConsole/Models/ProviderEditModel.cs | 4 + .../AdminConsole/Views/Providers/Edit.cshtml | 11 ++ src/Billing/Services/IStripeFacade.cs | 6 ++ .../PaymentMethodAttachedHandler.cs | 100 +++++++++++++++++- .../Services/Implementations/StripeFacade.cs | 7 ++ src/Core/Billing/Constants/StripeConstants.cs | 1 + .../Billing/Extensions/CustomerExtensions.cs | 4 + src/Core/Constants.cs | 1 + 9 files changed, 163 insertions(+), 4 deletions(-) diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index 6dc33e4909..264e9df069 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -3,11 +3,13 @@ using System.Net; using Bit.Admin.AdminConsole.Models; using Bit.Admin.Enums; using Bit.Admin.Utilities; +using Bit.Core; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; @@ -23,6 +25,7 @@ using Bit.Core.Settings; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Stripe; namespace Bit.Admin.AdminConsole.Controllers; @@ -44,6 +47,7 @@ public class ProvidersController : Controller private readonly IProviderPlanRepository _providerPlanRepository; private readonly IProviderBillingService _providerBillingService; private readonly IPricingClient _pricingClient; + private readonly IStripeAdapter _stripeAdapter; private readonly string _stripeUrl; private readonly string _braintreeMerchantUrl; private readonly string _braintreeMerchantId; @@ -63,7 +67,8 @@ public class ProvidersController : Controller IProviderPlanRepository providerPlanRepository, IProviderBillingService providerBillingService, IWebHostEnvironment webHostEnvironment, - IPricingClient pricingClient) + IPricingClient pricingClient, + IStripeAdapter stripeAdapter) { _organizationRepository = organizationRepository; _organizationService = organizationService; @@ -79,6 +84,7 @@ public class ProvidersController : Controller _providerPlanRepository = providerPlanRepository; _providerBillingService = providerBillingService; _pricingClient = pricingClient; + _stripeAdapter = stripeAdapter; _stripeUrl = webHostEnvironment.GetStripeUrl(); _braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl(); _braintreeMerchantId = globalSettings.Braintree.MerchantId; @@ -306,6 +312,23 @@ public class ProvidersController : Controller (Plan: PlanType.EnterpriseMonthly, SeatsMinimum: model.EnterpriseMonthlySeatMinimum) ]); await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand); + + if (_featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically)) + { + var customer = await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId); + + if (model.PayByInvoice != customer.ApprovedToPayByInvoice()) + { + var approvedToPayByInvoice = model.PayByInvoice ? "1" : "0"; + await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions + { + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.InvoiceApproved] = approvedToPayByInvoice + } + }); + } + } break; case ProviderType.BusinessUnit: { @@ -345,14 +368,18 @@ public class ProvidersController : Controller if (!provider.IsBillable()) { - return new ProviderEditModel(provider, users, providerOrganizations, new List()); + return new ProviderEditModel(provider, users, providerOrganizations, new List(), false); } var providerPlans = await _providerPlanRepository.GetByProviderId(id); + var payByInvoice = + _featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically) && + (await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId)).ApprovedToPayByInvoice(); + return new ProviderEditModel( provider, users, providerOrganizations, - providerPlans.ToList(), GetGatewayCustomerUrl(provider), GetGatewaySubscriptionUrl(provider)); + providerPlans.ToList(), payByInvoice, GetGatewayCustomerUrl(provider), GetGatewaySubscriptionUrl(provider)); } [RequirePermission(Permission.Provider_ResendEmailInvite)] diff --git a/src/Admin/AdminConsole/Models/ProviderEditModel.cs b/src/Admin/AdminConsole/Models/ProviderEditModel.cs index 7f8ffb224e..44eebb8d7d 100644 --- a/src/Admin/AdminConsole/Models/ProviderEditModel.cs +++ b/src/Admin/AdminConsole/Models/ProviderEditModel.cs @@ -18,6 +18,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject IEnumerable providerUsers, IEnumerable organizations, IReadOnlyCollection providerPlans, + bool payByInvoice, string gatewayCustomerUrl = null, string gatewaySubscriptionUrl = null) : base(provider, providerUsers, organizations, providerPlans) { @@ -33,6 +34,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject GatewayCustomerUrl = gatewayCustomerUrl; GatewaySubscriptionUrl = gatewaySubscriptionUrl; Type = provider.Type; + PayByInvoice = payByInvoice; if (Type == ProviderType.BusinessUnit) { @@ -62,6 +64,8 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject public string GatewaySubscriptionId { get; set; } public string GatewayCustomerUrl { get; } public string GatewaySubscriptionUrl { get; } + [Display(Name = "Pay By Invoice")] + public bool PayByInvoice { get; set; } [Display(Name = "Provider Type")] public ProviderType Type { get; set; } diff --git a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml index 2f48054600..ce215e1575 100644 --- a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml +++ b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml @@ -136,6 +136,17 @@ + @if (FeatureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically) && Model.Provider.Type == ProviderType.Msp && Model.Provider.IsBillable()) + { +
+
+
+ + +
+
+
+ } } @await Html.PartialAsync("Organizations", Model) diff --git a/src/Billing/Services/IStripeFacade.cs b/src/Billing/Services/IStripeFacade.cs index 77ba9a1ad4..e53d901083 100644 --- a/src/Billing/Services/IStripeFacade.cs +++ b/src/Billing/Services/IStripeFacade.cs @@ -16,6 +16,12 @@ public interface IStripeFacade RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + Task UpdateCustomer( + string customerId, + CustomerUpdateOptions customerUpdateOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); + Task GetEvent( string eventId, EventGetOptions eventGetOptions = null, diff --git a/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs b/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs index 6092e001ce..c46429412f 100644 --- a/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs +++ b/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs @@ -1,4 +1,8 @@ using Bit.Billing.Constants; +using Bit.Core; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Extensions; +using Bit.Core.Services; using Stripe; using Event = Stripe.Event; @@ -10,20 +14,114 @@ public class PaymentMethodAttachedHandler : IPaymentMethodAttachedHandler private readonly IStripeEventService _stripeEventService; private readonly IStripeFacade _stripeFacade; private readonly IStripeEventUtilityService _stripeEventUtilityService; + private readonly IFeatureService _featureService; public PaymentMethodAttachedHandler( ILogger logger, IStripeEventService stripeEventService, IStripeFacade stripeFacade, - IStripeEventUtilityService stripeEventUtilityService) + IStripeEventUtilityService stripeEventUtilityService, + IFeatureService featureService) { _logger = logger; _stripeEventService = stripeEventService; _stripeFacade = stripeFacade; _stripeEventUtilityService = stripeEventUtilityService; + _featureService = featureService; } public async Task HandleAsync(Event parsedEvent) + { + var updateMSPToChargeAutomatically = + _featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically); + + if (updateMSPToChargeAutomatically) + { + await HandleVNextAsync(parsedEvent); + } + else + { + await HandleVCurrentAsync(parsedEvent); + } + } + + private async Task HandleVNextAsync(Event parsedEvent) + { + var paymentMethod = await _stripeEventService.GetPaymentMethod(parsedEvent, true, ["customer.subscriptions.data.latest_invoice"]); + + if (paymentMethod == null) + { + _logger.LogWarning("Attempted to handle the event payment_method.attached but paymentMethod was null"); + return; + } + + var customer = paymentMethod.Customer; + var subscriptions = customer?.Subscriptions; + + // This represents a provider subscription set to "send_invoice" that was paid using a Stripe hosted invoice payment page. + var invoicedProviderSubscription = subscriptions?.Data.FirstOrDefault(subscription => + subscription.Metadata.ContainsKey(StripeConstants.MetadataKeys.ProviderId) && + subscription.Status != StripeConstants.SubscriptionStatus.Canceled && + subscription.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice); + + /* + * If we have an invoiced provider subscription where the customer hasn't been marked as invoice-approved, + * we need to try and set the default payment method and update the collection method to be "charge_automatically". + */ + if (invoicedProviderSubscription != null && !customer.ApprovedToPayByInvoice()) + { + if (customer.InvoiceSettings.DefaultPaymentMethodId != paymentMethod.Id) + { + try + { + await _stripeFacade.UpdateCustomer(customer.Id, + new CustomerUpdateOptions + { + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + DefaultPaymentMethod = paymentMethod.Id + } + }); + } + catch (Exception exception) + { + _logger.LogWarning(exception, + "Failed to set customer's ({CustomerID}) default payment method during 'payment_method.attached' webhook", + customer.Id); + } + } + + try + { + await _stripeFacade.UpdateSubscription(invoicedProviderSubscription.Id, + new SubscriptionUpdateOptions + { + CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically + }); + } + catch (Exception exception) + { + _logger.LogWarning(exception, + "Failed to set subscription's ({SubscriptionID}) collection method to 'charge_automatically' during 'payment_method.attached' webhook", + customer.Id); + } + } + + var unpaidSubscriptions = subscriptions?.Data.Where(subscription => + subscription.Status == StripeConstants.SubscriptionStatus.Unpaid).ToList(); + + if (unpaidSubscriptions == null || unpaidSubscriptions.Count == 0) + { + return; + } + + foreach (var unpaidSubscription in unpaidSubscriptions) + { + await AttemptToPayOpenSubscriptionAsync(unpaidSubscription); + } + } + + private async Task HandleVCurrentAsync(Event parsedEvent) { var paymentMethod = await _stripeEventService.GetPaymentMethod(parsedEvent); if (paymentMethod is null) diff --git a/src/Billing/Services/Implementations/StripeFacade.cs b/src/Billing/Services/Implementations/StripeFacade.cs index 91e0c1c33a..191f84a343 100644 --- a/src/Billing/Services/Implementations/StripeFacade.cs +++ b/src/Billing/Services/Implementations/StripeFacade.cs @@ -33,6 +33,13 @@ public class StripeFacade : IStripeFacade CancellationToken cancellationToken = default) => await _customerService.GetAsync(customerId, customerGetOptions, requestOptions, cancellationToken); + public async Task UpdateCustomer( + string customerId, + CustomerUpdateOptions customerUpdateOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + await _customerService.UpdateAsync(customerId, customerUpdateOptions, requestOptions, cancellationToken); + public async Task GetInvoice( string invoiceId, InvoiceGetOptions invoiceGetOptions = null, diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 326023e34c..8a4303e378 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -46,6 +46,7 @@ public static class StripeConstants public static class MetadataKeys { + public const string InvoiceApproved = "invoice_approved"; public const string OrganizationId = "organizationId"; public const string ProviderId = "providerId"; public const string UserId = "userId"; diff --git a/src/Core/Billing/Extensions/CustomerExtensions.cs b/src/Core/Billing/Extensions/CustomerExtensions.cs index 8f15f61a7f..3e0c1ea0fb 100644 --- a/src/Core/Billing/Extensions/CustomerExtensions.cs +++ b/src/Core/Billing/Extensions/CustomerExtensions.cs @@ -27,4 +27,8 @@ public static class CustomerExtensions { return customer != null ? customer.Balance / 100M : default; } + + public static bool ApprovedToPayByInvoice(this Customer customer) + => customer.Metadata.TryGetValue(StripeConstants.MetadataKeys.InvoiceApproved, out var value) && + int.TryParse(value, out var invoiceApproved) && invoiceApproved == 1; } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index c57544283b..44962cd0ab 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -149,6 +149,7 @@ public static class FeatureFlagKeys public const string PM19147_AutomaticTaxImprovements = "pm-19147-automatic-tax-improvements"; 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"; /* Key Management Team */ public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; From 1399b1417ea3bd99717473d8dd7f5724b5a8530e Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Wed, 16 Apr 2025 15:46:49 -0400 Subject: [PATCH 03/11] PM-6675 - Remove old registration endpoint (#5585) * feat : remove old registration endpoint * fix: update integration test user registration to match current registration; We need to keep the IRegistrationCommand.RegisterUser method to JIT user. * fix: updating accounts/profile tests to match current implementations --- .../Registration/IRegisterUserCommand.cs | 1 + .../Controllers/AccountsController.cs | 20 - .../Controllers/AccountsControllerTest.cs | 40 +- .../Factories/ApiApplicationFactory.cs | 26 +- .../RegisterFinishRequestModelFixtures.cs | 58 +++ .../EventsApplicationFactory.cs | 23 +- .../Controllers/AccountsControllerTests.cs | 35 +- .../Endpoints/IdentityServerSsoTests.cs | 32 +- .../Endpoints/IdentityServerTests.cs | 346 +++++++++--------- .../Endpoints/IdentityServerTwoFactorTests.cs | 104 +++--- .../Identity.IntegrationTest.csproj | 1 + .../ResourceOwnerPasswordValidatorTests.cs | 85 +++-- .../Controllers/AccountsControllerTests.cs | 44 --- .../Factories/IdentityApplicationFactory.cs | 74 +++- 14 files changed, 457 insertions(+), 432 deletions(-) create mode 100644 test/Core.Test/Auth/AutoFixture/RegisterFinishRequestModelFixtures.cs diff --git a/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs index f61cce895a..62dd9dd293 100644 --- a/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs @@ -8,6 +8,7 @@ public interface IRegisterUserCommand /// /// Creates a new user, sends a welcome email, and raises the signup reference event. + /// This method is used for JIT of organization Users. /// /// The to create /// diff --git a/src/Identity/Controllers/AccountsController.cs b/src/Identity/Controllers/AccountsController.cs index 9360da586c..fd42074359 100644 --- a/src/Identity/Controllers/AccountsController.cs +++ b/src/Identity/Controllers/AccountsController.cs @@ -8,7 +8,6 @@ using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Services; using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Auth.UserFeatures.WebAuthnLogin; -using Bit.Core.Auth.Utilities; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -114,16 +113,6 @@ public class AccountsController : Controller } } - [HttpPost("register")] - [CaptchaProtected] - public async Task PostRegister([FromBody] RegisterRequestModel model) - { - var user = model.ToUser(); - var identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken(user, model.MasterPasswordHash, - model.Token, model.OrganizationUserId); - return ProcessRegistrationResult(identityResult, user); - } - [HttpPost("register/send-verification-email")] public async Task PostRegisterSendVerificationEmail([FromBody] RegisterSendVerificationEmailRequestModel model) { @@ -175,8 +164,6 @@ public class AccountsController : Controller } return Ok(); - - } [HttpPost("register/finish")] @@ -185,7 +172,6 @@ public class AccountsController : Controller var user = model.ToUser(); // Users will either have an emailed token or an email verification token - not both. - IdentityResult identityResult = null; switch (model.GetTokenType()) @@ -196,33 +182,27 @@ public class AccountsController : Controller model.EmailVerificationToken); return ProcessRegistrationResult(identityResult, user); - break; case RegisterFinishTokenType.OrganizationInvite: identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken(user, model.MasterPasswordHash, model.OrgInviteToken, model.OrganizationUserId); return ProcessRegistrationResult(identityResult, user); - break; case RegisterFinishTokenType.OrgSponsoredFreeFamilyPlan: identityResult = await _registerUserCommand.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, model.MasterPasswordHash, model.OrgSponsoredFreeFamilyPlanToken); return ProcessRegistrationResult(identityResult, user); - break; case RegisterFinishTokenType.EmergencyAccessInvite: Debug.Assert(model.AcceptEmergencyAccessId.HasValue); identityResult = await _registerUserCommand.RegisterUserViaAcceptEmergencyAccessInviteToken(user, model.MasterPasswordHash, model.AcceptEmergencyAccessInviteToken, model.AcceptEmergencyAccessId.Value); return ProcessRegistrationResult(identityResult, user); - break; case RegisterFinishTokenType.ProviderInvite: Debug.Assert(model.ProviderUserId.HasValue); identityResult = await _registerUserCommand.RegisterUserViaProviderInviteToken(user, model.MasterPasswordHash, model.ProviderInviteToken, model.ProviderUserId.Value); return ProcessRegistrationResult(identityResult, user); - break; - default: throw new BadRequestException("Invalid registration finish request"); } diff --git a/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs b/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs index 277f558566..4e5a6850e7 100644 --- a/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs +++ b/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs @@ -1,13 +1,6 @@ using System.Net.Http.Headers; using Bit.Api.IntegrationTest.Factories; -using Bit.Api.IntegrationTest.Helpers; using Bit.Api.Models.Response; -using Bit.Core; -using Bit.Core.Billing.Enums; -using Bit.Core.Enums; -using Bit.Core.Models.Data; -using Bit.Core.Services; -using NSubstitute; using Xunit; namespace Bit.Api.IntegrationTest.Controllers; @@ -19,7 +12,7 @@ public class AccountsControllerTest : IClassFixture public AccountsControllerTest(ApiApplicationFactory factory) => _factory = factory; [Fact] - public async Task GetPublicKey() + public async Task GetAccountsProfile_success() { var tokens = await _factory.LoginWithNewAccount(); var client = _factory.CreateClient(); @@ -33,36 +26,13 @@ public class AccountsControllerTest : IClassFixture var content = await response.Content.ReadFromJsonAsync(); Assert.NotNull(content); Assert.Equal("integration-test@bitwarden.com", content.Email); - Assert.Null(content.Name); - Assert.False(content.EmailVerified); + Assert.NotNull(content.Name); + Assert.True(content.EmailVerified); Assert.False(content.Premium); Assert.False(content.PremiumFromOrganization); Assert.Equal("en-US", content.Culture); - Assert.Null(content.Key); - Assert.Null(content.PrivateKey); + Assert.NotNull(content.Key); + Assert.NotNull(content.PrivateKey); Assert.NotNull(content.SecurityStamp); } - - private async Task SetupOrganizationManagedAccount() - { - _factory.SubstituteService(featureService => - featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true)); - - // Create the owner account - var ownerEmail = $"{Guid.NewGuid()}@bitwarden.com"; - await _factory.LoginWithNewAccount(ownerEmail); - - // Create the organization - var (_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually2023, - ownerEmail: ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card); - - // Create a new organization member - var (email, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, - OrganizationUserType.Custom, new Permissions { AccessReports = true, ManageScim = true }); - - // Add a verified domain - await OrganizationTestHelpers.CreateVerifiedDomainAsync(_factory, _organization.Id, "bitwarden.com"); - - return email; - } } diff --git a/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs b/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs index 230f0bcf08..a0963745de 100644 --- a/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs +++ b/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs @@ -1,4 +1,6 @@ -using Bit.Identity.Models.Request.Accounts; +using Bit.Core; +using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Enums; using Bit.IntegrationTestCommon.Factories; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.TestHost; @@ -42,13 +44,23 @@ public class ApiApplicationFactory : WebApplicationFactoryBase /// /// Helper for registering and logging in to a new account /// - public async Task<(string Token, string RefreshToken)> LoginWithNewAccount(string email = "integration-test@bitwarden.com", string masterPasswordHash = "master_password_hash") + public async Task<(string Token, string RefreshToken)> LoginWithNewAccount( + string email = "integration-test@bitwarden.com", string masterPasswordHash = "master_password_hash") { - await _identityApplicationFactory.RegisterAsync(new RegisterRequestModel - { - Email = email, - MasterPasswordHash = masterPasswordHash, - }); + await _identityApplicationFactory.RegisterNewIdentityFactoryUserAsync( + new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordHash, + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + UserAsymmetricKeys = new KeysRequestModel() + { + PublicKey = "public_key", + EncryptedPrivateKey = "private_key" + }, + UserSymmetricKey = "sym_key", + }); return await _identityApplicationFactory.TokenFromPasswordAsync(email, masterPasswordHash); } diff --git a/test/Core.Test/Auth/AutoFixture/RegisterFinishRequestModelFixtures.cs b/test/Core.Test/Auth/AutoFixture/RegisterFinishRequestModelFixtures.cs new file mode 100644 index 0000000000..a751a16f31 --- /dev/null +++ b/test/Core.Test/Auth/AutoFixture/RegisterFinishRequestModelFixtures.cs @@ -0,0 +1,58 @@ +using System.ComponentModel.DataAnnotations; +using AutoFixture; +using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Enums; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture.Attributes; + +namespace Bit.Core.Test.Auth.AutoFixture; + +internal class RegisterFinishRequestModelCustomization : ICustomization +{ + [StrictEmailAddress, StringLength(256)] + public required string Email { get; set; } + public required KdfType Kdf { get; set; } + public required int KdfIterations { get; set; } + public string? EmailVerificationToken { get; set; } + public string? OrgInviteToken { get; set; } + public string? OrgSponsoredFreeFamilyPlanToken { get; set; } + public string? AcceptEmergencyAccessInviteToken { get; set; } + public string? ProviderInviteToken { get; set; } + + public void Customize(IFixture fixture) + { + fixture.Customize(composer => composer + .With(o => o.Email, Email) + .With(o => o.Kdf, Kdf) + .With(o => o.KdfIterations, KdfIterations) + .With(o => o.EmailVerificationToken, EmailVerificationToken) + .With(o => o.OrgInviteToken, OrgInviteToken) + .With(o => o.OrgSponsoredFreeFamilyPlanToken, OrgSponsoredFreeFamilyPlanToken) + .With(o => o.AcceptEmergencyAccessInviteToken, AcceptEmergencyAccessInviteToken) + .With(o => o.ProviderInviteToken, ProviderInviteToken)); + } +} + +public class RegisterFinishRequestModelCustomizeAttribute : BitCustomizeAttribute +{ + public string _email { get; set; } = "{0}@email.com"; + public KdfType _kdf { get; set; } = KdfType.PBKDF2_SHA256; + public int _kdfIterations { get; set; } = AuthConstants.PBKDF2_ITERATIONS.Default; + public string? _emailVerificationToken { get; set; } + public string? _orgInviteToken { get; set; } + public string? _orgSponsoredFreeFamilyPlanToken { get; set; } + public string? _acceptEmergencyAccessInviteToken { get; set; } + public string? _providerInviteToken { get; set; } + + public override ICustomization GetCustomization() => new RegisterFinishRequestModelCustomization() + { + Email = _email, + Kdf = _kdf, + KdfIterations = _kdfIterations, + EmailVerificationToken = _emailVerificationToken, + OrgInviteToken = _orgInviteToken, + OrgSponsoredFreeFamilyPlanToken = _orgSponsoredFreeFamilyPlanToken, + AcceptEmergencyAccessInviteToken = _acceptEmergencyAccessInviteToken, + ProviderInviteToken = _providerInviteToken + }; +} diff --git a/test/Events.IntegrationTest/EventsApplicationFactory.cs b/test/Events.IntegrationTest/EventsApplicationFactory.cs index 3faf5e81bf..b1c3ef8bf5 100644 --- a/test/Events.IntegrationTest/EventsApplicationFactory.cs +++ b/test/Events.IntegrationTest/EventsApplicationFactory.cs @@ -1,4 +1,6 @@ -using Bit.Identity.Models.Request.Accounts; +using Bit.Core; +using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Enums; using Bit.IntegrationTestCommon.Factories; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Hosting; @@ -40,11 +42,20 @@ public class EventsApplicationFactory : WebApplicationFactoryBase /// public async Task<(string Token, string RefreshToken)> LoginWithNewAccount(string email = "integration-test@bitwarden.com", string masterPasswordHash = "master_password_hash") { - await _identityApplicationFactory.RegisterAsync(new RegisterRequestModel - { - Email = email, - MasterPasswordHash = masterPasswordHash, - }); + await _identityApplicationFactory.RegisterNewIdentityFactoryUserAsync( + new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordHash, + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + UserAsymmetricKeys = new KeysRequestModel() + { + PublicKey = "public_key", + EncryptedPrivateKey = "private_key" + }, + UserSymmetricKey = "sym_key", + }); return await _identityApplicationFactory.TokenFromPasswordAsync(email, masterPasswordHash); } diff --git a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs index 3b8534ef32..88e8af3dc6 100644 --- a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs +++ b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs @@ -8,10 +8,8 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business.Tokenables; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Tokens; using Bit.Core.Utilities; -using Bit.Identity.Models.Request.Accounts; using Bit.IntegrationTestCommon.Factories; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.DataProtection; @@ -31,24 +29,6 @@ public class AccountsControllerTests : IClassFixture _factory = factory; } - [Fact] - public async Task PostRegister_Success() - { - var context = await _factory.RegisterAsync(new RegisterRequestModel - { - Email = "test+register@email.com", - MasterPasswordHash = "master_password_hash" - }); - - Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); - - var database = _factory.GetDatabaseContext(); - var user = await database.Users - .SingleAsync(u => u.Email == "test+register@email.com"); - - Assert.NotNull(user); - } - [Theory] [BitAutoData("invalidEmail")] [BitAutoData("")] @@ -154,6 +134,7 @@ public class AccountsControllerTests : IClassFixture } [Theory, BitAutoData] + // marketing emails can stay at top level public async Task RegistrationWithEmailVerification_WithEmailVerificationToken_Succeeds([Required] string name, bool receiveMarketingEmails, [StringLength(1000), Required] string masterPasswordHash, [StringLength(50)] string masterPasswordHint, [Required] string userSymmetricKey, [Required] KeysRequestModel userAsymmetricKeys, int kdfMemory, int kdfParallelism) @@ -161,16 +142,6 @@ public class AccountsControllerTests : IClassFixture // Localize substitutions to this test. var localFactory = new IdentityApplicationFactory(); - // First we must substitute the mail service in order to be able to get a valid email verification token - // for the complete registration step - string capturedEmailVerificationToken = null; - localFactory.SubstituteService(mailService => - { - mailService.SendRegistrationVerificationEmailAsync(Arg.Any(), Arg.Do(t => capturedEmailVerificationToken = t)) - .Returns(Task.CompletedTask); - - }); - // we must first call the send verification email endpoint to trigger the first part of the process var email = $"test+register+{name}@email.com"; var sendVerificationEmailReqModel = new RegisterSendVerificationEmailRequestModel @@ -183,7 +154,7 @@ public class AccountsControllerTests : IClassFixture var sendEmailVerificationResponseHttpContext = await localFactory.PostRegisterSendEmailVerificationAsync(sendVerificationEmailReqModel); Assert.Equal(StatusCodes.Status204NoContent, sendEmailVerificationResponseHttpContext.Response.StatusCode); - Assert.NotNull(capturedEmailVerificationToken); + Assert.NotNull(localFactory.RegistrationTokens[email]); // Now we call the finish registration endpoint with the email verification token var registerFinishReqModel = new RegisterFinishRequestModel @@ -191,7 +162,7 @@ public class AccountsControllerTests : IClassFixture Email = email, MasterPasswordHash = masterPasswordHash, MasterPasswordHint = masterPasswordHint, - EmailVerificationToken = capturedEmailVerificationToken, + EmailVerificationToken = localFactory.RegistrationTokens[email], Kdf = KdfType.PBKDF2_SHA256, KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, UserSymmetricKey = userSymmetricKey, diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs index 602d5cfe48..c2812cc58f 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs @@ -1,11 +1,13 @@ using System.Security.Claims; using System.Text.Json; +using Bit.Core; 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.Auth.Entities; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; using Bit.Core.Entities; @@ -13,7 +15,6 @@ using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Utilities; -using Bit.Identity.Models.Request.Accounts; using Bit.IntegrationTestCommon.Factories; using Bit.Test.Common.Helpers; using Duende.IdentityModel; @@ -545,16 +546,15 @@ public class IdentityServerSsoTests { var factory = new IdentityApplicationFactory(); - var authorizationCode = new AuthorizationCode { ClientId = "web", CreationTime = DateTime.UtcNow, Lifetime = (int)TimeSpan.FromMinutes(5).TotalSeconds, RedirectUri = "https://localhost:8080/sso-connector.html", - RequestedScopes = new[] { "api", "offline_access" }, + RequestedScopes = ["api", "offline_access"], CodeChallenge = challenge.Sha256(), - CodeChallengeMethod = "plain", // + CodeChallengeMethod = "plain", Subject = null!, // Temporarily set it to null }; @@ -564,16 +564,20 @@ public class IdentityServerSsoTests .Returns(authorizationCode); }); - // This starts the server and finalizes services - var registerResponse = await factory.RegisterAsync(new RegisterRequestModel - { - Email = TestEmail, - MasterPasswordHash = "master_password_hash", - }); - - var userRepository = factory.Services.GetRequiredService(); - var user = await userRepository.GetByEmailAsync(TestEmail); - Assert.NotNull(user); + var user = await factory.RegisterNewIdentityFactoryUserAsync( + new RegisterFinishRequestModel + { + Email = TestEmail, + MasterPasswordHash = "masterPasswordHash", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + UserAsymmetricKeys = new KeysRequestModel() + { + PublicKey = "public_key", + EncryptedPrivateKey = "private_key" + }, + UserSymmetricKey = "sym_key", + }); var organizationRepository = factory.Services.GetRequiredService(); var organization = await organizationRepository.CreateAsync(new Organization diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs index 38a1518d14..f4e36fa7d5 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs @@ -3,11 +3,13 @@ using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Platform.Installations; using Bit.Core.Repositories; +using Bit.Core.Test.Auth.AutoFixture; using Bit.Identity.IdentityServer; -using Bit.Identity.Models.Request.Accounts; using Bit.IntegrationTestCommon.Factories; using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.Helpers; @@ -17,6 +19,7 @@ using Xunit; namespace Bit.Identity.IntegrationTest.Endpoints; +[SutProviderCustomize] public class IdentityServerTests : IClassFixture { private const int SecondsInMinute = 60; @@ -27,7 +30,7 @@ public class IdentityServerTests : IClassFixture public IdentityServerTests(IdentityApplicationFactory factory) { _factory = factory; - ReinitializeDbForTests(); + ReinitializeDbForTests(_factory); } [Fact] @@ -48,18 +51,14 @@ public class IdentityServerTests : IClassFixture AssertHelper.AssertEqualJson(endpointRoot, knownConfigurationRoot); } - [Theory, BitAutoData] - public async Task TokenEndpoint_GrantTypePassword_Success(string deviceId) + [Theory, BitAutoData, RegisterFinishRequestModelCustomize] + public async Task TokenEndpoint_GrantTypePassword_Success(RegisterFinishRequestModel requestModel) { - var username = "test+tokenpassword@email.com"; + var localFactory = new IdentityApplicationFactory(); + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); - await _factory.RegisterAsync(new RegisterRequestModel - { - Email = username, - MasterPasswordHash = "master_password_hash" - }); - - var context = await PostLoginAsync(_factory.Server, username, deviceId, context => context.SetAuthEmail(username)); + var context = await PostLoginAsync(localFactory.Server, user, requestModel.MasterPasswordHash, + context => context.SetAuthEmail(user.Email)); using var body = await AssertDefaultTokenBodyAsync(context); var root = body.RootElement; @@ -73,18 +72,16 @@ public class IdentityServerTests : IClassFixture AssertUserDecryptionOptions(root); } - [Theory, BitAutoData] - public async Task TokenEndpoint_GrantTypePassword_NoAuthEmailHeader_Fails(string deviceId) + [Theory, BitAutoData, RegisterFinishRequestModelCustomize] + public async Task TokenEndpoint_GrantTypePassword_NoAuthEmailHeader_Fails( + RegisterFinishRequestModel requestModel) { - var username = "test+noauthemailheader@email.com"; + requestModel.Email = "test+noauthemailheader@email.com"; - await _factory.RegisterAsync(new RegisterRequestModel - { - Email = username, - MasterPasswordHash = "master_password_hash", - }); + var localFactory = new IdentityApplicationFactory(); + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); - var context = await PostLoginAsync(_factory.Server, username, deviceId, null); + var context = await PostLoginAsync(localFactory.Server, user, requestModel.MasterPasswordHash, null); Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); @@ -96,18 +93,17 @@ public class IdentityServerTests : IClassFixture AssertHelper.AssertJsonProperty(root, "error_description", JsonValueKind.String); } - [Theory, BitAutoData] - public async Task TokenEndpoint_GrantTypePassword_InvalidBase64AuthEmailHeader_Fails(string deviceId) + [Theory, BitAutoData, RegisterFinishRequestModelCustomize] + public async Task TokenEndpoint_GrantTypePassword_InvalidBase64AuthEmailHeader_Fails( + RegisterFinishRequestModel requestModel) { - var username = "test+badauthheader@email.com"; + requestModel.Email = "test+badauthheader@email.com"; - await _factory.RegisterAsync(new RegisterRequestModel - { - Email = username, - MasterPasswordHash = "master_password_hash", - }); + var localFactory = new IdentityApplicationFactory(); + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); - var context = await PostLoginAsync(_factory.Server, username, deviceId, context => context.Request.Headers.Append("Auth-Email", "bad_value")); + var context = await PostLoginAsync(localFactory.Server, user, requestModel.MasterPasswordHash, + context => context.Request.Headers.Append("Auth-Email", "bad_value")); Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); @@ -119,18 +115,17 @@ public class IdentityServerTests : IClassFixture AssertHelper.AssertJsonProperty(root, "error_description", JsonValueKind.String); } - [Theory, BitAutoData] - public async Task TokenEndpoint_GrantTypePassword_WrongAuthEmailHeader_Fails(string deviceId) + [Theory, BitAutoData, RegisterFinishRequestModelCustomize] + public async Task TokenEndpoint_GrantTypePassword_WrongAuthEmailHeader_Fails( + RegisterFinishRequestModel requestModel) { - var username = "test+badauthheader@email.com"; + requestModel.Email = "test+badauthheader@email.com"; - await _factory.RegisterAsync(new RegisterRequestModel - { - Email = username, - MasterPasswordHash = "master_password_hash", - }); + var localFactory = new IdentityApplicationFactory(); + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); - var context = await PostLoginAsync(_factory.Server, username, deviceId, context => context.SetAuthEmail("bad_value")); + var context = await PostLoginAsync(localFactory.Server, user, requestModel.MasterPasswordHash, + context => context.SetAuthEmail("bad_value")); Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); @@ -142,215 +137,198 @@ public class IdentityServerTests : IClassFixture AssertHelper.AssertJsonProperty(root, "error_description", JsonValueKind.String); } - [Theory] + [Theory, RegisterFinishRequestModelCustomize] [BitAutoData(OrganizationUserType.Owner)] [BitAutoData(OrganizationUserType.Admin)] [BitAutoData(OrganizationUserType.User)] [BitAutoData(OrganizationUserType.Custom)] - public async Task TokenEndpoint_GrantTypePassword_WithAllUserTypes_WithSsoPolicyDisabled_WithEnforceSsoPolicyForAllUsersTrue_Success(OrganizationUserType organizationUserType, Guid organizationId, string deviceId, int generatedUsername) + public async Task TokenEndpoint_GrantTypePassword_WithAllUserTypes_WithSsoPolicyDisabled_WithEnforceSsoPolicyForAllUsersTrue_Success( + OrganizationUserType organizationUserType, RegisterFinishRequestModel requestModel, Guid organizationId, int generatedUsername) { - var username = $"{generatedUsername}@example.com"; + requestModel.Email = $"{generatedUsername}@example.com"; - var server = _factory.WithWebHostBuilder(builder => + var localFactory = new IdentityApplicationFactory(); + var server = localFactory.WithWebHostBuilder(builder => { builder.UseSetting("globalSettings:sso:enforceSsoPolicyForAllUsers", "true"); }).Server; + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); - await server.PostAsync("/accounts/register", JsonContent.Create(new RegisterRequestModel - { - Email = username, - MasterPasswordHash = "master_password_hash" - })); + await CreateOrganizationWithSsoPolicyAsync(localFactory, + organizationId, user.Email, organizationUserType, ssoPolicyEnabled: false); - await CreateOrganizationWithSsoPolicyAsync(organizationId, username, organizationUserType, ssoPolicyEnabled: false); - - var context = await PostLoginAsync(server, username, deviceId, context => context.SetAuthEmail(username)); + var context = await PostLoginAsync(server, user, requestModel.MasterPasswordHash, + context => context.SetAuthEmail(user.Email)); Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); } - [Theory] + [Theory, RegisterFinishRequestModelCustomize] [BitAutoData(OrganizationUserType.Owner)] [BitAutoData(OrganizationUserType.Admin)] [BitAutoData(OrganizationUserType.User)] [BitAutoData(OrganizationUserType.Custom)] - public async Task TokenEndpoint_GrantTypePassword_WithAllUserTypes_WithSsoPolicyDisabled_WithEnforceSsoPolicyForAllUsersFalse_Success(OrganizationUserType organizationUserType, Guid organizationId, string deviceId, int generatedUsername) + public async Task TokenEndpoint_GrantTypePassword_WithAllUserTypes_WithSsoPolicyDisabled_WithEnforceSsoPolicyForAllUsersFalse_Success( + OrganizationUserType organizationUserType, RegisterFinishRequestModel requestModel, Guid organizationId, int generatedUsername) { - var username = $"{generatedUsername}@example.com"; + requestModel.Email = $"{generatedUsername}@example.com"; - var server = _factory.WithWebHostBuilder(builder => + var localFactory = new IdentityApplicationFactory(); + var server = localFactory.WithWebHostBuilder(builder => { builder.UseSetting("globalSettings:sso:enforceSsoPolicyForAllUsers", "false"); + }).Server; + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); - await server.PostAsync("/accounts/register", JsonContent.Create(new RegisterRequestModel - { - Email = username, - MasterPasswordHash = "master_password_hash" - })); + await CreateOrganizationWithSsoPolicyAsync( + localFactory, organizationId, user.Email, organizationUserType, ssoPolicyEnabled: false); - await CreateOrganizationWithSsoPolicyAsync(organizationId, username, organizationUserType, ssoPolicyEnabled: false); - - var context = await PostLoginAsync(server, username, deviceId, context => context.SetAuthEmail(username)); + var context = await PostLoginAsync(server, user, requestModel.MasterPasswordHash, + context => context.SetAuthEmail(user.Email)); Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); } - [Theory] + [Theory, RegisterFinishRequestModelCustomize] [BitAutoData(OrganizationUserType.Owner)] [BitAutoData(OrganizationUserType.Admin)] [BitAutoData(OrganizationUserType.User)] [BitAutoData(OrganizationUserType.Custom)] - public async Task TokenEndpoint_GrantTypePassword_WithAllUserTypes_WithSsoPolicyEnabled_WithEnforceSsoPolicyForAllUsersTrue_Throw(OrganizationUserType organizationUserType, Guid organizationId, string deviceId, int generatedUsername) + public async Task TokenEndpoint_GrantTypePassword_WithAllUserTypes_WithSsoPolicyEnabled_WithEnforceSsoPolicyForAllUsersTrue_Throw( + OrganizationUserType organizationUserType, RegisterFinishRequestModel requestModel, Guid organizationId, int generatedUsername) { - var username = $"{generatedUsername}@example.com"; + requestModel.Email = $"{generatedUsername}@example.com"; - var server = _factory.WithWebHostBuilder(builder => + var localFactory = new IdentityApplicationFactory(); + var server = localFactory.WithWebHostBuilder(builder => { builder.UseSetting("globalSettings:sso:enforceSsoPolicyForAllUsers", "true"); }).Server; + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); - await server.PostAsync("/accounts/register", JsonContent.Create(new RegisterRequestModel - { - Email = username, - MasterPasswordHash = "master_password_hash" - })); + await CreateOrganizationWithSsoPolicyAsync(localFactory, organizationId, user.Email, organizationUserType, ssoPolicyEnabled: true); - await CreateOrganizationWithSsoPolicyAsync(organizationId, username, organizationUserType, ssoPolicyEnabled: true); - - var context = await PostLoginAsync(server, username, deviceId, context => context.SetAuthEmail(username)); + var context = await PostLoginAsync(server, user, requestModel.MasterPasswordHash, + context => context.SetAuthEmail(user.Email)); Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); await AssertRequiredSsoAuthenticationResponseAsync(context); } - [Theory] + [Theory, RegisterFinishRequestModelCustomize] [BitAutoData(OrganizationUserType.Owner)] [BitAutoData(OrganizationUserType.Admin)] - public async Task TokenEndpoint_GrantTypePassword_WithOwnerOrAdmin_WithSsoPolicyEnabled_WithEnforceSsoPolicyForAllUsersFalse_Success(OrganizationUserType organizationUserType, Guid organizationId, string deviceId, int generatedUsername) + public async Task TokenEndpoint_GrantTypePassword_WithOwnerOrAdmin_WithSsoPolicyEnabled_WithEnforceSsoPolicyForAllUsersFalse_Success( + OrganizationUserType organizationUserType, RegisterFinishRequestModel requestModel, Guid organizationId, int generatedUsername) { - var username = $"{generatedUsername}@example.com"; + requestModel.Email = $"{generatedUsername}@example.com"; - var server = _factory.WithWebHostBuilder(builder => + var localFactory = new IdentityApplicationFactory(); + var server = localFactory.WithWebHostBuilder(builder => { builder.UseSetting("globalSettings:sso:enforceSsoPolicyForAllUsers", "false"); + }).Server; + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); - await server.PostAsync("/accounts/register", JsonContent.Create(new RegisterRequestModel - { - Email = username, - MasterPasswordHash = "master_password_hash" - })); + await CreateOrganizationWithSsoPolicyAsync(localFactory, organizationId, user.Email, organizationUserType, ssoPolicyEnabled: true); - await CreateOrganizationWithSsoPolicyAsync(organizationId, username, organizationUserType, ssoPolicyEnabled: true); - - var context = await PostLoginAsync(server, username, deviceId, context => context.SetAuthEmail(username)); + var context = await PostLoginAsync(server, user, requestModel.MasterPasswordHash, + context => context.SetAuthEmail(user.Email)); Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); } - [Theory] + [Theory, RegisterFinishRequestModelCustomize] [BitAutoData(OrganizationUserType.User)] [BitAutoData(OrganizationUserType.Custom)] - public async Task TokenEndpoint_GrantTypePassword_WithNonOwnerOrAdmin_WithSsoPolicyEnabled_WithEnforceSsoPolicyForAllUsersFalse_Throws(OrganizationUserType organizationUserType, Guid organizationId, string deviceId, int generatedUsername) + public async Task TokenEndpoint_GrantTypePassword_WithNonOwnerOrAdmin_WithSsoPolicyEnabled_WithEnforceSsoPolicyForAllUsersFalse_Throws( + OrganizationUserType organizationUserType, RegisterFinishRequestModel requestModel, Guid organizationId, int generatedUsername) { - var username = $"{generatedUsername}@example.com"; + requestModel.Email = $"{generatedUsername}@example.com"; - var server = _factory.WithWebHostBuilder(builder => + var localFactory = new IdentityApplicationFactory(); + var server = localFactory.WithWebHostBuilder(builder => { builder.UseSetting("globalSettings:sso:enforceSsoPolicyForAllUsers", "false"); + }).Server; + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); - await server.PostAsync("/accounts/register", JsonContent.Create(new RegisterRequestModel - { - Email = username, - MasterPasswordHash = "master_password_hash" - })); + await CreateOrganizationWithSsoPolicyAsync(localFactory, organizationId, user.Email, organizationUserType, ssoPolicyEnabled: true); - await CreateOrganizationWithSsoPolicyAsync(organizationId, username, organizationUserType, ssoPolicyEnabled: true); - - var context = await PostLoginAsync(server, username, deviceId, context => context.SetAuthEmail(username)); + var context = await PostLoginAsync(server, user, requestModel.MasterPasswordHash, + context => context.SetAuthEmail(user.Email)); Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); await AssertRequiredSsoAuthenticationResponseAsync(context); } - [Theory, BitAutoData] - public async Task TokenEndpoint_GrantTypeRefreshToken_Success(string deviceId) + [Theory, BitAutoData, RegisterFinishRequestModelCustomize] + public async Task TokenEndpoint_GrantTypeRefreshToken_Success(RegisterFinishRequestModel requestModel) { - var username = "test+tokenrefresh@email.com"; + var localFactory = new IdentityApplicationFactory(); - await _factory.RegisterAsync(new RegisterRequestModel - { - Email = username, - MasterPasswordHash = "master_password_hash", - }); + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); - var (_, refreshToken) = await _factory.TokenFromPasswordAsync(username, "master_password_hash", deviceId); + var (_, refreshToken) = await localFactory.TokenFromPasswordAsync( + requestModel.Email, requestModel.MasterPasswordHash); - var context = await _factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary - { - { "grant_type", "refresh_token" }, - { "client_id", "web" }, - { "refresh_token", refreshToken }, - })); + var context = await localFactory.Server.PostAsync("/connect/token", + new FormUrlEncodedContent(new Dictionary + { + { "grant_type", "refresh_token" }, + { "client_id", "web" }, + { "refresh_token", refreshToken }, + })); using var body = await AssertDefaultTokenBodyAsync(context); AssertRefreshTokenExists(body.RootElement); } - [Theory, BitAutoData] - public async Task TokenEndpoint_GrantTypeClientCredentials_Success(string deviceId) + [Theory, BitAutoData, RegisterFinishRequestModelCustomize] + public async Task TokenEndpoint_GrantTypeClientCredentials_Success(RegisterFinishRequestModel model) { - var username = "test+tokenclientcredentials@email.com"; + var localFactory = new IdentityApplicationFactory(); + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(model); - await _factory.RegisterAsync(new RegisterRequestModel - { - Email = username, - MasterPasswordHash = "master_password_hash", - }); - - var database = _factory.GetDatabaseContext(); - var user = await database.Users - .FirstAsync(u => u.Email == username); - - var context = await _factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary - { - { "grant_type", "client_credentials" }, - { "client_id", $"user.{user.Id}" }, - { "client_secret", user.ApiKey }, - { "scope", "api" }, - { "DeviceIdentifier", deviceId }, - { "DeviceType", DeviceTypeAsString(DeviceType.FirefoxBrowser) }, - { "DeviceName", "firefox" }, - })); + var context = await localFactory.Server.PostAsync("/connect/token", + new FormUrlEncodedContent(new Dictionary + { + { "grant_type", "client_credentials" }, + { "client_id", $"user.{user.Id}" }, + { "client_secret", user.ApiKey }, + { "scope", "api" }, + { "DeviceIdentifier", IdentityApplicationFactory.DefaultDeviceIdentifier }, + { "DeviceType", DeviceTypeAsString(DeviceType.FirefoxBrowser) }, + { "DeviceName", "firefox" }, + }) + ); await AssertDefaultTokenBodyAsync(context, "api"); } - [Theory, BitAutoData] - public async Task TokenEndpoint_GrantTypeClientCredentials_AsLegacyUser_NotOnWebClient_Fails(string deviceId) + [Theory, BitAutoData, RegisterFinishRequestModelCustomize] + public async Task TokenEndpoint_GrantTypeClientCredentials_AsLegacyUser_NotOnWebClient_Fails( + RegisterFinishRequestModel model, + string deviceId) { - var server = _factory.WithWebHostBuilder(builder => + var localFactory = new IdentityApplicationFactory(); + var server = localFactory.WithWebHostBuilder(builder => { builder.UseSetting("globalSettings:launchDarkly:flagValues:block-legacy-users", "true"); }).Server; - var username = "test+tokenclientcredentials@email.com"; + model.Email = "test+tokenclientcredentials@email.com"; + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(model); - - await server.PostAsync("/accounts/register", JsonContent.Create(new RegisterRequestModel - { - Email = username, - MasterPasswordHash = "master_password_hash" - })); - - - var database = _factory.GetDatabaseContext(); - var user = await database.Users - .FirstAsync(u => u.Email == username); - - user.PrivateKey = "EncryptedPrivateKey"; + // Modify user to be legacy user. We have to fetch the user again to put it in the ef-context + // so when we modify change tracking will save the changes. + var database = localFactory.GetDatabaseContext(); + user = await database.Users + .FirstAsync(u => u.Email == model.Email); + user.Key = null; await database.SaveChangesAsync(); var context = await server.PostAsync("/connect/token", new FormUrlEncodedContent( @@ -362,9 +340,9 @@ public class IdentityServerTests : IClassFixture { "deviceIdentifier", deviceId }, { "deviceName", "chrome" }, { "grant_type", "password" }, - { "username", username }, - { "password", "master_password_hash" }, - }), context => context.SetAuthEmail(username)); + { "username", model.Email }, + { "password", model.MasterPasswordHash }, + }), context => context.SetAuthEmail(model.Email)); Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); @@ -535,23 +513,21 @@ public class IdentityServerTests : IClassFixture Assert.Equal("invalid_client", error); } - [Theory, BitAutoData] - public async Task TokenEndpoint_TooQuickInOneSecond_BlockRequest(string deviceId) + [Theory, BitAutoData, RegisterFinishRequestModelCustomize] + public async Task TokenEndpoint_TooQuickInOneSecond_BlockRequest( + RegisterFinishRequestModel requestModel) { const int AmountInOneSecondAllowed = 10; // The rule we are testing is 10 requests in 1 second - var username = "test+ratelimiting@email.com"; + requestModel.Email = "test+ratelimiting@email.com"; - await _factory.RegisterAsync(new RegisterRequestModel - { - Email = username, - MasterPasswordHash = "master_password_hash", - }); + var localFactory = new IdentityApplicationFactory(); + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); - var database = _factory.GetDatabaseContext(); - var user = await database.Users - .FirstAsync(u => u.Email == username); + var database = localFactory.GetDatabaseContext(); + user = await database.Users + .FirstAsync(u => u.Email == user.Email); var tasks = new Task[AmountInOneSecondAllowed + 1]; @@ -573,36 +549,40 @@ public class IdentityServerTests : IClassFixture { "scope", "api offline_access" }, { "client_id", "web" }, { "deviceType", DeviceTypeAsString(DeviceType.FirefoxBrowser) }, - { "deviceIdentifier", deviceId }, + { "deviceIdentifier", IdentityApplicationFactory.DefaultDeviceIdentifier }, { "deviceName", "firefox" }, { "grant_type", "password" }, - { "username", username }, + { "username", user.Email}, { "password", "master_password_hash" }, - }), context => context.SetAuthEmail(username).SetIp("1.1.1.2")); + }), context => context.SetAuthEmail(user.Email).SetIp("1.1.1.2")); } } - private async Task PostLoginAsync(TestServer server, string username, string deviceId, Action extraConfiguration) + private async Task PostLoginAsync( + TestServer server, User user, string MasterPasswordHash, Action extraConfiguration) { return await server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary { { "scope", "api offline_access" }, { "client_id", "web" }, { "deviceType", DeviceTypeAsString(DeviceType.FirefoxBrowser) }, - { "deviceIdentifier", deviceId }, + { "deviceIdentifier", IdentityApplicationFactory.DefaultDeviceIdentifier }, { "deviceName", "firefox" }, { "grant_type", "password" }, - { "username", username }, - { "password", "master_password_hash" }, + { "username", user.Email }, + { "password", MasterPasswordHash }, }), extraConfiguration); } - private async Task CreateOrganizationWithSsoPolicyAsync(Guid organizationId, string username, OrganizationUserType organizationUserType, bool ssoPolicyEnabled) + private async Task CreateOrganizationWithSsoPolicyAsync( + IdentityApplicationFactory localFactory, + Guid organizationId, + string username, OrganizationUserType organizationUserType, bool ssoPolicyEnabled) { - var userRepository = _factory.Services.GetService(); - var organizationRepository = _factory.Services.GetService(); - var organizationUserRepository = _factory.Services.GetService(); - var policyRepository = _factory.Services.GetService(); + var userRepository = localFactory.Services.GetService(); + var organizationRepository = localFactory.Services.GetService(); + var organizationUserRepository = localFactory.Services.GetService(); + var policyRepository = localFactory.Services.GetService(); var organization = new Organization { @@ -617,7 +597,7 @@ public class IdentityServerTests : IClassFixture await organizationRepository.CreateAsync(organization); var user = await userRepository.GetByEmailAsync(username); - var organizationUser = new Bit.Core.Entities.OrganizationUser + var organizationUser = new OrganizationUser { OrganizationId = organization.Id, UserId = user.Id, @@ -703,9 +683,9 @@ public class IdentityServerTests : IClassFixture (prop) => { Assert.Equal("Object", prop.Name); Assert.Equal("userDecryptionOptions", prop.Value.GetString()); }); } - private void ReinitializeDbForTests() + private void ReinitializeDbForTests(IdentityApplicationFactory factory) { - var databaseContext = _factory.GetDatabaseContext(); + var databaseContext = factory.GetDatabaseContext(); databaseContext.Policies.RemoveRange(databaseContext.Policies); databaseContext.OrganizationUsers.RemoveRange(databaseContext.OrganizationUsers); databaseContext.Organizations.RemoveRange(databaseContext.Organizations); diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs index 6f0ef20295..82c6b13aad 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs @@ -1,8 +1,11 @@ using System.Security.Claims; +using System.Text; using System.Text.Json; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; using Bit.Core.Entities; @@ -11,7 +14,6 @@ using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; -using Bit.Identity.Models.Request.Accounts; using Bit.IntegrationTestCommon.Factories; using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.Helpers; @@ -19,6 +21,7 @@ using Duende.IdentityModel; using Duende.IdentityServer.Models; using Duende.IdentityServer.Stores; using LinqToDB; +using Microsoft.Extensions.Caching.Distributed; using NSubstitute; using Xunit; @@ -61,19 +64,14 @@ public class IdentityServerTwoFactorTests : IClassFixture(mailService => + // return specified email token from cache + var emailToken = "12345678"; + factory.SubstituteService(distCache => { - mailService.SendTwoFactorEmailAsync( - Arg.Any(), - Arg.Any(), - Arg.Do(t => emailToken = t), - Arg.Any(), - Arg.Any()) - .Returns(Task.CompletedTask); + distCache.GetAsync(Arg.Is(s => s.StartsWith("EmailToken_"))) + .Returns(Task.FromResult(Encoding.UTF8.GetBytes(emailToken))); }); // Create Test User @@ -102,10 +100,11 @@ public class IdentityServerTwoFactorTests : IClassFixture + var context = await localFactory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary { { "scope", "api offline_access" }, { "client_id", "web" }, @@ -156,10 +156,11 @@ public class IdentityServerTwoFactorTests : IClassFixture u.Email == _testEmail); // Act - var context = await _factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary + var context = await localFactory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary { { "grant_type", "client_credentials" }, { "client_id", $"user.{user.Id}" }, @@ -275,16 +277,13 @@ public class IdentityServerTwoFactorTests : IClassFixture(mailService => + + // return specified email token from cache + var emailToken = "12345678"; + localFactory.SubstituteService(distCache => { - mailService.SendTwoFactorEmailAsync( - Arg.Any(), - Arg.Any(), - Arg.Do(t => emailToken = t), - Arg.Any(), - Arg.Any()) - .Returns(Task.CompletedTask); + distCache.GetAsync(Arg.Is(s => s.StartsWith("EmailToken_"))) + .Returns(Task.FromResult(Encoding.UTF8.GetBytes(emailToken))); }); // Create Test User @@ -379,17 +378,24 @@ public class IdentityServerTwoFactorTests : IClassFixture(); - var user = await userRepository.GetByEmailAsync(testEmail); + var user = await factory.RegisterNewIdentityFactoryUserAsync( + new RegisterFinishRequestModel + { + Email = testEmail, + MasterPasswordHash = _testPassword, + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + UserAsymmetricKeys = new KeysRequestModel() + { + PublicKey = "public_key", + EncryptedPrivateKey = "private_key" + }, + UserSymmetricKey = "sym_key", + }); Assert.NotNull(user); var userService = factory.GetService(); + var userRepository = factory.Services.GetRequiredService(); if (userTwoFactor != null) { user.TwoFactorProviders = userTwoFactor; @@ -426,16 +432,20 @@ public class IdentityServerTwoFactorTests : IClassFixture(); - var user = await userRepository.GetByEmailAsync(testEmail); - Assert.NotNull(user); + var user = await factory.RegisterNewIdentityFactoryUserAsync( + new RegisterFinishRequestModel + { + Email = testEmail, + MasterPasswordHash = _testPassword, + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + UserAsymmetricKeys = new KeysRequestModel() + { + PublicKey = "public_key", + EncryptedPrivateKey = "private_key" + }, + UserSymmetricKey = "sym_key", + }); var userService = factory.GetService(); if (userTwoFactor != null) diff --git a/test/Identity.IntegrationTest/Identity.IntegrationTest.csproj b/test/Identity.IntegrationTest/Identity.IntegrationTest.csproj index d7a7bb9a01..5c94fad1d1 100644 --- a/test/Identity.IntegrationTest/Identity.IntegrationTest.csproj +++ b/test/Identity.IntegrationTest/Identity.IntegrationTest.csproj @@ -24,6 +24,7 @@ + diff --git a/test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs b/test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs index 4bec8d8167..9a1b8141ae 100644 --- a/test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs @@ -1,11 +1,11 @@ using System.Text.Json; +using Bit.Core; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; -using Bit.Core.Services; -using Bit.Identity.Models.Request.Accounts; using Bit.IntegrationTestCommon.Factories; using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.Helpers; @@ -19,28 +19,16 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture _userManager; - private readonly IAuthRequestRepository _authRequestRepository; - private readonly IDeviceService _deviceService; - - public ResourceOwnerPasswordValidatorTests(IdentityApplicationFactory factory) - { - _factory = factory; - - _userManager = _factory.GetService>(); - _authRequestRepository = _factory.GetService(); - _deviceService = _factory.GetService(); - } [Fact] public async Task ValidateAsync_Success() { // Arrange - await EnsureUserCreatedAsync(); + var localFactory = new IdentityApplicationFactory(); + await EnsureUserCreatedAsync(localFactory); // Act - var context = await _factory.Server.PostAsync("/connect/token", + var context = await localFactory.Server.PostAsync("/connect/token", GetFormUrlEncodedContent(), context => context.SetAuthEmail(DefaultUsername)); @@ -56,10 +44,11 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture context.SetAuthEmail(username)); @@ -105,13 +96,16 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture>(); // Verify the User is not null to ensure the failure is due to bad password - Assert.NotNull(await _userManager.FindByEmailAsync(DefaultUsername)); + Assert.NotNull(await userManager.FindByEmailAsync(DefaultUsername)); // Act - var context = await _factory.Server.PostAsync("/connect/token", + var context = await localFactory.Server.PostAsync("/connect/token", GetFormUrlEncodedContent(password: badPassword), context => context.SetAuthEmail(DefaultUsername)); @@ -128,9 +122,12 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture>(); + var user = await userManager.FindByEmailAsync(DefaultUsername); Assert.NotNull(user); // Connect Request to User and set CreationDate @@ -139,13 +136,14 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture(); + await authRequestRepository.CreateAsync(authRequest); - var expectedAuthRequest = await _authRequestRepository.GetManyByUserIdAsync(user.Id); + var expectedAuthRequest = await authRequestRepository.GetManyByUserIdAsync(user.Id); Assert.NotEmpty(expectedAuthRequest); // Act - var context = await _factory.Server.PostAsync("/connect/token", + var context = await localFactory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary { { "scope", "api offline_access" }, @@ -171,9 +169,12 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture>(); + + var user = await userManager.FindByEmailAsync(DefaultUsername); Assert.NotNull(user); // Create AuthRequest @@ -184,7 +185,7 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture { { "scope", "api offline_access" }, @@ -214,19 +215,23 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture(), passwordHash, token, userGuid) - .Returns(Task.FromResult(IdentityResult.Success)); - var request = new RegisterRequestModel - { - Name = "Example User", - Email = "user@example.com", - MasterPasswordHash = passwordHash, - MasterPasswordHint = "example", - Token = token, - OrganizationUserId = userGuid - }; - - await _sut.PostRegister(request); - - await _registerUserCommand.Received(1).RegisterUserViaOrganizationInviteToken(Arg.Any(), passwordHash, token, userGuid); - } - - [Fact] - public async Task PostRegister_WhenUserServiceFails_ShouldThrowBadRequestException() - { - var passwordHash = "abcdef"; - var token = "123456"; - var userGuid = new Guid(); - _registerUserCommand.RegisterUserViaOrganizationInviteToken(Arg.Any(), passwordHash, token, userGuid) - .Returns(Task.FromResult(IdentityResult.Failed())); - var request = new RegisterRequestModel - { - Name = "Example User", - Email = "user@example.com", - MasterPasswordHash = passwordHash, - MasterPasswordHint = "example", - Token = token, - OrganizationUserId = userGuid - }; - - await Assert.ThrowsAsync(() => _sut.PostRegister(request)); - } - [Theory] [BitAutoData] public async Task PostRegisterSendEmailVerification_WhenTokenReturnedFromCommand_Returns200WithToken(string email, string name, bool receiveMarketingEmails) diff --git a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs index b69a93013b..a686605836 100644 --- a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs +++ b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs @@ -1,23 +1,51 @@ -using System.Net.Http.Json; +using System.Collections.Concurrent; +using System.Net.Http.Json; using System.Text.Json; using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Services; using Bit.Core.Utilities; using Bit.Identity; -using Bit.Identity.Models.Request.Accounts; using Bit.Test.Common.Helpers; using HandlebarsDotNet; +using LinqToDB; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using NSubstitute; +using Xunit; namespace Bit.IntegrationTestCommon.Factories; public class IdentityApplicationFactory : WebApplicationFactoryBase { public const string DefaultDeviceIdentifier = "92b9d953-b9b6-4eaf-9d3e-11d57144dfeb"; + public const string DefaultUserEmail = "DefaultEmail@bitwarden.com"; + public const string DefaultUserPasswordHash = "default_password_hash"; - public async Task RegisterAsync(RegisterRequestModel model) + /// + /// A dictionary to store registration tokens for email verification. We cannot substitute the IMailService more than once, so + /// we capture the email tokens for new user registration in the constructor. The email must be unique otherwise an error will be thrown. + /// + public ConcurrentDictionary RegistrationTokens { get; private set; } = new ConcurrentDictionary(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) { - return await Server.PostAsync("/accounts/register", JsonContent.Create(model)); + // This allows us to use the official registration flow + SubstituteService(service => + { + service.SendRegistrationVerificationEmailAsync(Arg.Any(), Arg.Any()) + .ReturnsForAnyArgs(Task.CompletedTask) + .AndDoes(call => + { + if (!RegistrationTokens.TryAdd(call.ArgAt(0), call.ArgAt(1))) + { + throw new InvalidOperationException("This email was already registered for new user registration."); + } + }); + }); + + base.ConfigureWebHost(builder); } public async Task PostRegisterSendEmailVerificationAsync(RegisterSendVerificationEmailRequestModel model) @@ -155,4 +183,42 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase })); return context; } + + /// + /// Registers a new user to the Identity Application Factory based on the RegisterFinishRequestModel + /// + /// RegisterFinishRequestModel needed to seed data to the test user + /// optional parameter that is tracked during the inital steps of registration. + /// returns the newly created user + public async Task RegisterNewIdentityFactoryUserAsync( + RegisterFinishRequestModel requestModel, + bool marketingEmails = true) + { + var sendVerificationEmailReqModel = new RegisterSendVerificationEmailRequestModel + { + Email = requestModel.Email, + Name = "name", + ReceiveMarketingEmails = marketingEmails + }; + + var sendEmailVerificationResponseHttpContext = await PostRegisterSendEmailVerificationAsync(sendVerificationEmailReqModel); + + Assert.Equal(StatusCodes.Status204NoContent, sendEmailVerificationResponseHttpContext.Response.StatusCode); + Assert.NotNull(RegistrationTokens[requestModel.Email]); + + // Now we call the finish registration endpoint with the email verification token + requestModel.EmailVerificationToken = RegistrationTokens[requestModel.Email]; + + var postRegisterFinishHttpContext = await PostRegisterFinishAsync(requestModel); + + Assert.Equal(StatusCodes.Status200OK, postRegisterFinishHttpContext.Response.StatusCode); + + var database = GetDatabaseContext(); + var user = await database.Users + .SingleAsync(u => u.Email == requestModel.Email); + + Assert.NotNull(user); + + return user; + } } From 49bae6c241d96953d5658e5ffb34aa5eae41c83b Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Wed, 16 Apr 2025 15:38:09 -0700 Subject: [PATCH 04/11] [PM-10611] Add EndUserNotifications feature flag (#5663) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 44962cd0ab..5f3e954a46 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -203,6 +203,7 @@ public static class FeatureFlagKeys public const string CipherKeyEncryption = "cipher-key-encryption"; public const string DesktopCipherForms = "pm-18520-desktop-cipher-forms"; public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk"; + public const string EndUserNotifications = "pm-10609-end-user-notifications"; public static List GetAllKeys() { From ca29cda9ed332f091f999cd53c394acc8abaf141 Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Thu, 17 Apr 2025 08:45:05 -0400 Subject: [PATCH 05/11] [PM-17830] Force Admin Initiated Sponsorships migration script to run in QA (#5662) * Copy and pasted scripts for admin initiated sponsorship to force migration in QA * Include idempotency to ensure columns are correct if prior version of this script added them already without default value * Ensure this script works if the default constraints already exist --- ...-16_00_AddUseAdminInitiatedSponsorship.sql | 571 ++++++++++++++++++ 1 file changed, 571 insertions(+) create mode 100644 util/Migrator/DbScripts/2025-04-16_00_AddUseAdminInitiatedSponsorship.sql diff --git a/util/Migrator/DbScripts/2025-04-16_00_AddUseAdminInitiatedSponsorship.sql b/util/Migrator/DbScripts/2025-04-16_00_AddUseAdminInitiatedSponsorship.sql new file mode 100644 index 0000000000..73b37ce969 --- /dev/null +++ b/util/Migrator/DbScripts/2025-04-16_00_AddUseAdminInitiatedSponsorship.sql @@ -0,0 +1,571 @@ +IF EXISTS (SELECT * FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[Organization]') AND name = 'UseAdminSponsoredFamilies') +BEGIN + -- First drop the default constraint + DECLARE @ConstraintName nvarchar(200) + SELECT @ConstraintName = name FROM sys.default_constraints + WHERE parent_object_id = OBJECT_ID(N'[dbo].[Organization]') + AND parent_column_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[Organization]') AND name = 'UseAdminSponsoredFamilies') + + IF @ConstraintName IS NOT NULL + EXEC('ALTER TABLE [dbo].[Organization] DROP CONSTRAINT ' + @ConstraintName) + + -- Then drop the column + ALTER TABLE [dbo].[Organization] DROP COLUMN [UseAdminSponsoredFamilies] +END +GO; + +ALTER TABLE [dbo].[Organization] ADD [UseAdminSponsoredFamilies] bit NOT NULL CONSTRAINT [DF_Organization_UseAdminSponsoredFamilies] default (0) +GO; + +IF EXISTS (SELECT * FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[OrganizationSponsorship]') AND name = 'IsAdminInitiated') +BEGIN + -- First drop the default constraint + DECLARE @ConstraintName nvarchar(200) + SELECT @ConstraintName = name FROM sys.default_constraints + WHERE parent_object_id = OBJECT_ID(N'[dbo].[OrganizationSponsorship]') + AND parent_column_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[OrganizationSponsorship]') AND name = 'IsAdminInitiated') + + IF @ConstraintName IS NOT NULL + EXEC('ALTER TABLE [dbo].[OrganizationSponsorship] DROP CONSTRAINT ' + @ConstraintName) + + -- Then drop the column + ALTER TABLE [dbo].[OrganizationSponsorship] DROP COLUMN [IsAdminInitiated] +END +GO; + +ALTER TABLE [dbo].[OrganizationSponsorship] ADD [IsAdminInitiated] BIT CONSTRAINT [DF_OrganizationSponsorship_IsAdminInitiated] DEFAULT (0) NOT NULL +GO; + +IF EXISTS (SELECT * FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[OrganizationSponsorship]') AND name = 'Notes') +BEGIN + -- Notes column doesn't have a default constraint, so we can just drop it + ALTER TABLE [dbo].[OrganizationSponsorship] DROP COLUMN [Notes] +END +GO; + +ALTER TABLE [dbo].[OrganizationSponsorship] ADD [Notes] NVARCHAR(512) NULL +GO; + +CREATE OR ALTER PROCEDURE [dbo].[Organization_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @Identifier NVARCHAR(50), + @Name NVARCHAR(50), + @BusinessName NVARCHAR(50), + @BusinessAddress1 NVARCHAR(50), + @BusinessAddress2 NVARCHAR(50), + @BusinessAddress3 NVARCHAR(50), + @BusinessCountry VARCHAR(2), + @BusinessTaxNumber NVARCHAR(30), + @BillingEmail NVARCHAR(256), + @Plan NVARCHAR(50), + @PlanType TINYINT, + @Seats INT, + @MaxCollections SMALLINT, + @UsePolicies BIT, + @UseSso BIT, + @UseGroups BIT, + @UseDirectory BIT, + @UseEvents BIT, + @UseTotp BIT, + @Use2fa BIT, + @UseApi BIT, + @UseResetPassword BIT, + @SelfHost BIT, + @UsersGetPremium BIT, + @Storage BIGINT, + @MaxStorageGb SMALLINT, + @Gateway TINYINT, + @GatewayCustomerId VARCHAR(50), + @GatewaySubscriptionId VARCHAR(50), + @ReferenceData VARCHAR(MAX), + @Enabled BIT, + @LicenseKey VARCHAR(100), + @PublicKey VARCHAR(MAX), + @PrivateKey VARCHAR(MAX), + @TwoFactorProviders NVARCHAR(MAX), + @ExpirationDate DATETIME2(7), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @OwnersNotifiedOfAutoscaling DATETIME2(7), + @MaxAutoscaleSeats INT, + @UseKeyConnector BIT = 0, + @UseScim BIT = 0, + @UseCustomPermissions BIT = 0, + @UseSecretsManager BIT = 0, + @Status TINYINT = 0, + @UsePasswordManager BIT = 1, + @SmSeats INT = null, + @SmServiceAccounts INT = null, + @MaxAutoscaleSmSeats INT= null, + @MaxAutoscaleSmServiceAccounts INT = null, + @SecretsManagerBeta BIT = 0, + @LimitCollectionCreation BIT = NULL, + @LimitCollectionDeletion BIT = NULL, + @AllowAdminAccessToAllCollectionItems BIT = 0, + @UseRiskInsights BIT = 0, + @LimitItemDeletion BIT = 0, + @UseAdminSponsoredFamilies BIT = 0 +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[Organization] + ( + [Id], + [Identifier], + [Name], + [BusinessName], + [BusinessAddress1], + [BusinessAddress2], + [BusinessAddress3], + [BusinessCountry], + [BusinessTaxNumber], + [BillingEmail], + [Plan], + [PlanType], + [Seats], + [MaxCollections], + [UsePolicies], + [UseSso], + [UseGroups], + [UseDirectory], + [UseEvents], + [UseTotp], + [Use2fa], + [UseApi], + [UseResetPassword], + [SelfHost], + [UsersGetPremium], + [Storage], + [MaxStorageGb], + [Gateway], + [GatewayCustomerId], + [GatewaySubscriptionId], + [ReferenceData], + [Enabled], + [LicenseKey], + [PublicKey], + [PrivateKey], + [TwoFactorProviders], + [ExpirationDate], + [CreationDate], + [RevisionDate], + [OwnersNotifiedOfAutoscaling], + [MaxAutoscaleSeats], + [UseKeyConnector], + [UseScim], + [UseCustomPermissions], + [UseSecretsManager], + [Status], + [UsePasswordManager], + [SmSeats], + [SmServiceAccounts], + [MaxAutoscaleSmSeats], + [MaxAutoscaleSmServiceAccounts], + [SecretsManagerBeta], + [LimitCollectionCreation], + [LimitCollectionDeletion], + [AllowAdminAccessToAllCollectionItems], + [UseRiskInsights], + [LimitItemDeletion], + [UseAdminSponsoredFamilies] + ) + VALUES + ( + @Id, + @Identifier, + @Name, + @BusinessName, + @BusinessAddress1, + @BusinessAddress2, + @BusinessAddress3, + @BusinessCountry, + @BusinessTaxNumber, + @BillingEmail, + @Plan, + @PlanType, + @Seats, + @MaxCollections, + @UsePolicies, + @UseSso, + @UseGroups, + @UseDirectory, + @UseEvents, + @UseTotp, + @Use2fa, + @UseApi, + @UseResetPassword, + @SelfHost, + @UsersGetPremium, + @Storage, + @MaxStorageGb, + @Gateway, + @GatewayCustomerId, + @GatewaySubscriptionId, + @ReferenceData, + @Enabled, + @LicenseKey, + @PublicKey, + @PrivateKey, + @TwoFactorProviders, + @ExpirationDate, + @CreationDate, + @RevisionDate, + @OwnersNotifiedOfAutoscaling, + @MaxAutoscaleSeats, + @UseKeyConnector, + @UseScim, + @UseCustomPermissions, + @UseSecretsManager, + @Status, + @UsePasswordManager, + @SmSeats, + @SmServiceAccounts, + @MaxAutoscaleSmSeats, + @MaxAutoscaleSmServiceAccounts, + @SecretsManagerBeta, + @LimitCollectionCreation, + @LimitCollectionDeletion, + @AllowAdminAccessToAllCollectionItems, + @UseRiskInsights, + @LimitItemDeletion, + @UseAdminSponsoredFamilies + ) +END +GO; + +CREATE OR ALTER PROCEDURE [dbo].[Organization_ReadAbilities] +AS +BEGIN + SET NOCOUNT ON + +SELECT + [Id], + [UseEvents], + [Use2fa], + CASE + WHEN [Use2fa] = 1 AND [TwoFactorProviders] IS NOT NULL AND [TwoFactorProviders] != '{}' THEN + 1 + ELSE + 0 +END AS [Using2fa], + [UsersGetPremium], + [UseCustomPermissions], + [UseSso], + [UseKeyConnector], + [UseScim], + [UseResetPassword], + [UsePolicies], + [Enabled], + [LimitCollectionCreation], + [LimitCollectionDeletion], + [AllowAdminAccessToAllCollectionItems], + [UseRiskInsights], + [LimitItemDeletion], + [UseAdminSponsoredFamilies] + FROM + [dbo].[Organization] +END +GO; + +CREATE OR ALTER PROCEDURE [dbo].[Organization_Update] + @Id UNIQUEIDENTIFIER, + @Identifier NVARCHAR(50), + @Name NVARCHAR(50), + @BusinessName NVARCHAR(50), + @BusinessAddress1 NVARCHAR(50), + @BusinessAddress2 NVARCHAR(50), + @BusinessAddress3 NVARCHAR(50), + @BusinessCountry VARCHAR(2), + @BusinessTaxNumber NVARCHAR(30), + @BillingEmail NVARCHAR(256), + @Plan NVARCHAR(50), + @PlanType TINYINT, + @Seats INT, + @MaxCollections SMALLINT, + @UsePolicies BIT, + @UseSso BIT, + @UseGroups BIT, + @UseDirectory BIT, + @UseEvents BIT, + @UseTotp BIT, + @Use2fa BIT, + @UseApi BIT, + @UseResetPassword BIT, + @SelfHost BIT, + @UsersGetPremium BIT, + @Storage BIGINT, + @MaxStorageGb SMALLINT, + @Gateway TINYINT, + @GatewayCustomerId VARCHAR(50), + @GatewaySubscriptionId VARCHAR(50), + @ReferenceData VARCHAR(MAX), + @Enabled BIT, + @LicenseKey VARCHAR(100), + @PublicKey VARCHAR(MAX), + @PrivateKey VARCHAR(MAX), + @TwoFactorProviders NVARCHAR(MAX), + @ExpirationDate DATETIME2(7), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @OwnersNotifiedOfAutoscaling DATETIME2(7), + @MaxAutoscaleSeats INT, + @UseKeyConnector BIT = 0, + @UseScim BIT = 0, + @UseCustomPermissions BIT = 0, + @UseSecretsManager BIT = 0, + @Status TINYINT = 0, + @UsePasswordManager BIT = 1, + @SmSeats INT = null, + @SmServiceAccounts INT = null, + @MaxAutoscaleSmSeats INT = null, + @MaxAutoscaleSmServiceAccounts INT = null, + @SecretsManagerBeta BIT = 0, + @LimitCollectionCreation BIT = null, + @LimitCollectionDeletion BIT = null, + @AllowAdminAccessToAllCollectionItems BIT = 0, + @UseRiskInsights BIT = 0, + @LimitItemDeletion BIT = 0, + @UseAdminSponsoredFamilies BIT = 0 +AS +BEGIN + SET NOCOUNT ON + +UPDATE + [dbo].[Organization] +SET + [Identifier] = @Identifier, + [Name] = @Name, + [BusinessName] = @BusinessName, + [BusinessAddress1] = @BusinessAddress1, + [BusinessAddress2] = @BusinessAddress2, + [BusinessAddress3] = @BusinessAddress3, + [BusinessCountry] = @BusinessCountry, + [BusinessTaxNumber] = @BusinessTaxNumber, + [BillingEmail] = @BillingEmail, + [Plan] = @Plan, + [PlanType] = @PlanType, + [Seats] = @Seats, + [MaxCollections] = @MaxCollections, + [UsePolicies] = @UsePolicies, + [UseSso] = @UseSso, + [UseGroups] = @UseGroups, + [UseDirectory] = @UseDirectory, + [UseEvents] = @UseEvents, + [UseTotp] = @UseTotp, + [Use2fa] = @Use2fa, + [UseApi] = @UseApi, + [UseResetPassword] = @UseResetPassword, + [SelfHost] = @SelfHost, + [UsersGetPremium] = @UsersGetPremium, + [Storage] = @Storage, + [MaxStorageGb] = @MaxStorageGb, + [Gateway] = @Gateway, + [GatewayCustomerId] = @GatewayCustomerId, + [GatewaySubscriptionId] = @GatewaySubscriptionId, + [ReferenceData] = @ReferenceData, + [Enabled] = @Enabled, + [LicenseKey] = @LicenseKey, + [PublicKey] = @PublicKey, + [PrivateKey] = @PrivateKey, + [TwoFactorProviders] = @TwoFactorProviders, + [ExpirationDate] = @ExpirationDate, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate, + [OwnersNotifiedOfAutoscaling] = @OwnersNotifiedOfAutoscaling, + [MaxAutoscaleSeats] = @MaxAutoscaleSeats, + [UseKeyConnector] = @UseKeyConnector, + [UseScim] = @UseScim, + [UseCustomPermissions] = @UseCustomPermissions, + [UseSecretsManager] = @UseSecretsManager, + [Status] = @Status, + [UsePasswordManager] = @UsePasswordManager, + [SmSeats] = @SmSeats, + [SmServiceAccounts] = @SmServiceAccounts, + [MaxAutoscaleSmSeats] = @MaxAutoscaleSmSeats, + [MaxAutoscaleSmServiceAccounts] = @MaxAutoscaleSmServiceAccounts, + [SecretsManagerBeta] = @SecretsManagerBeta, + [LimitCollectionCreation] = @LimitCollectionCreation, + [LimitCollectionDeletion] = @LimitCollectionDeletion, + [AllowAdminAccessToAllCollectionItems] = @AllowAdminAccessToAllCollectionItems, + [UseRiskInsights] = @UseRiskInsights, + [LimitItemDeletion] = @LimitItemDeletion, + [UseAdminSponsoredFamilies] = @UseAdminSponsoredFamilies +WHERE + [Id] = @Id +END +GO; + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationSponsorship_Update] + @Id UNIQUEIDENTIFIER, + @SponsoringOrganizationId UNIQUEIDENTIFIER, + @SponsoringOrganizationUserID UNIQUEIDENTIFIER, + @SponsoredOrganizationId UNIQUEIDENTIFIER, + @FriendlyName NVARCHAR(256), + @OfferedToEmail NVARCHAR(256), + @PlanSponsorshipType TINYINT, + @ToDelete BIT, + @LastSyncDate DATETIME2 (7), + @ValidUntil DATETIME2 (7), + @IsAdminInitiated BIT = 0, + @Notes NVARCHAR(512) = NULL +AS +BEGIN + SET NOCOUNT ON + +UPDATE + [dbo].[OrganizationSponsorship] +SET + [SponsoringOrganizationId] = @SponsoringOrganizationId, + [SponsoringOrganizationUserID] = @SponsoringOrganizationUserID, + [SponsoredOrganizationId] = @SponsoredOrganizationId, + [FriendlyName] = @FriendlyName, + [OfferedToEmail] = @OfferedToEmail, + [PlanSponsorshipType] = @PlanSponsorshipType, + [ToDelete] = @ToDelete, + [LastSyncDate] = @LastSyncDate, + [ValidUntil] = @ValidUntil, + [IsAdminInitiated] = @IsAdminInitiated, + [Notes] = @Notes +WHERE + [Id] = @Id +END +GO; + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationSponsorship_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @SponsoringOrganizationId UNIQUEIDENTIFIER, + @SponsoringOrganizationUserID UNIQUEIDENTIFIER, + @SponsoredOrganizationId UNIQUEIDENTIFIER, + @FriendlyName NVARCHAR(256), + @OfferedToEmail NVARCHAR(256), + @PlanSponsorshipType TINYINT, + @ToDelete BIT, + @LastSyncDate DATETIME2 (7), + @ValidUntil DATETIME2 (7), + @IsAdminInitiated BIT = 0, + @Notes NVARCHAR(512) = NULL +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[OrganizationSponsorship] + ( + [Id], + [SponsoringOrganizationId], + [SponsoringOrganizationUserID], + [SponsoredOrganizationId], + [FriendlyName], + [OfferedToEmail], + [PlanSponsorshipType], + [ToDelete], + [LastSyncDate], + [ValidUntil], + [IsAdminInitiated], + [Notes] + ) + VALUES + ( + @Id, + @SponsoringOrganizationId, + @SponsoringOrganizationUserID, + @SponsoredOrganizationId, + @FriendlyName, + @OfferedToEmail, + @PlanSponsorshipType, + @ToDelete, + @LastSyncDate, + @ValidUntil, + @IsAdminInitiated, + @Notes + ) +END +GO; + +DROP PROCEDURE IF EXISTS [dbo].[OrganizationSponsorship_CreateMany]; +DROP PROCEDURE IF EXISTS [dbo].[OrganizationSponsorship_UpdateMany]; +DROP TYPE IF EXISTS [dbo].[OrganizationSponsorshipType] GO; + +CREATE TYPE [dbo].[OrganizationSponsorshipType] AS TABLE( + [Id] UNIQUEIDENTIFIER, + [SponsoringOrganizationId] UNIQUEIDENTIFIER, + [SponsoringOrganizationUserID] UNIQUEIDENTIFIER, + [SponsoredOrganizationId] UNIQUEIDENTIFIER, + [FriendlyName] NVARCHAR(256), + [OfferedToEmail] VARCHAR(256), + [PlanSponsorshipType] TINYINT, + [LastSyncDate] DATETIME2(7), + [ValidUntil] DATETIME2(7), + [ToDelete] BIT, + [IsAdminInitiated] BIT DEFAULT 0, + [Notes] NVARCHAR(512) NULL +); +GO; + +CREATE PROCEDURE [dbo].[OrganizationSponsorship_CreateMany] + @OrganizationSponsorshipsInput [dbo].[OrganizationSponsorshipType] READONLY +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[OrganizationSponsorship] + ( + [Id], + [SponsoringOrganizationId], + [SponsoringOrganizationUserID], + [SponsoredOrganizationId], + [FriendlyName], + [OfferedToEmail], + [PlanSponsorshipType], + [ToDelete], + [LastSyncDate], + [ValidUntil], + [IsAdminInitiated], + [Notes] + ) +SELECT + OS.[Id], + OS.[SponsoringOrganizationId], + OS.[SponsoringOrganizationUserID], + OS.[SponsoredOrganizationId], + OS.[FriendlyName], + OS.[OfferedToEmail], + OS.[PlanSponsorshipType], + OS.[ToDelete], + OS.[LastSyncDate], + OS.[ValidUntil], + OS.[IsAdminInitiated], + OS.[Notes] +FROM + @OrganizationSponsorshipsInput OS +END +GO; + +CREATE PROCEDURE [dbo].[OrganizationSponsorship_UpdateMany] + @OrganizationSponsorshipsInput [dbo].[OrganizationSponsorshipType] READONLY +AS +BEGIN + SET NOCOUNT ON + +UPDATE + OS +SET + [Id] = OSI.[Id], + [SponsoringOrganizationId] = OSI.[SponsoringOrganizationId], + [SponsoringOrganizationUserID] = OSI.[SponsoringOrganizationUserID], + [SponsoredOrganizationId] = OSI.[SponsoredOrganizationId], + [FriendlyName] = OSI.[FriendlyName], + [OfferedToEmail] = OSI.[OfferedToEmail], + [PlanSponsorshipType] = OSI.[PlanSponsorshipType], + [ToDelete] = OSI.[ToDelete], + [LastSyncDate] = OSI.[LastSyncDate], + [ValidUntil] = OSI.[ValidUntil], + [IsAdminInitiated] = OSI.[IsAdminInitiated], + [Notes] = OSI.[Notes] +FROM + [dbo].[OrganizationSponsorship] OS + INNER JOIN + @OrganizationSponsorshipsInput OSI ON OS.Id = OSI.Id + +END +GO; From f7e5759e7bdde9953518df6eb6f8515fc7f12a3f Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Thu, 17 Apr 2025 14:59:00 +0200 Subject: [PATCH 06/11] Remove GeneratorToolsModernization feature flag (#5660) Co-authored-by: Daniel James Smith --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 5f3e954a46..8071a933f6 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -190,7 +190,6 @@ public static class FeatureFlagKeys public const string EnableRiskInsightsNotifications = "enable-risk-insights-notifications"; public const string DesktopSendUIRefresh = "desktop-send-ui-refresh"; public const string ExportAttachments = "export-attachments"; - public const string GeneratorToolsModernization = "generator-tools-modernization"; /* Vault Team */ public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge"; From 60e7db7dbb9fbf07a6b48246f42fb22e38948202 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Thu, 17 Apr 2025 14:58:29 +0100 Subject: [PATCH 07/11] [PM-17823]Add feature toggle for admin sponsored families to admin portal (#5595) * 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 * Add the admin sponsored families to admin portal * Add a feature flag * Rename the migration file name * Resolve the existing conflict and remove added file * Add a migration for the change * Remove the migration Because is already added * Resolve the failing migration --------- Co-authored-by: Jonas Hendrickx --- .../Controllers/OrganizationsController.cs | 1 + .../Views/Shared/_OrganizationForm.cshtml | 9 ++ ...-02_00_UpdateUseAdminSponsoredFamilies.sql | 127 ++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 util/Migrator/DbScripts/2025-04-02_00_UpdateUseAdminSponsoredFamilies.sql diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index fdb4961d9b..cb163f400a 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -462,6 +462,7 @@ public class OrganizationsController : Controller organization.UsersGetPremium = model.UsersGetPremium; organization.UseSecretsManager = model.UseSecretsManager; organization.UseRiskInsights = model.UseRiskInsights; + organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies; //secrets organization.SmSeats = model.SmSeats; diff --git a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml index aeff65c900..7b19b19939 100644 --- a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml +++ b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml @@ -1,9 +1,11 @@ @using Bit.Admin.Enums; +@using Bit.Core @using Bit.Core.Enums @using Bit.Core.AdminConsole.Enums.Provider @using Bit.Core.Billing.Enums @using Bit.SharedWeb.Utilities @inject Bit.Admin.Services.IAccessControlService AccessControlService; +@inject Bit.Core.Services.IFeatureService FeatureService @model OrganizationEditModel @@ -146,6 +148,13 @@ + @if(FeatureService.IsEnabled(FeatureFlagKeys.PM17772_AdminInitiatedSponsorships)) + { +
+ + +
+ }

Password Manager

diff --git a/util/Migrator/DbScripts/2025-04-02_00_UpdateUseAdminSponsoredFamilies.sql b/util/Migrator/DbScripts/2025-04-02_00_UpdateUseAdminSponsoredFamilies.sql new file mode 100644 index 0000000000..3c7e4675e4 --- /dev/null +++ b/util/Migrator/DbScripts/2025-04-02_00_UpdateUseAdminSponsoredFamilies.sql @@ -0,0 +1,127 @@ +CREATE OR ALTER PROCEDURE [dbo].[Organization_Update] + @Id UNIQUEIDENTIFIER, + @Identifier NVARCHAR(50), + @Name NVARCHAR(50), + @BusinessName NVARCHAR(50), + @BusinessAddress1 NVARCHAR(50), + @BusinessAddress2 NVARCHAR(50), + @BusinessAddress3 NVARCHAR(50), + @BusinessCountry VARCHAR(2), + @BusinessTaxNumber NVARCHAR(30), + @BillingEmail NVARCHAR(256), + @Plan NVARCHAR(50), + @PlanType TINYINT, + @Seats INT, + @MaxCollections SMALLINT, + @UsePolicies BIT, + @UseSso BIT, + @UseGroups BIT, + @UseDirectory BIT, + @UseEvents BIT, + @UseTotp BIT, + @Use2fa BIT, + @UseApi BIT, + @UseResetPassword BIT, + @SelfHost BIT, + @UsersGetPremium BIT, + @Storage BIGINT, + @MaxStorageGb SMALLINT, + @Gateway TINYINT, + @GatewayCustomerId VARCHAR(50), + @GatewaySubscriptionId VARCHAR(50), + @ReferenceData VARCHAR(MAX), + @Enabled BIT, + @LicenseKey VARCHAR(100), + @PublicKey VARCHAR(MAX), + @PrivateKey VARCHAR(MAX), + @TwoFactorProviders NVARCHAR(MAX), + @ExpirationDate DATETIME2(7), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @OwnersNotifiedOfAutoscaling DATETIME2(7), + @MaxAutoscaleSeats INT, + @UseKeyConnector BIT = 0, + @UseScim BIT = 0, + @UseCustomPermissions BIT = 0, + @UseSecretsManager BIT = 0, + @Status TINYINT = 0, + @UsePasswordManager BIT = 1, + @SmSeats INT = null, + @SmServiceAccounts INT = null, + @MaxAutoscaleSmSeats INT = null, + @MaxAutoscaleSmServiceAccounts INT = null, + @SecretsManagerBeta BIT = 0, + @LimitCollectionCreation BIT = null, + @LimitCollectionDeletion BIT = null, + @AllowAdminAccessToAllCollectionItems BIT = 0, + @UseRiskInsights BIT = 0, + @LimitItemDeletion BIT = 0, + @UseAdminSponsoredFamilies BIT = 0 +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[Organization] + SET + [Identifier] = @Identifier, + [Name] = @Name, + [BusinessName] = @BusinessName, + [BusinessAddress1] = @BusinessAddress1, + [BusinessAddress2] = @BusinessAddress2, + [BusinessAddress3] = @BusinessAddress3, + [BusinessCountry] = @BusinessCountry, + [BusinessTaxNumber] = @BusinessTaxNumber, + [BillingEmail] = @BillingEmail, + [Plan] = @Plan, + [PlanType] = @PlanType, + [Seats] = @Seats, + [MaxCollections] = @MaxCollections, + [UsePolicies] = @UsePolicies, + [UseSso] = @UseSso, + [UseGroups] = @UseGroups, + [UseDirectory] = @UseDirectory, + [UseEvents] = @UseEvents, + [UseTotp] = @UseTotp, + [Use2fa] = @Use2fa, + [UseApi] = @UseApi, + [UseResetPassword] = @UseResetPassword, + [SelfHost] = @SelfHost, + [UsersGetPremium] = @UsersGetPremium, + [Storage] = @Storage, + [MaxStorageGb] = @MaxStorageGb, + [Gateway] = @Gateway, + [GatewayCustomerId] = @GatewayCustomerId, + [GatewaySubscriptionId] = @GatewaySubscriptionId, + [ReferenceData] = @ReferenceData, + [Enabled] = @Enabled, + [LicenseKey] = @LicenseKey, + [PublicKey] = @PublicKey, + [PrivateKey] = @PrivateKey, + [TwoFactorProviders] = @TwoFactorProviders, + [ExpirationDate] = @ExpirationDate, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate, + [OwnersNotifiedOfAutoscaling] = @OwnersNotifiedOfAutoscaling, + [MaxAutoscaleSeats] = @MaxAutoscaleSeats, + [UseKeyConnector] = @UseKeyConnector, + [UseScim] = @UseScim, + [UseCustomPermissions] = @UseCustomPermissions, + [UseSecretsManager] = @UseSecretsManager, + [Status] = @Status, + [UsePasswordManager] = @UsePasswordManager, + [SmSeats] = @SmSeats, + [SmServiceAccounts] = @SmServiceAccounts, + [MaxAutoscaleSmSeats] = @MaxAutoscaleSmSeats, + [MaxAutoscaleSmServiceAccounts] = @MaxAutoscaleSmServiceAccounts, + [SecretsManagerBeta] = @SecretsManagerBeta, + [LimitCollectionCreation] = @LimitCollectionCreation, + [LimitCollectionDeletion] = @LimitCollectionDeletion, + [AllowAdminAccessToAllCollectionItems] = @AllowAdminAccessToAllCollectionItems, + [UseRiskInsights] = @UseRiskInsights, + [LimitItemDeletion] = @LimitItemDeletion, + [UseAdminSponsoredFamilies] = @UseAdminSponsoredFamilies + WHERE + [Id] = @Id +END +GO \ No newline at end of file From bd90c34af2ff9fb0dc342c39a6c5e77d3c0761cb Mon Sep 17 00:00:00 2001 From: Jonas Hendrickx Date: Thu, 17 Apr 2025 17:33:16 +0200 Subject: [PATCH 08/11] [PM-19180] Calculate sales tax correctly for sponsored plans (#5611) * [PM-19180] Calculate sales tax correctly for sponsored plans * Cannot divide by zero if total amount excluding tax is zero. * Unit tests for families & families for enterprise --------- Co-authored-by: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> --- .../PreviewOrganizationInvoiceRequestModel.cs | 3 + .../Implementations/StripePaymentService.cs | 63 ++--- .../Services/StripePaymentServiceTests.cs | 227 ++++++++++++++++++ 3 files changed, 263 insertions(+), 30 deletions(-) create mode 100644 test/Core.Test/Services/StripePaymentServiceTests.cs diff --git a/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs b/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs index 466c32f42d..461a6dca65 100644 --- a/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs +++ b/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Billing.Enums; +using Bit.Core.Enums; namespace Bit.Core.Billing.Models.Api.Requests.Organizations; @@ -20,6 +21,8 @@ public class OrganizationPasswordManagerRequestModel { public PlanType Plan { get; set; } + public PlanSponsorshipType? SponsoredPlan { get; set; } + [Range(0, int.MaxValue)] public int Seats { get; set; } diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index d82a4d60a7..51be369527 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -1265,7 +1265,7 @@ public class StripePaymentService : IPaymentService { var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options); - var effectiveTaxRate = invoice.Tax != null && invoice.TotalExcludingTax != null + var effectiveTaxRate = invoice.Tax != null && invoice.TotalExcludingTax != null && invoice.TotalExcludingTax.Value != 0 ? invoice.Tax.Value.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor() : 0M; @@ -1300,6 +1300,7 @@ public class StripePaymentService : IPaymentService string gatewaySubscriptionId) { var plan = await _pricingClient.GetPlanOrThrow(parameters.PasswordManager.Plan); + var isSponsored = parameters.PasswordManager.SponsoredPlan.HasValue; var options = new InvoiceCreatePreviewOptions { @@ -1325,45 +1326,47 @@ public class StripePaymentService : IPaymentService }, }; - if (plan.PasswordManager.HasAdditionalSeatsOption) + if (isSponsored) { + var sponsoredPlan = Utilities.StaticStore.GetSponsoredPlan(parameters.PasswordManager.SponsoredPlan.Value); options.SubscriptionDetails.Items.Add( - new() - { - Quantity = parameters.PasswordManager.Seats, - Plan = plan.PasswordManager.StripeSeatPlanId - } + new() { Quantity = 1, Plan = sponsoredPlan.StripePlanId } ); } else { - options.SubscriptionDetails.Items.Add( - new() - { - Quantity = 1, - Plan = plan.PasswordManager.StripePlanId - } - ); - } - - if (plan.SupportsSecretsManager) - { - if (plan.SecretsManager.HasAdditionalSeatsOption) + if (plan.PasswordManager.HasAdditionalSeatsOption) { - options.SubscriptionDetails.Items.Add(new() - { - Quantity = parameters.SecretsManager?.Seats ?? 0, - Plan = plan.SecretsManager.StripeSeatPlanId - }); + options.SubscriptionDetails.Items.Add( + new() { Quantity = parameters.PasswordManager.Seats, Plan = plan.PasswordManager.StripeSeatPlanId } + ); + } + else + { + options.SubscriptionDetails.Items.Add( + new() { Quantity = 1, Plan = plan.PasswordManager.StripePlanId } + ); } - if (plan.SecretsManager.HasAdditionalServiceAccountOption) + if (plan.SupportsSecretsManager) { - options.SubscriptionDetails.Items.Add(new() + if (plan.SecretsManager.HasAdditionalSeatsOption) { - Quantity = parameters.SecretsManager?.AdditionalMachineAccounts ?? 0, - Plan = plan.SecretsManager.StripeServiceAccountPlanId - }); + options.SubscriptionDetails.Items.Add(new() + { + Quantity = parameters.SecretsManager?.Seats ?? 0, + Plan = plan.SecretsManager.StripeSeatPlanId + }); + } + + if (plan.SecretsManager.HasAdditionalServiceAccountOption) + { + options.SubscriptionDetails.Items.Add(new() + { + Quantity = parameters.SecretsManager?.AdditionalMachineAccounts ?? 0, + Plan = plan.SecretsManager.StripeServiceAccountPlanId + }); + } } } @@ -1420,7 +1423,7 @@ public class StripePaymentService : IPaymentService { var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options); - var effectiveTaxRate = invoice.Tax != null && invoice.TotalExcludingTax != null + var effectiveTaxRate = invoice.Tax != null && invoice.TotalExcludingTax != null && invoice.TotalExcludingTax.Value != 0 ? invoice.Tax.Value.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor() : 0M; diff --git a/test/Core.Test/Services/StripePaymentServiceTests.cs b/test/Core.Test/Services/StripePaymentServiceTests.cs new file mode 100644 index 0000000000..835f69b214 --- /dev/null +++ b/test/Core.Test/Services/StripePaymentServiceTests.cs @@ -0,0 +1,227 @@ +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models.Api.Requests; +using Bit.Core.Billing.Models.Api.Requests.Organizations; +using Bit.Core.Billing.Models.StaticStore.Plans; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; +using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Enums; +using Bit.Core.Services; +using Bit.Core.Test.Billing.Stubs; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Services; + +[SutProviderCustomize] +public class StripePaymentServiceTests +{ + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesWithoutAdditionalStorage( + SutProvider sutProvider) + { + sutProvider.GetDependency() + .CreateAsync(Arg.Is(p => p.PlanType == PlanType.FamiliesAnnually)) + .Returns(new FakeAutomaticTaxStrategy(true)); + + var familiesPlan = new FamiliesPlan(); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) + .Returns(familiesPlan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.FamiliesAnnually, + AdditionalStorage = 0 + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "FR", + PostalCode = "12345" + } + }; + + sutProvider.GetDependency() + .InvoiceCreatePreviewAsync(Arg.Is(p => + p.Currency == "usd" && + p.SubscriptionDetails.Items.Any(x => + x.Plan == familiesPlan.PasswordManager.StripePlanId && + x.Quantity == 1) && + p.SubscriptionDetails.Items.Any(x => + x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId && + x.Quantity == 0))) + .Returns(new Invoice + { + TotalExcludingTax = 4000, + Tax = 800, + Total = 4800 + }); + + var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + Assert.Equal(8M, actual.TaxAmount); + Assert.Equal(48M, actual.TotalAmount); + Assert.Equal(40M, actual.TaxableBaseAmount); + } + + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesWithAdditionalStorage( + SutProvider sutProvider) + { + sutProvider.GetDependency() + .CreateAsync(Arg.Is(p => p.PlanType == PlanType.FamiliesAnnually)) + .Returns(new FakeAutomaticTaxStrategy(true)); + + var familiesPlan = new FamiliesPlan(); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) + .Returns(familiesPlan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.FamiliesAnnually, + AdditionalStorage = 1 + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "FR", + PostalCode = "12345" + } + }; + + sutProvider.GetDependency() + .InvoiceCreatePreviewAsync(Arg.Is(p => + p.Currency == "usd" && + p.SubscriptionDetails.Items.Any(x => + x.Plan == familiesPlan.PasswordManager.StripePlanId && + x.Quantity == 1) && + p.SubscriptionDetails.Items.Any(x => + x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId && + x.Quantity == 1))) + .Returns(new Invoice + { + TotalExcludingTax = 4000, + Tax = 800, + Total = 4800 + }); + + var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + Assert.Equal(8M, actual.TaxAmount); + Assert.Equal(48M, actual.TotalAmount); + Assert.Equal(40M, actual.TaxableBaseAmount); + } + + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithoutAdditionalStorage( + SutProvider sutProvider) + { + sutProvider.GetDependency() + .CreateAsync(Arg.Is(p => p.PlanType == PlanType.FamiliesAnnually)) + .Returns(new FakeAutomaticTaxStrategy(true)); + + var familiesPlan = new FamiliesPlan(); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) + .Returns(familiesPlan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.FamiliesAnnually, + SponsoredPlan = PlanSponsorshipType.FamiliesForEnterprise, + AdditionalStorage = 0 + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "FR", + PostalCode = "12345" + } + }; + + sutProvider.GetDependency() + .InvoiceCreatePreviewAsync(Arg.Is(p => + p.Currency == "usd" && + p.SubscriptionDetails.Items.Any(x => + x.Plan == "2021-family-for-enterprise-annually" && + x.Quantity == 1) && + p.SubscriptionDetails.Items.Any(x => + x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId && + x.Quantity == 0))) + .Returns(new Invoice + { + TotalExcludingTax = 0, + Tax = 0, + Total = 0 + }); + + var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + Assert.Equal(0M, actual.TaxAmount); + Assert.Equal(0M, actual.TotalAmount); + Assert.Equal(0M, actual.TaxableBaseAmount); + } + + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithAdditionalStorage( + SutProvider sutProvider) + { + sutProvider.GetDependency() + .CreateAsync(Arg.Is(p => p.PlanType == PlanType.FamiliesAnnually)) + .Returns(new FakeAutomaticTaxStrategy(true)); + + var familiesPlan = new FamiliesPlan(); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) + .Returns(familiesPlan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.FamiliesAnnually, + SponsoredPlan = PlanSponsorshipType.FamiliesForEnterprise, + AdditionalStorage = 1 + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "FR", + PostalCode = "12345" + } + }; + + sutProvider.GetDependency() + .InvoiceCreatePreviewAsync(Arg.Is(p => + p.Currency == "usd" && + p.SubscriptionDetails.Items.Any(x => + x.Plan == "2021-family-for-enterprise-annually" && + x.Quantity == 1) && + p.SubscriptionDetails.Items.Any(x => + x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId && + x.Quantity == 1))) + .Returns(new Invoice + { + TotalExcludingTax = 400, + Tax = 8, + Total = 408 + }); + + var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + Assert.Equal(0.08M, actual.TaxAmount); + Assert.Equal(4.08M, actual.TotalAmount); + Assert.Equal(4M, actual.TaxableBaseAmount); + } +} From 4379e326a53a1cbc4178c4e8809c984ce887ae91 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Thu, 17 Apr 2025 14:37:11 -0400 Subject: [PATCH 09/11] =?UTF-8?q?Revert=20"[PM-20264]=20Replace=20`StaticS?= =?UTF-8?q?tore`=20with=20`PricingClient`=20in=20`MaxProjects=E2=80=A6"=20?= =?UTF-8?q?(#5665)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit e943a2f051a254c4a031f39f2638d418bdd2e4a2. --- .../Queries/Projects/MaxProjectsQuery.cs | 10 ++++------ .../Queries/Projects/MaxProjectsQueryTests.cs | 8 -------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs index 106483ec4a..d9a7d4a2ce 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs @@ -1,9 +1,9 @@ using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Pricing; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Queries.Projects.Interfaces; using Bit.Core.SecretsManager.Repositories; +using Bit.Core.Utilities; namespace Bit.Commercial.Core.SecretsManager.Queries.Projects; @@ -11,16 +11,13 @@ public class MaxProjectsQuery : IMaxProjectsQuery { private readonly IOrganizationRepository _organizationRepository; private readonly IProjectRepository _projectRepository; - private readonly IPricingClient _pricingClient; public MaxProjectsQuery( IOrganizationRepository organizationRepository, - IProjectRepository projectRepository, - IPricingClient pricingClient) + IProjectRepository projectRepository) { _organizationRepository = organizationRepository; _projectRepository = projectRepository; - _pricingClient = pricingClient; } public async Task<(short? max, bool? overMax)> GetByOrgIdAsync(Guid organizationId, int projectsToAdd) @@ -31,7 +28,8 @@ public class MaxProjectsQuery : IMaxProjectsQuery throw new NotFoundException(); } - var plan = await _pricingClient.GetPlan(org.PlanType); + // TODO: PRICING -> https://bitwarden.atlassian.net/browse/PM-17122 + var plan = StaticStore.GetPlan(org.PlanType); if (plan?.SecretsManager == null) { throw new BadRequestException("Existing plan not found."); diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs index afe9533292..347f5b2128 100644 --- a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs @@ -1,11 +1,9 @@ using Bit.Commercial.Core.SecretsManager.Queries.Projects; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Pricing; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; -using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -68,9 +66,6 @@ public class MaxProjectsQueryTests SutProvider sutProvider, Organization organization) { organization.PlanType = planType; - - sutProvider.GetDependency().GetPlan(planType).Returns(StaticStore.GetPlan(planType)); - sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); var (limit, overLimit) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1); @@ -111,9 +106,6 @@ public class MaxProjectsQueryTests SutProvider sutProvider, Organization organization) { organization.PlanType = planType; - - sutProvider.GetDependency().GetPlan(planType).Returns(StaticStore.GetPlan(planType)); - sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency().GetProjectCountByOrganizationIdAsync(organization.Id) .Returns(projects); From 89fc27b0148b76ee8b9b12c744b8013adb3a63b8 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Fri, 18 Apr 2025 08:13:55 -0500 Subject: [PATCH 10/11] [PM-20230] - Send owners email when autoscaling (#5658) * Added email when autoscaling. Added tests as well. * Wrote tests. Renamed methods. --- .../InviteOrganizationUsersCommand.cs | 20 +- .../InviteOrganizationUserCommandTests.cs | 304 ++++++++++++++++++ 2 files changed, 322 insertions(+), 2 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs index 4eacb9386a..1aff71c636 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs @@ -206,10 +206,26 @@ public class InviteOrganizationUsersCommand(IEventService eventService, private async Task SendAdditionalEmailsAsync(Valid validatedResult, Organization organization) { - await SendPasswordManagerMaxSeatLimitEmailsAsync(validatedResult, organization); + await NotifyOwnersIfAutoscaleOccursAsync(validatedResult, organization); + await NotifyOwnersIfPasswordManagerMaxSeatLimitReachedAsync(validatedResult, organization); } - private async Task SendPasswordManagerMaxSeatLimitEmailsAsync(Valid validatedResult, Organization organization) + private async Task NotifyOwnersIfAutoscaleOccursAsync(Valid validatedResult, Organization organization) + { + if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd > 0 + && !organization.OwnersNotifiedOfAutoscaling.HasValue) + { + await mailService.SendOrganizationAutoscaledEmailAsync( + organization, + validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats!.Value, + await GetOwnerEmailAddressesAsync(validatedResult.Value.InviteOrganization)); + + organization.OwnersNotifiedOfAutoscaling = validatedResult.Value.PerformedAt.UtcDateTime; + await organizationRepository.UpsertAsync(organization); + } + } + + private async Task NotifyOwnersIfPasswordManagerMaxSeatLimitReachedAsync(Valid validatedResult, Organization organization) { if (!validatedResult.Value.PasswordManagerSubscriptionUpdate.MaxSeatsReached) { diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs index ba7605d682..6ae2d58c73 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs @@ -287,6 +287,77 @@ public class InviteOrganizationUserCommandTests Arg.Is>(emails => emails.Any(email => email == ownerDetails.Email))); } + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenValidInviteCausesOrgToAutoscale_ThenOrganizationOwnersShouldBeNotified( + MailAddress address, + Organization organization, + OrganizationUser user, + FakeTimeProvider timeProvider, + string externalId, + OrganizationUserUserDetails ownerDetails, + SutProvider sutProvider) + { + // Arrange + user.Email = address.Address; + organization.Seats = 1; + organization.MaxAutoscaleSeats = 2; + organization.OwnersNotifiedOfAutoscaling = null; + ownerDetails.Type = OrganizationUserType.Owner; + + var inviteOrganization = new InviteOrganization(organization, new Enterprise2019Plan(true)); + + var request = new InviteOrganizationUsersRequest( + invites: + [ + new OrganizationUserInvite( + email: user.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + var orgUserRepository = sutProvider.GetDependency(); + + orgUserRepository + .SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any>(), false) + .Returns([]); + orgUserRepository + .GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner) + .Returns([ownerDetails]); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Valid( + GetInviteValidationRequestMock(request, inviteOrganization, organization) + .WithPasswordManagerUpdate( + new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1)))); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert + Assert.IsType>(result); + + Assert.NotNull(inviteOrganization.MaxAutoScaleSeats); + + await sutProvider.GetDependency() + .Received(1) + .SendOrganizationAutoscaledEmailAsync(organization, + inviteOrganization.Seats.Value, + Arg.Is>(emails => emails.Any(email => email == ownerDetails.Email))); + } + [Theory] [BitAutoData] public async Task InviteScimOrganizationUserAsync_WhenValidInviteIncreasesSeats_ThenSeatTotalShouldBeUpdated( @@ -610,4 +681,237 @@ public class InviteOrganizationUserCommandTests .SendOrganizationMaxSeatLimitReachedEmailAsync(organization, 2, Arg.Is>(emails => emails.Any(email => email == "provider@email.com"))); } + + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenAnOrganizationIsManagedByAProviderAndAutoscaleOccurs_ThenAnEmailShouldBeSentToTheProvider( + MailAddress address, + Organization organization, + OrganizationUser user, + FakeTimeProvider timeProvider, + string externalId, + OrganizationUserUserDetails ownerDetails, + ProviderOrganization providerOrganization, + SutProvider sutProvider) + { + // Arrange + user.Email = address.Address; + organization.Seats = 1; + organization.SmSeats = 1; + organization.MaxAutoscaleSeats = 2; + organization.MaxAutoscaleSmSeats = 2; + organization.OwnersNotifiedOfAutoscaling = null; + ownerDetails.Type = OrganizationUserType.Owner; + + providerOrganization.OrganizationId = organization.Id; + + var inviteOrganization = new InviteOrganization(organization, new Enterprise2019Plan(true)); + + var request = new InviteOrganizationUsersRequest( + invites: [ + new OrganizationUserInvite( + email: user.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + var secretsManagerSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(organization, inviteOrganization.Plan, true) + .AdjustSeats(request.Invites.Count(x => x.AccessSecretsManager)); + + var passwordManagerSubscriptionUpdate = + new PasswordManagerSubscriptionUpdate(inviteOrganization, 1, request.Invites.Length); + + var orgUserRepository = sutProvider.GetDependency(); + + orgUserRepository + .SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any>(), false) + .Returns([]); + orgUserRepository + .GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner) + .Returns([ownerDetails]); + + var orgRepository = sutProvider.GetDependency(); + + orgRepository.GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Valid(GetInviteValidationRequestMock(request, inviteOrganization, organization) + .WithPasswordManagerUpdate(passwordManagerSubscriptionUpdate) + .WithSecretsManagerUpdate(secretsManagerSubscriptionUpdate))); + + sutProvider.GetDependency() + .GetByOrganizationId(organization.Id) + .Returns(providerOrganization); + + sutProvider.GetDependency() + .GetManyDetailsByProviderAsync(providerOrganization.ProviderId, ProviderUserStatusType.Confirmed) + .Returns(new List + { + new() + { + Email = "provider@email.com" + } + }); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert + Assert.IsType>(result); + + sutProvider.GetDependency().Received(1) + .SendOrganizationAutoscaledEmailAsync(organization, 1, + Arg.Is>(emails => emails.Any(email => email == "provider@email.com"))); + } + + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenAnOrganizationAutoscalesButOwnersHaveAlreadyBeenNotified_ThenAnEmailShouldNotBeSent( + MailAddress address, + Organization organization, + OrganizationUser user, + FakeTimeProvider timeProvider, + string externalId, + OrganizationUserUserDetails ownerDetails, + SutProvider sutProvider) + { + // Arrange + user.Email = address.Address; + organization.Seats = 1; + organization.MaxAutoscaleSeats = 2; + organization.OwnersNotifiedOfAutoscaling = DateTime.UtcNow; + ownerDetails.Type = OrganizationUserType.Owner; + + var inviteOrganization = new InviteOrganization(organization, new Enterprise2019Plan(true)); + + var request = new InviteOrganizationUsersRequest( + invites: + [ + new OrganizationUserInvite( + email: user.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + var orgUserRepository = sutProvider.GetDependency(); + + orgUserRepository + .SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any>(), false) + .Returns([]); + orgUserRepository + .GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner) + .Returns([ownerDetails]); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Valid( + GetInviteValidationRequestMock(request, inviteOrganization, organization) + .WithPasswordManagerUpdate( + new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1)))); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert + Assert.IsType>(result); + + Assert.NotNull(inviteOrganization.MaxAutoScaleSeats); + + await sutProvider.GetDependency() + .DidNotReceive() + .SendOrganizationAutoscaledEmailAsync(Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenAnOrganizationDoesNotAutoScale_ThenAnEmailShouldNotBeSent( + MailAddress address, + Organization organization, + OrganizationUser user, + FakeTimeProvider timeProvider, + string externalId, + OrganizationUserUserDetails ownerDetails, + SutProvider sutProvider) + { + // Arrange + user.Email = address.Address; + organization.Seats = 2; + organization.MaxAutoscaleSeats = 2; + organization.OwnersNotifiedOfAutoscaling = DateTime.UtcNow; + ownerDetails.Type = OrganizationUserType.Owner; + + var inviteOrganization = new InviteOrganization(organization, new Enterprise2019Plan(true)); + + var request = new InviteOrganizationUsersRequest( + invites: + [ + new OrganizationUserInvite( + email: user.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + var orgUserRepository = sutProvider.GetDependency(); + + orgUserRepository + .SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any>(), false) + .Returns([]); + orgUserRepository + .GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner) + .Returns([ownerDetails]); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Valid( + GetInviteValidationRequestMock(request, inviteOrganization, organization) + .WithPasswordManagerUpdate( + new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1)))); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert + Assert.IsType>(result); + + Assert.NotNull(inviteOrganization.MaxAutoScaleSeats); + + await sutProvider.GetDependency() + .DidNotReceive() + .SendOrganizationAutoscaledEmailAsync(Arg.Any(), + Arg.Any(), + Arg.Any>()); + } } From bfd98c703a2dc5ea7fba103a835fd66605522542 Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> Date: Fri, 18 Apr 2025 16:26:51 +0200 Subject: [PATCH 11/11] [PM-18017] Move Key Connector endpoints into Key Management team ownership (#5563) * Move Key Connector controller endpoints into Key Management team ownership * revert new key management endpoints --- .../Auth/Controllers/AccountsController.cs | 46 ------- .../AccountsKeyManagementController.cs | 52 ++++++- .../SetKeyConnectorKeyRequestModel.cs | 2 +- .../Helpers/OrganizationTestHelpers.cs | 5 +- .../AccountsKeyManagementControllerTests.cs | 102 +++++++++++++- .../AccountsKeyManagementControllerTests.cs | 129 ++++++++++++++++++ 6 files changed, 282 insertions(+), 54 deletions(-) rename src/Api/{Auth/Models/Request/Accounts => KeyManagement/Models/Requests}/SetKeyConnectorKeyRequestModel.cs (94%) diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index b22d54fa55..621524228a 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -284,52 +284,6 @@ public class AccountsController : Controller throw new BadRequestException(ModelState); } - [HttpPost("set-key-connector-key")] - public async Task PostSetKeyConnectorKeyAsync([FromBody] SetKeyConnectorKeyRequestModel model) - { - var user = await _userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier); - if (result.Succeeded) - { - return; - } - - foreach (var error in result.Errors) - { - ModelState.AddModelError(string.Empty, error.Description); - } - - throw new BadRequestException(ModelState); - } - - [HttpPost("convert-to-key-connector")] - public async Task PostConvertToKeyConnector() - { - var user = await _userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - var result = await _userService.ConvertToKeyConnectorAsync(user); - if (result.Succeeded) - { - return; - } - - foreach (var error in result.Errors) - { - ModelState.AddModelError(string.Empty, error.Description); - } - - throw new BadRequestException(ModelState); - } - [HttpPost("kdf")] public async Task PostKdf([FromBody] KdfRequestModel model) { diff --git a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs index 0764e2ee28..9fc0e9a75a 100644 --- a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs +++ b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs @@ -24,7 +24,7 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Api.KeyManagement.Controllers; -[Route("accounts/key-management")] +[Route("accounts")] [Authorize("Application")] public class AccountsKeyManagementController : Controller { @@ -77,7 +77,7 @@ public class AccountsKeyManagementController : Controller _deviceValidator = deviceValidator; } - [HttpPost("regenerate-keys")] + [HttpPost("key-management/regenerate-keys")] public async Task RegenerateKeysAsync([FromBody] KeyRegenerationRequestModel request) { if (!_featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration)) @@ -93,7 +93,7 @@ public class AccountsKeyManagementController : Controller } - [HttpPost("rotate-user-account-keys")] + [HttpPost("key-management/rotate-user-account-keys")] public async Task RotateUserAccountKeysAsync([FromBody] RotateUserAccountKeysAndDataRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -133,4 +133,50 @@ public class AccountsKeyManagementController : Controller throw new BadRequestException(ModelState); } + + [HttpPost("set-key-connector-key")] + public async Task PostSetKeyConnectorKeyAsync([FromBody] SetKeyConnectorKeyRequestModel model) + { + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new UnauthorizedAccessException(); + } + + var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier); + if (result.Succeeded) + { + return; + } + + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + + throw new BadRequestException(ModelState); + } + + [HttpPost("convert-to-key-connector")] + public async Task PostConvertToKeyConnectorAsync() + { + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new UnauthorizedAccessException(); + } + + var result = await _userService.ConvertToKeyConnectorAsync(user); + if (result.Succeeded) + { + return; + } + + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + + throw new BadRequestException(ModelState); + } } diff --git a/src/Api/Auth/Models/Request/Accounts/SetKeyConnectorKeyRequestModel.cs b/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs similarity index 94% rename from src/Api/Auth/Models/Request/Accounts/SetKeyConnectorKeyRequestModel.cs rename to src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs index 25d543b916..bac42bc302 100644 --- a/src/Api/Auth/Models/Request/Accounts/SetKeyConnectorKeyRequestModel.cs +++ b/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs @@ -3,7 +3,7 @@ using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Entities; using Bit.Core.Enums; -namespace Bit.Api.Auth.Models.Request.Accounts; +namespace Bit.Api.KeyManagement.Models.Requests; public class SetKeyConnectorKeyRequestModel { diff --git a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs index 9370948a85..f2bc9f4bac 100644 --- a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs +++ b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs @@ -59,7 +59,8 @@ public static class OrganizationTestHelpers string userEmail, OrganizationUserType type, bool accessSecretsManager = false, - Permissions? permissions = null + Permissions? permissions = null, + OrganizationUserStatusType userStatusType = OrganizationUserStatusType.Confirmed ) where T : class { var userRepository = factory.GetService(); @@ -74,7 +75,7 @@ public static class OrganizationTestHelpers UserId = user.Id, Key = null, Type = type, - Status = OrganizationUserStatusType.Confirmed, + Status = userStatusType, ExternalId = null, AccessSecretsManager = accessSecretsManager, }; diff --git a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs index 1b065adbd6..bf27d7f0d1 100644 --- a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -1,4 +1,5 @@ -using System.Net; +#nullable enable +using System.Net; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Api.KeyManagement.Models.Requests; @@ -7,6 +8,7 @@ using Bit.Api.Vault.Models; using Bit.Api.Vault.Models.Request; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; @@ -31,6 +33,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture _passwordHasher; + private readonly IOrganizationRepository _organizationRepository; private string _ownerEmail = null!; public AccountsKeyManagementControllerTests(ApiApplicationFactory factory) @@ -45,6 +48,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture(); _organizationUserRepository = _factory.GetService(); _passwordHasher = _factory.GetService>(); + _organizationRepository = _factory.GetService(); } public async Task InitializeAsync() @@ -174,7 +178,8 @@ public class AccountsKeyManagementControllerTests : IClassFixture sutProvider, + SetKeyConnectorKeyRequestModel data) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).ReturnsNull(); + + await Assert.ThrowsAsync(() => sutProvider.Sut.PostSetKeyConnectorKeyAsync(data)); + + await sutProvider.GetDependency().ReceivedWithAnyArgs(0) + .SetKeyConnectorKeyAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task PostSetKeyConnectorKeyAsync_SetKeyConnectorKeyFails_ThrowsBadRequestWithErrorResponse( + SutProvider sutProvider, + SetKeyConnectorKeyRequestModel data, User expectedUser) + { + expectedUser.PublicKey = null; + expectedUser.PrivateKey = null; + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .Returns(expectedUser); + sutProvider.GetDependency() + .SetKeyConnectorKeyAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(IdentityResult.Failed(new IdentityError { Description = "set key connector key error" })); + + var badRequestException = + await Assert.ThrowsAsync(() => sutProvider.Sut.PostSetKeyConnectorKeyAsync(data)); + + Assert.Equal(1, badRequestException.ModelState.ErrorCount); + Assert.Equal("set key connector key error", badRequestException.ModelState.Root.Errors[0].ErrorMessage); + await sutProvider.GetDependency().Received(1) + .SetKeyConnectorKeyAsync(Arg.Do(user => + { + Assert.Equal(expectedUser.Id, user.Id); + Assert.Equal(data.Key, user.Key); + Assert.Equal(data.Kdf, user.Kdf); + Assert.Equal(data.KdfIterations, user.KdfIterations); + Assert.Equal(data.KdfMemory, user.KdfMemory); + Assert.Equal(data.KdfParallelism, user.KdfParallelism); + Assert.Equal(data.Keys.PublicKey, user.PublicKey); + Assert.Equal(data.Keys.EncryptedPrivateKey, user.PrivateKey); + }), Arg.Is(data.Key), Arg.Is(data.OrgIdentifier)); + } + + [Theory] + [BitAutoData] + public async Task PostSetKeyConnectorKeyAsync_SetKeyConnectorKeySucceeds_OkResponse( + SutProvider sutProvider, + SetKeyConnectorKeyRequestModel data, User expectedUser) + { + expectedUser.PublicKey = null; + expectedUser.PrivateKey = null; + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .Returns(expectedUser); + sutProvider.GetDependency() + .SetKeyConnectorKeyAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(IdentityResult.Success); + + await sutProvider.Sut.PostSetKeyConnectorKeyAsync(data); + + await sutProvider.GetDependency().Received(1) + .SetKeyConnectorKeyAsync(Arg.Do(user => + { + Assert.Equal(expectedUser.Id, user.Id); + Assert.Equal(data.Key, user.Key); + Assert.Equal(data.Kdf, user.Kdf); + Assert.Equal(data.KdfIterations, user.KdfIterations); + Assert.Equal(data.KdfMemory, user.KdfMemory); + Assert.Equal(data.KdfParallelism, user.KdfParallelism); + Assert.Equal(data.Keys.PublicKey, user.PublicKey); + Assert.Equal(data.Keys.EncryptedPrivateKey, user.PrivateKey); + }), Arg.Is(data.Key), Arg.Is(data.OrgIdentifier)); + } + + [Theory] + [BitAutoData] + public async Task PostConvertToKeyConnectorAsync_UserNull_Throws( + SutProvider sutProvider) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).ReturnsNull(); + + await Assert.ThrowsAsync(() => sutProvider.Sut.PostConvertToKeyConnectorAsync()); + + await sutProvider.GetDependency().ReceivedWithAnyArgs(0) + .ConvertToKeyConnectorAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task PostConvertToKeyConnectorAsync_ConvertToKeyConnectorFails_ThrowsBadRequestWithErrorResponse( + SutProvider sutProvider, + User expectedUser) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .Returns(expectedUser); + sutProvider.GetDependency() + .ConvertToKeyConnectorAsync(Arg.Any()) + .Returns(IdentityResult.Failed(new IdentityError { Description = "convert to key connector error" })); + + var badRequestException = + await Assert.ThrowsAsync(() => sutProvider.Sut.PostConvertToKeyConnectorAsync()); + + Assert.Equal(1, badRequestException.ModelState.ErrorCount); + Assert.Equal("convert to key connector error", badRequestException.ModelState.Root.Errors[0].ErrorMessage); + await sutProvider.GetDependency().Received(1) + .ConvertToKeyConnectorAsync(Arg.Is(expectedUser)); + } + + [Theory] + [BitAutoData] + public async Task PostConvertToKeyConnectorAsync_ConvertToKeyConnectorSucceeds_OkResponse( + SutProvider sutProvider, + User expectedUser) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .Returns(expectedUser); + sutProvider.GetDependency() + .ConvertToKeyConnectorAsync(Arg.Any()) + .Returns(IdentityResult.Success); + + await sutProvider.Sut.PostConvertToKeyConnectorAsync(); + + await sutProvider.GetDependency().Received(1) + .ConvertToKeyConnectorAsync(Arg.Is(expectedUser)); + } }