From bd75c71d1076c88b9220acce9c91b3e6b61a441d Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Mon, 8 Dec 2025 13:42:54 -0500 Subject: [PATCH 01/19] chore(feature-flag): [PM-28331] Remove pm-24425-send-2fa-failed-email feature flag * Removed pm-24425-send-2fa-failed-email * Removed flagged logic. --- src/Core/Constants.cs | 1 - .../RequestValidators/BaseRequestValidator.cs | 7 ++---- .../BaseRequestValidatorTests.cs | 23 ++++++------------- 3 files changed, 9 insertions(+), 22 deletions(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index fb939f50cd..ef47c2d559 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -156,7 +156,6 @@ public static class FeatureFlagKeys public const string SetInitialPasswordRefactor = "pm-16117-set-initial-password-refactor"; public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor"; public const string Otp6Digits = "pm-18612-otp-6-digits"; - public const string FailedTwoFactorEmail = "pm-24425-send-2fa-failed-email"; public const string PM24579_PreventSsoOnExistingNonCompliantUsers = "pm-24579-prevent-sso-on-existing-non-compliant-users"; public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods"; public const string PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword = diff --git a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs index fdc70b0edf..429c16a6b3 100644 --- a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs @@ -736,11 +736,8 @@ public abstract class BaseRequestValidator where T : class private async Task SendFailedTwoFactorEmail(User user, TwoFactorProviderType failedAttemptType) { - if (_featureService.IsEnabled(FeatureFlagKeys.FailedTwoFactorEmail)) - { - await _mailService.SendFailedTwoFactorAttemptEmailAsync(user.Email, failedAttemptType, DateTime.UtcNow, - CurrentContext.IpAddress); - } + await _mailService.SendFailedTwoFactorAttemptEmailAsync(user.Email, failedAttemptType, DateTime.UtcNow, + CurrentContext.IpAddress); } private async Task GetMasterPasswordPolicyAsync(User user) diff --git a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs index 214fa74ff4..2ead26e4d2 100644 --- a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs @@ -372,19 +372,16 @@ public class BaseRequestValidatorTests // 1 -> initial validation passes _sut.isValid = true; - // 2 -> enable the FailedTwoFactorEmail feature flag - _featureService.IsEnabled(FeatureFlagKeys.FailedTwoFactorEmail).Returns(true); - - // 3 -> set up 2FA as required + // 2 -> set up 2FA as required _twoFactorAuthenticationValidator .RequiresTwoFactorAsync(Arg.Any(), tokenRequest) .Returns(Task.FromResult(new Tuple(true, null))); - // 4 -> provide invalid 2FA token + // 3 -> provide invalid 2FA token tokenRequest.Raw["TwoFactorToken"] = "invalid_token"; tokenRequest.Raw["TwoFactorProvider"] = TwoFactorProviderType.Email.ToString(); - // 5 -> set up 2FA verification to fail + // 4 -> set up 2FA verification to fail _twoFactorAuthenticationValidator .VerifyTwoFactorAsync(user, null, TwoFactorProviderType.Email, "invalid_token") .Returns(Task.FromResult(false)); @@ -419,24 +416,21 @@ public class BaseRequestValidatorTests // 1 -> initial validation passes _sut.isValid = true; - // 2 -> enable the FailedTwoFactorEmail feature flag - _featureService.IsEnabled(FeatureFlagKeys.FailedTwoFactorEmail).Returns(true); - - // 3 -> set up 2FA as required + // 2 -> set up 2FA as required _twoFactorAuthenticationValidator .RequiresTwoFactorAsync(Arg.Any(), tokenRequest) .Returns(Task.FromResult(new Tuple(true, null))); - // 4 -> provide invalid remember token (remember token expired) + // 3 -> provide invalid remember token (remember token expired) tokenRequest.Raw["TwoFactorToken"] = "expired_remember_token"; tokenRequest.Raw["TwoFactorProvider"] = "5"; // Remember provider - // 5 -> set up remember token verification to fail + // 4 -> set up remember token verification to fail _twoFactorAuthenticationValidator .VerifyTwoFactorAsync(user, null, TwoFactorProviderType.Remember, "expired_remember_token") .Returns(Task.FromResult(false)); - // 6 -> set up dummy BuildTwoFactorResultAsync + // 5 -> set up dummy BuildTwoFactorResultAsync var twoFactorResultDict = new Dictionary { { "TwoFactorProviders", new[] { "0", "1" } }, @@ -1119,9 +1113,6 @@ public class BaseRequestValidatorTests .VerifyTwoFactorAsync(user, null, TwoFactorProviderType.RecoveryCode, "INVALID-recovery-code") .Returns(Task.FromResult(false)); - // 6. Setup for failed 2FA email (if feature flag enabled) - _featureService.IsEnabled(FeatureFlagKeys.FailedTwoFactorEmail).Returns(true); - // Act await _sut.ValidateAsync(context); From 014376b545b0ae5a1e0d6074587a421d5ff26a83 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Mon, 8 Dec 2025 13:47:28 -0500 Subject: [PATCH 02/19] chore(docs): Document email asset process --- src/Core/MailTemplates/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Core/MailTemplates/README.md b/src/Core/MailTemplates/README.md index f8ec78c1d2..312821afd3 100644 --- a/src/Core/MailTemplates/README.md +++ b/src/Core/MailTemplates/README.md @@ -76,3 +76,13 @@ The `IMailService` automatically uses both versions when sending emails: ## `*.mjml` This is a templating language we use to increase efficiency when creating email content. See the `MJML` [documentation](./Mjml/README.md) for more details. + +# Managing email assets + +We host assets that are included in emails at `assets.bitwarden.com`, at the `/email/v1` path. This corresponds to a static file storage container that is managed by our SRE team. For example: https://assets.bitwarden.com/email/v1/mail-github.png. This is the URL for all assets for emails sent from any environment. + +## Adding an asset + +When you are creating an email that needs a new asset, you should first check to see if that asset already exists. The easiest way to do this is check at the corresponding `https://assets.bitwarden.com/email/v1/` URL (e.g. https://assets.bitwarden.com/email/v1/my_new_image.png). + +If the asset you are adding is not there, enter a ticket for the SRE team to add the asset to the email asset container. The preferred format for assets is a `.png` file, and the file(s) should be attached to the ticket. \ No newline at end of file From acc25293533c8da1b9a6d03e3a2270a18fb37a66 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 12:58:41 -0600 Subject: [PATCH 03/19] [deps]: Update Divergic.Logging.Xunit to 4.3.1 (#4821) * [deps]: Update Divergic.Logging.Xunit to 4.3.1 * Switch to Neovolve.Logging.Xunit and clean up test file --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Co-authored-by: Alex Morask --- test/Billing.Test/Billing.Test.csproj | 2 +- .../Controllers/PayPalControllerTests.cs | 71 ++++++------------- 2 files changed, 24 insertions(+), 49 deletions(-) diff --git a/test/Billing.Test/Billing.Test.csproj b/test/Billing.Test/Billing.Test.csproj index 84443753ce..87a1c28ca1 100644 --- a/test/Billing.Test/Billing.Test.csproj +++ b/test/Billing.Test/Billing.Test.csproj @@ -5,8 +5,8 @@ - + diff --git a/test/Billing.Test/Controllers/PayPalControllerTests.cs b/test/Billing.Test/Controllers/PayPalControllerTests.cs index 7ec17bd85a..f52a304bb6 100644 --- a/test/Billing.Test/Controllers/PayPalControllerTests.cs +++ b/test/Billing.Test/Controllers/PayPalControllerTests.cs @@ -8,13 +8,13 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; -using Divergic.Logging.Xunit; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; +using Neovolve.Logging.Xunit; using NSubstitute; using NSubstitute.ReturnsExtensions; using Xunit; @@ -23,10 +23,8 @@ using Transaction = Bit.Core.Entities.Transaction; namespace Bit.Billing.Test.Controllers; -public class PayPalControllerTests +public class PayPalControllerTests(ITestOutputHelper testOutputHelper) { - private readonly ITestOutputHelper _testOutputHelper; - private readonly IOptions _billingSettings = Substitute.For>(); private readonly IMailService _mailService = Substitute.For(); private readonly IOrganizationRepository _organizationRepository = Substitute.For(); @@ -38,15 +36,10 @@ public class PayPalControllerTests private const string _defaultWebhookKey = "webhook-key"; - public PayPalControllerTests(ITestOutputHelper testOutputHelper) - { - _testOutputHelper = testOutputHelper; - } - [Fact] public async Task PostIpn_NullKey_BadRequest() { - var logger = _testOutputHelper.BuildLoggerFor(); + var logger = testOutputHelper.BuildLoggerFor(); var controller = ConfigureControllerContextWith(logger, null, null); @@ -60,7 +53,7 @@ public class PayPalControllerTests [Fact] public async Task PostIpn_IncorrectKey_BadRequest() { - var logger = _testOutputHelper.BuildLoggerFor(); + var logger = testOutputHelper.BuildLoggerFor(); _billingSettings.Value.Returns(new BillingSettings { @@ -79,7 +72,7 @@ public class PayPalControllerTests [Fact] public async Task PostIpn_EmptyIPNBody_BadRequest() { - var logger = _testOutputHelper.BuildLoggerFor(); + var logger = testOutputHelper.BuildLoggerFor(); _billingSettings.Value.Returns(new BillingSettings { @@ -98,7 +91,7 @@ public class PayPalControllerTests [Fact] public async Task PostIpn_IPNHasNoEntityId_BadRequest() { - var logger = _testOutputHelper.BuildLoggerFor(); + var logger = testOutputHelper.BuildLoggerFor(); _billingSettings.Value.Returns(new BillingSettings { @@ -119,15 +112,13 @@ public class PayPalControllerTests [Fact] public async Task PostIpn_OtherTransactionType_Unprocessed_Ok() { - var logger = _testOutputHelper.BuildLoggerFor(); + var logger = testOutputHelper.BuildLoggerFor(); _billingSettings.Value.Returns(new BillingSettings { PayPal = { WebhookKey = _defaultWebhookKey } }); - var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); - var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.UnsupportedTransactionType); var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); @@ -142,7 +133,7 @@ public class PayPalControllerTests [Fact] public async Task PostIpn_MismatchedReceiverID_Unprocessed_Ok() { - var logger = _testOutputHelper.BuildLoggerFor(); + var logger = testOutputHelper.BuildLoggerFor(); _billingSettings.Value.Returns(new BillingSettings { @@ -153,8 +144,6 @@ public class PayPalControllerTests } }); - var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); - var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulPayment); var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); @@ -169,7 +158,7 @@ public class PayPalControllerTests [Fact] public async Task PostIpn_RefundMissingParent_Unprocessed_Ok() { - var logger = _testOutputHelper.BuildLoggerFor(); + var logger = testOutputHelper.BuildLoggerFor(); _billingSettings.Value.Returns(new BillingSettings { @@ -180,8 +169,6 @@ public class PayPalControllerTests } }); - var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); - var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.RefundMissingParentTransaction); var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); @@ -196,7 +183,7 @@ public class PayPalControllerTests [Fact] public async Task PostIpn_eCheckPayment_Unprocessed_Ok() { - var logger = _testOutputHelper.BuildLoggerFor(); + var logger = testOutputHelper.BuildLoggerFor(); _billingSettings.Value.Returns(new BillingSettings { @@ -207,8 +194,6 @@ public class PayPalControllerTests } }); - var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); - var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.ECheckPayment); var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); @@ -223,7 +208,7 @@ public class PayPalControllerTests [Fact] public async Task PostIpn_NonUSD_Unprocessed_Ok() { - var logger = _testOutputHelper.BuildLoggerFor(); + var logger = testOutputHelper.BuildLoggerFor(); _billingSettings.Value.Returns(new BillingSettings { @@ -234,8 +219,6 @@ public class PayPalControllerTests } }); - var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); - var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.NonUSDPayment); var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); @@ -250,7 +233,7 @@ public class PayPalControllerTests [Fact] public async Task PostIpn_Completed_ExistingTransaction_Unprocessed_Ok() { - var logger = _testOutputHelper.BuildLoggerFor(); + var logger = testOutputHelper.BuildLoggerFor(); _billingSettings.Value.Returns(new BillingSettings { @@ -261,8 +244,6 @@ public class PayPalControllerTests } }); - var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); - var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulPayment); _transactionRepository.GetByGatewayIdAsync( @@ -281,7 +262,7 @@ public class PayPalControllerTests [Fact] public async Task PostIpn_Completed_CreatesTransaction_Ok() { - var logger = _testOutputHelper.BuildLoggerFor(); + var logger = testOutputHelper.BuildLoggerFor(); _billingSettings.Value.Returns(new BillingSettings { @@ -292,8 +273,6 @@ public class PayPalControllerTests } }); - var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); - var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulPayment); _transactionRepository.GetByGatewayIdAsync( @@ -314,7 +293,7 @@ public class PayPalControllerTests [Fact] public async Task PostIpn_Completed_CreatesTransaction_CreditsOrganizationAccount_Ok() { - var logger = _testOutputHelper.BuildLoggerFor(); + var logger = testOutputHelper.BuildLoggerFor(); _billingSettings.Value.Returns(new BillingSettings { @@ -362,7 +341,7 @@ public class PayPalControllerTests [Fact] public async Task PostIpn_Completed_CreatesTransaction_CreditsUserAccount_Ok() { - var logger = _testOutputHelper.BuildLoggerFor(); + var logger = testOutputHelper.BuildLoggerFor(); _billingSettings.Value.Returns(new BillingSettings { @@ -406,7 +385,7 @@ public class PayPalControllerTests [Fact] public async Task PostIpn_Refunded_ExistingTransaction_Unprocessed_Ok() { - var logger = _testOutputHelper.BuildLoggerFor(); + var logger = testOutputHelper.BuildLoggerFor(); _billingSettings.Value.Returns(new BillingSettings { @@ -417,8 +396,6 @@ public class PayPalControllerTests } }); - var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); - var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulRefund); _transactionRepository.GetByGatewayIdAsync( @@ -441,7 +418,7 @@ public class PayPalControllerTests [Fact] public async Task PostIpn_Refunded_MissingParentTransaction_Ok() { - var logger = _testOutputHelper.BuildLoggerFor(); + var logger = testOutputHelper.BuildLoggerFor(); _billingSettings.Value.Returns(new BillingSettings { @@ -452,8 +429,6 @@ public class PayPalControllerTests } }); - var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); - var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulRefund); _transactionRepository.GetByGatewayIdAsync( @@ -480,7 +455,7 @@ public class PayPalControllerTests [Fact] public async Task PostIpn_Refunded_ReplacesParent_CreatesTransaction_Ok() { - var logger = _testOutputHelper.BuildLoggerFor(); + var logger = testOutputHelper.BuildLoggerFor(); _billingSettings.Value.Returns(new BillingSettings { @@ -531,8 +506,8 @@ public class PayPalControllerTests private PayPalController ConfigureControllerContextWith( ILogger logger, - string webhookKey, - string ipnBody) + string? webhookKey, + string? ipnBody) { var controller = new PayPalController( _billingSettings, @@ -578,16 +553,16 @@ public class PayPalControllerTests Assert.Equal(statusCode, statusCodeActionResult.StatusCode); } - private static void Logged(ICacheLogger logger, LogLevel logLevel, string message) + private static void Logged(ICacheLogger logger, LogLevel logLevel, string message) { Assert.NotNull(logger.Last); Assert.Equal(logLevel, logger.Last!.LogLevel); Assert.Equal(message, logger.Last!.Message); } - private static void LoggedError(ICacheLogger logger, string message) + private static void LoggedError(ICacheLogger logger, string message) => Logged(logger, LogLevel.Error, message); - private static void LoggedWarning(ICacheLogger logger, string message) + private static void LoggedWarning(ICacheLogger logger, string message) => Logged(logger, LogLevel.Warning, message); } From b5f7f9f6a08549a852d2620d0bc93f9a1c6e7e72 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:54:55 -0500 Subject: [PATCH 04/19] chore(premium): [PM-29186] Remove 2FA user interface from premium method signatures * Removed 2FA user interface from premium method signatures * Added some more comments for clarity and small touchups. * Suggested documentation updates. --------- Co-authored-by: Patrick Pimentel --- .../OrganizationUserUserDetails.cs | 5 ----- src/Core/Auth/Models/ITwoFactorProvidersUser.cs | 17 ++++++++++------- src/Core/Entities/User.cs | 5 ----- src/Core/Services/IUserService.cs | 16 +++++++++++++--- .../Services/Implementations/UserService.cs | 7 ++++--- 5 files changed, 27 insertions(+), 23 deletions(-) diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs index 6d182e197f..00bac01f76 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs @@ -63,11 +63,6 @@ public class OrganizationUserUserDetails : IExternal, ITwoFactorProvidersUser, I return UserId; } - public bool GetPremium() - { - return Premium.GetValueOrDefault(false); - } - public Permissions GetPermissions() { return string.IsNullOrWhiteSpace(Permissions) ? null diff --git a/src/Core/Auth/Models/ITwoFactorProvidersUser.cs b/src/Core/Auth/Models/ITwoFactorProvidersUser.cs index 5cf137b76f..816d460572 100644 --- a/src/Core/Auth/Models/ITwoFactorProvidersUser.cs +++ b/src/Core/Auth/Models/ITwoFactorProvidersUser.cs @@ -1,14 +1,14 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Enums; using Bit.Core.Services; namespace Bit.Core.Auth.Models; +/// +/// An interface representing a user entity that supports two-factor providers +/// public interface ITwoFactorProvidersUser { - string TwoFactorProviders { get; } + string? TwoFactorProviders { get; } /// /// Get the two factor providers for the user. Currently it can be assumed providers are enabled /// if they exists in the dictionary. When two factor providers are disabled they are removed @@ -16,7 +16,10 @@ public interface ITwoFactorProvidersUser /// /// /// Dictionary of providers with the type enum as the key - Dictionary GetTwoFactorProviders(); + Dictionary? GetTwoFactorProviders(); + /// + /// The unique `UserId` of the user entity for which there are two-factor providers configured. + /// + /// The unique identifier for the user Guid? GetUserId(); - bool GetPremium(); } diff --git a/src/Core/Entities/User.cs b/src/Core/Entities/User.cs index fec9b80d8e..1ca6606779 100644 --- a/src/Core/Entities/User.cs +++ b/src/Core/Entities/User.cs @@ -200,11 +200,6 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac return Id; } - public bool GetPremium() - { - return Premium; - } - public int GetSecurityVersion() { // If no security version is set, it is version 1. The minimum initialized version is 2. diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index 412f9db36e..0506e08cfc 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -4,7 +4,6 @@ using System.Security.Claims; using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Enums; -using Bit.Core.Auth.Models; using Bit.Core.Billing.Models.Business; using Bit.Core.Entities; using Bit.Core.Enums; @@ -60,11 +59,22 @@ public interface IUserService Task CheckPasswordAsync(User user, string password); /// /// Checks if the user has access to premium features, either through a personal subscription or through an organization. + /// + /// This is the preferred way to definitively know if a user has access to premium features. /// /// user being acted on /// true if they can access premium; false otherwise. - Task CanAccessPremium(ITwoFactorProvidersUser user); - Task HasPremiumFromOrganization(ITwoFactorProvidersUser user); + Task CanAccessPremium(User user); + + /// + /// Checks if the user has inherited access to premium features through an organization. + /// + /// This primarily serves as a means to communicate to the client when a user has inherited their premium status + /// through an organization. Feature gating logic probably should not be behind this check. + /// + /// user being acted on + /// true if they can access premium because of organization membership; false otherwise. + Task HasPremiumFromOrganization(User user); Task GenerateSignInTokenAsync(User user, string purpose); Task UpdatePasswordHash(User user, string newPassword, diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 57b69deb71..2d2a9f0ae7 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -1104,7 +1104,7 @@ public class UserService : UserManager, IUserService return success; } - public async Task CanAccessPremium(ITwoFactorProvidersUser user) + public async Task CanAccessPremium(User user) { var userId = user.GetUserId(); if (!userId.HasValue) @@ -1112,10 +1112,10 @@ public class UserService : UserManager, IUserService return false; } - return user.GetPremium() || await this.HasPremiumFromOrganization(user); + return user.Premium || await HasPremiumFromOrganization(user); } - public async Task HasPremiumFromOrganization(ITwoFactorProvidersUser user) + public async Task HasPremiumFromOrganization(User user) { var userId = user.GetUserId(); if (!userId.HasValue) @@ -1138,6 +1138,7 @@ public class UserService : UserManager, IUserService orgAbility.UsersGetPremium && orgAbility.Enabled); } + public async Task GenerateSignInTokenAsync(User user, string purpose) { var token = await GenerateUserTokenAsync(user, Options.Tokens.PasswordResetTokenProvider, From 2e0a4161bee286bd8e2fd72379c1174da65fe764 Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Mon, 8 Dec 2025 22:08:23 -0500 Subject: [PATCH 05/19] Added feature flag (#6694) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index ef47c2d559..a20408f0d9 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -250,6 +250,7 @@ public static class FeatureFlagKeys public const string PM25083_AutofillConfirmFromSearch = "pm-25083-autofill-confirm-from-search"; public const string VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders"; public const string BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight"; + public const string MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems"; /* Innovation Team */ public const string ArchiveVaultItems = "pm-19148-innovation-archive"; From d26b5fa0293deea0dcc7194ac9dac2eeef916582 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:40:46 +0100 Subject: [PATCH 06/19] [deps]: Update Microsoft.NET.Test.Sdk to v18 (#6449) * [deps]: Update Microsoft.NET.Test.Sdk to v18 * Use MicrosoftNetTestSdkVersion variable instead of fixed versions * Bump Microsoft.NET.Test.Sdk from 17.8.0 to 18.0.1 in Directory.Build.props --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel James Smith Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> --- Directory.Build.props | 2 +- test/Core.IntegrationTest/Core.IntegrationTest.csproj | 2 +- .../Infrastructure.Dapper.Test.csproj | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index d0998430c4..221200147c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -16,7 +16,7 @@ - 17.8.0 + 18.0.1 2.6.6 diff --git a/test/Core.IntegrationTest/Core.IntegrationTest.csproj b/test/Core.IntegrationTest/Core.IntegrationTest.csproj index babe974ffd..133793d3d8 100644 --- a/test/Core.IntegrationTest/Core.IntegrationTest.csproj +++ b/test/Core.IntegrationTest/Core.IntegrationTest.csproj @@ -12,7 +12,7 @@ - + diff --git a/test/Infrastructure.Dapper.Test/Infrastructure.Dapper.Test.csproj b/test/Infrastructure.Dapper.Test/Infrastructure.Dapper.Test.csproj index a7fdfa2df5..7a6bd3ba20 100644 --- a/test/Infrastructure.Dapper.Test/Infrastructure.Dapper.Test.csproj +++ b/test/Infrastructure.Dapper.Test/Infrastructure.Dapper.Test.csproj @@ -8,7 +8,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive From d1ae1fffd60fe455b6fc07ee92dc20c11ca01edf Mon Sep 17 00:00:00 2001 From: Dave <3836813+enmande@users.noreply.github.com> Date: Tue, 9 Dec 2025 09:30:06 -0500 Subject: [PATCH 07/19] [PM-24211]: 2FA Send Email Login validation should use AuthRequest.IsValidForAuthentication (#6695) * fix(two-factor-controller) [PM-24211]: Update send email validation to use auth request's IsValidForAuthentication. * refactor(login-features) [PM-24211]: Remove Core.LoginFeatures as no longer used; AuthRequest.IsValidForAuthentication should be used for any applicable use cases. * feat(auth-request) [PM-24211]: Add tests for AuthRequest.IsValidForAuthentication. * fix(two-factor-controller) [PM-24211]: Branching logic should return on successful send. * chore(auth-request) [PM-24211]: Remove some old comments (solved-for). * fix(two-factor-controller) [PM-24211]: Update some comments (clarification/naming). * fix(two-factor-controller) [PM-24211]: Rephrase a comment (accuracy). --- .../Auth/Controllers/TwoFactorController.cs | 16 +- src/Core/Auth/Entities/AuthRequest.cs | 2 - .../LoginServiceCollectionExtensions.cs | 14 -- .../Interfaces/IVerifyAuthRequest.cs | 6 - .../PasswordlessLogin/VerifyAuthRequest.cs | 25 -- .../Utilities/ServiceCollectionExtensions.cs | 2 - .../Auth/Entities/AuthRequestTests.cs | 224 ++++++++++++++++++ 7 files changed, 232 insertions(+), 57 deletions(-) delete mode 100644 src/Core/Auth/LoginFeatures/LoginServiceCollectionExtensions.cs delete mode 100644 src/Core/Auth/LoginFeatures/PasswordlessLogin/Interfaces/IVerifyAuthRequest.cs delete mode 100644 src/Core/Auth/LoginFeatures/PasswordlessLogin/VerifyAuthRequest.cs create mode 100644 test/Core.Test/Auth/Entities/AuthRequestTests.cs diff --git a/src/Api/Auth/Controllers/TwoFactorController.cs b/src/Api/Auth/Controllers/TwoFactorController.cs index 0af46fb57c..ba6cf66859 100644 --- a/src/Api/Auth/Controllers/TwoFactorController.cs +++ b/src/Api/Auth/Controllers/TwoFactorController.cs @@ -9,7 +9,6 @@ using Bit.Api.Models.Response; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Identity; using Bit.Core.Auth.Identity.TokenProviders; -using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Services; using Bit.Core.Context; @@ -35,7 +34,7 @@ public class TwoFactorController : Controller private readonly IOrganizationService _organizationService; private readonly UserManager _userManager; private readonly ICurrentContext _currentContext; - private readonly IVerifyAuthRequestCommand _verifyAuthRequestCommand; + private readonly IAuthRequestRepository _authRequestRepository; private readonly IDuoUniversalTokenService _duoUniversalTokenService; private readonly IDataProtectorTokenFactory _twoFactorAuthenticatorDataProtector; private readonly IDataProtectorTokenFactory _ssoEmailTwoFactorSessionDataProtector; @@ -47,7 +46,7 @@ public class TwoFactorController : Controller IOrganizationService organizationService, UserManager userManager, ICurrentContext currentContext, - IVerifyAuthRequestCommand verifyAuthRequestCommand, + IAuthRequestRepository authRequestRepository, IDuoUniversalTokenService duoUniversalConfigService, IDataProtectorTokenFactory twoFactorAuthenticatorDataProtector, IDataProtectorTokenFactory ssoEmailTwoFactorSessionDataProtector, @@ -58,7 +57,7 @@ public class TwoFactorController : Controller _organizationService = organizationService; _userManager = userManager; _currentContext = currentContext; - _verifyAuthRequestCommand = verifyAuthRequestCommand; + _authRequestRepository = authRequestRepository; _duoUniversalTokenService = duoUniversalConfigService; _twoFactorAuthenticatorDataProtector = twoFactorAuthenticatorDataProtector; _ssoEmailTwoFactorSessionDataProtector = ssoEmailTwoFactorSessionDataProtector; @@ -350,14 +349,15 @@ public class TwoFactorController : Controller if (user != null) { - // Check if 2FA email is from Passwordless. + // Check if 2FA email is from a device approval ("Log in with device") scenario. if (!string.IsNullOrEmpty(requestModel.AuthRequestAccessCode)) { - if (await _verifyAuthRequestCommand - .VerifyAuthRequestAsync(new Guid(requestModel.AuthRequestId), - requestModel.AuthRequestAccessCode)) + var authRequest = await _authRequestRepository.GetByIdAsync(new Guid(requestModel.AuthRequestId)); + if (authRequest != null && + authRequest.IsValidForAuthentication(user.Id, requestModel.AuthRequestAccessCode)) { await _twoFactorEmailService.SendTwoFactorEmailAsync(user); + return; } } else if (!string.IsNullOrEmpty(requestModel.SsoEmail2FaSessionToken)) diff --git a/src/Core/Auth/Entities/AuthRequest.cs b/src/Core/Auth/Entities/AuthRequest.cs index 2117c575c0..38dc0534c1 100644 --- a/src/Core/Auth/Entities/AuthRequest.cs +++ b/src/Core/Auth/Entities/AuthRequest.cs @@ -49,11 +49,9 @@ public class AuthRequest : ITableObject public bool IsExpired() { - // TODO: PM-24252 - consider using TimeProvider for better mocking in tests return GetExpirationDate() < DateTime.UtcNow; } - // TODO: PM-24252 - this probably belongs in a service. public bool IsValidForAuthentication(Guid userId, string password) { diff --git a/src/Core/Auth/LoginFeatures/LoginServiceCollectionExtensions.cs b/src/Core/Auth/LoginFeatures/LoginServiceCollectionExtensions.cs deleted file mode 100644 index f8caad448b..0000000000 --- a/src/Core/Auth/LoginFeatures/LoginServiceCollectionExtensions.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Bit.Core.Auth.LoginFeatures.PasswordlessLogin; -using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces; -using Microsoft.Extensions.DependencyInjection; - -namespace Bit.Core.Auth.LoginFeatures; - -public static class LoginServiceCollectionExtensions -{ - public static void AddLoginServices(this IServiceCollection services) - { - services.AddScoped(); - } -} - diff --git a/src/Core/Auth/LoginFeatures/PasswordlessLogin/Interfaces/IVerifyAuthRequest.cs b/src/Core/Auth/LoginFeatures/PasswordlessLogin/Interfaces/IVerifyAuthRequest.cs deleted file mode 100644 index e5da1b06d8..0000000000 --- a/src/Core/Auth/LoginFeatures/PasswordlessLogin/Interfaces/IVerifyAuthRequest.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces; - -public interface IVerifyAuthRequestCommand -{ - Task VerifyAuthRequestAsync(Guid authRequestId, string accessCode); -} diff --git a/src/Core/Auth/LoginFeatures/PasswordlessLogin/VerifyAuthRequest.cs b/src/Core/Auth/LoginFeatures/PasswordlessLogin/VerifyAuthRequest.cs deleted file mode 100644 index 7def7fea76..0000000000 --- a/src/Core/Auth/LoginFeatures/PasswordlessLogin/VerifyAuthRequest.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces; -using Bit.Core.Repositories; -using Bit.Core.Utilities; - -namespace Bit.Core.Auth.LoginFeatures.PasswordlessLogin; - -public class VerifyAuthRequestCommand : IVerifyAuthRequestCommand -{ - private readonly IAuthRequestRepository _authRequestRepository; - - public VerifyAuthRequestCommand(IAuthRequestRepository authRequestRepository) - { - _authRequestRepository = authRequestRepository; - } - - public async Task VerifyAuthRequestAsync(Guid authRequestId, string accessCode) - { - var authRequest = await _authRequestRepository.GetByIdAsync(authRequestId); - if (authRequest == null || !CoreHelpers.FixedTimeEquals(authRequest.AccessCode, accessCode)) - { - return false; - } - return true; - } -} diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index b7fabc5b58..d1fb0b8ac4 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -21,7 +21,6 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Identity; using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Auth.IdentityServer; -using Bit.Core.Auth.LoginFeatures; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Services; @@ -140,7 +139,6 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddLoginServices(); services.AddScoped(); services.AddVaultServices(); services.AddReportingServices(); diff --git a/test/Core.Test/Auth/Entities/AuthRequestTests.cs b/test/Core.Test/Auth/Entities/AuthRequestTests.cs new file mode 100644 index 0000000000..9efeb1ded1 --- /dev/null +++ b/test/Core.Test/Auth/Entities/AuthRequestTests.cs @@ -0,0 +1,224 @@ +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Xunit; + +namespace Bit.Core.Test.Auth.Entities; + +public class AuthRequestTests +{ + [Fact] + public void IsValidForAuthentication_WithValidRequest_ReturnsTrue() + { + // Arrange + var userId = Guid.NewGuid(); + var accessCode = "test-access-code"; + var authRequest = new AuthRequest + { + UserId = userId, + Type = AuthRequestType.AuthenticateAndUnlock, + ResponseDate = DateTime.UtcNow, + Approved = true, + CreationDate = DateTime.UtcNow, + AuthenticationDate = null, + AccessCode = accessCode + }; + + // Act + var result = authRequest.IsValidForAuthentication(userId, accessCode); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsValidForAuthentication_WithWrongUserId_ReturnsFalse() + { + // Arrange + var userId = Guid.NewGuid(); + var differentUserId = Guid.NewGuid(); + var accessCode = "test-access-code"; + var authRequest = new AuthRequest + { + UserId = userId, + Type = AuthRequestType.AuthenticateAndUnlock, + ResponseDate = DateTime.UtcNow, + Approved = true, + CreationDate = DateTime.UtcNow, + AuthenticationDate = null, + AccessCode = accessCode + }; + + // Act + var result = authRequest.IsValidForAuthentication(differentUserId, accessCode); + + // Assert + Assert.False(result, "Auth request should not validate for a different user"); + } + + [Fact] + public void IsValidForAuthentication_WithWrongAccessCode_ReturnsFalse() + { + // Arrange + var userId = Guid.NewGuid(); + var authRequest = new AuthRequest + { + UserId = userId, + Type = AuthRequestType.AuthenticateAndUnlock, + ResponseDate = DateTime.UtcNow, + Approved = true, + CreationDate = DateTime.UtcNow, + AuthenticationDate = null, + AccessCode = "correct-code" + }; + + // Act + var result = authRequest.IsValidForAuthentication(userId, "wrong-code"); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsValidForAuthentication_WithoutResponseDate_ReturnsFalse() + { + // Arrange + var userId = Guid.NewGuid(); + var accessCode = "test-access-code"; + var authRequest = new AuthRequest + { + UserId = userId, + Type = AuthRequestType.AuthenticateAndUnlock, + ResponseDate = null, // Not responded to + Approved = true, + CreationDate = DateTime.UtcNow, + AuthenticationDate = null, + AccessCode = accessCode + }; + + // Act + var result = authRequest.IsValidForAuthentication(userId, accessCode); + + // Assert + Assert.False(result, "Unanswered auth requests should not be valid"); + } + + [Fact] + public void IsValidForAuthentication_WithApprovedFalse_ReturnsFalse() + { + // Arrange + var userId = Guid.NewGuid(); + var accessCode = "test-access-code"; + var authRequest = new AuthRequest + { + UserId = userId, + Type = AuthRequestType.AuthenticateAndUnlock, + ResponseDate = DateTime.UtcNow, + Approved = false, // Denied + CreationDate = DateTime.UtcNow, + AuthenticationDate = null, + AccessCode = accessCode + }; + + // Act + var result = authRequest.IsValidForAuthentication(userId, accessCode); + + // Assert + Assert.False(result, "Denied auth requests should not be valid"); + } + + [Fact] + public void IsValidForAuthentication_WithApprovedNull_ReturnsFalse() + { + // Arrange + var userId = Guid.NewGuid(); + var accessCode = "test-access-code"; + var authRequest = new AuthRequest + { + UserId = userId, + Type = AuthRequestType.AuthenticateAndUnlock, + ResponseDate = DateTime.UtcNow, + Approved = null, // Pending + CreationDate = DateTime.UtcNow, + AuthenticationDate = null, + AccessCode = accessCode + }; + + // Act + var result = authRequest.IsValidForAuthentication(userId, accessCode); + + // Assert + Assert.False(result, "Pending auth requests should not be valid"); + } + + [Fact] + public void IsValidForAuthentication_WithExpiredRequest_ReturnsFalse() + { + // Arrange + var userId = Guid.NewGuid(); + var accessCode = "test-access-code"; + var authRequest = new AuthRequest + { + UserId = userId, + Type = AuthRequestType.AuthenticateAndUnlock, + ResponseDate = DateTime.UtcNow, + Approved = true, + CreationDate = DateTime.UtcNow.AddMinutes(-20), // Expired (15 min timeout) + AuthenticationDate = null, + AccessCode = accessCode + }; + + // Act + var result = authRequest.IsValidForAuthentication(userId, accessCode); + + // Assert + Assert.False(result, "Expired auth requests should not be valid"); + } + + [Fact] + public void IsValidForAuthentication_WithWrongType_ReturnsFalse() + { + // Arrange + var userId = Guid.NewGuid(); + var accessCode = "test-access-code"; + var authRequest = new AuthRequest + { + UserId = userId, + Type = AuthRequestType.Unlock, // Wrong type + ResponseDate = DateTime.UtcNow, + Approved = true, + CreationDate = DateTime.UtcNow, + AuthenticationDate = null, + AccessCode = accessCode + }; + + // Act + var result = authRequest.IsValidForAuthentication(userId, accessCode); + + // Assert + Assert.False(result, "Only AuthenticateAndUnlock type should be valid"); + } + + [Fact] + public void IsValidForAuthentication_WithAlreadyUsed_ReturnsFalse() + { + // Arrange + var userId = Guid.NewGuid(); + var accessCode = "test-access-code"; + var authRequest = new AuthRequest + { + UserId = userId, + Type = AuthRequestType.AuthenticateAndUnlock, + ResponseDate = DateTime.UtcNow, + Approved = true, + CreationDate = DateTime.UtcNow, + AuthenticationDate = DateTime.UtcNow, // Already used + AccessCode = accessCode + }; + + // Act + var result = authRequest.IsValidForAuthentication(userId, accessCode); + + // Assert + Assert.False(result, "Auth requests should only be valid for one-time use"); + } +} From 3e12cfc6dfc82511f761942937092b73edb846c7 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:45:03 +0100 Subject: [PATCH 08/19] Resolve the failing test (#6622) --- .../Extensions/InvoiceExtensionsTests.cs | 56 ++++++++++++------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/test/Core.Test/Billing/Extensions/InvoiceExtensionsTests.cs b/test/Core.Test/Billing/Extensions/InvoiceExtensionsTests.cs index 65d9e99e3b..1a4f92a224 100644 --- a/test/Core.Test/Billing/Extensions/InvoiceExtensionsTests.cs +++ b/test/Core.Test/Billing/Extensions/InvoiceExtensionsTests.cs @@ -1,4 +1,5 @@ -using Bit.Core.Billing.Extensions; +using System.Globalization; +using Bit.Core.Billing.Extensions; using Stripe; using Xunit; @@ -356,9 +357,18 @@ public class InvoiceExtensionsTests [Fact] public void FormatForProvider_ComplexScenario_HandlesAllLineTypes() { - // Arrange - var lineItems = new StripeList(); - lineItems.Data = new List + // Set culture to en-US to ensure consistent decimal formatting in tests + // This ensures tests pass on all machines regardless of system locale + var originalCulture = Thread.CurrentThread.CurrentCulture; + var originalUICulture = Thread.CurrentThread.CurrentUICulture; + try + { + Thread.CurrentThread.CurrentCulture = new CultureInfo("en-US"); + Thread.CurrentThread.CurrentUICulture = new CultureInfo("en-US"); + + // Arrange + var lineItems = new StripeList(); + lineItems.Data = new List { new InvoiceLineItem { @@ -372,23 +382,29 @@ public class InvoiceExtensionsTests new InvoiceLineItem { Description = "Custom Service", Quantity = 2, Amount = 2000 } }; - var invoice = new Invoice + var invoice = new Invoice + { + Lines = lineItems, + TotalTaxes = [new InvoiceTotalTax { Amount = 200 }] // Additional $2.00 tax + }; + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.Equal(5, result.Count); + Assert.Equal("5 × Manage service provider (at $6.00 / month)", result[0]); + Assert.Equal("10 × Manage service provider (at $4.00 / month)", result[1]); + Assert.Equal("1 × Tax (at $8.00 / month)", result[2]); + Assert.Equal("Custom Service", result[3]); + Assert.Equal("1 × Tax (at $2.00 / month)", result[4]); + } + finally { - Lines = lineItems, - TotalTaxes = [new InvoiceTotalTax { Amount = 200 }] // Additional $2.00 tax - }; - var subscription = new Subscription(); - - // Act - var result = invoice.FormatForProvider(subscription); - - // Assert - Assert.Equal(5, result.Count); - Assert.Equal("5 × Manage service provider (at $6.00 / month)", result[0]); - Assert.Equal("10 × Manage service provider (at $4.00 / month)", result[1]); - Assert.Equal("1 × Tax (at $8.00 / month)", result[2]); - Assert.Equal("Custom Service", result[3]); - Assert.Equal("1 × Tax (at $2.00 / month)", result[4]); + Thread.CurrentThread.CurrentCulture = originalCulture; + Thread.CurrentThread.CurrentUICulture = originalUICulture; + } } #endregion From 579d8004ff97b8c5f5ec7de0a0e8efe9fa41409b Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 9 Dec 2025 08:46:15 -0600 Subject: [PATCH 09/19] [PM-29224] Remove unused billing endpoints and code paths (#6692) * Remove unused endpoints and code paths * MOAR DELETE * Run dotnet format --- .../Controllers/AccountsBillingController.cs | 35 +- .../Billing/Controllers/AccountsController.cs | 74 +-- .../Billing/Controllers/InvoicesController.cs | 45 -- .../Billing/Controllers/LicensesController.cs | 91 ---- .../OrganizationBillingController.cs | 129 +---- .../Controllers/OrganizationsController.cs | 48 -- .../Controllers/ProviderBillingController.cs | 99 +--- ...elfHostedAccountBillingVNextController.cs} | 5 +- ...stedOrganizationBillingVNextController.cs} | 2 +- .../Requests/TaxInformationRequestBody.cs | 31 -- .../TokenizedPaymentSourceRequestBody.cs | 25 - .../UpdatePaymentMethodRequestBody.cs | 15 - .../Requests/VerifyBankAccountRequestBody.cs | 12 - .../Responses/BillingPaymentResponseModel.cs | 20 - .../Models/Responses/PaymentMethodResponse.cs | 18 - .../Models/Responses/PaymentSourceResponse.cs | 16 - .../Responses/TaxInformationResponse.cs | 23 - .../Billing/Services/ISubscriberService.cs | 32 -- .../Implementations/SubscriberService.cs | 113 ---- src/Core/Services/IPaymentService.cs | 7 - .../Implementations/StripePaymentService.cs | 440 --------------- .../ProviderBillingControllerTests.cs | 48 -- .../Services/SubscriberServiceTests.cs | 252 --------- .../Services/StripePaymentServiceTests.cs | 504 ------------------ 24 files changed, 28 insertions(+), 2056 deletions(-) delete mode 100644 src/Api/Billing/Controllers/InvoicesController.cs delete mode 100644 src/Api/Billing/Controllers/LicensesController.cs rename src/Api/Billing/Controllers/VNext/{SelfHostedAccountBillingController.cs => SelfHostedAccountBillingVNextController.cs} (92%) rename src/Api/Billing/Controllers/VNext/{SelfHostedBillingController.cs => SelfHostedOrganizationBillingVNextController.cs} (95%) delete mode 100644 src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs delete mode 100644 src/Api/Billing/Models/Requests/TokenizedPaymentSourceRequestBody.cs delete mode 100644 src/Api/Billing/Models/Requests/UpdatePaymentMethodRequestBody.cs delete mode 100644 src/Api/Billing/Models/Requests/VerifyBankAccountRequestBody.cs delete mode 100644 src/Api/Billing/Models/Responses/BillingPaymentResponseModel.cs delete mode 100644 src/Api/Billing/Models/Responses/PaymentMethodResponse.cs delete mode 100644 src/Api/Billing/Models/Responses/PaymentSourceResponse.cs delete mode 100644 src/Api/Billing/Models/Responses/TaxInformationResponse.cs diff --git a/src/Api/Billing/Controllers/AccountsBillingController.cs b/src/Api/Billing/Controllers/AccountsBillingController.cs index 7abcf8c357..99b6a47da0 100644 --- a/src/Api/Billing/Controllers/AccountsBillingController.cs +++ b/src/Api/Billing/Controllers/AccountsBillingController.cs @@ -1,7 +1,5 @@ -#nullable enable -using Bit.Api.Billing.Models.Responses; +using Bit.Api.Billing.Models.Responses; using Bit.Core.Billing.Services; -using Bit.Core.Billing.Tax.Requests; using Bit.Core.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; @@ -16,6 +14,7 @@ public class AccountsBillingController( IUserService userService, IPaymentHistoryService paymentHistoryService) : Controller { + // TODO: Migrate to Query / AccountBillingVNextController [HttpGet("history")] [SelfHosted(NotSelfHostedOnly = true)] public async Task GetBillingHistoryAsync() @@ -30,20 +29,7 @@ public class AccountsBillingController( return new BillingHistoryResponseModel(billingInfo); } - [HttpGet("payment-method")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task GetPaymentMethodAsync() - { - var user = await userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - var billingInfo = await paymentService.GetBillingAsync(user); - return new BillingPaymentResponseModel(billingInfo); - } - + // TODO: Migrate to Query / AccountBillingVNextController [HttpGet("invoices")] public async Task GetInvoicesAsync([FromQuery] string? status = null, [FromQuery] string? startAfter = null) { @@ -62,6 +48,7 @@ public class AccountsBillingController( return TypedResults.Ok(invoices); } + // TODO: Migrate to Query / AccountBillingVNextController [HttpGet("transactions")] public async Task GetTransactionsAsync([FromQuery] DateTime? startAfter = null) { @@ -78,18 +65,4 @@ public class AccountsBillingController( return TypedResults.Ok(transactions); } - - [HttpPost("preview-invoice")] - public async Task PreviewInvoiceAsync([FromBody] PreviewIndividualInvoiceRequestBody model) - { - var user = await userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - var invoice = await paymentService.PreviewInvoiceAsync(model, user.GatewayCustomerId, user.GatewaySubscriptionId); - - return TypedResults.Ok(invoice); - } } diff --git a/src/Api/Billing/Controllers/AccountsController.cs b/src/Api/Billing/Controllers/AccountsController.cs index 506ce13e4e..e136513c77 100644 --- a/src/Api/Billing/Controllers/AccountsController.cs +++ b/src/Api/Billing/Controllers/AccountsController.cs @@ -1,6 +1,4 @@ -#nullable enable - -using Bit.Api.Models.Request; +using Bit.Api.Models.Request; using Bit.Api.Models.Request.Accounts; using Bit.Api.Models.Response; using Bit.Api.Utilities; @@ -29,6 +27,7 @@ public class AccountsController( IFeatureService featureService, ILicensingService licensingService) : Controller { + // TODO: Remove when pm-24996-implement-upgrade-from-free-dialog is removed [HttpPost("premium")] public async Task PostPremiumAsync( PremiumRequestModel model, @@ -76,6 +75,7 @@ public class AccountsController( }; } + // TODO: Migrate to Query / AccountBillingVNextController as part of Premium -> Organization upgrade work. [HttpGet("subscription")] public async Task GetSubscriptionAsync( [FromServices] GlobalSettings globalSettings, @@ -114,29 +114,7 @@ public class AccountsController( } } - [HttpPost("payment")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostPaymentAsync([FromBody] PaymentRequestModel model) - { - var user = await userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - await userService.ReplacePaymentMethodAsync(user, model.PaymentToken, model.PaymentMethodType!.Value, - new TaxInfo - { - BillingAddressLine1 = model.Line1, - BillingAddressLine2 = model.Line2, - BillingAddressCity = model.City, - BillingAddressState = model.State, - BillingAddressCountry = model.Country, - BillingAddressPostalCode = model.PostalCode, - TaxIdNumber = model.TaxId - }); - } - + // TODO: Migrate to Command / AccountBillingVNextController as PUT /account/billing/vnext/subscription [HttpPost("storage")] [SelfHosted(NotSelfHostedOnly = true)] public async Task PostStorageAsync([FromBody] StorageRequestModel model) @@ -151,8 +129,11 @@ public class AccountsController( return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result }; } - - + /* + * TODO: A new version of this exists in the AccountBillingVNextController. + * The individual-self-hosting-license-uploader.component needs to be updated to use it. + * Then, this can be removed. + */ [HttpPost("license")] [SelfHosted(SelfHostedOnly = true)] public async Task PostLicenseAsync(LicenseRequestModel model) @@ -172,6 +153,7 @@ public class AccountsController( await userService.UpdateLicenseAsync(user, license); } + // TODO: Migrate to Command / AccountBillingVNextController as DELETE /account/billing/vnext/subscription [HttpPost("cancel")] public async Task PostCancelAsync( [FromBody] SubscriptionCancellationRequestModel request, @@ -189,6 +171,7 @@ public class AccountsController( user.IsExpired()); } + // TODO: Migrate to Command / AccountBillingVNextController as POST /account/billing/vnext/subscription/reinstate [HttpPost("reinstate-premium")] [SelfHosted(NotSelfHostedOnly = true)] public async Task PostReinstateAsync() @@ -202,41 +185,6 @@ public class AccountsController( await userService.ReinstatePremiumAsync(user); } - [HttpGet("tax")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task GetTaxInfoAsync( - [FromServices] IPaymentService paymentService) - { - var user = await userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - var taxInfo = await paymentService.GetTaxInfoAsync(user); - return new TaxInfoResponseModel(taxInfo); - } - - [HttpPut("tax")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PutTaxInfoAsync( - [FromBody] TaxInfoUpdateRequestModel model, - [FromServices] IPaymentService paymentService) - { - var user = await userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - var taxInfo = new TaxInfo - { - BillingAddressPostalCode = model.PostalCode, - BillingAddressCountry = model.Country, - }; - await paymentService.SaveTaxInfoAsync(user, taxInfo); - } - private async Task> GetOrganizationIdsClaimingUserAsync(Guid userId) { var organizationsClaimingUser = await userService.GetOrganizationsClaimingUserAsync(userId); diff --git a/src/Api/Billing/Controllers/InvoicesController.cs b/src/Api/Billing/Controllers/InvoicesController.cs deleted file mode 100644 index 30ea975e09..0000000000 --- a/src/Api/Billing/Controllers/InvoicesController.cs +++ /dev/null @@ -1,45 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Billing.Tax.Requests; -using Bit.Core.Context; -using Bit.Core.Repositories; -using Bit.Core.Services; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Bit.Api.Billing.Controllers; - -[Route("invoices")] -[Authorize("Application")] -public class InvoicesController : BaseBillingController -{ - [HttpPost("preview-organization")] - public async Task PreviewInvoiceAsync( - [FromBody] PreviewOrganizationInvoiceRequestBody model, - [FromServices] ICurrentContext currentContext, - [FromServices] IOrganizationRepository organizationRepository, - [FromServices] IPaymentService paymentService) - { - Organization organization = null; - if (model.OrganizationId != default) - { - if (!await currentContext.EditPaymentMethods(model.OrganizationId)) - { - return Error.Unauthorized(); - } - - organization = await organizationRepository.GetByIdAsync(model.OrganizationId); - if (organization == null) - { - return Error.NotFound(); - } - } - - var invoice = await paymentService.PreviewInvoiceAsync(model, organization?.GatewayCustomerId, - organization?.GatewaySubscriptionId); - - return TypedResults.Ok(invoice); - } -} diff --git a/src/Api/Billing/Controllers/LicensesController.cs b/src/Api/Billing/Controllers/LicensesController.cs deleted file mode 100644 index 29313bd4d8..0000000000 --- a/src/Api/Billing/Controllers/LicensesController.cs +++ /dev/null @@ -1,91 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces; -using Bit.Core.Billing.Models.Business; -using Bit.Core.Billing.Organizations.Models; -using Bit.Core.Billing.Organizations.Queries; -using Bit.Core.Context; -using Bit.Core.Exceptions; -using Bit.Core.Models.Api.OrganizationLicenses; -using Bit.Core.Repositories; -using Bit.Core.Services; -using Bit.Core.Utilities; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Bit.Api.Billing.Controllers; - -[Route("licenses")] -[Authorize("Licensing")] -[SelfHosted(NotSelfHostedOnly = true)] -public class LicensesController : Controller -{ - private readonly IUserRepository _userRepository; - private readonly IUserService _userService; - private readonly IOrganizationRepository _organizationRepository; - private readonly IGetCloudOrganizationLicenseQuery _getCloudOrganizationLicenseQuery; - private readonly IValidateBillingSyncKeyCommand _validateBillingSyncKeyCommand; - private readonly ICurrentContext _currentContext; - - public LicensesController( - IUserRepository userRepository, - IUserService userService, - IOrganizationRepository organizationRepository, - IGetCloudOrganizationLicenseQuery getCloudOrganizationLicenseQuery, - IValidateBillingSyncKeyCommand validateBillingSyncKeyCommand, - ICurrentContext currentContext) - { - _userRepository = userRepository; - _userService = userService; - _organizationRepository = organizationRepository; - _getCloudOrganizationLicenseQuery = getCloudOrganizationLicenseQuery; - _validateBillingSyncKeyCommand = validateBillingSyncKeyCommand; - _currentContext = currentContext; - } - - [HttpGet("user/{id}")] - public async Task GetUser(string id, [FromQuery] string key) - { - var user = await _userRepository.GetByIdAsync(new Guid(id)); - if (user == null) - { - return null; - } - else if (!user.LicenseKey.Equals(key)) - { - await Task.Delay(2000); - throw new BadRequestException("Invalid license key."); - } - - var license = await _userService.GenerateLicenseAsync(user, null); - return license; - } - - /// - /// Used by self-hosted installations to get an updated license file - /// - [HttpGet("organization/{id}")] - public async Task OrganizationSync(string id, [FromBody] SelfHostedOrganizationLicenseRequestModel model) - { - var organization = await _organizationRepository.GetByIdAsync(new Guid(id)); - if (organization == null) - { - throw new NotFoundException("Organization not found."); - } - - if (!organization.LicenseKey.Equals(model.LicenseKey)) - { - await Task.Delay(2000); - throw new BadRequestException("Invalid license key."); - } - - if (!await _validateBillingSyncKeyCommand.ValidateBillingSyncKeyAsync(organization, model.BillingSyncKey)) - { - throw new BadRequestException("Invalid Billing Sync Key"); - } - - var license = await _getCloudOrganizationLicenseQuery.GetLicenseAsync(organization, _currentContext.InstallationId.Value); - return license; - } -} diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index 6e4cacc155..a0a3e48b60 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -20,9 +20,9 @@ public class OrganizationBillingController( IOrganizationBillingService organizationBillingService, IOrganizationRepository organizationRepository, IPaymentService paymentService, - ISubscriberService subscriberService, IPaymentHistoryService paymentHistoryService) : BaseBillingController { + // TODO: Remove when pm-25379-use-new-organization-metadata-structure is removed. [HttpGet("metadata")] public async Task GetMetadataAsync([FromRoute] Guid organizationId) { @@ -41,6 +41,7 @@ public class OrganizationBillingController( return TypedResults.Ok(metadata); } + // TODO: Migrate to Query / OrganizationBillingVNextController [HttpGet("history")] public async Task GetHistoryAsync([FromRoute] Guid organizationId) { @@ -61,6 +62,7 @@ public class OrganizationBillingController( return TypedResults.Ok(billingInfo); } + // TODO: Migrate to Query / OrganizationBillingVNextController [HttpGet("invoices")] public async Task GetInvoicesAsync([FromRoute] Guid organizationId, [FromQuery] string? status = null, [FromQuery] string? startAfter = null) { @@ -85,6 +87,7 @@ public class OrganizationBillingController( return TypedResults.Ok(invoices); } + // TODO: Migrate to Query / OrganizationBillingVNextController [HttpGet("transactions")] public async Task GetTransactionsAsync([FromRoute] Guid organizationId, [FromQuery] DateTime? startAfter = null) { @@ -108,6 +111,7 @@ public class OrganizationBillingController( return TypedResults.Ok(transactions); } + // TODO: Can be removed once we do away with the organization-plans.component. [HttpGet] [SelfHosted(NotSelfHostedOnly = true)] public async Task GetBillingAsync(Guid organizationId) @@ -131,127 +135,7 @@ public class OrganizationBillingController( return TypedResults.Ok(response); } - [HttpGet("payment-method")] - public async Task GetPaymentMethodAsync([FromRoute] Guid organizationId) - { - if (!await currentContext.EditPaymentMethods(organizationId)) - { - return Error.Unauthorized(); - } - - var organization = await organizationRepository.GetByIdAsync(organizationId); - - if (organization == null) - { - return Error.NotFound(); - } - - var paymentMethod = await subscriberService.GetPaymentMethod(organization); - - var response = PaymentMethodResponse.From(paymentMethod); - - return TypedResults.Ok(response); - } - - [HttpPut("payment-method")] - public async Task UpdatePaymentMethodAsync( - [FromRoute] Guid organizationId, - [FromBody] UpdatePaymentMethodRequestBody requestBody) - { - if (!await currentContext.EditPaymentMethods(organizationId)) - { - return Error.Unauthorized(); - } - - var organization = await organizationRepository.GetByIdAsync(organizationId); - - if (organization == null) - { - return Error.NotFound(); - } - - var tokenizedPaymentSource = requestBody.PaymentSource.ToDomain(); - - var taxInformation = requestBody.TaxInformation.ToDomain(); - - await organizationBillingService.UpdatePaymentMethod(organization, tokenizedPaymentSource, taxInformation); - - return TypedResults.Ok(); - } - - [HttpPost("payment-method/verify-bank-account")] - public async Task VerifyBankAccountAsync( - [FromRoute] Guid organizationId, - [FromBody] VerifyBankAccountRequestBody requestBody) - { - if (!await currentContext.EditPaymentMethods(organizationId)) - { - return Error.Unauthorized(); - } - - if (requestBody.DescriptorCode.Length != 6 || !requestBody.DescriptorCode.StartsWith("SM")) - { - return Error.BadRequest("Statement descriptor should be a 6-character value that starts with 'SM'"); - } - - var organization = await organizationRepository.GetByIdAsync(organizationId); - - if (organization == null) - { - return Error.NotFound(); - } - - await subscriberService.VerifyBankAccount(organization, requestBody.DescriptorCode); - - return TypedResults.Ok(); - } - - [HttpGet("tax-information")] - public async Task GetTaxInformationAsync([FromRoute] Guid organizationId) - { - if (!await currentContext.EditPaymentMethods(organizationId)) - { - return Error.Unauthorized(); - } - - var organization = await organizationRepository.GetByIdAsync(organizationId); - - if (organization == null) - { - return Error.NotFound(); - } - - var taxInformation = await subscriberService.GetTaxInformation(organization); - - var response = TaxInformationResponse.From(taxInformation); - - return TypedResults.Ok(response); - } - - [HttpPut("tax-information")] - public async Task UpdateTaxInformationAsync( - [FromRoute] Guid organizationId, - [FromBody] TaxInformationRequestBody requestBody) - { - if (!await currentContext.EditPaymentMethods(organizationId)) - { - return Error.Unauthorized(); - } - - var organization = await organizationRepository.GetByIdAsync(organizationId); - - if (organization == null) - { - return Error.NotFound(); - } - - var taxInformation = requestBody.ToDomain(); - - await subscriberService.UpdateTaxInformation(organization, taxInformation); - - return TypedResults.Ok(); - } - + // TODO: Migrate to Command / OrganizationBillingVNextController [HttpPost("setup-business-unit")] [SelfHosted(NotSelfHostedOnly = true)] public async Task SetupBusinessUnitAsync( @@ -280,6 +164,7 @@ public class OrganizationBillingController( return TypedResults.Ok(providerId); } + // TODO: Migrate to Command / OrganizationBillingVNextController [HttpPost("change-frequency")] [SelfHosted(NotSelfHostedOnly = true)] public async Task ChangePlanSubscriptionFrequencyAsync( diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs index 6b8061c03c..16fb00a3e7 100644 --- a/src/Api/Billing/Controllers/OrganizationsController.cs +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -19,7 +19,6 @@ using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Business; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; @@ -249,53 +248,6 @@ public class OrganizationsController( await organizationService.ReinstateSubscriptionAsync(id); } - [HttpGet("{id:guid}/tax")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task GetTaxInfo(Guid id) - { - if (!await currentContext.OrganizationOwner(id)) - { - throw new NotFoundException(); - } - - var organization = await organizationRepository.GetByIdAsync(id); - if (organization == null) - { - throw new NotFoundException(); - } - - var taxInfo = await paymentService.GetTaxInfoAsync(organization); - return new TaxInfoResponseModel(taxInfo); - } - - [HttpPut("{id:guid}/tax")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PutTaxInfo(Guid id, [FromBody] ExpandedTaxInfoUpdateRequestModel model) - { - if (!await currentContext.OrganizationOwner(id)) - { - throw new NotFoundException(); - } - - var organization = await organizationRepository.GetByIdAsync(id); - if (organization == null) - { - throw new NotFoundException(); - } - - var taxInfo = new TaxInfo - { - TaxIdNumber = model.TaxId, - BillingAddressLine1 = model.Line1, - BillingAddressLine2 = model.Line2, - BillingAddressCity = model.City, - BillingAddressState = model.State, - BillingAddressPostalCode = model.PostalCode, - BillingAddressCountry = model.Country, - }; - await paymentService.SaveTaxInfoAsync(organization, taxInfo); - } - /// /// Tries to grant owner access to the Secrets Manager for the organization /// diff --git a/src/Api/Billing/Controllers/ProviderBillingController.cs b/src/Api/Billing/Controllers/ProviderBillingController.cs index 006a7ce068..d358f8efd2 100644 --- a/src/Api/Billing/Controllers/ProviderBillingController.cs +++ b/src/Api/Billing/Controllers/ProviderBillingController.cs @@ -1,7 +1,6 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable -using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Pricing; @@ -9,7 +8,6 @@ using Bit.Core.Billing.Providers.Models; using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; -using Bit.Core.Billing.Tax.Models; using Bit.Core.Context; using Bit.Core.Models.BitStripe; using Bit.Core.Services; @@ -34,6 +32,7 @@ public class ProviderBillingController( IStripeAdapter stripeAdapter, IUserService userService) : BaseProviderController(currentContext, logger, providerRepository, userService) { + // TODO: Migrate to Query / ProviderBillingVNextController [HttpGet("invoices")] public async Task GetInvoicesAsync([FromRoute] Guid providerId) { @@ -54,6 +53,7 @@ public class ProviderBillingController( return TypedResults.Ok(response); } + // TODO: Migrate to Query / ProviderBillingVNextController [HttpGet("invoices/{invoiceId}")] public async Task GenerateClientInvoiceReportAsync([FromRoute] Guid providerId, string invoiceId) { @@ -76,51 +76,7 @@ public class ProviderBillingController( "text/csv"); } - [HttpPut("payment-method")] - public async Task UpdatePaymentMethodAsync( - [FromRoute] Guid providerId, - [FromBody] UpdatePaymentMethodRequestBody requestBody) - { - var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId); - - if (provider == null) - { - return result; - } - - var tokenizedPaymentSource = requestBody.PaymentSource.ToDomain(); - var taxInformation = requestBody.TaxInformation.ToDomain(); - - await providerBillingService.UpdatePaymentMethod( - provider, - tokenizedPaymentSource, - taxInformation); - - return TypedResults.Ok(); - } - - [HttpPost("payment-method/verify-bank-account")] - public async Task VerifyBankAccountAsync( - [FromRoute] Guid providerId, - [FromBody] VerifyBankAccountRequestBody requestBody) - { - var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId); - - if (provider == null) - { - return result; - } - - if (requestBody.DescriptorCode.Length != 6 || !requestBody.DescriptorCode.StartsWith("SM")) - { - return Error.BadRequest("Statement descriptor should be a 6-character value that starts with 'SM'"); - } - - await subscriberService.VerifyBankAccount(provider, requestBody.DescriptorCode); - - return TypedResults.Ok(); - } - + // TODO: Migrate to Query / ProviderBillingVNextController [HttpGet("subscription")] public async Task GetSubscriptionAsync([FromRoute] Guid providerId) { @@ -172,53 +128,4 @@ public class ProviderBillingController( return TypedResults.Ok(response); } - - [HttpGet("tax-information")] - public async Task GetTaxInformationAsync([FromRoute] Guid providerId) - { - var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId); - - if (provider == null) - { - return result; - } - - var taxInformation = await subscriberService.GetTaxInformation(provider); - - var response = TaxInformationResponse.From(taxInformation); - - return TypedResults.Ok(response); - } - - [HttpPut("tax-information")] - public async Task UpdateTaxInformationAsync( - [FromRoute] Guid providerId, - [FromBody] TaxInformationRequestBody requestBody) - { - var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId); - - if (provider == null) - { - return result; - } - - if (requestBody is not { Country: not null, PostalCode: not null }) - { - return Error.BadRequest("Country and postal code are required to update your tax information."); - } - - var taxInformation = new TaxInformation( - requestBody.Country, - requestBody.PostalCode, - requestBody.TaxId, - requestBody.TaxIdType, - requestBody.Line1, - requestBody.Line2, - requestBody.City, - requestBody.State); - - await subscriberService.UpdateTaxInformation(provider, taxInformation); - - return TypedResults.Ok(); - } } diff --git a/src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingController.cs b/src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingVNextController.cs similarity index 92% rename from src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingController.cs rename to src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingVNextController.cs index 973a7d99a1..b86f29bdbc 100644 --- a/src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingController.cs +++ b/src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingVNextController.cs @@ -1,5 +1,4 @@ -#nullable enable -using Bit.Api.Billing.Attributes; +using Bit.Api.Billing.Attributes; using Bit.Api.Billing.Models.Requests.Premium; using Bit.Api.Utilities; using Bit.Core; @@ -17,7 +16,7 @@ namespace Bit.Api.Billing.Controllers.VNext; [Authorize("Application")] [Route("account/billing/vnext/self-host")] [SelfHosted(SelfHostedOnly = true)] -public class SelfHostedAccountBillingController( +public class SelfHostedAccountBillingVNextController( ICreatePremiumSelfHostedSubscriptionCommand createPremiumSelfHostedSubscriptionCommand) : BaseBillingController { [HttpPost("license")] diff --git a/src/Api/Billing/Controllers/VNext/SelfHostedBillingController.cs b/src/Api/Billing/Controllers/VNext/SelfHostedOrganizationBillingVNextController.cs similarity index 95% rename from src/Api/Billing/Controllers/VNext/SelfHostedBillingController.cs rename to src/Api/Billing/Controllers/VNext/SelfHostedOrganizationBillingVNextController.cs index bd40c777dc..625a97c998 100644 --- a/src/Api/Billing/Controllers/VNext/SelfHostedBillingController.cs +++ b/src/Api/Billing/Controllers/VNext/SelfHostedOrganizationBillingVNextController.cs @@ -14,7 +14,7 @@ namespace Bit.Api.Billing.Controllers.VNext; [Authorize("Application")] [Route("organizations/{organizationId:guid}/billing/vnext/self-host")] [SelfHosted(SelfHostedOnly = true)] -public class SelfHostedBillingController( +public class SelfHostedOrganizationBillingVNextController( IGetOrganizationMetadataQuery getOrganizationMetadataQuery) : BaseBillingController { [Authorize] diff --git a/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs b/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs deleted file mode 100644 index a1b754a9dc..0000000000 --- a/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs +++ /dev/null @@ -1,31 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; -using Bit.Core.Billing.Tax.Models; - -namespace Bit.Api.Billing.Models.Requests; - -public class TaxInformationRequestBody -{ - [Required] - public string Country { get; set; } - [Required] - public string PostalCode { get; set; } - public string TaxId { get; set; } - public string TaxIdType { get; set; } - public string Line1 { get; set; } - public string Line2 { get; set; } - public string City { get; set; } - public string State { get; set; } - - public TaxInformation ToDomain() => new( - Country, - PostalCode, - TaxId, - TaxIdType, - Line1, - Line2, - City, - State); -} diff --git a/src/Api/Billing/Models/Requests/TokenizedPaymentSourceRequestBody.cs b/src/Api/Billing/Models/Requests/TokenizedPaymentSourceRequestBody.cs deleted file mode 100644 index b469ce2576..0000000000 --- a/src/Api/Billing/Models/Requests/TokenizedPaymentSourceRequestBody.cs +++ /dev/null @@ -1,25 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; -using Bit.Api.Utilities; -using Bit.Core.Billing.Models; -using Bit.Core.Enums; - -namespace Bit.Api.Billing.Models.Requests; - -public class TokenizedPaymentSourceRequestBody -{ - [Required] - [EnumMatches( - PaymentMethodType.BankAccount, - PaymentMethodType.Card, - PaymentMethodType.PayPal, - ErrorMessage = "'type' must be BankAccount, Card or PayPal")] - public PaymentMethodType Type { get; set; } - - [Required] - public string Token { get; set; } - - public TokenizedPaymentSource ToDomain() => new(Type, Token); -} diff --git a/src/Api/Billing/Models/Requests/UpdatePaymentMethodRequestBody.cs b/src/Api/Billing/Models/Requests/UpdatePaymentMethodRequestBody.cs deleted file mode 100644 index 05ab1e34c9..0000000000 --- a/src/Api/Billing/Models/Requests/UpdatePaymentMethodRequestBody.cs +++ /dev/null @@ -1,15 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; - -namespace Bit.Api.Billing.Models.Requests; - -public class UpdatePaymentMethodRequestBody -{ - [Required] - public TokenizedPaymentSourceRequestBody PaymentSource { get; set; } - - [Required] - public TaxInformationRequestBody TaxInformation { get; set; } -} diff --git a/src/Api/Billing/Models/Requests/VerifyBankAccountRequestBody.cs b/src/Api/Billing/Models/Requests/VerifyBankAccountRequestBody.cs deleted file mode 100644 index e248d55dde..0000000000 --- a/src/Api/Billing/Models/Requests/VerifyBankAccountRequestBody.cs +++ /dev/null @@ -1,12 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; - -namespace Bit.Api.Billing.Models.Requests; - -public class VerifyBankAccountRequestBody -{ - [Required] - public string DescriptorCode { get; set; } -} diff --git a/src/Api/Billing/Models/Responses/BillingPaymentResponseModel.cs b/src/Api/Billing/Models/Responses/BillingPaymentResponseModel.cs deleted file mode 100644 index f305e41c4f..0000000000 --- a/src/Api/Billing/Models/Responses/BillingPaymentResponseModel.cs +++ /dev/null @@ -1,20 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.Billing.Models; -using Bit.Core.Models.Api; - -namespace Bit.Api.Billing.Models.Responses; - -public class BillingPaymentResponseModel : ResponseModel -{ - public BillingPaymentResponseModel(BillingInfo billing) - : base("billingPayment") - { - Balance = billing.Balance; - PaymentSource = billing.PaymentSource != null ? new BillingSource(billing.PaymentSource) : null; - } - - public decimal Balance { get; set; } - public BillingSource PaymentSource { get; set; } -} diff --git a/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs b/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs deleted file mode 100644 index a54ac0a876..0000000000 --- a/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Bit.Core.Billing.Models; -using Bit.Core.Billing.Tax.Models; - -namespace Bit.Api.Billing.Models.Responses; - -public record PaymentMethodResponse( - decimal AccountCredit, - PaymentSource PaymentSource, - string SubscriptionStatus, - TaxInformation TaxInformation) -{ - public static PaymentMethodResponse From(PaymentMethod paymentMethod) => - new( - paymentMethod.AccountCredit, - paymentMethod.PaymentSource, - paymentMethod.SubscriptionStatus, - paymentMethod.TaxInformation); -} diff --git a/src/Api/Billing/Models/Responses/PaymentSourceResponse.cs b/src/Api/Billing/Models/Responses/PaymentSourceResponse.cs deleted file mode 100644 index 2c9a63b1d0..0000000000 --- a/src/Api/Billing/Models/Responses/PaymentSourceResponse.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Bit.Core.Billing.Models; -using Bit.Core.Enums; - -namespace Bit.Api.Billing.Models.Responses; - -public record PaymentSourceResponse( - PaymentMethodType Type, - string Description, - bool NeedsVerification) -{ - public static PaymentSourceResponse From(PaymentSource paymentMethod) - => new( - paymentMethod.Type, - paymentMethod.Description, - paymentMethod.NeedsVerification); -} diff --git a/src/Api/Billing/Models/Responses/TaxInformationResponse.cs b/src/Api/Billing/Models/Responses/TaxInformationResponse.cs deleted file mode 100644 index 59e4934751..0000000000 --- a/src/Api/Billing/Models/Responses/TaxInformationResponse.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Bit.Core.Billing.Tax.Models; - -namespace Bit.Api.Billing.Models.Responses; - -public record TaxInformationResponse( - string Country, - string PostalCode, - string TaxId, - string Line1, - string Line2, - string City, - string State) -{ - public static TaxInformationResponse From(TaxInformation taxInformation) - => new( - taxInformation.Country, - taxInformation.PostalCode, - taxInformation.TaxId, - taxInformation.Line1, - taxInformation.Line2, - taxInformation.City, - taxInformation.State); -} diff --git a/src/Core/Billing/Services/ISubscriberService.cs b/src/Core/Billing/Services/ISubscriberService.cs index f88727f37b..343a0e4f38 100644 --- a/src/Core/Billing/Services/ISubscriberService.cs +++ b/src/Core/Billing/Services/ISubscriberService.cs @@ -6,7 +6,6 @@ using Bit.Core.Billing.Tax.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Stripe; -using PaymentMethod = Bit.Core.Billing.Models.PaymentMethod; namespace Bit.Core.Billing.Services; @@ -64,16 +63,6 @@ public interface ISubscriberService ISubscriber subscriber, CustomerGetOptions customerGetOptions = null); - /// - /// Retrieves the account credit, a masked representation of the default payment source and the tax information for the - /// provided . This is essentially a consolidated invocation of the - /// and methods with a response that includes the customer's as account credit in order to cut down on Stripe API calls. - /// - /// The subscriber to retrieve payment method for. - /// A containing the subscriber's account credit, payment source and tax information. - Task GetPaymentMethod( - ISubscriber subscriber); - /// /// Retrieves a masked representation of the subscriber's payment source for presentation to a client. /// @@ -107,16 +96,6 @@ public interface ISubscriberService ISubscriber subscriber, SubscriptionGetOptions subscriptionGetOptions = null); - /// - /// Retrieves the 's tax information using their Stripe 's . - /// - /// The subscriber to retrieve the tax information for. - /// A representing the 's tax information. - /// Thrown when the is . - /// This method opts for returning rather than throwing exceptions, making it ideal for surfacing data from API endpoints. - Task GetTaxInformation( - ISubscriber subscriber); - /// /// Attempts to remove a subscriber's saved payment source. If the Stripe representing the /// contains a valid "btCustomerId" key in its property, @@ -147,17 +126,6 @@ public interface ISubscriberService ISubscriber subscriber, TaxInformation taxInformation); - /// - /// Verifies the subscriber's pending bank account using the provided . - /// - /// The subscriber to verify the bank account for. - /// The code attached to a deposit made to the subscriber's bank account in order to ensure they have access to it. - /// Learn more. - /// - Task VerifyBankAccount( - ISubscriber subscriber, - string descriptorCode); - /// /// Validates whether the 's exists in the gateway. /// If the 's is or empty, returns . diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 8e75bf3dca..4b2ea26294 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -24,7 +24,6 @@ using Stripe; using static Bit.Core.Billing.Utilities; using Customer = Stripe.Customer; -using PaymentMethod = Bit.Core.Billing.Models.PaymentMethod; using Subscription = Stripe.Subscription; namespace Bit.Core.Billing.Services.Implementations; @@ -330,38 +329,6 @@ public class SubscriberService( } } - public async Task GetPaymentMethod( - ISubscriber subscriber) - { - ArgumentNullException.ThrowIfNull(subscriber); - - var customer = await GetCustomer(subscriber, new CustomerGetOptions - { - Expand = ["default_source", "invoice_settings.default_payment_method", "subscriptions", "tax_ids"] - }); - - if (customer == null) - { - return PaymentMethod.Empty; - } - - var accountCredit = customer.Balance * -1 / 100M; - - var paymentMethod = await GetPaymentSourceAsync(subscriber.Id, customer); - - var subscriptionStatus = customer.Subscriptions - .FirstOrDefault(subscription => subscription.Id == subscriber.GatewaySubscriptionId)? - .Status; - - var taxInformation = GetTaxInformation(customer); - - return new PaymentMethod( - accountCredit, - paymentMethod, - subscriptionStatus, - taxInformation); - } - public async Task GetPaymentSource( ISubscriber subscriber) { @@ -449,16 +416,6 @@ public class SubscriberService( } } - public async Task GetTaxInformation( - ISubscriber subscriber) - { - ArgumentNullException.ThrowIfNull(subscriber); - - var customer = await GetCustomerOrThrow(subscriber, new CustomerGetOptions { Expand = ["tax_ids"] }); - - return GetTaxInformation(customer); - } - public async Task RemovePaymentSource( ISubscriber subscriber) { @@ -823,57 +780,6 @@ public class SubscriberService( } } - public async Task VerifyBankAccount( - ISubscriber subscriber, - string descriptorCode) - { - var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriber.Id); - - if (string.IsNullOrEmpty(setupIntentId)) - { - logger.LogError("No setup intent ID exists to verify for subscriber with ID ({SubscriberID})", subscriber.Id); - throw new BillingException(); - } - - try - { - await stripeAdapter.SetupIntentVerifyMicroDeposit(setupIntentId, - new SetupIntentVerifyMicrodepositsOptions { DescriptorCode = descriptorCode }); - - var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId); - - await stripeAdapter.PaymentMethodAttachAsync(setupIntent.PaymentMethodId, - new PaymentMethodAttachOptions { Customer = subscriber.GatewayCustomerId }); - - await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, - new CustomerUpdateOptions - { - InvoiceSettings = new CustomerInvoiceSettingsOptions - { - DefaultPaymentMethod = setupIntent.PaymentMethodId - } - }); - } - catch (StripeException stripeException) - { - if (!string.IsNullOrEmpty(stripeException.StripeError?.Code)) - { - var message = stripeException.StripeError.Code switch - { - StripeConstants.ErrorCodes.PaymentMethodMicroDepositVerificationAttemptsExceeded => "You have exceeded the number of allowed verification attempts. Please contact support.", - StripeConstants.ErrorCodes.PaymentMethodMicroDepositVerificationDescriptorCodeMismatch => "The verification code you provided does not match the one sent to your bank account. Please try again.", - StripeConstants.ErrorCodes.PaymentMethodMicroDepositVerificationTimeout => "Your bank account was not verified within the required time period. Please contact support.", - _ => BillingException.DefaultMessage - }; - - throw new BadRequestException(message); - } - - logger.LogError(stripeException, "An unhandled Stripe exception was thrown while verifying subscriber's ({SubscriberID}) bank account", subscriber.Id); - throw new BillingException(); - } - } - public async Task IsValidGatewayCustomerIdAsync(ISubscriber subscriber) { ArgumentNullException.ThrowIfNull(subscriber); @@ -970,25 +876,6 @@ public class SubscriberService( return PaymentSource.From(setupIntent); } - private static TaxInformation GetTaxInformation( - Customer customer) - { - if (customer.Address == null) - { - return null; - } - - return new TaxInformation( - customer.Address.Country, - customer.Address.PostalCode, - customer.TaxIds?.FirstOrDefault()?.Value, - customer.TaxIds?.FirstOrDefault()?.Type, - customer.Address.Line1, - customer.Address.Line2, - customer.Address.City, - customer.Address.State); - } - private async Task RemoveBraintreeCustomerIdAsync( Customer customer) { diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index e7e848bcba..b4a4639992 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -4,8 +4,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Business; using Bit.Core.Billing.Models; -using Bit.Core.Billing.Tax.Requests; -using Bit.Core.Billing.Tax.Responses; using Bit.Core.Entities; using Bit.Core.Models.Business; using Bit.Core.Models.StaticStore; @@ -44,8 +42,6 @@ public interface IPaymentService Task GetBillingAsync(ISubscriber subscriber); Task GetBillingHistoryAsync(ISubscriber subscriber); Task GetSubscriptionAsync(ISubscriber subscriber); - Task GetTaxInfoAsync(ISubscriber subscriber); - Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo); Task AddSecretsManagerToSubscription(Organization org, Plan plan, int additionalSmSeats, int additionalServiceAccount); /// /// Secrets Manager Standalone is a discount in Stripe that is used to give an organization access to Secrets Manager. @@ -68,7 +64,4 @@ public interface IPaymentService /// Organization Representation used for Inviting Organization Users /// If the organization has Secrets Manager and has the Standalone Stripe Discount Task HasSecretsManagerStandalone(InviteOrganization organization); - Task PreviewInvoiceAsync(PreviewIndividualInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId); - Task PreviewInvoiceAsync(PreviewOrganizationInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId); - } diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 4c64abc73e..c887a388bd 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -8,11 +8,7 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; using Bit.Core.Billing.Organizations.Models; -using Bit.Core.Billing.Premium.Commands; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Tax.Requests; -using Bit.Core.Billing.Tax.Responses; -using Bit.Core.Billing.Tax.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -36,8 +32,6 @@ public class StripePaymentService : IPaymentService private readonly Braintree.IBraintreeGateway _btGateway; private readonly IStripeAdapter _stripeAdapter; private readonly IGlobalSettings _globalSettings; - private readonly IFeatureService _featureService; - private readonly ITaxService _taxService; private readonly IPricingClient _pricingClient; public StripePaymentService( @@ -46,8 +40,6 @@ public class StripePaymentService : IPaymentService IStripeAdapter stripeAdapter, Braintree.IBraintreeGateway braintreeGateway, IGlobalSettings globalSettings, - IFeatureService featureService, - ITaxService taxService, IPricingClient pricingClient) { _transactionRepository = transactionRepository; @@ -55,8 +47,6 @@ public class StripePaymentService : IPaymentService _stripeAdapter = stripeAdapter; _btGateway = braintreeGateway; _globalSettings = globalSettings; - _featureService = featureService; - _taxService = taxService; _pricingClient = pricingClient; } @@ -705,133 +695,6 @@ public class StripePaymentService : IPaymentService return subscriptionInfo; } - public async Task GetTaxInfoAsync(ISubscriber subscriber) - { - if (subscriber == null || string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) - { - return null; - } - - var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, - new CustomerGetOptions { Expand = ["tax_ids"] }); - - if (customer == null) - { - return null; - } - - var address = customer.Address; - var taxId = customer.TaxIds?.FirstOrDefault(); - - // Line1 is required, so if missing we're using the subscriber name, - // see: https://stripe.com/docs/api/customers/create#create_customer-address-line1 - if (address != null && string.IsNullOrWhiteSpace(address.Line1)) - { - address.Line1 = null; - } - - return new TaxInfo - { - TaxIdNumber = taxId?.Value, - TaxIdType = taxId?.Type, - BillingAddressLine1 = address?.Line1, - BillingAddressLine2 = address?.Line2, - BillingAddressCity = address?.City, - BillingAddressState = address?.State, - BillingAddressPostalCode = address?.PostalCode, - BillingAddressCountry = address?.Country, - }; - } - - public async Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo) - { - if (string.IsNullOrWhiteSpace(subscriber?.GatewayCustomerId) || subscriber.IsUser()) - { - return; - } - - var customer = await _stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, - new CustomerUpdateOptions - { - Address = new AddressOptions - { - Line1 = taxInfo.BillingAddressLine1 ?? string.Empty, - Line2 = taxInfo.BillingAddressLine2, - City = taxInfo.BillingAddressCity, - State = taxInfo.BillingAddressState, - PostalCode = taxInfo.BillingAddressPostalCode, - Country = taxInfo.BillingAddressCountry, - }, - Expand = ["tax_ids"] - }); - - if (customer == null) - { - return; - } - - var taxId = customer.TaxIds?.FirstOrDefault(); - - if (taxId != null) - { - await _stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id); - } - - if (string.IsNullOrWhiteSpace(taxInfo.TaxIdNumber)) - { - return; - } - - var taxIdType = taxInfo.TaxIdType; - - if (string.IsNullOrWhiteSpace(taxIdType)) - { - taxIdType = _taxService.GetStripeTaxCode(taxInfo.BillingAddressCountry, taxInfo.TaxIdNumber); - - if (taxIdType == null) - { - _logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.", - taxInfo.BillingAddressCountry, - taxInfo.TaxIdNumber); - throw new BadRequestException("billingTaxIdTypeInferenceError"); - } - } - - try - { - await _stripeAdapter.TaxIdCreateAsync(customer.Id, - new TaxIdCreateOptions { Type = taxInfo.TaxIdType, Value = taxInfo.TaxIdNumber }); - - if (taxInfo.TaxIdType == StripeConstants.TaxIdType.SpanishNIF) - { - await _stripeAdapter.TaxIdCreateAsync(customer.Id, - new TaxIdCreateOptions - { - Type = StripeConstants.TaxIdType.EUVAT, - Value = $"ES{taxInfo.TaxIdNumber}" - }); - } - } - catch (StripeException e) - { - switch (e.StripeError.Code) - { - case StripeConstants.ErrorCodes.TaxIdInvalid: - _logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", - taxInfo.TaxIdNumber, - taxInfo.BillingAddressCountry); - throw new BadRequestException("billingInvalidTaxIdError"); - default: - _logger.LogError(e, - "Error creating tax ID '{TaxId}' in country '{Country}' for customer '{CustomerID}'.", - taxInfo.TaxIdNumber, - taxInfo.BillingAddressCountry, - customer.Id); - throw new BadRequestException("billingTaxIdCreationError"); - } - } - } - public async Task AddSecretsManagerToSubscription( Organization org, StaticStore.Plan plan, @@ -909,309 +772,6 @@ public class StripePaymentService : IPaymentService } } - [Obsolete($"Use {nameof(PreviewPremiumTaxCommand)} instead.")] - public async Task PreviewInvoiceAsync( - PreviewIndividualInvoiceRequestBody parameters, - string gatewayCustomerId, - string gatewaySubscriptionId) - { - var premiumPlan = await _pricingClient.GetAvailablePremiumPlan(); - - var options = new InvoiceCreatePreviewOptions - { - AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true, }, - Currency = "usd", - SubscriptionDetails = new InvoiceSubscriptionDetailsOptions - { - Items = - [ - new InvoiceSubscriptionDetailsItemOptions - { - Quantity = 1, - Plan = premiumPlan.Seat.StripePriceId - }, - - new InvoiceSubscriptionDetailsItemOptions - { - Quantity = parameters.PasswordManager.AdditionalStorage, - Plan = premiumPlan.Storage.StripePriceId - } - ] - }, - CustomerDetails = new InvoiceCustomerDetailsOptions - { - Address = new AddressOptions - { - PostalCode = parameters.TaxInformation.PostalCode, - Country = parameters.TaxInformation.Country, - } - }, - }; - - if (!string.IsNullOrEmpty(parameters.TaxInformation.TaxId)) - { - var taxIdType = _taxService.GetStripeTaxCode( - options.CustomerDetails.Address.Country, - parameters.TaxInformation.TaxId); - - if (taxIdType == null) - { - _logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", - parameters.TaxInformation.TaxId, - parameters.TaxInformation.Country); - throw new BadRequestException("billingPreviewInvalidTaxIdError"); - } - - options.CustomerDetails.TaxIds = - [ - new InvoiceCustomerDetailsTaxIdOptions { Type = taxIdType, Value = parameters.TaxInformation.TaxId } - ]; - - if (taxIdType == StripeConstants.TaxIdType.SpanishNIF) - { - options.CustomerDetails.TaxIds.Add(new InvoiceCustomerDetailsTaxIdOptions - { - Type = StripeConstants.TaxIdType.EUVAT, - Value = $"ES{parameters.TaxInformation.TaxId}" - }); - } - } - - if (!string.IsNullOrWhiteSpace(gatewayCustomerId)) - { - var gatewayCustomer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId); - - if (gatewayCustomer.Discount != null) - { - options.Discounts = [new InvoiceDiscountOptions { Coupon = gatewayCustomer.Discount.Coupon.Id }]; - } - } - - if (!string.IsNullOrWhiteSpace(gatewaySubscriptionId)) - { - var gatewaySubscription = await _stripeAdapter.SubscriptionGetAsync(gatewaySubscriptionId); - - if (gatewaySubscription?.Discounts is { Count: > 0 }) - { - options.Discounts = gatewaySubscription.Discounts.Select(x => new InvoiceDiscountOptions { Coupon = x.Coupon.Id }).ToList(); - } - } - - if (options.Discounts is { Count: > 0 }) - { - options.Discounts = options.Discounts.DistinctBy(invoiceDiscountOptions => invoiceDiscountOptions.Coupon).ToList(); - } - - try - { - var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options); - - var tax = invoice.TotalTaxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount); - - var effectiveTaxRate = invoice.TotalExcludingTax != null && invoice.TotalExcludingTax.Value != 0 - ? tax.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor() - : 0M; - - var result = new PreviewInvoiceResponseModel( - effectiveTaxRate, - invoice.TotalExcludingTax.ToMajor() ?? 0, - tax.ToMajor(), - invoice.Total.ToMajor()); - return result; - } - catch (StripeException e) - { - switch (e.StripeError.Code) - { - case StripeConstants.ErrorCodes.TaxIdInvalid: - _logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", - parameters.TaxInformation.TaxId, - parameters.TaxInformation.Country); - throw new BadRequestException("billingPreviewInvalidTaxIdError"); - default: - _logger.LogError(e, - "Unexpected error previewing invoice with tax ID '{TaxId}' in country '{Country}'.", - parameters.TaxInformation.TaxId, - parameters.TaxInformation.Country); - throw new BadRequestException("billingPreviewInvoiceError"); - } - } - } - - public async Task PreviewInvoiceAsync( - PreviewOrganizationInvoiceRequestBody parameters, - string gatewayCustomerId, - string gatewaySubscriptionId) - { - var plan = await _pricingClient.GetPlanOrThrow(parameters.PasswordManager.Plan); - var isSponsored = parameters.PasswordManager.SponsoredPlan.HasValue; - - var options = new InvoiceCreatePreviewOptions - { - Currency = "usd", - SubscriptionDetails = new InvoiceSubscriptionDetailsOptions - { - Items = - [ - new InvoiceSubscriptionDetailsItemOptions - { - Quantity = parameters.PasswordManager.AdditionalStorage, - Plan = plan.PasswordManager.StripeStoragePlanId - } - ] - }, - CustomerDetails = new InvoiceCustomerDetailsOptions - { - Address = new AddressOptions - { - PostalCode = parameters.TaxInformation.PostalCode, - Country = parameters.TaxInformation.Country, - } - }, - }; - - if (isSponsored) - { - var sponsoredPlan = SponsoredPlans.Get(parameters.PasswordManager.SponsoredPlan.Value); - options.SubscriptionDetails.Items.Add( - new InvoiceSubscriptionDetailsItemOptions { Quantity = 1, Plan = sponsoredPlan.StripePlanId } - ); - } - else - { - if (plan.PasswordManager.HasAdditionalSeatsOption) - { - options.SubscriptionDetails.Items.Add( - new InvoiceSubscriptionDetailsItemOptions { Quantity = parameters.PasswordManager.Seats, Plan = plan.PasswordManager.StripeSeatPlanId } - ); - } - else - { - options.SubscriptionDetails.Items.Add( - new InvoiceSubscriptionDetailsItemOptions { Quantity = 1, Plan = plan.PasswordManager.StripePlanId } - ); - } - - if (plan.SupportsSecretsManager) - { - if (plan.SecretsManager.HasAdditionalSeatsOption) - { - options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions - { - Quantity = parameters.SecretsManager?.Seats ?? 0, - Plan = plan.SecretsManager.StripeSeatPlanId - }); - } - - if (plan.SecretsManager.HasAdditionalServiceAccountOption) - { - options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions - { - Quantity = parameters.SecretsManager?.AdditionalMachineAccounts ?? 0, - Plan = plan.SecretsManager.StripeServiceAccountPlanId - }); - } - } - } - - if (!string.IsNullOrWhiteSpace(parameters.TaxInformation.TaxId)) - { - var taxIdType = _taxService.GetStripeTaxCode( - options.CustomerDetails.Address.Country, - parameters.TaxInformation.TaxId); - - if (taxIdType == null) - { - _logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", - parameters.TaxInformation.TaxId, - parameters.TaxInformation.Country); - throw new BadRequestException("billingTaxIdTypeInferenceError"); - } - - options.CustomerDetails.TaxIds = - [ - new InvoiceCustomerDetailsTaxIdOptions { Type = taxIdType, Value = parameters.TaxInformation.TaxId } - ]; - - if (taxIdType == StripeConstants.TaxIdType.SpanishNIF) - { - options.CustomerDetails.TaxIds.Add(new InvoiceCustomerDetailsTaxIdOptions - { - Type = StripeConstants.TaxIdType.EUVAT, - Value = $"ES{parameters.TaxInformation.TaxId}" - }); - } - } - - Customer gatewayCustomer = null; - - if (!string.IsNullOrWhiteSpace(gatewayCustomerId)) - { - gatewayCustomer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId); - - if (gatewayCustomer.Discount != null) - { - options.Discounts = - [ - new InvoiceDiscountOptions { Coupon = gatewayCustomer.Discount.Coupon.Id } - ]; - } - } - - if (!string.IsNullOrWhiteSpace(gatewaySubscriptionId)) - { - var gatewaySubscription = await _stripeAdapter.SubscriptionGetAsync(gatewaySubscriptionId); - - if (gatewaySubscription?.Discounts != null) - { - options.Discounts = gatewaySubscription.Discounts - .Select(discount => new InvoiceDiscountOptions { Coupon = discount.Coupon.Id }).ToList(); - } - } - - options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }; - if (parameters.PasswordManager.Plan.IsBusinessProductTierType() && - parameters.TaxInformation.Country != Constants.CountryAbbreviations.UnitedStates) - { - options.CustomerDetails.TaxExempt = StripeConstants.TaxExempt.Reverse; - } - - try - { - var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options); - - var tax = invoice.TotalTaxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount); - - var effectiveTaxRate = invoice.TotalExcludingTax != null && invoice.TotalExcludingTax.Value != 0 - ? tax.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor() - : 0M; - - var result = new PreviewInvoiceResponseModel( - effectiveTaxRate, - invoice.TotalExcludingTax.ToMajor() ?? 0, - tax.ToMajor(), - invoice.Total.ToMajor()); - return result; - } - catch (StripeException e) - { - switch (e.StripeError.Code) - { - case StripeConstants.ErrorCodes.TaxIdInvalid: - _logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", - parameters.TaxInformation.TaxId, - parameters.TaxInformation.Country); - throw new BadRequestException("billingPreviewInvalidTaxIdError"); - default: - _logger.LogError(e, - "Unexpected error previewing invoice with tax ID '{TaxId}' in country '{Country}'.", - parameters.TaxInformation.TaxId, - parameters.TaxInformation.Country); - throw new BadRequestException("billingPreviewInvoiceError"); - } - } - } - private PaymentMethod GetLatestCardPaymentMethod(string customerId) { var cardPaymentMethods = _stripeAdapter.PaymentMethodListAutoPaging( diff --git a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs index f59fce4011..b7349c09d9 100644 --- a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs @@ -1,5 +1,4 @@ using Bit.Api.Billing.Controllers; -using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; @@ -11,8 +10,6 @@ using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Entities; using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Billing.Providers.Services; -using Bit.Core.Billing.Services; -using Bit.Core.Billing.Tax.Models; using Bit.Core.Context; using Bit.Core.Models.Api; using Bit.Core.Models.BitStripe; @@ -521,49 +518,4 @@ public class ProviderBillingControllerTests } #endregion - - #region UpdateTaxInformationAsync - - [Theory, BitAutoData] - public async Task UpdateTaxInformation_NoCountry_BadRequest( - Provider provider, - TaxInformationRequestBody requestBody, - SutProvider sutProvider) - { - ConfigureStableProviderAdminInputs(provider, sutProvider); - - requestBody.Country = null; - - var result = await sutProvider.Sut.UpdateTaxInformationAsync(provider.Id, requestBody); - - Assert.IsType>(result); - - var response = (BadRequest)result; - - Assert.Equal("Country and postal code are required to update your tax information.", response.Value.Message); - } - - [Theory, BitAutoData] - public async Task UpdateTaxInformation_Ok( - Provider provider, - TaxInformationRequestBody requestBody, - SutProvider sutProvider) - { - ConfigureStableProviderAdminInputs(provider, sutProvider); - - await sutProvider.Sut.UpdateTaxInformationAsync(provider.Id, requestBody); - - await sutProvider.GetDependency().Received(1).UpdateTaxInformation( - provider, Arg.Is( - options => - options.Country == requestBody.Country && - options.PostalCode == requestBody.PostalCode && - options.TaxId == requestBody.TaxId && - options.Line1 == requestBody.Line1 && - options.Line2 == requestBody.Line2 && - options.City == requestBody.City && - options.State == requestBody.State)); - } - - #endregion } diff --git a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs index 2569ffff00..50fb160754 100644 --- a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs @@ -328,157 +328,6 @@ public class SubscriberServiceTests #endregion - #region GetPaymentMethod - - [Theory, BitAutoData] - public async Task GetPaymentMethod_NullSubscriber_ThrowsArgumentNullException( - SutProvider sutProvider) => - await Assert.ThrowsAsync(() => sutProvider.Sut.GetPaymentSource(null)); - - [Theory, BitAutoData] - public async Task GetPaymentMethod_WithNegativeStripeAccountBalance_ReturnsCorrectAccountCreditAmount(Organization organization, - SutProvider sutProvider) - { - // Arrange - // Stripe reports balance in cents as a negative number for credit - const int stripeAccountBalance = -593; // $5.93 credit (negative cents) - const decimal creditAmount = 5.93M; // Same value in dollars - - - var customer = new Customer - { - Balance = stripeAccountBalance, - Subscriptions = new StripeList() - { - Data = - [new Subscription { Id = organization.GatewaySubscriptionId, Status = "active" }] - }, - InvoiceSettings = new CustomerInvoiceSettings - { - DefaultPaymentMethod = new PaymentMethod - { - Type = StripeConstants.PaymentMethodTypes.USBankAccount, - UsBankAccount = new PaymentMethodUsBankAccount { BankName = "Chase", Last4 = "9999" } - } - } - }; - sutProvider.GetDependency().CustomerGetAsync(organization.GatewayCustomerId, - Arg.Is(options => options.Expand.Contains("default_source") && - options.Expand.Contains("invoice_settings.default_payment_method") - && options.Expand.Contains("subscriptions") - && options.Expand.Contains("tax_ids"))) - .Returns(customer); - - // Act - var result = await sutProvider.Sut.GetPaymentMethod(organization); - - // Assert - Assert.NotNull(result); - Assert.Equal(creditAmount, result.AccountCredit); - await sutProvider.GetDependency().Received(1).CustomerGetAsync( - organization.GatewayCustomerId, - Arg.Is(options => - options.Expand.Contains("default_source") && - options.Expand.Contains("invoice_settings.default_payment_method") && - options.Expand.Contains("subscriptions") && - options.Expand.Contains("tax_ids"))); - - } - - [Theory, BitAutoData] - public async Task GetPaymentMethod_WithZeroStripeAccountBalance_ReturnsCorrectAccountCreditAmount( - Organization organization, SutProvider sutProvider) - { - // Arrange - const int stripeAccountBalance = 0; - - var customer = new Customer - { - Balance = stripeAccountBalance, - Subscriptions = new StripeList() - { - Data = - [new Subscription { Id = organization.GatewaySubscriptionId, Status = "active" }] - }, - InvoiceSettings = new CustomerInvoiceSettings - { - DefaultPaymentMethod = new PaymentMethod - { - Type = StripeConstants.PaymentMethodTypes.USBankAccount, - UsBankAccount = new PaymentMethodUsBankAccount { BankName = "Chase", Last4 = "9999" } - } - } - }; - sutProvider.GetDependency().CustomerGetAsync(organization.GatewayCustomerId, - Arg.Is(options => options.Expand.Contains("default_source") && - options.Expand.Contains("invoice_settings.default_payment_method") - && options.Expand.Contains("subscriptions") - && options.Expand.Contains("tax_ids"))) - .Returns(customer); - - // Act - var result = await sutProvider.Sut.GetPaymentMethod(organization); - - // Assert - Assert.NotNull(result); - Assert.Equal(0, result.AccountCredit); - await sutProvider.GetDependency().Received(1).CustomerGetAsync( - organization.GatewayCustomerId, - Arg.Is(options => - options.Expand.Contains("default_source") && - options.Expand.Contains("invoice_settings.default_payment_method") && - options.Expand.Contains("subscriptions") && - options.Expand.Contains("tax_ids"))); - } - - [Theory, BitAutoData] - public async Task GetPaymentMethod_WithPositiveStripeAccountBalance_ReturnsCorrectAccountCreditAmount( - Organization organization, SutProvider sutProvider) - { - // Arrange - const int stripeAccountBalance = 593; // $5.93 charge balance - const decimal accountBalance = -5.93M; // account balance - var customer = new Customer - { - Balance = stripeAccountBalance, - Subscriptions = new StripeList() - { - Data = - [new Subscription { Id = organization.GatewaySubscriptionId, Status = "active" }] - }, - InvoiceSettings = new CustomerInvoiceSettings - { - DefaultPaymentMethod = new PaymentMethod - { - Type = StripeConstants.PaymentMethodTypes.USBankAccount, - UsBankAccount = new PaymentMethodUsBankAccount { BankName = "Chase", Last4 = "9999" } - } - } - }; - sutProvider.GetDependency().CustomerGetAsync(organization.GatewayCustomerId, - Arg.Is(options => options.Expand.Contains("default_source") && - options.Expand.Contains("invoice_settings.default_payment_method") - && options.Expand.Contains("subscriptions") - && options.Expand.Contains("tax_ids"))) - .Returns(customer); - - // Act - var result = await sutProvider.Sut.GetPaymentMethod(organization); - - // Assert - Assert.NotNull(result); - Assert.Equal(accountBalance, result.AccountCredit); - await sutProvider.GetDependency().Received(1).CustomerGetAsync( - organization.GatewayCustomerId, - Arg.Is(options => - options.Expand.Contains("default_source") && - options.Expand.Contains("invoice_settings.default_payment_method") && - options.Expand.Contains("subscriptions") && - options.Expand.Contains("tax_ids"))); - - } - #endregion - #region GetPaymentSource [Theory, BitAutoData] @@ -889,65 +738,6 @@ public class SubscriberServiceTests } #endregion - #region GetTaxInformation - - [Theory, BitAutoData] - public async Task GetTaxInformation_NullSubscriber_ThrowsArgumentNullException( - SutProvider sutProvider) => - await Assert.ThrowsAsync(() => sutProvider.Sut.GetTaxInformation(null)); - - [Theory, BitAutoData] - public async Task GetTaxInformation_NullAddress_ReturnsNull( - Organization organization, - SutProvider sutProvider) - { - sutProvider.GetDependency().CustomerGetAsync(organization.GatewayCustomerId, Arg.Any()) - .Returns(new Customer()); - - var taxInformation = await sutProvider.Sut.GetTaxInformation(organization); - - Assert.Null(taxInformation); - } - - [Theory, BitAutoData] - public async Task GetTaxInformation_Success( - Organization organization, - SutProvider sutProvider) - { - var address = new Address - { - Country = "US", - PostalCode = "12345", - Line1 = "123 Example St.", - Line2 = "Unit 1", - City = "Example Town", - State = "NY" - }; - - sutProvider.GetDependency().CustomerGetAsync(organization.GatewayCustomerId, Arg.Any()) - .Returns(new Customer - { - Address = address, - TaxIds = new StripeList - { - Data = [new TaxId { Value = "tax_id" }] - } - }); - - var taxInformation = await sutProvider.Sut.GetTaxInformation(organization); - - Assert.NotNull(taxInformation); - Assert.Equal(address.Country, taxInformation.Country); - Assert.Equal(address.PostalCode, taxInformation.PostalCode); - Assert.Equal("tax_id", taxInformation.TaxId); - Assert.Equal(address.Line1, taxInformation.Line1); - Assert.Equal(address.Line2, taxInformation.Line2); - Assert.Equal(address.City, taxInformation.City); - Assert.Equal(address.State, taxInformation.State); - } - - #endregion - #region RemovePaymentMethod [Theory, BitAutoData] public async Task RemovePaymentMethod_NullSubscriber_ThrowsArgumentNullException( @@ -1844,48 +1634,6 @@ public class SubscriberServiceTests #endregion - #region VerifyBankAccount - - [Theory, BitAutoData] - public async Task VerifyBankAccount_NoSetupIntentId_ThrowsBillingException( - Provider provider, - SutProvider sutProvider) => await ThrowsBillingExceptionAsync(() => sutProvider.Sut.VerifyBankAccount(provider, "")); - - [Theory, BitAutoData] - public async Task VerifyBankAccount_MakesCorrectInvocations( - Provider provider, - SutProvider sutProvider) - { - const string descriptorCode = "SM1234"; - - var setupIntent = new SetupIntent - { - Id = "setup_intent_id", - PaymentMethodId = "payment_method_id" - }; - - sutProvider.GetDependency().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntent.Id); - - var stripeAdapter = sutProvider.GetDependency(); - - stripeAdapter.SetupIntentGet(setupIntent.Id).Returns(setupIntent); - - await sutProvider.Sut.VerifyBankAccount(provider, descriptorCode); - - await stripeAdapter.Received(1).SetupIntentVerifyMicroDeposit(setupIntent.Id, - Arg.Is( - options => options.DescriptorCode == descriptorCode)); - - await stripeAdapter.Received(1).PaymentMethodAttachAsync(setupIntent.PaymentMethodId, - Arg.Is( - options => options.Customer == provider.GatewayCustomerId)); - - await stripeAdapter.Received(1).CustomerUpdateAsync(provider.GatewayCustomerId, Arg.Is( - options => options.InvoiceSettings.DefaultPaymentMethod == setupIntent.PaymentMethodId)); - } - - #endregion - #region IsValidGatewayCustomerIdAsync [Theory, BitAutoData] diff --git a/test/Core.Test/Services/StripePaymentServiceTests.cs b/test/Core.Test/Services/StripePaymentServiceTests.cs index dc62af0872..8f556be57a 100644 --- a/test/Core.Test/Services/StripePaymentServiceTests.cs +++ b/test/Core.Test/Services/StripePaymentServiceTests.cs @@ -1,11 +1,7 @@ using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Tax.Requests; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Services; -using Bit.Core.Test.Billing.Mocks.Plans; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -17,506 +13,6 @@ namespace Bit.Core.Test.Services; [SutProviderCustomize] public class StripePaymentServiceTests { - [Theory] - [BitAutoData] - public async Task - PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesWithoutAdditionalStorage( - SutProvider sutProvider) - { - 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, - TotalTaxes = [new InvoiceTotalTax { Amount = 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) - { - 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, TotalTaxes = [new InvoiceTotalTax { Amount = 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) - { - 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, TotalTaxes = [new InvoiceTotalTax { Amount = 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) - { - 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, TotalTaxes = [new InvoiceTotalTax { Amount = 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); - } - - [Theory] - [BitAutoData] - public async Task PreviewInvoiceAsync_USBased_PersonalUse_SetsAutomaticTaxEnabled(SutProvider sutProvider) - { - // Arrange - 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 - }, - TaxInformation = new TaxInformationRequestModel - { - Country = "US", - PostalCode = "12345" - } - }; - - var stripeAdapter = sutProvider.GetDependency(); - stripeAdapter - .InvoiceCreatePreviewAsync(Arg.Any()) - .Returns(new Invoice - { - TotalExcludingTax = 400, - TotalTaxes = [new InvoiceTotalTax { Amount = 8 }], - Total = 408 - }); - - // Act - await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); - - // Assert - await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => - options.AutomaticTax.Enabled == true - )); - } - - [Theory] - [BitAutoData] - public async Task PreviewInvoiceAsync_USBased_BusinessUse_SetsAutomaticTaxEnabled(SutProvider sutProvider) - { - // Arrange - var plan = new EnterprisePlan(true); - sutProvider.GetDependency() - .GetPlanOrThrow(Arg.Is(p => p == PlanType.EnterpriseAnnually)) - .Returns(plan); - - var parameters = new PreviewOrganizationInvoiceRequestBody - { - PasswordManager = new OrganizationPasswordManagerRequestModel - { - Plan = PlanType.EnterpriseAnnually - }, - TaxInformation = new TaxInformationRequestModel - { - Country = "US", - PostalCode = "12345" - } - }; - - var stripeAdapter = sutProvider.GetDependency(); - stripeAdapter - .InvoiceCreatePreviewAsync(Arg.Any()) - .Returns(new Invoice - { - TotalExcludingTax = 400, - TotalTaxes = [new InvoiceTotalTax { Amount = 8 }], - Total = 408 - }); - - // Act - await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); - - // Assert - await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => - options.AutomaticTax.Enabled == true - )); - } - - [Theory] - [BitAutoData] - public async Task PreviewInvoiceAsync_NonUSBased_PersonalUse_SetsAutomaticTaxEnabled(SutProvider sutProvider) - { - // Arrange - 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 - }, - TaxInformation = new TaxInformationRequestModel - { - Country = "FR", - PostalCode = "12345" - } - }; - - var stripeAdapter = sutProvider.GetDependency(); - stripeAdapter - .InvoiceCreatePreviewAsync(Arg.Any()) - .Returns(new Invoice - { - TotalExcludingTax = 400, - TotalTaxes = [new InvoiceTotalTax { Amount = 8 }], - Total = 408 - }); - - // Act - await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); - - // Assert - await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => - options.AutomaticTax.Enabled == true - )); - } - - [Theory] - [BitAutoData] - public async Task PreviewInvoiceAsync_NonUSBased_BusinessUse_SetsAutomaticTaxEnabled(SutProvider sutProvider) - { - // Arrange - var plan = new EnterprisePlan(true); - sutProvider.GetDependency() - .GetPlanOrThrow(Arg.Is(p => p == PlanType.EnterpriseAnnually)) - .Returns(plan); - - var parameters = new PreviewOrganizationInvoiceRequestBody - { - PasswordManager = new OrganizationPasswordManagerRequestModel - { - Plan = PlanType.EnterpriseAnnually - }, - TaxInformation = new TaxInformationRequestModel - { - Country = "FR", - PostalCode = "12345" - } - }; - - var stripeAdapter = sutProvider.GetDependency(); - stripeAdapter - .InvoiceCreatePreviewAsync(Arg.Any()) - .Returns(new Invoice - { - TotalExcludingTax = 400, - TotalTaxes = [new InvoiceTotalTax { Amount = 8 }], - Total = 408 - }); - - // Act - await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); - - // Assert - await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => - options.AutomaticTax.Enabled == true - )); - } - - [Theory] - [BitAutoData] - public async Task PreviewInvoiceAsync_USBased_PersonalUse_DoesNotSetTaxExempt(SutProvider sutProvider) - { - // Arrange - 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 - }, - TaxInformation = new TaxInformationRequestModel - { - Country = "US", - PostalCode = "12345" - } - }; - - var stripeAdapter = sutProvider.GetDependency(); - stripeAdapter - .InvoiceCreatePreviewAsync(Arg.Any()) - .Returns(new Invoice - { - TotalExcludingTax = 400, - TotalTaxes = [new InvoiceTotalTax { Amount = 8 }], - Total = 408 - }); - - // Act - await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); - - // Assert - await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => - options.CustomerDetails.TaxExempt == null - )); - } - - [Theory] - [BitAutoData] - public async Task PreviewInvoiceAsync_USBased_BusinessUse_DoesNotSetTaxExempt(SutProvider sutProvider) - { - // Arrange - var plan = new EnterprisePlan(true); - sutProvider.GetDependency() - .GetPlanOrThrow(Arg.Is(p => p == PlanType.EnterpriseAnnually)) - .Returns(plan); - - var parameters = new PreviewOrganizationInvoiceRequestBody - { - PasswordManager = new OrganizationPasswordManagerRequestModel - { - Plan = PlanType.EnterpriseAnnually - }, - TaxInformation = new TaxInformationRequestModel - { - Country = "US", - PostalCode = "12345" - } - }; - - var stripeAdapter = sutProvider.GetDependency(); - stripeAdapter - .InvoiceCreatePreviewAsync(Arg.Any()) - .Returns(new Invoice - { - TotalExcludingTax = 400, - TotalTaxes = [new InvoiceTotalTax { Amount = 8 }], - Total = 408 - }); - - // Act - await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); - - // Assert - await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => - options.CustomerDetails.TaxExempt == null - )); - } - - [Theory] - [BitAutoData] - public async Task PreviewInvoiceAsync_NonUSBased_PersonalUse_DoesNotSetTaxExempt(SutProvider sutProvider) - { - // Arrange - 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 - }, - TaxInformation = new TaxInformationRequestModel - { - Country = "FR", - PostalCode = "12345" - } - }; - - var stripeAdapter = sutProvider.GetDependency(); - stripeAdapter - .InvoiceCreatePreviewAsync(Arg.Any()) - .Returns(new Invoice - { - TotalExcludingTax = 400, - TotalTaxes = [new InvoiceTotalTax { Amount = 8 }], - Total = 408 - }); - - // Act - await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); - - // Assert - await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => - options.CustomerDetails.TaxExempt == null - )); - } - - [Theory] - [BitAutoData] - public async Task PreviewInvoiceAsync_NonUSBased_BusinessUse_SetsTaxExemptReverse(SutProvider sutProvider) - { - // Arrange - var plan = new EnterprisePlan(true); - sutProvider.GetDependency() - .GetPlanOrThrow(Arg.Is(p => p == PlanType.EnterpriseAnnually)) - .Returns(plan); - - var parameters = new PreviewOrganizationInvoiceRequestBody - { - PasswordManager = new OrganizationPasswordManagerRequestModel - { - Plan = PlanType.EnterpriseAnnually - }, - TaxInformation = new TaxInformationRequestModel - { - Country = "FR", - PostalCode = "12345" - } - }; - - var stripeAdapter = sutProvider.GetDependency(); - stripeAdapter - .InvoiceCreatePreviewAsync(Arg.Any()) - .Returns(new Invoice - { - TotalExcludingTax = 400, - TotalTaxes = [new InvoiceTotalTax { Amount = 8 }], - Total = 408 - }); - - // Act - await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); - - // Assert - await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => - options.CustomerDetails.TaxExempt == StripeConstants.TaxExempt.Reverse - )); - } - [Theory] [BitAutoData] public async Task GetSubscriptionAsync_WithCustomerDiscount_ReturnsDiscountFromCustomer( From dd74e966e519e66480fa7b6e2c6682b7cec078a5 Mon Sep 17 00:00:00 2001 From: Mike Amirault Date: Tue, 9 Dec 2025 10:37:09 -0500 Subject: [PATCH 10/19] [PM-28177] Add feature flag for Send UI refresh (#6708) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index a20408f0d9..3710cb4a23 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -238,6 +238,7 @@ public static class FeatureFlagKeys public const string UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators"; public const string UseChromiumImporter = "pm-23982-chromium-importer"; public const string ChromiumImporterWithABE = "pm-25855-chromium-importer-abe"; + public const string SendUIRefresh = "pm-28175-send-ui-refresh"; /* Vault Team */ public const string CipherKeyEncryption = "cipher-key-encryption"; From 6d5d7e58a6f3d61fd7e0dc8ad540976ae7527e4f Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Tue, 9 Dec 2025 10:59:18 -0500 Subject: [PATCH 11/19] Ac/pm 21742/update confirmed to org email templates (#6683) --- ...ion-confirmation-enterprise-teams.html.hbs | 815 +++++++++++++++ ...ion-confirmation-enterprise-teams.text.hbs | 4 + ...nization-confirmation-family-free.html.hbs | 983 ++++++++++++++++++ ...nization-confirmation-family-free.text.hbs | 4 + .../Mjml/components/mj-bw-icon-row.js | 69 +- ...ization-confirmation-enterprise-teams.mjml | 50 + ...organization-confirmation-family-free.mjml | 55 + 7 files changed, 1948 insertions(+), 32 deletions(-) create mode 100644 src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.text.hbs create mode 100644 src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.text.hbs create mode 100644 src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.mjml create mode 100644 src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.mjml diff --git a/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.html.hbs b/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.html.hbs new file mode 100644 index 0000000000..65e37e87dd --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.html.hbs @@ -0,0 +1,815 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +

+ You can now share passwords with members of {{OrganizationName}}! +

+ +
+ + + + + + + +
+ + Log in + +
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
As a member of {{OrganizationName}}:
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Organization Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + +
+ +
Your account is owned by {{OrganizationName}} and is subject to their security and management policies.
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Group Users Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + +
+ +
You can easily access and share passwords with your team.
+ +
+ + + +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

+ Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center.
+ +
+ +
+ + + +
+ + + + + + + + + +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + +
+ +

+ © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa + Barbara, CA, USA +

+

+ Always confirm you are on a trusted Bitwarden domain before logging + in:
+ bitwarden.com | + Learn why we include this +

+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.text.hbs b/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.text.hbs new file mode 100644 index 0000000000..38c45f2dd1 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.text.hbs @@ -0,0 +1,4 @@ +{{#>TitleContactUsTextLayout}} + You may now access logins and other items {{OrganizationName}} has shared with you from your Bitwarden vault. + Tip: Use the Bitwarden mobile app to quickly save logins and auto-fill forms. Download from the App Store or Google Play. +{{/TitleContactUsTextLayout}} diff --git a/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.html.hbs b/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.html.hbs new file mode 100644 index 0000000000..c22bc80a51 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.html.hbs @@ -0,0 +1,983 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +

+ You can now share passwords with members of {{OrganizationName}}! +

+ +
+ + + + + + + +
+ + Log in + +
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
As a member of {{OrganizationName}}:
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Collections Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + +
+ +
You can access passwords {{OrganizationName}} has shared with you.
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Group Users Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + +
+ +
You can easily share passwords with friends, family, or coworkers.
+ +
+ + + +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ +
Download Bitwarden on all devices
+ +
+ +
Already using the browser extension? + Download the Bitwarden mobile app from the + App Store + or Google Play + to quickly save logins and autofill forms on the go.
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + + + Download on the App Store + + + +
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + + Get it on Google Play + + + +
+ +
+ +
+ + +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

+ Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center.
+ +
+ +
+ + + +
+ + + + + + + + + +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + +
+ +

+ © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa + Barbara, CA, USA +

+

+ Always confirm you are on a trusted Bitwarden domain before logging + in:
+ bitwarden.com | + Learn why we include this +

+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.text.hbs b/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.text.hbs new file mode 100644 index 0000000000..38c45f2dd1 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.text.hbs @@ -0,0 +1,4 @@ +{{#>TitleContactUsTextLayout}} + You may now access logins and other items {{OrganizationName}} has shared with you from your Bitwarden vault. + Tip: Use the Bitwarden mobile app to quickly save logins and auto-fill forms. Download from the App Store or Google Play. +{{/TitleContactUsTextLayout}} diff --git a/src/Core/MailTemplates/Mjml/components/mj-bw-icon-row.js b/src/Core/MailTemplates/Mjml/components/mj-bw-icon-row.js index c4d3b5da01..d0ccde5513 100644 --- a/src/Core/MailTemplates/Mjml/components/mj-bw-icon-row.js +++ b/src/Core/MailTemplates/Mjml/components/mj-bw-icon-row.js @@ -1,4 +1,12 @@ const { BodyComponent } = require("mjml-core"); + +const BODY_TEXT_STYLES = ` + font-family="Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif" + font-size="16px" + font-weight="400" + line-height="24px" +`; + class MjBwIconRow extends BodyComponent { static dependencies = { "mj-column": ["mj-bw-icon-row"], @@ -18,7 +26,7 @@ class MjBwIconRow extends BodyComponent { static defaultAttributes = {}; - headStyle = (breakpoint) => { + headStyle = (breakpoint) => { return ` @media only screen and (max-width:${breakpoint}) { .mj-bw-icon-row-text { @@ -36,30 +44,35 @@ class MjBwIconRow extends BodyComponent { render() { const headAnchorElement = this.getAttribute("head-url-text") && this.getAttribute("head-url") - ? ` - ${this.getAttribute("head-url-text")} - - External Link Icon - - ` + ? ` + + + ${this.getAttribute("head-url-text")} + + External Link Icon + + + ` : ""; const footAnchorElement = this.getAttribute("foot-url-text") && this.getAttribute("foot-url") - ? ` - ${this.getAttribute("foot-url-text")} - - External Link Icon - - ` + ? ` + + ${this.getAttribute("foot-url-text")} + + External Link Icon + + + ` : ""; return this.renderMJML( @@ -76,19 +89,11 @@ class MjBwIconRow extends BodyComponent { /> - - ` + - headAnchorElement + - ` - - + ${headAnchorElement} + ${this.getAttribute("text")} - - ` + - footAnchorElement + - ` - + ${footAnchorElement} diff --git a/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.mjml b/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.mjml new file mode 100644 index 0000000000..24f85af31c --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.mjml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + As a member of {{OrganizationName}}: + + + + + + + + + + + + + + + + + + diff --git a/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.mjml b/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.mjml new file mode 100644 index 0000000000..2e48e82f84 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.mjml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + As a member of {{OrganizationName}}: + + + + + + + + + + + + + + + + + + + + + + + From 8064ae1e050925b75bb05ec7f429522ec51e4936 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 09:37:00 +0100 Subject: [PATCH 12/19] [deps]: Update MarkDig to 0.44.0 (#6390) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Billing/Billing.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Billing/Billing.csproj b/src/Billing/Billing.csproj index e2b7447eb7..fdac4fc3e4 100644 --- a/src/Billing/Billing.csproj +++ b/src/Billing/Billing.csproj @@ -11,7 +11,7 @@ - + From f86d1a51dd663379356567e19245afd33646ce26 Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:53:38 -0600 Subject: [PATCH 13/19] [PM-25652] Add endpoint to fetch key connector confirmation details (#6635) * Add new endpoint and query for key connector * Add unit tests --- .../AccountsKeyManagementController.cs | 21 ++++- ...nnectorConfirmationDetailsResponseModel.cs | 24 ++++++ ...eyManagementServiceCollectionExtensions.cs | 1 + .../Data/KeyConnectorConfirmationDetails.cs | 6 ++ .../IKeyConnectorConfirmationDetailsQuery.cs | 8 ++ .../KeyConnectorConfirmationDetailsQuery.cs | 35 ++++++++ .../AccountsKeyManagementControllerTests.cs | 68 +++++++++------ .../AccountsKeyManagementControllerTests.cs | 36 ++++++++ ...yConnectorConfirmationDetailsQueryTests.cs | 86 +++++++++++++++++++ 9 files changed, 256 insertions(+), 29 deletions(-) create mode 100644 src/Api/KeyManagement/Models/Responses/KeyConnectorConfirmationDetailsResponseModel.cs create mode 100644 src/Core/KeyManagement/Models/Data/KeyConnectorConfirmationDetails.cs create mode 100644 src/Core/KeyManagement/Queries/Interfaces/IKeyConnectorConfirmationDetailsQuery.cs create mode 100644 src/Core/KeyManagement/Queries/KeyConnectorConfirmationDetailsQuery.cs create mode 100644 test/Core.Test/KeyManagement/Queries/KeyConnectorConfirmationDetailsQueryTests.cs diff --git a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs index 5feda856d5..b944cdd052 100644 --- a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs +++ b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs @@ -1,8 +1,8 @@ -#nullable enable -using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Request.WebAuthn; using Bit.Api.KeyManagement.Models.Requests; +using Bit.Api.KeyManagement.Models.Responses; using Bit.Api.KeyManagement.Validators; using Bit.Api.Tools.Models.Request; using Bit.Api.Vault.Models.Request; @@ -14,6 +14,7 @@ using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Commands.Interfaces; using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.KeyManagement.Queries.Interfaces; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Repositories; using Bit.Core.Services; @@ -45,11 +46,13 @@ public class AccountsKeyManagementController : Controller private readonly IRotationValidator, IEnumerable> _webauthnKeyValidator; private readonly IRotationValidator, IEnumerable> _deviceValidator; + private readonly IKeyConnectorConfirmationDetailsQuery _keyConnectorConfirmationDetailsQuery; public AccountsKeyManagementController(IUserService userService, IFeatureService featureService, IOrganizationUserRepository organizationUserRepository, IEmergencyAccessRepository emergencyAccessRepository, + IKeyConnectorConfirmationDetailsQuery keyConnectorConfirmationDetailsQuery, IRegenerateUserAsymmetricKeysCommand regenerateUserAsymmetricKeysCommand, IRotateUserAccountKeysCommand rotateUserKeyCommandV2, IRotationValidator, IEnumerable> cipherValidator, @@ -75,6 +78,7 @@ public class AccountsKeyManagementController : Controller _organizationUserValidator = organizationUserValidator; _webauthnKeyValidator = webAuthnKeyValidator; _deviceValidator = deviceValidator; + _keyConnectorConfirmationDetailsQuery = keyConnectorConfirmationDetailsQuery; } [HttpPost("key-management/regenerate-keys")] @@ -178,4 +182,17 @@ public class AccountsKeyManagementController : Controller throw new BadRequestException(ModelState); } + + [HttpGet("key-connector/confirmation-details/{orgSsoIdentifier}")] + public async Task GetKeyConnectorConfirmationDetailsAsync(string orgSsoIdentifier) + { + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new UnauthorizedAccessException(); + } + + var details = await _keyConnectorConfirmationDetailsQuery.Run(orgSsoIdentifier, user.Id); + return new KeyConnectorConfirmationDetailsResponseModel(details); + } } diff --git a/src/Api/KeyManagement/Models/Responses/KeyConnectorConfirmationDetailsResponseModel.cs b/src/Api/KeyManagement/Models/Responses/KeyConnectorConfirmationDetailsResponseModel.cs new file mode 100644 index 0000000000..68d2c689df --- /dev/null +++ b/src/Api/KeyManagement/Models/Responses/KeyConnectorConfirmationDetailsResponseModel.cs @@ -0,0 +1,24 @@ +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Models.Api; + +namespace Bit.Api.KeyManagement.Models.Responses; + +public class KeyConnectorConfirmationDetailsResponseModel : ResponseModel +{ + private const string _objectName = "keyConnectorConfirmationDetails"; + + public KeyConnectorConfirmationDetailsResponseModel(KeyConnectorConfirmationDetails details, + string obj = _objectName) : base(obj) + { + ArgumentNullException.ThrowIfNull(details); + + OrganizationName = details.OrganizationName; + } + + public KeyConnectorConfirmationDetailsResponseModel() : base(_objectName) + { + OrganizationName = string.Empty; + } + + public string OrganizationName { get; set; } +} diff --git a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs index 0e551c5d0e..abaf9406ba 100644 --- a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs +++ b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs @@ -26,5 +26,6 @@ public static class KeyManagementServiceCollectionExtensions private static void AddKeyManagementQueries(this IServiceCollection services) { services.AddScoped(); + services.AddScoped(); } } diff --git a/src/Core/KeyManagement/Models/Data/KeyConnectorConfirmationDetails.cs b/src/Core/KeyManagement/Models/Data/KeyConnectorConfirmationDetails.cs new file mode 100644 index 0000000000..3821831bad --- /dev/null +++ b/src/Core/KeyManagement/Models/Data/KeyConnectorConfirmationDetails.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.KeyManagement.Models.Data; + +public class KeyConnectorConfirmationDetails +{ + public required string OrganizationName { get; set; } +} diff --git a/src/Core/KeyManagement/Queries/Interfaces/IKeyConnectorConfirmationDetailsQuery.cs b/src/Core/KeyManagement/Queries/Interfaces/IKeyConnectorConfirmationDetailsQuery.cs new file mode 100644 index 0000000000..60b78c03f4 --- /dev/null +++ b/src/Core/KeyManagement/Queries/Interfaces/IKeyConnectorConfirmationDetailsQuery.cs @@ -0,0 +1,8 @@ +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Core.KeyManagement.Queries.Interfaces; + +public interface IKeyConnectorConfirmationDetailsQuery +{ + public Task Run(string orgSsoIdentifier, Guid userId); +} diff --git a/src/Core/KeyManagement/Queries/KeyConnectorConfirmationDetailsQuery.cs b/src/Core/KeyManagement/Queries/KeyConnectorConfirmationDetailsQuery.cs new file mode 100644 index 0000000000..0c210e2fd1 --- /dev/null +++ b/src/Core/KeyManagement/Queries/KeyConnectorConfirmationDetailsQuery.cs @@ -0,0 +1,35 @@ +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.KeyManagement.Queries.Interfaces; +using Bit.Core.Repositories; + +namespace Bit.Core.KeyManagement.Queries; + +public class KeyConnectorConfirmationDetailsQuery : IKeyConnectorConfirmationDetailsQuery +{ + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + + public KeyConnectorConfirmationDetailsQuery(IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository) + { + _organizationRepository = organizationRepository; + _organizationUserRepository = organizationUserRepository; + } + + public async Task Run(string orgSsoIdentifier, Guid userId) + { + var org = await _organizationRepository.GetByIdentifierAsync(orgSsoIdentifier); + if (org is not { UseKeyConnector: true }) + { + throw new NotFoundException(); + } + + var orgUser = await _organizationUserRepository.GetByOrganizationAsync(org.Id, userId); + if (orgUser == null) + { + throw new NotFoundException(); + } + + return new KeyConnectorConfirmationDetails { OrganizationName = org.Name, }; + } +} diff --git a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs index 1630bc0dc0..1c456df106 100644 --- a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -3,9 +3,11 @@ using System.Net; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Api.KeyManagement.Models.Requests; +using Bit.Api.KeyManagement.Models.Responses; using Bit.Api.Tools.Models.Request; using Bit.Api.Vault.Models; using Bit.Api.Vault.Models.Request; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Api.Request.Accounts; @@ -286,20 +288,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture(); + + Assert.NotNull(result); + Assert.Equal(organization.Name, result.OrganizationName); + } + + private async Task<(string, Organization)> SetupKeyConnectorTestAsync(OrganizationUserStatusType userStatusType, + string organizationSsoIdentifier = "test-sso-identifier") + { + var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, + PlanType.EnterpriseAnnually, _ownerEmail, passwordManagerSeats: 10, + paymentMethod: PaymentMethodType.Card); + organization.UseKeyConnector = true; + organization.UseSso = true; + organization.Identifier = organizationSsoIdentifier; + await _organizationRepository.ReplaceAsync(organization); + + var ssoUserEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(ssoUserEmail); + await _loginHelper.LoginAsync(ssoUserEmail); + + await OrganizationTestHelpers.CreateUserAsync(_factory, organization.Id, ssoUserEmail, + OrganizationUserType.User, userStatusType: userStatusType); + + return (ssoUserEmail, organization); + } } diff --git a/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs index b0afcd9144..a1f3088f52 100644 --- a/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -16,6 +16,7 @@ using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Commands.Interfaces; using Bit.Core.KeyManagement.Models.Api.Request; using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.KeyManagement.Queries.Interfaces; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Repositories; using Bit.Core.Services; @@ -363,4 +364,39 @@ public class AccountsKeyManagementControllerTests await sutProvider.GetDependency().Received(1) .ConvertToKeyConnectorAsync(Arg.Is(expectedUser)); } + + [Theory] + [BitAutoData] + public async Task GetKeyConnectorConfirmationDetailsAsync_NoUser_Throws( + SutProvider sutProvider, string orgSsoIdentifier) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .ReturnsNull(); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetKeyConnectorConfirmationDetailsAsync(orgSsoIdentifier)); + + await sutProvider.GetDependency().ReceivedWithAnyArgs(0) + .Run(Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetKeyConnectorConfirmationDetailsAsync_Success( + SutProvider sutProvider, User expectedUser, string orgSsoIdentifier) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .Returns(expectedUser); + sutProvider.GetDependency().Run(orgSsoIdentifier, expectedUser.Id) + .Returns( + new KeyConnectorConfirmationDetails { OrganizationName = "test" } + ); + + var result = await sutProvider.Sut.GetKeyConnectorConfirmationDetailsAsync(orgSsoIdentifier); + + Assert.NotNull(result); + Assert.Equal("test", result.OrganizationName); + await sutProvider.GetDependency().Received(1) + .Run(orgSsoIdentifier, expectedUser.Id); + } } diff --git a/test/Core.Test/KeyManagement/Queries/KeyConnectorConfirmationDetailsQueryTests.cs b/test/Core.Test/KeyManagement/Queries/KeyConnectorConfirmationDetailsQueryTests.cs new file mode 100644 index 0000000000..612d63f289 --- /dev/null +++ b/test/Core.Test/KeyManagement/Queries/KeyConnectorConfirmationDetailsQueryTests.cs @@ -0,0 +1,86 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Queries; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.KeyManagement.Queries; + +[SutProviderCustomize] +public class KeyConnectorConfirmationDetailsQueryTests +{ + [Theory] + [BitAutoData] + public async Task Run_OrganizationNotFound_Throws(SutProvider sutProvider, + Guid userId, string orgSsoIdentifier) + { + await Assert.ThrowsAsync(() => sutProvider.Sut.Run(orgSsoIdentifier, userId)); + + await sutProvider.GetDependency() + .ReceivedWithAnyArgs(0) + .GetByOrganizationAsync(Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task Run_OrganizationNotKeyConnector_Throws( + SutProvider sutProvider, + Guid userId, string orgSsoIdentifier, Organization org) + { + org.Identifier = orgSsoIdentifier; + org.UseKeyConnector = false; + sutProvider.GetDependency().GetByIdentifierAsync(orgSsoIdentifier).Returns(org); + + await Assert.ThrowsAsync(() => sutProvider.Sut.Run(orgSsoIdentifier, userId)); + + await sutProvider.GetDependency() + .ReceivedWithAnyArgs(0) + .GetByOrganizationAsync(Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task Run_OrganizationUserNotFound_Throws(SutProvider sutProvider, + Guid userId, string orgSsoIdentifier + , Organization org) + { + org.Identifier = orgSsoIdentifier; + org.UseKeyConnector = true; + sutProvider.GetDependency().GetByIdentifierAsync(orgSsoIdentifier).Returns(org); + sutProvider.GetDependency() + .GetByOrganizationAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(null)); + + await Assert.ThrowsAsync(() => sutProvider.Sut.Run(orgSsoIdentifier, userId)); + + await sutProvider.GetDependency() + .Received(1) + .GetByOrganizationAsync(org.Id, userId); + } + + [Theory] + [BitAutoData] + public async Task Run_Success(SutProvider sutProvider, Guid userId, + string orgSsoIdentifier + , Organization org, OrganizationUser orgUser) + { + org.Identifier = orgSsoIdentifier; + org.UseKeyConnector = true; + orgUser.OrganizationId = org.Id; + orgUser.UserId = userId; + + sutProvider.GetDependency().GetByIdentifierAsync(orgSsoIdentifier).Returns(org); + sutProvider.GetDependency().GetByOrganizationAsync(org.Id, userId) + .Returns(orgUser); + + var result = await sutProvider.Sut.Run(orgSsoIdentifier, userId); + + Assert.Equal(org.Name, result.OrganizationName); + await sutProvider.GetDependency() + .Received(1) + .GetByOrganizationAsync(org.Id, userId); + } +} From 742280c99984d1bcdb780acb4ca4d4e31174d50e Mon Sep 17 00:00:00 2001 From: gitclonebrian <235774926+gitclonebrian@users.noreply.github.com> Date: Wed, 10 Dec 2025 17:47:54 -0500 Subject: [PATCH 14/19] [repository-management.yml] Implement least privilege permissions (#6646) - Add empty permission set at workflow level to remove default GITHUB_TOKEN permissions - Add empty permission set to setup job as it only runs bash commands - Add contents:write to GitHub App tokens in bump_version and cut_branch jobs for git operations - Add empty permission set to move_edd_db_scripts job as called workflow declares its own permissions - Remove secrets:inherit as called workflow accesses Azure secrets directly --- .github/workflows/repository-management.yml | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index 92452102cf..74823c34b5 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -22,9 +22,7 @@ on: required: false type: string -permissions: - pull-requests: write - contents: write +permissions: {} jobs: setup: @@ -32,6 +30,7 @@ jobs: runs-on: ubuntu-24.04 outputs: branch: ${{ steps.set-branch.outputs.branch }} + permissions: {} steps: - name: Set branch id: set-branch @@ -89,6 +88,7 @@ jobs: with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + permission-contents: write - name: Check out branch uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -212,6 +212,7 @@ jobs: with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + permission-contents: write - name: Check out target ref uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -240,10 +241,5 @@ jobs: move_edd_db_scripts: name: Move EDD database scripts needs: cut_branch - permissions: - actions: read - contents: write - id-token: write - pull-requests: write + permissions: {} uses: ./.github/workflows/_move_edd_db_scripts.yml - secrets: inherit From 1aad410128c336ce7e2950882be66ac0698a10f8 Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Thu, 11 Dec 2025 01:55:16 +0100 Subject: [PATCH 15/19] Remove epic link for bitwarden lite issue template (#6719) Co-authored-by: Daniel James Smith --- .github/ISSUE_TEMPLATE/bw-lite.yml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bw-lite.yml b/.github/ISSUE_TEMPLATE/bw-lite.yml index cc36164e8f..0c43fa5835 100644 --- a/.github/ISSUE_TEMPLATE/bw-lite.yml +++ b/.github/ISSUE_TEMPLATE/bw-lite.yml @@ -70,15 +70,6 @@ body: mariadb:10 # Postgres Example postgres:14 - - type: textarea - id: epic-label - attributes: - label: Issue-Link - description: Link to our pinned issue, tracking all Bitwarden lite - value: | - https://github.com/bitwarden/server/issues/2480 - validations: - required: true - type: checkboxes id: issue-tracking-info attributes: From 919d0be6d2fcd2f94fb2b538d4e738b52faba8f1 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Thu, 11 Dec 2025 12:10:50 +0100 Subject: [PATCH 16/19] Add UpdateAccountCryptographicState repository function (#6669) * Add user repository update function for account cryptographic state * Remove comment * Remove transaction logic * Fix security version * Apply feedback * Update tests * Add support for external actions --- .../Models/Data/UserAccountKeysData.cs | 27 ++++++- src/Core/Repositories/IUserRepository.cs | 13 ++++ .../Repositories/UserRepository.cs | 61 ++++++++++++++- .../Repositories/UserRepository.cs | 75 +++++++++++++++++++ .../User_UpdateAccountCryptographicState.sql | 65 ++++++++++++++++ .../Repositories/UserRepositoryTests.cs | 64 ++++++++++++++++ ...0_User_UpdateAccountCryptographicState.sql | 72 ++++++++++++++++++ 7 files changed, 374 insertions(+), 3 deletions(-) create mode 100644 src/Sql/dbo/Stored Procedures/User_UpdateAccountCryptographicState.sql create mode 100644 util/Migrator/DbScripts/2025-12-08_00_User_UpdateAccountCryptographicState.sql diff --git a/src/Core/KeyManagement/Models/Data/UserAccountKeysData.cs b/src/Core/KeyManagement/Models/Data/UserAccountKeysData.cs index cabdca59ea..3d552a10de 100644 --- a/src/Core/KeyManagement/Models/Data/UserAccountKeysData.cs +++ b/src/Core/KeyManagement/Models/Data/UserAccountKeysData.cs @@ -1,9 +1,34 @@ namespace Bit.Core.KeyManagement.Models.Data; - +/// +/// Represents an expanded account cryptographic state for a user. Expanded here means +/// that it does not only contain the (wrapped) private / signing key, but also the public +/// key / verifying key. The client side only needs a subset of this data to unlock +/// their vault and the public parts can be derived. +/// public class UserAccountKeysData { public required PublicKeyEncryptionKeyPairData PublicKeyEncryptionKeyPairData { get; set; } public SignatureKeyPairData? SignatureKeyPairData { get; set; } public SecurityStateData? SecurityStateData { get; set; } + + /// + /// Checks whether the account cryptographic state is for a V1 encryption user or a V2 encryption user. + /// Throws if the state is invalid + /// + public bool IsV2Encryption() + { + if (PublicKeyEncryptionKeyPairData.SignedPublicKey != null && SignatureKeyPairData != null && SecurityStateData != null) + { + return true; + } + else if (PublicKeyEncryptionKeyPairData.SignedPublicKey == null && SignatureKeyPairData == null && SecurityStateData == null) + { + return false; + } + else + { + throw new InvalidOperationException("Invalid account cryptographic state: V2 encryption fields must be either all present or all absent."); + } + } } diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs index 22effb4329..7cdd159224 100644 --- a/src/Core/Repositories/IUserRepository.cs +++ b/src/Core/Repositories/IUserRepository.cs @@ -1,4 +1,5 @@ using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Models.Data; @@ -44,5 +45,17 @@ public interface IUserRepository : IRepository IEnumerable updateDataActions); Task UpdateUserKeyAndEncryptedDataV2Async(User user, IEnumerable updateDataActions); + /// + /// Sets the account cryptographic state to a user in a single transaction. The provided + /// MUST be a V2 encryption state. Passing in a V1 encryption state will throw. + /// Extra actions can be passed in case other user data needs to be updated in the same transaction. + /// + Task SetV2AccountCryptographicStateAsync( + Guid userId, + UserAccountKeysData accountKeysData, + IEnumerable? updateUserDataActions = null); Task DeleteManyAsync(IEnumerable users); } + +public delegate Task UpdateUserData(Microsoft.Data.SqlClient.SqlConnection? connection = null, + Microsoft.Data.SqlClient.SqlTransaction? transaction = null); diff --git a/src/Infrastructure.Dapper/Repositories/UserRepository.cs b/src/Infrastructure.Dapper/Repositories/UserRepository.cs index 6b11d64cda..86ab063a5f 100644 --- a/src/Infrastructure.Dapper/Repositories/UserRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/UserRepository.cs @@ -2,16 +2,16 @@ using System.Text.Json; using Bit.Core; using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Settings; +using Bit.Core.Utilities; using Dapper; using Microsoft.AspNetCore.DataProtection; using Microsoft.Data.SqlClient; -#nullable enable - namespace Bit.Infrastructure.Dapper.Repositories; public class UserRepository : Repository, IUserRepository @@ -288,6 +288,63 @@ public class UserRepository : Repository, IUserRepository UnprotectData(user); } + public async Task SetV2AccountCryptographicStateAsync( + Guid userId, + UserAccountKeysData accountKeysData, + IEnumerable? updateUserDataActions = null) + { + if (!accountKeysData.IsV2Encryption()) + { + throw new ArgumentException("Provided account keys data is not valid V2 encryption data.", nameof(accountKeysData)); + } + + var timestamp = DateTime.UtcNow; + var signatureKeyPairId = CoreHelpers.GenerateComb(); + + await using var connection = new SqlConnection(ConnectionString); + await connection.OpenAsync(); + + await using var transaction = connection.BeginTransaction(); + try + { + await connection.ExecuteAsync( + "[dbo].[User_UpdateAccountCryptographicState]", + new + { + Id = userId, + PublicKey = accountKeysData.PublicKeyEncryptionKeyPairData.PublicKey, + PrivateKey = accountKeysData.PublicKeyEncryptionKeyPairData.WrappedPrivateKey, + SignedPublicKey = accountKeysData.PublicKeyEncryptionKeyPairData.SignedPublicKey, + SecurityState = accountKeysData.SecurityStateData!.SecurityState, + SecurityVersion = accountKeysData.SecurityStateData!.SecurityVersion, + SignatureKeyPairId = signatureKeyPairId, + SignatureAlgorithm = accountKeysData.SignatureKeyPairData!.SignatureAlgorithm, + SigningKey = accountKeysData.SignatureKeyPairData!.WrappedSigningKey, + VerifyingKey = accountKeysData.SignatureKeyPairData!.VerifyingKey, + RevisionDate = timestamp, + AccountRevisionDate = timestamp + }, + transaction: transaction, + commandType: CommandType.StoredProcedure); + + // Update user data that depends on cryptographic state + if (updateUserDataActions != null) + { + foreach (var action in updateUserDataActions) + { + await action(connection, transaction); + } + } + + await transaction.CommitAsync(); + } + catch + { + await transaction.RollbackAsync(); + throw; + } + } + public async Task> GetManyAsync(IEnumerable ids) { using (var connection = new SqlConnection(ReadOnlyConnectionString)) diff --git a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs index 809704edb7..a43c692be3 100644 --- a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs @@ -1,4 +1,5 @@ using AutoMapper; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Models.Data; using Bit.Core.Repositories; @@ -241,6 +242,80 @@ public class UserRepository : Repository, IUserR await transaction.CommitAsync(); } + public async Task SetV2AccountCryptographicStateAsync( + Guid userId, + UserAccountKeysData accountKeysData, + IEnumerable? updateUserDataActions = null) + { + if (!accountKeysData.IsV2Encryption()) + { + throw new ArgumentException("Provided account keys data is not valid V2 encryption data.", nameof(accountKeysData)); + } + + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + + await using var transaction = await dbContext.Database.BeginTransactionAsync(); + + // Update user + var userEntity = await dbContext.Users.FindAsync(userId); + if (userEntity == null) + { + throw new ArgumentException("User not found", nameof(userId)); + } + + // Update public key encryption key pair + var timestamp = DateTime.UtcNow; + + userEntity.RevisionDate = timestamp; + userEntity.AccountRevisionDate = timestamp; + + // V1 + V2 user crypto changes + userEntity.PublicKey = accountKeysData.PublicKeyEncryptionKeyPairData.PublicKey; + userEntity.PrivateKey = accountKeysData.PublicKeyEncryptionKeyPairData.WrappedPrivateKey; + + userEntity.SecurityState = accountKeysData.SecurityStateData!.SecurityState; + userEntity.SecurityVersion = accountKeysData.SecurityStateData.SecurityVersion; + userEntity.SignedPublicKey = accountKeysData.PublicKeyEncryptionKeyPairData.SignedPublicKey; + + // Replace existing keypair if it exists + var existingKeyPair = await dbContext.UserSignatureKeyPairs + .FirstOrDefaultAsync(x => x.UserId == userId); + if (existingKeyPair != null) + { + existingKeyPair.SignatureAlgorithm = accountKeysData.SignatureKeyPairData!.SignatureAlgorithm; + existingKeyPair.SigningKey = accountKeysData.SignatureKeyPairData.WrappedSigningKey; + existingKeyPair.VerifyingKey = accountKeysData.SignatureKeyPairData.VerifyingKey; + existingKeyPair.RevisionDate = timestamp; + } + else + { + var newKeyPair = new UserSignatureKeyPair + { + UserId = userId, + SignatureAlgorithm = accountKeysData.SignatureKeyPairData!.SignatureAlgorithm, + SigningKey = accountKeysData.SignatureKeyPairData.WrappedSigningKey, + VerifyingKey = accountKeysData.SignatureKeyPairData.VerifyingKey, + CreationDate = timestamp, + RevisionDate = timestamp + }; + newKeyPair.SetNewId(); + await dbContext.UserSignatureKeyPairs.AddAsync(newKeyPair); + } + + await dbContext.SaveChangesAsync(); + + // Update additional user data within the same transaction + if (updateUserDataActions != null) + { + foreach (var action in updateUserDataActions) + { + await action(); + } + } + await transaction.CommitAsync(); + } + public async Task> GetManyAsync(IEnumerable ids) { using (var scope = ServiceScopeFactory.CreateScope()) diff --git a/src/Sql/dbo/Stored Procedures/User_UpdateAccountCryptographicState.sql b/src/Sql/dbo/Stored Procedures/User_UpdateAccountCryptographicState.sql new file mode 100644 index 0000000000..8f1fb664ea --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/User_UpdateAccountCryptographicState.sql @@ -0,0 +1,65 @@ +CREATE PROCEDURE [dbo].[User_UpdateAccountCryptographicState] + @Id UNIQUEIDENTIFIER, + @PublicKey NVARCHAR(MAX), + @PrivateKey NVARCHAR(MAX), + @SignedPublicKey NVARCHAR(MAX) = NULL, + @SecurityState NVARCHAR(MAX) = NULL, + @SecurityVersion INT = NULL, + @SignatureKeyPairId UNIQUEIDENTIFIER = NULL, + @SignatureAlgorithm TINYINT = NULL, + @SigningKey VARCHAR(MAX) = NULL, + @VerifyingKey VARCHAR(MAX) = NULL, + @RevisionDate DATETIME2(7), + @AccountRevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[User] + SET + [PublicKey] = @PublicKey, + [PrivateKey] = @PrivateKey, + [SignedPublicKey] = @SignedPublicKey, + [SecurityState] = @SecurityState, + [SecurityVersion] = @SecurityVersion, + [RevisionDate] = @RevisionDate, + [AccountRevisionDate] = @AccountRevisionDate + WHERE + [Id] = @Id + + IF EXISTS (SELECT 1 FROM [dbo].[UserSignatureKeyPair] WHERE [UserId] = @Id) + BEGIN + UPDATE [dbo].[UserSignatureKeyPair] + SET + [SignatureAlgorithm] = @SignatureAlgorithm, + [SigningKey] = @SigningKey, + [VerifyingKey] = @VerifyingKey, + [RevisionDate] = @RevisionDate + WHERE + [UserId] = @Id + END + ELSE + BEGIN + INSERT INTO [dbo].[UserSignatureKeyPair] + ( + [Id], + [UserId], + [SignatureAlgorithm], + [SigningKey], + [VerifyingKey], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @SignatureKeyPairId, + @Id, + @SignatureAlgorithm, + @SigningKey, + @VerifyingKey, + @RevisionDate, + @RevisionDate + ) + END +END diff --git a/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs b/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs index 151bd47c44..37a3512d76 100644 --- a/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs +++ b/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs @@ -2,6 +2,8 @@ using Bit.Core.Auth.Entities; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.KeyManagement.Enums; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Models.Data; using Bit.Core.Test.AutoFixture.Attributes; using Bit.Infrastructure.EFIntegration.Test.AutoFixture; @@ -313,4 +315,66 @@ public class UserRepositoryTests Assert.Equal(sqlUser.MasterPasswordHint, updatedUser.MasterPasswordHint); Assert.Equal(sqlUser.Email, updatedUser.Email); } + + [CiSkippedTheory, EfUserAutoData] + public async Task UpdateAccountCryptographicStateAsync_Works_DataMatches( + User user, + List suts, + SqlRepo.UserRepository sqlUserRepo) + { + // Test for V1 user (no signature key pair or security state) + var accountKeysDataV1 = new UserAccountKeysData + { + PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData( + wrappedPrivateKey: "v1-wrapped-private-key", + publicKey: "v1-public-key" + ) + }; + + foreach (var sut in suts) + { + var createdUser = await sut.CreateAsync(user); + sut.ClearChangeTracking(); + + await sut.SetV2AccountCryptographicStateAsync(createdUser.Id, accountKeysDataV1); + sut.ClearChangeTracking(); + + var updatedUser = await sut.GetByIdAsync(createdUser.Id); + Assert.Equal("v1-public-key", updatedUser.PublicKey); + Assert.Equal("v1-wrapped-private-key", updatedUser.PrivateKey); + Assert.Null(updatedUser.SignedPublicKey); + Assert.Null(updatedUser.SecurityState); + Assert.Null(updatedUser.SecurityVersion); + } + + // Test for V2 user (with signature key pair and security state) + var accountKeysDataV2 = new UserAccountKeysData + { + PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData( + wrappedPrivateKey: "v2-wrapped-private-key", + publicKey: "v2-public-key", + signedPublicKey: "v2-signed-public-key" + ), + SignatureKeyPairData = new SignatureKeyPairData( + signatureAlgorithm: SignatureAlgorithm.Ed25519, + wrappedSigningKey: "v2-wrapped-signing-key", + verifyingKey: "v2-verifying-key" + ), + SecurityStateData = new SecurityStateData + { + SecurityState = "v2-security-state", + SecurityVersion = 2 + } + }; + + var sqlUser = await sqlUserRepo.CreateAsync(user); + await sqlUserRepo.SetV2AccountCryptographicStateAsync(sqlUser.Id, accountKeysDataV2); + + var updatedSqlUser = await sqlUserRepo.GetByIdAsync(sqlUser.Id); + Assert.Equal("v2-public-key", updatedSqlUser.PublicKey); + Assert.Equal("v2-wrapped-private-key", updatedSqlUser.PrivateKey); + Assert.Equal("v2-signed-public-key", updatedSqlUser.SignedPublicKey); + Assert.Equal("v2-security-state", updatedSqlUser.SecurityState); + Assert.Equal(2, updatedSqlUser.SecurityVersion); + } } diff --git a/util/Migrator/DbScripts/2025-12-08_00_User_UpdateAccountCryptographicState.sql b/util/Migrator/DbScripts/2025-12-08_00_User_UpdateAccountCryptographicState.sql new file mode 100644 index 0000000000..259a126220 --- /dev/null +++ b/util/Migrator/DbScripts/2025-12-08_00_User_UpdateAccountCryptographicState.sql @@ -0,0 +1,72 @@ +IF OBJECT_ID('[dbo].[User_UpdateAccountCryptographicState]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[User_UpdateAccountCryptographicState] +END +GO + +CREATE PROCEDURE [dbo].[User_UpdateAccountCryptographicState] + @Id UNIQUEIDENTIFIER, + @PublicKey NVARCHAR(MAX), + @PrivateKey NVARCHAR(MAX), + @SignedPublicKey NVARCHAR(MAX) = NULL, + @SecurityState NVARCHAR(MAX) = NULL, + @SecurityVersion INT = NULL, + @SignatureKeyPairId UNIQUEIDENTIFIER = NULL, + @SignatureAlgorithm TINYINT = NULL, + @SigningKey VARCHAR(MAX) = NULL, + @VerifyingKey VARCHAR(MAX) = NULL, + @RevisionDate DATETIME2(7), + @AccountRevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[User] + SET + [PublicKey] = @PublicKey, + [PrivateKey] = @PrivateKey, + [SignedPublicKey] = @SignedPublicKey, + [SecurityState] = @SecurityState, + [SecurityVersion] = @SecurityVersion, + [RevisionDate] = @RevisionDate, + [AccountRevisionDate] = @AccountRevisionDate + WHERE + [Id] = @Id + + IF EXISTS (SELECT 1 FROM [dbo].[UserSignatureKeyPair] WHERE [UserId] = @Id) + BEGIN + UPDATE [dbo].[UserSignatureKeyPair] + SET + [SignatureAlgorithm] = @SignatureAlgorithm, + [SigningKey] = @SigningKey, + [VerifyingKey] = @VerifyingKey, + [RevisionDate] = @RevisionDate + WHERE + [UserId] = @Id + END + ELSE + BEGIN + INSERT INTO [dbo].[UserSignatureKeyPair] + ( + [Id], + [UserId], + [SignatureAlgorithm], + [SigningKey], + [VerifyingKey], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @SignatureKeyPairId, + @Id, + @SignatureAlgorithm, + @SigningKey, + @VerifyingKey, + @RevisionDate, + @RevisionDate + ) + END +END +GO From e3d54060fe4a0f406432415385789c948b1645b6 Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Thu, 11 Dec 2025 08:38:19 -0500 Subject: [PATCH 17/19] Add configurable queue name support to AzureQueueHostedService (#6718) --- src/EventsProcessor/AzureQueueHostedService.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/EventsProcessor/AzureQueueHostedService.cs b/src/EventsProcessor/AzureQueueHostedService.cs index c6f5afbfdd..8dc0f12c0c 100644 --- a/src/EventsProcessor/AzureQueueHostedService.cs +++ b/src/EventsProcessor/AzureQueueHostedService.cs @@ -57,14 +57,16 @@ public class AzureQueueHostedService : IHostedService, IDisposable private async Task ExecuteAsync(CancellationToken cancellationToken) { var storageConnectionString = _configuration["azureStorageConnectionString"]; - if (string.IsNullOrWhiteSpace(storageConnectionString)) + var queueName = _configuration["azureQueueServiceQueueName"]; + if (string.IsNullOrWhiteSpace(storageConnectionString) || + string.IsNullOrWhiteSpace(queueName)) { return; } var repo = new Core.Repositories.TableStorage.EventRepository(storageConnectionString); _eventWriteService = new RepositoryEventWriteService(repo); - _queueClient = new QueueClient(storageConnectionString, "event"); + _queueClient = new QueueClient(storageConnectionString, queueName); while (!cancellationToken.IsCancellationRequested) { From 20755f6c2f3cd5d13465661776522d4e72e07468 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Thu, 11 Dec 2025 12:31:12 -0600 Subject: [PATCH 18/19] [PM-25947] Add folders and favorites when sharing a cipher (#6402) * add folders and favorites when sharing a cipher * refactor folders and favorites assignment to consider existing folders/favorite assignments on a cipher * remove unneeded string manipulation * remove comment * add unit test for folder/favorite sharing * add migration for sharing a cipher to org and collect reprompt, favorite and folders * update date timestamp of migration --- .../Vault/Controllers/CiphersController.cs | 2 +- .../Models/Request/CipherRequestModel.cs | 36 ++- .../Vault/Repositories/CipherRepository.cs | 3 + .../Cipher/Cipher_UpdateWithCollections.sql | 9 +- .../Controllers/CiphersControllerTests.cs | 233 ++++++++++++++++++ .../Repositories/CipherRepositoryTests.cs | 54 ++++ ...5-12-09_00_ShareFavoriteFolderReprompt.sql | 62 +++++ 7 files changed, 395 insertions(+), 4 deletions(-) create mode 100644 util/Migrator/DbScripts/2025-12-09_00_ShareFavoriteFolderReprompt.sql diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 8c5df96262..6a506cc01f 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -760,7 +760,7 @@ public class CiphersController : Controller ValidateClientVersionForFido2CredentialSupport(cipher); var original = cipher.Clone(); - await _cipherService.ShareAsync(original, model.Cipher.ToCipher(cipher), new Guid(model.Cipher.OrganizationId), + await _cipherService.ShareAsync(original, model.Cipher.ToCipher(cipher, user.Id), new Guid(model.Cipher.OrganizationId), model.CollectionIds.Select(c => new Guid(c)), user.Id, model.Cipher.LastKnownRevisionDate); var sharedCipher = await GetByIdAsync(id, user.Id); diff --git a/src/Api/Vault/Models/Request/CipherRequestModel.cs b/src/Api/Vault/Models/Request/CipherRequestModel.cs index b0589a62f9..18a1aec559 100644 --- a/src/Api/Vault/Models/Request/CipherRequestModel.cs +++ b/src/Api/Vault/Models/Request/CipherRequestModel.cs @@ -84,7 +84,7 @@ public class CipherRequestModel return existingCipher; } - public Cipher ToCipher(Cipher existingCipher) + public Cipher ToCipher(Cipher existingCipher, Guid? userId = null) { // If Data field is provided, use it directly if (!string.IsNullOrWhiteSpace(Data)) @@ -124,9 +124,12 @@ public class CipherRequestModel } } + var userIdKey = userId.HasValue ? userId.ToString().ToUpperInvariant() : null; existingCipher.Reprompt = Reprompt; existingCipher.Key = Key; existingCipher.ArchivedDate = ArchivedDate; + existingCipher.Folders = UpdateUserSpecificJsonField(existingCipher.Folders, userIdKey, FolderId); + existingCipher.Favorites = UpdateUserSpecificJsonField(existingCipher.Favorites, userIdKey, Favorite); var hasAttachments2 = (Attachments2?.Count ?? 0) > 0; var hasAttachments = (Attachments?.Count ?? 0) > 0; @@ -291,6 +294,37 @@ public class CipherRequestModel KeyFingerprint = SSHKey.KeyFingerprint, }; } + + /// + /// Updates a JSON string representing a dictionary by adding, updating, or removing a key-value pair + /// based on the provided userIdKey and newValue. + /// + private static string UpdateUserSpecificJsonField(string existingJson, string userIdKey, object newValue) + { + if (userIdKey == null) + { + return existingJson; + } + + var jsonDict = string.IsNullOrWhiteSpace(existingJson) + ? new Dictionary() + : JsonSerializer.Deserialize>(existingJson) ?? new Dictionary(); + + var shouldRemove = newValue == null || + (newValue is string strValue && string.IsNullOrWhiteSpace(strValue)) || + (newValue is bool boolValue && !boolValue); + + if (shouldRemove) + { + jsonDict.Remove(userIdKey); + } + else + { + jsonDict[userIdKey] = newValue is string str ? str.ToUpperInvariant() : newValue; + } + + return jsonDict.Count == 0 ? null : JsonSerializer.Serialize(jsonDict); + } } public class CipherWithIdRequestModel : CipherRequestModel diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs index 3c45afe530..ebe39852f4 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs @@ -704,6 +704,9 @@ public class CipherRepository : Repository(() => sutProvider.Sut.PostPurge(model, organizationId)); } + + [Theory, BitAutoData] + public async Task PutShare_WithNullFolderAndFalseFavorite_UpdatesFieldsCorrectly( + Guid cipherId, + Guid userId, + Guid organizationId, + Guid folderId, + SutProvider sutProvider) + { + var user = new User { Id = userId }; + var userIdKey = userId.ToString().ToUpperInvariant(); + + var existingCipher = new Cipher + { + Id = cipherId, + UserId = userId, + Type = CipherType.Login, + Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }), + Folders = JsonSerializer.Serialize(new Dictionary { { userIdKey, folderId.ToString().ToUpperInvariant() } }), + Favorites = JsonSerializer.Serialize(new Dictionary { { userIdKey, true } }) + }; + + // Clears folder and favorite when sharing + var model = new CipherShareRequestModel + { + Cipher = new CipherRequestModel + { + Type = CipherType.Login, + OrganizationId = organizationId.ToString(), + Name = "SharedCipher", + Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }), + FolderId = null, + Favorite = false, + EncryptedFor = userId + }, + CollectionIds = [Guid.NewGuid().ToString()] + }; + + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(user); + + sutProvider.GetDependency() + .GetByIdAsync(cipherId) + .Returns(existingCipher); + + sutProvider.GetDependency() + .OrganizationUser(organizationId) + .Returns(true); + + var sharedCipher = new CipherDetails + { + Id = cipherId, + OrganizationId = organizationId, + Type = CipherType.Login, + Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }), + FolderId = null, + Favorite = false + }; + + sutProvider.GetDependency() + .GetByIdAsync(cipherId, userId) + .Returns(sharedCipher); + + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(new Dictionary + { + { organizationId, new OrganizationAbility { Id = organizationId } } + }); + + var result = await sutProvider.Sut.PutShare(cipherId, model); + + Assert.Null(result.FolderId); + Assert.False(result.Favorite); + } + + [Theory, BitAutoData] + public async Task PutShare_WithFolderAndFavoriteSet_AddsUserSpecificFields( + Guid cipherId, + Guid userId, + Guid organizationId, + Guid folderId, + SutProvider sutProvider) + { + var user = new User { Id = userId }; + var userIdKey = userId.ToString().ToUpperInvariant(); + + var existingCipher = new Cipher + { + Id = cipherId, + UserId = userId, + Type = CipherType.Login, + Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }), + Folders = null, + Favorites = null + }; + + // Sets folder and favorite when sharing + var model = new CipherShareRequestModel + { + Cipher = new CipherRequestModel + { + Type = CipherType.Login, + OrganizationId = organizationId.ToString(), + Name = "SharedCipher", + Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }), + FolderId = folderId.ToString(), + Favorite = true, + EncryptedFor = userId + }, + CollectionIds = [Guid.NewGuid().ToString()] + }; + + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(user); + + sutProvider.GetDependency() + .GetByIdAsync(cipherId) + .Returns(existingCipher); + + sutProvider.GetDependency() + .OrganizationUser(organizationId) + .Returns(true); + + var sharedCipher = new CipherDetails + { + Id = cipherId, + OrganizationId = organizationId, + Type = CipherType.Login, + Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }), + Folders = JsonSerializer.Serialize(new Dictionary { { userIdKey, folderId.ToString().ToUpperInvariant() } }), + Favorites = JsonSerializer.Serialize(new Dictionary { { userIdKey, true } }), + FolderId = folderId, + Favorite = true + }; + + sutProvider.GetDependency() + .GetByIdAsync(cipherId, userId) + .Returns(sharedCipher); + + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(new Dictionary + { + { organizationId, new OrganizationAbility { Id = organizationId } } + }); + + var result = await sutProvider.Sut.PutShare(cipherId, model); + + Assert.Equal(folderId, result.FolderId); + Assert.True(result.Favorite); + } + + [Theory, BitAutoData] + public async Task PutShare_UpdateExistingFolderAndFavorite_UpdatesUserSpecificFields( + Guid cipherId, + Guid userId, + Guid organizationId, + Guid oldFolderId, + Guid newFolderId, + SutProvider sutProvider) + { + var user = new User { Id = userId }; + var userIdKey = userId.ToString().ToUpperInvariant(); + + // Existing cipher with old folder and not favorited + var existingCipher = new Cipher + { + Id = cipherId, + UserId = userId, + Type = CipherType.Login, + Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }), + Folders = JsonSerializer.Serialize(new Dictionary { { userIdKey, oldFolderId.ToString().ToUpperInvariant() } }), + Favorites = null + }; + + var model = new CipherShareRequestModel + { + Cipher = new CipherRequestModel + { + Type = CipherType.Login, + OrganizationId = organizationId.ToString(), + Name = "SharedCipher", + Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }), + FolderId = newFolderId.ToString(), // Update to new folder + Favorite = true, // Add favorite + EncryptedFor = userId + }, + CollectionIds = [Guid.NewGuid().ToString()] + }; + + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(user); + + sutProvider.GetDependency() + .GetByIdAsync(cipherId) + .Returns(existingCipher); + + sutProvider.GetDependency() + .OrganizationUser(organizationId) + .Returns(true); + + var sharedCipher = new CipherDetails + { + Id = cipherId, + OrganizationId = organizationId, + Type = CipherType.Login, + Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }), + Folders = JsonSerializer.Serialize(new Dictionary { { userIdKey, newFolderId.ToString().ToUpperInvariant() } }), + Favorites = JsonSerializer.Serialize(new Dictionary { { userIdKey, true } }), + FolderId = newFolderId, + Favorite = true + }; + + sutProvider.GetDependency() + .GetByIdAsync(cipherId, userId) + .Returns(sharedCipher); + + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(new Dictionary + { + { organizationId, new OrganizationAbility { Id = organizationId } } + }); + + var result = await sutProvider.Sut.PutShare(cipherId, model); + + Assert.Equal(newFolderId, result.FolderId); + Assert.True(result.Favorite); + } } diff --git a/test/Infrastructure.EFIntegration.Test/Vault/Repositories/CipherRepositoryTests.cs b/test/Infrastructure.EFIntegration.Test/Vault/Repositories/CipherRepositoryTests.cs index 689bd5e243..5aceb15124 100644 --- a/test/Infrastructure.EFIntegration.Test/Vault/Repositories/CipherRepositoryTests.cs +++ b/test/Infrastructure.EFIntegration.Test/Vault/Repositories/CipherRepositoryTests.cs @@ -225,4 +225,58 @@ public class CipherRepositoryTests Assert.True(savedCipher == null); } } + + [CiSkippedTheory, EfOrganizationCipherCustomize, BitAutoData] + public async Task ReplaceAsync_WithCollections_UpdatesFoldersFavoritesRepromptAndArchivedDateAsync( + Cipher cipher, + User user, + Organization org, + Collection collection, + List suts, + List efUserRepos, + List efOrgRepos, + List efCollectionRepos) + { + foreach (var sut in suts) + { + var i = suts.IndexOf(sut); + + var postEfOrg = await efOrgRepos[i].CreateAsync(org); + efOrgRepos[i].ClearChangeTracking(); + var postEfUser = await efUserRepos[i].CreateAsync(user); + efUserRepos[i].ClearChangeTracking(); + + collection.OrganizationId = postEfOrg.Id; + var postEfCollection = await efCollectionRepos[i].CreateAsync(collection); + efCollectionRepos[i].ClearChangeTracking(); + + cipher.UserId = postEfUser.Id; + cipher.OrganizationId = null; + cipher.Folders = $"{{\"{postEfUser.Id}\":\"some-folder-id\"}}"; + cipher.Favorites = $"{{\"{postEfUser.Id}\":true}}"; + cipher.Reprompt = Core.Vault.Enums.CipherRepromptType.Password; + + var createdCipher = await sut.CreateAsync(cipher); + sut.ClearChangeTracking(); + + var updatedCipher = await sut.GetByIdAsync(createdCipher.Id); + updatedCipher.UserId = postEfUser.Id; + updatedCipher.OrganizationId = postEfOrg.Id; + updatedCipher.Folders = $"{{\"{postEfUser.Id}\":\"new-folder-id\"}}"; + updatedCipher.Favorites = $"{{\"{postEfUser.Id}\":true}}"; + updatedCipher.Reprompt = Core.Vault.Enums.CipherRepromptType.Password; + + await sut.ReplaceAsync(updatedCipher, new List { postEfCollection.Id }); + sut.ClearChangeTracking(); + + + var savedCipher = await sut.GetByIdAsync(createdCipher.Id); + Assert.NotNull(savedCipher); + Assert.Null(savedCipher.UserId); + Assert.Equal(postEfOrg.Id, savedCipher.OrganizationId); + Assert.Equal($"{{\"{postEfUser.Id}\":\"new-folder-id\"}}", savedCipher.Folders); + Assert.Equal($"{{\"{postEfUser.Id}\":true}}", savedCipher.Favorites); + Assert.Equal(Core.Vault.Enums.CipherRepromptType.Password, savedCipher.Reprompt); + } + } } diff --git a/util/Migrator/DbScripts/2025-12-09_00_ShareFavoriteFolderReprompt.sql b/util/Migrator/DbScripts/2025-12-09_00_ShareFavoriteFolderReprompt.sql new file mode 100644 index 0000000000..6d4ea668a3 --- /dev/null +++ b/util/Migrator/DbScripts/2025-12-09_00_ShareFavoriteFolderReprompt.sql @@ -0,0 +1,62 @@ +CREATE OR ALTER PROCEDURE [dbo].[Cipher_UpdateWithCollections] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Type TINYINT, + @Data NVARCHAR(MAX), + @Favorites NVARCHAR(MAX), + @Folders NVARCHAR(MAX), + @Attachments NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @DeletedDate DATETIME2(7), + @Reprompt TINYINT, + @Key VARCHAR(MAX) = NULL, + @CollectionIds AS [dbo].[GuidIdArray] READONLY, + @ArchivedDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + + BEGIN TRANSACTION Cipher_UpdateWithCollections + + DECLARE @UpdateCollectionsSuccess INT + EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds + + IF @UpdateCollectionsSuccess < 0 + BEGIN + COMMIT TRANSACTION Cipher_UpdateWithCollections + SELECT -1 -- -1 = Failure + RETURN + END + + UPDATE + [dbo].[Cipher] + SET + [UserId] = NULL, + [OrganizationId] = @OrganizationId, + [Data] = @Data, + [Attachments] = @Attachments, + [RevisionDate] = @RevisionDate, + [DeletedDate] = @DeletedDate, + [Key] = @Key, + [ArchivedDate] = @ArchivedDate, + [Folders] = @Folders, + [Favorites] = @Favorites, + [Reprompt] = @Reprompt + -- No need to update CreationDate or Type since that data will not change + WHERE + [Id] = @Id + + COMMIT TRANSACTION Cipher_UpdateWithCollections + + IF @Attachments IS NOT NULL + BEGIN + EXEC [dbo].[Organization_UpdateStorage] @OrganizationId + EXEC [dbo].[User_UpdateStorage] @UserId + END + + EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId + + SELECT 0 -- 0 = Success +END From 3de2f98681845ef062bdcdd447775e0e6c762336 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Thu, 11 Dec 2025 14:50:25 -0500 Subject: [PATCH 19/19] [PM-28754] add accepted and decline types (#6721) --- src/Core/AdminConsole/Enums/EventType.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Core/AdminConsole/Enums/EventType.cs b/src/Core/AdminConsole/Enums/EventType.cs index 09cda7ca0e..916f408fe6 100644 --- a/src/Core/AdminConsole/Enums/EventType.cs +++ b/src/Core/AdminConsole/Enums/EventType.cs @@ -81,6 +81,8 @@ public enum EventType : int Organization_CollectionManagement_LimitItemDeletionDisabled = 1615, Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsEnabled = 1616, Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsDisabled = 1617, + Organization_ItemOrganization_Accepted = 1618, + Organization_ItemOrganization_Declined = 1619, Policy_Updated = 1700,