From b00f11fc43c0d5f20a8e224a8f9e3fc4dc23e60e Mon Sep 17 00:00:00 2001
From: Ike <137194738+ike-kottlowski@users.noreply.github.com>
Date: Fri, 21 Feb 2025 11:12:31 -0500
Subject: [PATCH 01/26] [PM-17645] : update email for new email multi factor
tokens (#5428)
* feat(newDeviceVerification) : Initial update to email
* fix : email copying over extra whitespace when using keyboard short cuts
* test : Fixing tests for new device verificaiton email format
---
.../Auth/Controllers/TwoFactorController.cs | 7 ++-
.../Handlebars/Auth/TwoFactorEmail.html.hbs | 46 ++++++++++----
.../Handlebars/Auth/TwoFactorEmail.text.hbs | 15 ++++-
.../Mail/TwoFactorEmailTokenViewModel.cs | 25 ++++++++
...=> UserVerificationEmailTokenViewModel.cs} | 2 +-
src/Core/Services/IMailService.cs | 2 +-
src/Core/Services/IUserService.cs | 16 ++++-
.../Implementations/HandlebarsMailService.cs | 20 ++++--
.../Services/Implementations/UserService.cs | 29 +++++++--
.../NoopImplementations/NoopMailService.cs | 2 +-
.../RequestValidators/DeviceValidator.cs | 10 ++-
test/Core.Test/Services/UserServiceTests.cs | 62 +++++++++++++++++--
.../Endpoints/IdentityServerTwoFactorTests.cs | 14 ++++-
.../IdentityServer/DeviceValidatorTests.cs | 2 +-
14 files changed, 214 insertions(+), 38 deletions(-)
create mode 100644 src/Core/Models/Mail/TwoFactorEmailTokenViewModel.cs
rename src/Core/Models/Mail/{EmailTokenViewModel.cs => UserVerificationEmailTokenViewModel.cs} (54%)
diff --git a/src/Api/Auth/Controllers/TwoFactorController.cs b/src/Api/Auth/Controllers/TwoFactorController.cs
index c7d39f64b0..83490f1c2f 100644
--- a/src/Api/Auth/Controllers/TwoFactorController.cs
+++ b/src/Api/Auth/Controllers/TwoFactorController.cs
@@ -288,12 +288,17 @@ public class TwoFactorController : Controller
return response;
}
+ ///
+ /// This endpoint is only used to set-up email two factor authentication.
+ ///
+ /// secret verification model
+ /// void
[HttpPost("send-email")]
public async Task SendEmail([FromBody] TwoFactorEmailRequestModel model)
{
var user = await CheckAsync(model, false, true);
model.ToUser(user);
- await _userService.SendTwoFactorEmailAsync(user);
+ await _userService.SendTwoFactorEmailAsync(user, false);
}
[AllowAnonymous]
diff --git a/src/Core/MailTemplates/Handlebars/Auth/TwoFactorEmail.html.hbs b/src/Core/MailTemplates/Handlebars/Auth/TwoFactorEmail.html.hbs
index be51c4e9f3..27a222f1de 100644
--- a/src/Core/MailTemplates/Handlebars/Auth/TwoFactorEmail.html.hbs
+++ b/src/Core/MailTemplates/Handlebars/Auth/TwoFactorEmail.html.hbs
@@ -1,14 +1,38 @@
{{#>FullHtmlLayout}}
-
-
- Your two-step verification code is: {{Token}}
- |
-
-
-
- Use this code to complete logging in with Bitwarden.
- |
-
+
+
+ To finish {{EmailTotpAction}}, enter this verification code: {{Token}}
+ |
+
+
+
+
+ If this was not you, take these immediate steps to secure your account in the web app:
+
+ - Deauthorize unrecognized devices
+ - Change your master password
+ - Turn on two-step login
+
+ |
+
+
+
+
+
+
+ Account:
+ {{AccountEmail}}
+
+ Date:
+ {{TheDate}} at {{TheTime}} {{TimeZone}}
+
+ IP:
+ {{DeviceIp}}
+
+ DeviceType:
+ {{DeviceType}}
+ |
+
-{{/FullHtmlLayout}}
+{{/FullHtmlLayout}}
\ No newline at end of file
diff --git a/src/Core/MailTemplates/Handlebars/Auth/TwoFactorEmail.text.hbs b/src/Core/MailTemplates/Handlebars/Auth/TwoFactorEmail.text.hbs
index c7e64e5da2..211a870d6a 100644
--- a/src/Core/MailTemplates/Handlebars/Auth/TwoFactorEmail.text.hbs
+++ b/src/Core/MailTemplates/Handlebars/Auth/TwoFactorEmail.text.hbs
@@ -1,5 +1,16 @@
{{#>BasicTextLayout}}
-Your two-step verification code is: {{Token}}
+To finish {{EmailTotpAction}}, enter this verification code: {{Token}}
-Use this code to complete logging in with Bitwarden.
+If this was not you, take these immediate steps to secure your account in the web app:
+
+Deauthorize unrecognized devices
+
+Change your master password
+
+Turn on two-step login
+
+Account : {{AccountEmail}}
+Date : {{TheDate}} at {{TheTime}} {{TimeZone}}
+IP : {{DeviceIp}}
+Device Type : {{DeviceType}}
{{/BasicTextLayout}}
\ No newline at end of file
diff --git a/src/Core/Models/Mail/TwoFactorEmailTokenViewModel.cs b/src/Core/Models/Mail/TwoFactorEmailTokenViewModel.cs
new file mode 100644
index 0000000000..dbd47af35a
--- /dev/null
+++ b/src/Core/Models/Mail/TwoFactorEmailTokenViewModel.cs
@@ -0,0 +1,25 @@
+namespace Bit.Core.Models.Mail;
+
+///
+/// This view model is used to set-up email two factor authentication, to log in with email two factor authentication,
+/// and for new device verification.
+///
+public class TwoFactorEmailTokenViewModel : BaseMailModel
+{
+ public string Token { get; set; }
+ ///
+ /// This view model is used to also set-up email two factor authentication. We use this property to communicate
+ /// the purpose of the email, since it can be used for logging in and for setting up.
+ ///
+ public string EmailTotpAction { get; set; }
+ ///
+ /// When logging in with email two factor the account email may not be the same as the email used for two factor.
+ /// we want to show the account email in the email, so the user knows which account they are logging into.
+ ///
+ public string AccountEmail { get; set; }
+ public string TheDate { get; set; }
+ public string TheTime { get; set; }
+ public string TimeZone { get; set; }
+ public string DeviceIp { get; set; }
+ public string DeviceType { get; set; }
+}
diff --git a/src/Core/Models/Mail/EmailTokenViewModel.cs b/src/Core/Models/Mail/UserVerificationEmailTokenViewModel.cs
similarity index 54%
rename from src/Core/Models/Mail/EmailTokenViewModel.cs
rename to src/Core/Models/Mail/UserVerificationEmailTokenViewModel.cs
index 561df580e8..b8850b5f00 100644
--- a/src/Core/Models/Mail/EmailTokenViewModel.cs
+++ b/src/Core/Models/Mail/UserVerificationEmailTokenViewModel.cs
@@ -1,6 +1,6 @@
namespace Bit.Core.Models.Mail;
-public class EmailTokenViewModel : BaseMailModel
+public class UserVerificationEmailTokenViewModel : BaseMailModel
{
public string Token { get; set; }
}
diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs
index 92d05ddb7d..3492ada838 100644
--- a/src/Core/Services/IMailService.cs
+++ b/src/Core/Services/IMailService.cs
@@ -23,7 +23,7 @@ public interface IMailService
Task SendCannotDeleteManagedAccountEmailAsync(string email);
Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail);
Task SendChangeEmailEmailAsync(string newEmailAddress, string token);
- Task SendTwoFactorEmailAsync(string email, string token);
+ Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, bool authentication = true);
Task SendNoMasterPasswordHintEmailAsync(string email);
Task SendMasterPasswordHintEmailAsync(string email, string hint);
diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs
index d1c61e4418..2ac7796547 100644
--- a/src/Core/Services/IUserService.cs
+++ b/src/Core/Services/IUserService.cs
@@ -21,7 +21,21 @@ public interface IUserService
Task CreateUserAsync(User user);
Task CreateUserAsync(User user, string masterPasswordHash);
Task SendMasterPasswordHintAsync(string email);
- Task SendTwoFactorEmailAsync(User user);
+ ///
+ /// Used for both email two factor and email two factor setup.
+ ///
+ /// user requesting the action
+ /// this controls if what verbiage is shown in the email
+ /// void
+ Task SendTwoFactorEmailAsync(User user, bool authentication = true);
+ ///
+ /// Calls the same email implementation but instead it sends the token to the account email not the
+ /// email set up for two-factor, since in practice they can be different.
+ ///
+ /// user attepting to login with a new device
+ /// void
+ Task SendNewDeviceVerificationEmailAsync(User user);
+ Task VerifyTwoFactorEmailAsync(User user, string token);
Task StartWebAuthnRegistrationAsync(User user);
Task DeleteWebAuthnKeyAsync(User user, int id);
Task CompleteWebAuthRegistrationAsync(User user, int value, string name, AuthenticatorAttestationRawResponse attestationResponse);
diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs
index d18a29b13a..44be3bfdf4 100644
--- a/src/Core/Services/Implementations/HandlebarsMailService.cs
+++ b/src/Core/Services/Implementations/HandlebarsMailService.cs
@@ -146,7 +146,7 @@ public class HandlebarsMailService : IMailService
public async Task SendChangeEmailEmailAsync(string newEmailAddress, string token)
{
var message = CreateDefaultMessage("Your Email Change", newEmailAddress);
- var model = new EmailTokenViewModel
+ var model = new UserVerificationEmailTokenViewModel
{
Token = token,
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
@@ -158,14 +158,22 @@ public class HandlebarsMailService : IMailService
await _mailDeliveryService.SendEmailAsync(message);
}
- public async Task SendTwoFactorEmailAsync(string email, string token)
+ public async Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, bool authentication = true)
{
- var message = CreateDefaultMessage("Your Two-step Login Verification Code", email);
- var model = new EmailTokenViewModel
+ var message = CreateDefaultMessage("Your Bitwarden Verification Code", email);
+ var requestDateTime = DateTime.UtcNow;
+ var model = new TwoFactorEmailTokenViewModel
{
Token = token,
+ EmailTotpAction = authentication ? "logging in" : "setting up two-step login",
+ AccountEmail = accountEmail,
+ TheDate = requestDateTime.ToLongDateString(),
+ TheTime = requestDateTime.ToShortTimeString(),
+ TimeZone = _utcTimeZoneDisplay,
+ DeviceIp = deviceIp,
+ DeviceType = deviceType,
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
- SiteName = _globalSettings.SiteName
+ SiteName = _globalSettings.SiteName,
};
await AddMessageContentAsync(message, "Auth.TwoFactorEmail", model);
message.MetaData.Add("SendGridBypassListManagement", true);
@@ -1012,7 +1020,7 @@ public class HandlebarsMailService : IMailService
public async Task SendOTPEmailAsync(string email, string token)
{
var message = CreateDefaultMessage("Your Bitwarden Verification Code", email);
- var model = new EmailTokenViewModel
+ var model = new UserVerificationEmailTokenViewModel
{
Token = token,
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs
index 47637d0f75..2374d8f4e1 100644
--- a/src/Core/Services/Implementations/UserService.cs
+++ b/src/Core/Services/Implementations/UserService.cs
@@ -1,4 +1,6 @@
-using System.Security.Claims;
+using System.ComponentModel.DataAnnotations;
+using System.Reflection;
+using System.Security.Claims;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
@@ -350,7 +352,7 @@ public class UserService : UserManager, IUserService, IDisposable
await _mailService.SendMasterPasswordHintEmailAsync(email, user.MasterPasswordHint);
}
- public async Task SendTwoFactorEmailAsync(User user)
+ public async Task SendTwoFactorEmailAsync(User user, bool authentication = true)
{
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Email);
if (provider == null || provider.MetaData == null || !provider.MetaData.ContainsKey("Email"))
@@ -361,7 +363,26 @@ public class UserService : UserManager, IUserService, IDisposable
var email = ((string)provider.MetaData["Email"]).ToLowerInvariant();
var token = await base.GenerateTwoFactorTokenAsync(user,
CoreHelpers.CustomProviderName(TwoFactorProviderType.Email));
- await _mailService.SendTwoFactorEmailAsync(email, token);
+
+ var deviceType = _currentContext.DeviceType?.GetType().GetMember(_currentContext.DeviceType?.ToString())
+ .FirstOrDefault()?.GetCustomAttribute()?.GetName() ?? "Unknown Browser";
+
+ await _mailService.SendTwoFactorEmailAsync(
+ email, user.Email, token, _currentContext.IpAddress, deviceType, authentication);
+ }
+
+ public async Task SendNewDeviceVerificationEmailAsync(User user)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+
+ var token = await base.GenerateUserTokenAsync(user, TokenOptions.DefaultEmailProvider,
+ "otp:" + user.Email);
+
+ var deviceType = _currentContext.DeviceType?.GetType().GetMember(_currentContext.DeviceType?.ToString())
+ .FirstOrDefault()?.GetCustomAttribute()?.GetName() ?? "Unknown Browser";
+
+ await _mailService.SendTwoFactorEmailAsync(
+ user.Email, user.Email, token, _currentContext.IpAddress, deviceType);
}
public async Task VerifyTwoFactorEmailAsync(User user, string token)
@@ -1519,7 +1540,7 @@ public class UserService : UserManager, IUserService, IDisposable
if (await VerifySecretAsync(user, secret))
{
- await SendOTPAsync(user);
+ await SendNewDeviceVerificationEmailAsync(user);
}
}
diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs
index d6b330294d..9984f8ee90 100644
--- a/src/Core/Services/NoopImplementations/NoopMailService.cs
+++ b/src/Core/Services/NoopImplementations/NoopMailService.cs
@@ -87,7 +87,7 @@ public class NoopMailService : IMailService
public Task SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(string organizationName, string email) =>
Task.CompletedTask;
- public Task SendTwoFactorEmailAsync(string email, string token)
+ public Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, bool authentication = true)
{
return Task.FromResult(0);
}
diff --git a/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs b/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs
index fee10e10ff..17d16f5949 100644
--- a/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs
+++ b/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs
@@ -79,7 +79,7 @@ public class DeviceValidator(
BuildDeviceErrorResult(validationResult);
if (validationResult == DeviceValidationResultType.NewDeviceVerificationRequired)
{
- await _userService.SendOTPAsync(context.User);
+ await _userService.SendNewDeviceVerificationEmailAsync(context.User);
}
return false;
}
@@ -163,6 +163,14 @@ public class DeviceValidator(
return DeviceValidationResultType.NewDeviceVerificationRequired;
}
+ ///
+ /// Sends an email whenever the user logs in from a new device. Will not send to a user who's account
+ /// is less than 10 minutes old. We assume an account that is less than 10 minutes old is new and does
+ /// not need an email stating they just logged in.
+ ///
+ /// user logging in
+ /// current device being approved to login
+ /// void
private async Task SendNewDeviceLoginEmail(User user, Device requestDevice)
{
// Ensure that the user doesn't receive a "new device" email on the first login
diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs
index 7f1dd37b6b..3158c1595c 100644
--- a/test/Core.Test/Services/UserServiceTests.cs
+++ b/test/Core.Test/Services/UserServiceTests.cs
@@ -96,6 +96,9 @@ public class UserServiceTests
{
var email = user.Email.ToLowerInvariant();
var token = "thisisatokentocompare";
+ var authentication = true;
+ var IpAddress = "1.1.1.1";
+ var deviceType = "Android";
var userTwoFactorTokenProvider = Substitute.For>();
userTwoFactorTokenProvider
@@ -105,6 +108,10 @@ public class UserServiceTests
.GenerateAsync("TwoFactor", Arg.Any>(), user)
.Returns(Task.FromResult(token));
+ var context = sutProvider.GetDependency();
+ context.DeviceType = DeviceType.Android;
+ context.IpAddress = IpAddress;
+
sutProvider.Sut.RegisterTokenProvider("Custom_Email", userTwoFactorTokenProvider);
user.SetTwoFactorProviders(new Dictionary
@@ -119,7 +126,7 @@ public class UserServiceTests
await sutProvider.GetDependency()
.Received(1)
- .SendTwoFactorEmailAsync(email, token);
+ .SendTwoFactorEmailAsync(email, user.Email, token, IpAddress, deviceType, authentication);
}
[Theory, BitAutoData]
@@ -160,6 +167,44 @@ public class UserServiceTests
await Assert.ThrowsAsync("No email.", () => sutProvider.Sut.SendTwoFactorEmailAsync(user));
}
+ [Theory, BitAutoData]
+ public async Task SendNewDeviceVerificationEmailAsync_ExceptionBecauseUserNull(SutProvider sutProvider)
+ {
+ await Assert.ThrowsAsync(() => sutProvider.Sut.SendNewDeviceVerificationEmailAsync(null));
+ }
+
+ [Theory]
+ [BitAutoData(DeviceType.UnknownBrowser, "Unknown Browser")]
+ [BitAutoData(DeviceType.Android, "Android")]
+ public async Task SendNewDeviceVerificationEmailAsync_DeviceMatches(DeviceType deviceType, string deviceTypeName, SutProvider sutProvider, User user)
+ {
+ SetupFakeTokenProvider(sutProvider, user);
+ var context = sutProvider.GetDependency();
+ context.DeviceType = deviceType;
+ context.IpAddress = "1.1.1.1";
+
+ await sutProvider.Sut.SendNewDeviceVerificationEmailAsync(user);
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .SendTwoFactorEmailAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), deviceTypeName, Arg.Any());
+ }
+
+ [Theory, BitAutoData]
+ public async Task SendNewDeviceVerificationEmailAsync_NullDeviceTypeShouldSendUnkownBrowserType(SutProvider sutProvider, User user)
+ {
+ SetupFakeTokenProvider(sutProvider, user);
+ var context = sutProvider.GetDependency();
+ context.DeviceType = null;
+ context.IpAddress = "1.1.1.1";
+
+ await sutProvider.Sut.SendNewDeviceVerificationEmailAsync(user);
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .SendTwoFactorEmailAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), "Unknown Browser", Arg.Any());
+ }
+
[Theory, BitAutoData]
public async Task HasPremiumFromOrganization_Returns_False_If_No_Orgs(SutProvider sutProvider, User user)
{
@@ -577,7 +622,7 @@ public class UserServiceTests
}
[Theory, BitAutoData]
- public async Task ResendNewDeviceVerificationEmail_UserNull_SendOTPAsyncNotCalled(
+ public async Task ResendNewDeviceVerificationEmail_UserNull_SendTwoFactorEmailAsyncNotCalled(
SutProvider sutProvider, string email, string secret)
{
sutProvider.GetDependency()
@@ -588,11 +633,11 @@ public class UserServiceTests
await sutProvider.GetDependency()
.DidNotReceive()
- .SendOTPEmailAsync(Arg.Any(), Arg.Any());
+ .SendTwoFactorEmailAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any());
}
[Theory, BitAutoData]
- public async Task ResendNewDeviceVerificationEmail_SecretNotValid_SendOTPAsyncNotCalled(
+ public async Task ResendNewDeviceVerificationEmail_SecretNotValid_SendTwoFactorEmailAsyncNotCalled(
SutProvider sutProvider, string email, string secret)
{
sutProvider.GetDependency()
@@ -603,7 +648,7 @@ public class UserServiceTests
await sutProvider.GetDependency()
.DidNotReceive()
- .SendOTPEmailAsync(Arg.Any(), Arg.Any());
+ .SendTwoFactorEmailAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any());
}
[Theory, BitAutoData]
@@ -637,6 +682,10 @@ public class UserServiceTests
.GetByEmailAsync(user.Email)
.Returns(user);
+ var context = sutProvider.GetDependency();
+ context.DeviceType = DeviceType.Android;
+ context.IpAddress = "1.1.1.1";
+
// HACK: SutProvider is being weird about not injecting the IPasswordHasher that I configured
var sut = RebuildSut(sutProvider);
@@ -644,7 +693,8 @@ public class UserServiceTests
await sutProvider.GetDependency()
.Received(1)
- .SendOTPEmailAsync(user.Email, Arg.Any());
+ .SendTwoFactorEmailAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any());
+
}
[Theory]
diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs
index 4e598c436d..289f321512 100644
--- a/test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs
+++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs
@@ -67,7 +67,12 @@ public class IdentityServerTwoFactorTests : IClassFixture(mailService =>
{
- mailService.SendTwoFactorEmailAsync(Arg.Any(), Arg.Do(t => emailToken = t))
+ mailService.SendTwoFactorEmailAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Do(t => emailToken = t),
+ Arg.Any(),
+ Arg.Any())
.Returns(Task.CompletedTask);
});
@@ -273,7 +278,12 @@ public class IdentityServerTwoFactorTests : IClassFixture(mailService =>
{
- mailService.SendTwoFactorEmailAsync(Arg.Any(), Arg.Do(t => emailToken = t))
+ mailService.SendTwoFactorEmailAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Do(t => emailToken = t),
+ Arg.Any(),
+ Arg.Any())
.Returns(Task.CompletedTask);
});
diff --git a/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs b/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs
index 6e6406f16b..fddcf2005d 100644
--- a/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs
+++ b/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs
@@ -574,7 +574,7 @@ public class DeviceValidatorTests
var result = await _sut.ValidateRequestDeviceAsync(request, context);
// Assert
- await _userService.Received(1).SendOTPAsync(context.User);
+ await _userService.Received(1).SendNewDeviceVerificationEmailAsync(context.User);
await _deviceService.Received(0).SaveAsync(Arg.Any());
Assert.False(result);
From c1ac96814e3a6b689abd3fe5fc60f6fdd2a26330 Mon Sep 17 00:00:00 2001
From: Brandon Treston
Date: Fri, 21 Feb 2025 13:23:06 -0500
Subject: [PATCH 02/26] remove feature flag (#5432)
---
src/Core/Constants.cs | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs
index c310674bcc..879e8365fc 100644
--- a/src/Core/Constants.cs
+++ b/src/Core/Constants.cs
@@ -105,7 +105,6 @@ public static class FeatureFlagKeys
public const string ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner";
public const string AccountDeprovisioning = "pm-10308-account-deprovisioning";
public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint";
- public const string IntegrationPage = "pm-14505-admin-console-integration-page";
public const string DeviceApprovalRequestAdminNotifications = "pm-15637-device-approval-request-admin-notifications";
public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission";
public const string ShortcutDuplicatePatchRequests = "pm-16812-shortcut-duplicate-patch-requests";
From d8cf658207ba3984e2c27f26ec6e43ee5d1ed3b4 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Fri, 21 Feb 2025 19:35:39 +0000
Subject: [PATCH 03/26] [deps] Auth: Update sass to v1.85.0 (#4947)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
bitwarden_license/src/Sso/package-lock.json | 33 ++++++++++++++-------
bitwarden_license/src/Sso/package.json | 2 +-
src/Admin/package-lock.json | 33 ++++++++++++++-------
src/Admin/package.json | 2 +-
4 files changed, 48 insertions(+), 22 deletions(-)
diff --git a/bitwarden_license/src/Sso/package-lock.json b/bitwarden_license/src/Sso/package-lock.json
index f1e23abd60..a0e7b767cc 100644
--- a/bitwarden_license/src/Sso/package-lock.json
+++ b/bitwarden_license/src/Sso/package-lock.json
@@ -17,7 +17,7 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.0",
"mini-css-extract-plugin": "2.9.2",
- "sass": "1.79.5",
+ "sass": "1.85.0",
"sass-loader": "16.0.4",
"webpack": "5.97.1",
"webpack-cli": "5.1.4"
@@ -104,6 +104,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
"detect-libc": "^1.0.3",
"is-glob": "^4.0.3",
@@ -771,6 +772,7 @@
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
"fill-range": "^7.1.1"
},
@@ -964,6 +966,7 @@
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
"dev": true,
"license": "Apache-2.0",
+ "optional": true,
"bin": {
"detect-libc": "bin/detect-libc.js"
},
@@ -1133,6 +1136,7 @@
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
"to-regex-range": "^5.0.1"
},
@@ -1234,9 +1238,9 @@
}
},
"node_modules/immutable": {
- "version": "4.3.7",
- "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz",
- "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==",
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz",
+ "integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==",
"dev": true,
"license": "MIT"
},
@@ -1292,6 +1296,7 @@
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
+ "optional": true,
"engines": {
"node": ">=0.10.0"
}
@@ -1302,6 +1307,7 @@
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
"is-extglob": "^2.1.1"
},
@@ -1315,6 +1321,7 @@
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
+ "optional": true,
"engines": {
"node": ">=0.12.0"
}
@@ -1430,6 +1437,7 @@
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
@@ -1513,7 +1521,8 @@
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "optional": true
},
"node_modules/node-releases": {
"version": "2.0.19",
@@ -1601,6 +1610,7 @@
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
+ "optional": true,
"engines": {
"node": ">=8.6"
},
@@ -1857,15 +1867,14 @@
"license": "MIT"
},
"node_modules/sass": {
- "version": "1.79.5",
- "resolved": "https://registry.npmjs.org/sass/-/sass-1.79.5.tgz",
- "integrity": "sha512-W1h5kp6bdhqFh2tk3DsI771MoEJjvrSY/2ihJRJS4pjIyfJCw0nTsxqhnrUzaLMOJjFchj8rOvraI/YUVjtx5g==",
+ "version": "1.85.0",
+ "resolved": "https://registry.npmjs.org/sass/-/sass-1.85.0.tgz",
+ "integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@parcel/watcher": "^2.4.1",
"chokidar": "^4.0.0",
- "immutable": "^4.0.0",
+ "immutable": "^5.0.2",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
@@ -1873,6 +1882,9 @@
},
"engines": {
"node": ">=14.0.0"
+ },
+ "optionalDependencies": {
+ "@parcel/watcher": "^2.4.1"
}
},
"node_modules/sass-loader": {
@@ -2125,6 +2137,7 @@
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
"is-number": "^7.0.0"
},
diff --git a/bitwarden_license/src/Sso/package.json b/bitwarden_license/src/Sso/package.json
index fa1fac3907..d9aefafef3 100644
--- a/bitwarden_license/src/Sso/package.json
+++ b/bitwarden_license/src/Sso/package.json
@@ -16,7 +16,7 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.0",
"mini-css-extract-plugin": "2.9.2",
- "sass": "1.79.5",
+ "sass": "1.85.0",
"sass-loader": "16.0.4",
"webpack": "5.97.1",
"webpack-cli": "5.1.4"
diff --git a/src/Admin/package-lock.json b/src/Admin/package-lock.json
index cc2693eae6..152edd6fc9 100644
--- a/src/Admin/package-lock.json
+++ b/src/Admin/package-lock.json
@@ -18,7 +18,7 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.0",
"mini-css-extract-plugin": "2.9.2",
- "sass": "1.79.5",
+ "sass": "1.85.0",
"sass-loader": "16.0.4",
"webpack": "5.97.1",
"webpack-cli": "5.1.4"
@@ -105,6 +105,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
"detect-libc": "^1.0.3",
"is-glob": "^4.0.3",
@@ -772,6 +773,7 @@
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
"fill-range": "^7.1.1"
},
@@ -965,6 +967,7 @@
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
"dev": true,
"license": "Apache-2.0",
+ "optional": true,
"bin": {
"detect-libc": "bin/detect-libc.js"
},
@@ -1134,6 +1137,7 @@
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
"to-regex-range": "^5.0.1"
},
@@ -1235,9 +1239,9 @@
}
},
"node_modules/immutable": {
- "version": "4.3.7",
- "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz",
- "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==",
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz",
+ "integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==",
"dev": true,
"license": "MIT"
},
@@ -1293,6 +1297,7 @@
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
+ "optional": true,
"engines": {
"node": ">=0.10.0"
}
@@ -1303,6 +1308,7 @@
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
"is-extglob": "^2.1.1"
},
@@ -1316,6 +1322,7 @@
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
+ "optional": true,
"engines": {
"node": ">=0.12.0"
}
@@ -1431,6 +1438,7 @@
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
@@ -1514,7 +1522,8 @@
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "optional": true
},
"node_modules/node-releases": {
"version": "2.0.19",
@@ -1602,6 +1611,7 @@
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
+ "optional": true,
"engines": {
"node": ">=8.6"
},
@@ -1858,15 +1868,14 @@
"license": "MIT"
},
"node_modules/sass": {
- "version": "1.79.5",
- "resolved": "https://registry.npmjs.org/sass/-/sass-1.79.5.tgz",
- "integrity": "sha512-W1h5kp6bdhqFh2tk3DsI771MoEJjvrSY/2ihJRJS4pjIyfJCw0nTsxqhnrUzaLMOJjFchj8rOvraI/YUVjtx5g==",
+ "version": "1.85.0",
+ "resolved": "https://registry.npmjs.org/sass/-/sass-1.85.0.tgz",
+ "integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@parcel/watcher": "^2.4.1",
"chokidar": "^4.0.0",
- "immutable": "^4.0.0",
+ "immutable": "^5.0.2",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
@@ -1874,6 +1883,9 @@
},
"engines": {
"node": ">=14.0.0"
+ },
+ "optionalDependencies": {
+ "@parcel/watcher": "^2.4.1"
}
},
"node_modules/sass-loader": {
@@ -2126,6 +2138,7 @@
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
"is-number": "^7.0.0"
},
diff --git a/src/Admin/package.json b/src/Admin/package.json
index 2a8e91f43e..7f3c8046a2 100644
--- a/src/Admin/package.json
+++ b/src/Admin/package.json
@@ -17,7 +17,7 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.0",
"mini-css-extract-plugin": "2.9.2",
- "sass": "1.79.5",
+ "sass": "1.85.0",
"sass-loader": "16.0.4",
"webpack": "5.97.1",
"webpack-cli": "5.1.4"
From 5241e09c1a29e65c17308d2cdc77f788ccd8da37 Mon Sep 17 00:00:00 2001
From: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com>
Date: Fri, 21 Feb 2025 20:59:37 +0100
Subject: [PATCH 04/26] PM-15882: Added RemoveUnlockWithPin policy (#5388)
---
src/Core/AdminConsole/Enums/PolicyType.cs | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/Core/AdminConsole/Enums/PolicyType.cs b/src/Core/AdminConsole/Enums/PolicyType.cs
index 80ab18e174..6f3bcd0102 100644
--- a/src/Core/AdminConsole/Enums/PolicyType.cs
+++ b/src/Core/AdminConsole/Enums/PolicyType.cs
@@ -15,7 +15,8 @@ public enum PolicyType : byte
DisablePersonalVaultExport = 10,
ActivateAutofill = 11,
AutomaticAppLogIn = 12,
- FreeFamiliesSponsorshipPolicy = 13
+ FreeFamiliesSponsorshipPolicy = 13,
+ RemoveUnlockWithPin = 14,
}
public static class PolicyTypeExtensions
@@ -41,7 +42,8 @@ public static class PolicyTypeExtensions
PolicyType.DisablePersonalVaultExport => "Remove individual vault export",
PolicyType.ActivateAutofill => "Active auto-fill",
PolicyType.AutomaticAppLogIn => "Automatically log in users for allowed applications",
- PolicyType.FreeFamiliesSponsorshipPolicy => "Remove Free Bitwarden Families sponsorship"
+ PolicyType.FreeFamiliesSponsorshipPolicy => "Remove Free Bitwarden Families sponsorship",
+ PolicyType.RemoveUnlockWithPin => "Remove unlock with PIN"
};
}
}
From b0c6fc9146433c95019e51f64cc38ed1f658293d Mon Sep 17 00:00:00 2001
From: Thomas Rittson <31796059+eliykat@users.noreply.github.com>
Date: Mon, 24 Feb 2025 09:19:52 +1000
Subject: [PATCH 05/26] [PM-18234] Add SendPolicyRequirement (#5409)
---
.../SendPolicyRequirement.cs | 54 +++++++
.../PolicyServiceCollectionExtensions.cs | 1 +
.../Services/Implementations/SendService.cs | 41 +++++-
.../AutoFixture/PolicyDetailsFixtures.cs | 35 +++++
.../PolicyDetailsTestExtensions.cs | 10 ++
.../SendPolicyRequirementTests.cs | 138 ++++++++++++++++++
.../Tools/AutoFixture/SendFixtures.cs | 21 ++-
.../Tools/Services/SendServiceTests.cs | 90 ++++++++++++
8 files changed, 384 insertions(+), 6 deletions(-)
create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/SendPolicyRequirement.cs
create mode 100644 test/Core.Test/AdminConsole/AutoFixture/PolicyDetailsFixtures.cs
create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/PolicyDetailsTestExtensions.cs
create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/SendPolicyRequirementTests.cs
diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/SendPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/SendPolicyRequirement.cs
new file mode 100644
index 0000000000..c54cc98373
--- /dev/null
+++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/SendPolicyRequirement.cs
@@ -0,0 +1,54 @@
+using Bit.Core.AdminConsole.Enums;
+using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
+using Bit.Core.Enums;
+
+namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
+
+///
+/// Policy requirements for the Disable Send and Send Options policies.
+///
+public class SendPolicyRequirement : IPolicyRequirement
+{
+ ///
+ /// Indicates whether Send is disabled for the user. If true, the user should not be able to create or edit Sends.
+ /// They may still delete existing Sends.
+ ///
+ public bool DisableSend { get; init; }
+ ///
+ /// Indicates whether the user is prohibited from hiding their email from the recipient of a Send.
+ ///
+ public bool DisableHideEmail { get; init; }
+
+ ///
+ /// Create a new SendPolicyRequirement.
+ ///
+ /// All PolicyDetails relating to the user.
+ ///
+ /// This is a for the SendPolicyRequirement.
+ ///
+ public static SendPolicyRequirement Create(IEnumerable policyDetails)
+ {
+ var filteredPolicies = policyDetails
+ .ExemptRoles([OrganizationUserType.Owner, OrganizationUserType.Admin])
+ .ExemptStatus([OrganizationUserStatusType.Invited, OrganizationUserStatusType.Revoked])
+ .ExemptProviders()
+ .ToList();
+
+ var result = filteredPolicies
+ .GetPolicyType(PolicyType.SendOptions)
+ .Select(p => p.GetDataModel())
+ .Aggregate(
+ new SendPolicyRequirement
+ {
+ // Set Disable Send requirement in the initial seed
+ DisableSend = filteredPolicies.GetPolicyType(PolicyType.DisableSend).Any()
+ },
+ (result, data) => new SendPolicyRequirement
+ {
+ DisableSend = result.DisableSend,
+ DisableHideEmail = result.DisableHideEmail || data.DisableHideEmail
+ });
+
+ return result;
+ }
+}
diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs
index f7b35f2f06..7bc8a7b5a3 100644
--- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs
+++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs
@@ -32,6 +32,7 @@ public static class PolicyServiceCollectionExtensions
private static void AddPolicyRequirements(this IServiceCollection services)
{
// Register policy requirement factories here
+ services.AddPolicyRequirement(SendPolicyRequirement.Create);
}
///
diff --git a/src/Core/Tools/Services/Implementations/SendService.cs b/src/Core/Tools/Services/Implementations/SendService.cs
index 918379d7a5..bddaa93bfc 100644
--- a/src/Core/Tools/Services/Implementations/SendService.cs
+++ b/src/Core/Tools/Services/Implementations/SendService.cs
@@ -1,7 +1,8 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
-using Bit.Core.AdminConsole.Repositories;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Context;
using Bit.Core.Entities;
@@ -26,7 +27,6 @@ public class SendService : ISendService
public const string MAX_FILE_SIZE_READABLE = "500 MB";
private readonly ISendRepository _sendRepository;
private readonly IUserRepository _userRepository;
- private readonly IPolicyRepository _policyRepository;
private readonly IPolicyService _policyService;
private readonly IUserService _userService;
private readonly IOrganizationRepository _organizationRepository;
@@ -36,6 +36,9 @@ public class SendService : ISendService
private readonly IReferenceEventService _referenceEventService;
private readonly GlobalSettings _globalSettings;
private readonly ICurrentContext _currentContext;
+ private readonly IPolicyRequirementQuery _policyRequirementQuery;
+ private readonly IFeatureService _featureService;
+
private const long _fileSizeLeeway = 1024L * 1024L; // 1MB
public SendService(
@@ -48,14 +51,14 @@ public class SendService : ISendService
IPushNotificationService pushService,
IReferenceEventService referenceEventService,
GlobalSettings globalSettings,
- IPolicyRepository policyRepository,
IPolicyService policyService,
- ICurrentContext currentContext)
+ ICurrentContext currentContext,
+ IPolicyRequirementQuery policyRequirementQuery,
+ IFeatureService featureService)
{
_sendRepository = sendRepository;
_userRepository = userRepository;
_userService = userService;
- _policyRepository = policyRepository;
_policyService = policyService;
_organizationRepository = organizationRepository;
_sendFileStorageService = sendFileStorageService;
@@ -64,6 +67,8 @@ public class SendService : ISendService
_referenceEventService = referenceEventService;
_globalSettings = globalSettings;
_currentContext = currentContext;
+ _policyRequirementQuery = policyRequirementQuery;
+ _featureService = featureService;
}
public async Task SaveSendAsync(Send send)
@@ -286,6 +291,12 @@ public class SendService : ISendService
private async Task ValidateUserCanSaveAsync(Guid? userId, Send send)
{
+ if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements))
+ {
+ await ValidateUserCanSaveAsync_vNext(userId, send);
+ return;
+ }
+
if (!userId.HasValue || (!_currentContext.Organizations?.Any() ?? true))
{
return;
@@ -308,6 +319,26 @@ public class SendService : ISendService
}
}
+ private async Task ValidateUserCanSaveAsync_vNext(Guid? userId, Send send)
+ {
+ if (!userId.HasValue)
+ {
+ return;
+ }
+
+ var sendPolicyRequirement = await _policyRequirementQuery.GetAsync(userId.Value);
+
+ if (sendPolicyRequirement.DisableSend)
+ {
+ throw new BadRequestException("Due to an Enterprise Policy, you are only able to delete an existing Send.");
+ }
+
+ if (sendPolicyRequirement.DisableHideEmail && send.HideEmail.GetValueOrDefault())
+ {
+ throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to hide your email address from recipients when creating or editing a Send.");
+ }
+ }
+
private async Task StorageRemainingForSendAsync(Send send)
{
var storageBytesRemaining = 0L;
diff --git a/test/Core.Test/AdminConsole/AutoFixture/PolicyDetailsFixtures.cs b/test/Core.Test/AdminConsole/AutoFixture/PolicyDetailsFixtures.cs
new file mode 100644
index 0000000000..87ea390cb6
--- /dev/null
+++ b/test/Core.Test/AdminConsole/AutoFixture/PolicyDetailsFixtures.cs
@@ -0,0 +1,35 @@
+using System.Reflection;
+using AutoFixture;
+using AutoFixture.Xunit2;
+using Bit.Core.AdminConsole.Enums;
+using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
+using Bit.Core.Enums;
+
+namespace Bit.Core.Test.AdminConsole.AutoFixture;
+
+internal class PolicyDetailsCustomization(
+ PolicyType policyType,
+ OrganizationUserType userType,
+ bool isProvider,
+ OrganizationUserStatusType userStatus) : ICustomization
+{
+ public void Customize(IFixture fixture)
+ {
+ fixture.Customize(composer => composer
+ .With(o => o.PolicyType, policyType)
+ .With(o => o.OrganizationUserType, userType)
+ .With(o => o.IsProvider, isProvider)
+ .With(o => o.OrganizationUserStatus, userStatus)
+ .Without(o => o.PolicyData)); // avoid autogenerating invalid json data
+ }
+}
+
+public class PolicyDetailsAttribute(
+ PolicyType policyType,
+ OrganizationUserType userType = OrganizationUserType.User,
+ bool isProvider = false,
+ OrganizationUserStatusType userStatus = OrganizationUserStatusType.Confirmed) : CustomizeAttribute
+{
+ public override ICustomization GetCustomization(ParameterInfo parameter)
+ => new PolicyDetailsCustomization(policyType, userType, isProvider, userStatus);
+}
diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/PolicyDetailsTestExtensions.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/PolicyDetailsTestExtensions.cs
new file mode 100644
index 0000000000..3323c9c754
--- /dev/null
+++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/PolicyDetailsTestExtensions.cs
@@ -0,0 +1,10 @@
+using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
+using Bit.Core.Utilities;
+
+namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
+
+public static class PolicyDetailsTestExtensions
+{
+ public static void SetDataModel(this PolicyDetails policyDetails, T data) where T : IPolicyDataModel
+ => policyDetails.PolicyData = CoreHelpers.ClassToJsonData(data);
+}
diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/SendPolicyRequirementTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/SendPolicyRequirementTests.cs
new file mode 100644
index 0000000000..4d7bf5db4e
--- /dev/null
+++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/SendPolicyRequirementTests.cs
@@ -0,0 +1,138 @@
+using AutoFixture.Xunit2;
+using Bit.Core.AdminConsole.Enums;
+using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
+using Bit.Core.Enums;
+using Bit.Core.Test.AdminConsole.AutoFixture;
+using Xunit;
+
+namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
+
+public class SendPolicyRequirementTests
+{
+ [Theory, AutoData]
+ public void DisableSend_IsFalse_IfNoDisableSendPolicies(
+ [PolicyDetails(PolicyType.RequireSso)] PolicyDetails otherPolicy1,
+ [PolicyDetails(PolicyType.SendOptions)] PolicyDetails otherPolicy2)
+ {
+ EnableDisableHideEmail(otherPolicy2);
+
+ var actual = SendPolicyRequirement.Create([otherPolicy1, otherPolicy2]);
+
+ Assert.False(actual.DisableSend);
+ }
+
+ [Theory]
+ [InlineAutoData(OrganizationUserType.Owner, false)]
+ [InlineAutoData(OrganizationUserType.Admin, false)]
+ [InlineAutoData(OrganizationUserType.User, true)]
+ [InlineAutoData(OrganizationUserType.Custom, true)]
+ public void DisableSend_TestRoles(
+ OrganizationUserType userType,
+ bool shouldBeEnforced,
+ [PolicyDetails(PolicyType.DisableSend)] PolicyDetails policyDetails)
+ {
+ policyDetails.OrganizationUserType = userType;
+
+ var actual = SendPolicyRequirement.Create([policyDetails]);
+
+ Assert.Equal(shouldBeEnforced, actual.DisableSend);
+ }
+
+ [Theory, AutoData]
+ public void DisableSend_Not_EnforcedAgainstProviders(
+ [PolicyDetails(PolicyType.DisableSend, isProvider: true)] PolicyDetails policyDetails)
+ {
+ var actual = SendPolicyRequirement.Create([policyDetails]);
+
+ Assert.False(actual.DisableSend);
+ }
+
+ [Theory]
+ [InlineAutoData(OrganizationUserStatusType.Confirmed, true)]
+ [InlineAutoData(OrganizationUserStatusType.Accepted, true)]
+ [InlineAutoData(OrganizationUserStatusType.Invited, false)]
+ [InlineAutoData(OrganizationUserStatusType.Revoked, false)]
+ public void DisableSend_TestStatuses(
+ OrganizationUserStatusType userStatus,
+ bool shouldBeEnforced,
+ [PolicyDetails(PolicyType.DisableSend)] PolicyDetails policyDetails)
+ {
+ policyDetails.OrganizationUserStatus = userStatus;
+
+ var actual = SendPolicyRequirement.Create([policyDetails]);
+
+ Assert.Equal(shouldBeEnforced, actual.DisableSend);
+ }
+
+ [Theory, AutoData]
+ public void DisableHideEmail_IsFalse_IfNoSendOptionsPolicies(
+ [PolicyDetails(PolicyType.RequireSso)] PolicyDetails otherPolicy1,
+ [PolicyDetails(PolicyType.DisableSend)] PolicyDetails otherPolicy2)
+ {
+ var actual = SendPolicyRequirement.Create([otherPolicy1, otherPolicy2]);
+
+ Assert.False(actual.DisableHideEmail);
+ }
+
+ [Theory]
+ [InlineAutoData(OrganizationUserType.Owner, false)]
+ [InlineAutoData(OrganizationUserType.Admin, false)]
+ [InlineAutoData(OrganizationUserType.User, true)]
+ [InlineAutoData(OrganizationUserType.Custom, true)]
+ public void DisableHideEmail_TestRoles(
+ OrganizationUserType userType,
+ bool shouldBeEnforced,
+ [PolicyDetails(PolicyType.SendOptions)] PolicyDetails policyDetails)
+ {
+ EnableDisableHideEmail(policyDetails);
+ policyDetails.OrganizationUserType = userType;
+
+ var actual = SendPolicyRequirement.Create([policyDetails]);
+
+ Assert.Equal(shouldBeEnforced, actual.DisableHideEmail);
+ }
+
+ [Theory, AutoData]
+ public void DisableHideEmail_Not_EnforcedAgainstProviders(
+ [PolicyDetails(PolicyType.SendOptions, isProvider: true)] PolicyDetails policyDetails)
+ {
+ EnableDisableHideEmail(policyDetails);
+
+ var actual = SendPolicyRequirement.Create([policyDetails]);
+
+ Assert.False(actual.DisableHideEmail);
+ }
+
+ [Theory]
+ [InlineAutoData(OrganizationUserStatusType.Confirmed, true)]
+ [InlineAutoData(OrganizationUserStatusType.Accepted, true)]
+ [InlineAutoData(OrganizationUserStatusType.Invited, false)]
+ [InlineAutoData(OrganizationUserStatusType.Revoked, false)]
+ public void DisableHideEmail_TestStatuses(
+ OrganizationUserStatusType userStatus,
+ bool shouldBeEnforced,
+ [PolicyDetails(PolicyType.SendOptions)] PolicyDetails policyDetails)
+ {
+ EnableDisableHideEmail(policyDetails);
+ policyDetails.OrganizationUserStatus = userStatus;
+
+ var actual = SendPolicyRequirement.Create([policyDetails]);
+
+ Assert.Equal(shouldBeEnforced, actual.DisableHideEmail);
+ }
+
+ [Theory, AutoData]
+ public void DisableHideEmail_HandlesNullData(
+ [PolicyDetails(PolicyType.SendOptions)] PolicyDetails policyDetails)
+ {
+ policyDetails.PolicyData = null;
+
+ var actual = SendPolicyRequirement.Create([policyDetails]);
+
+ Assert.False(actual.DisableHideEmail);
+ }
+
+ private static void EnableDisableHideEmail(PolicyDetails policyDetails)
+ => policyDetails.SetDataModel(new SendOptionsPolicyData { DisableHideEmail = true });
+}
diff --git a/test/Core.Test/Tools/AutoFixture/SendFixtures.cs b/test/Core.Test/Tools/AutoFixture/SendFixtures.cs
index c8005f4faf..0d58ca1671 100644
--- a/test/Core.Test/Tools/AutoFixture/SendFixtures.cs
+++ b/test/Core.Test/Tools/AutoFixture/SendFixtures.cs
@@ -1,4 +1,6 @@
-using AutoFixture;
+using System.Reflection;
+using AutoFixture;
+using AutoFixture.Xunit2;
using Bit.Core.Tools.Entities;
using Bit.Test.Common.AutoFixture.Attributes;
@@ -19,3 +21,20 @@ internal class UserSendCustomizeAttribute : BitCustomizeAttribute
{
public override ICustomization GetCustomization() => new UserSend();
}
+
+internal class NewUserSend : ICustomization
+{
+ public void Customize(IFixture fixture)
+ {
+ fixture.Customize(composer => composer
+ .With(s => s.Id, Guid.Empty)
+ .Without(s => s.OrganizationId));
+ }
+}
+
+internal class NewUserSendCustomizeAttribute : CustomizeAttribute
+{
+ public override ICustomization GetCustomization(ParameterInfo parameterInfo)
+ => new NewUserSend();
+}
+
diff --git a/test/Core.Test/Tools/Services/SendServiceTests.cs b/test/Core.Test/Tools/Services/SendServiceTests.cs
index cabb438b61..ae65ee1388 100644
--- a/test/Core.Test/Tools/Services/SendServiceTests.cs
+++ b/test/Core.Test/Tools/Services/SendServiceTests.cs
@@ -3,6 +3,8 @@ using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
+using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
@@ -22,6 +24,7 @@ using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Identity;
using NSubstitute;
+using NSubstitute.ExceptionExtensions;
using Xunit;
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
@@ -118,6 +121,93 @@ public class SendServiceTests
await sutProvider.GetDependency().Received(1).CreateAsync(send);
}
+ // Disable Send policy check - vNext
+ private void SaveSendAsync_Setup_vNext(SutProvider sutProvider, Send send,
+ SendPolicyRequirement sendPolicyRequirement)
+ {
+ sutProvider.GetDependency().GetAsync(send.UserId!.Value)
+ .Returns(sendPolicyRequirement);
+ sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
+
+ // Should not be called in these tests
+ sutProvider.GetDependency().AnyPoliciesApplicableToUserAsync(
+ Arg.Any(), Arg.Any()).ThrowsAsync();
+ }
+
+ [Theory]
+ [BitAutoData(SendType.File)]
+ [BitAutoData(SendType.Text)]
+ public async Task SaveSendAsync_DisableSend_Applies_Throws_vNext(SendType sendType,
+ SutProvider sutProvider, [NewUserSendCustomize] Send send)
+ {
+ send.Type = sendType;
+ SaveSendAsync_Setup_vNext(sutProvider, send, new SendPolicyRequirement { DisableSend = true });
+
+ var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SaveSendAsync(send));
+ Assert.Contains("Due to an Enterprise Policy, you are only able to delete an existing Send.",
+ exception.Message);
+ }
+
+ [Theory]
+ [BitAutoData(SendType.File)]
+ [BitAutoData(SendType.Text)]
+ public async Task SaveSendAsync_DisableSend_DoesntApply_Success_vNext(SendType sendType,
+ SutProvider sutProvider, [NewUserSendCustomize] Send send)
+ {
+ send.Type = sendType;
+ SaveSendAsync_Setup_vNext(sutProvider, send, new SendPolicyRequirement());
+
+ await sutProvider.Sut.SaveSendAsync(send);
+
+ await sutProvider.GetDependency().Received(1).CreateAsync(send);
+ }
+
+ // Send Options Policy - Disable Hide Email check
+
+ [Theory]
+ [BitAutoData(SendType.File)]
+ [BitAutoData(SendType.Text)]
+ public async Task SaveSendAsync_DisableHideEmail_Applies_Throws_vNext(SendType sendType,
+ SutProvider sutProvider, [NewUserSendCustomize] Send send)
+ {
+ send.Type = sendType;
+ SaveSendAsync_Setup_vNext(sutProvider, send, new SendPolicyRequirement { DisableHideEmail = true });
+ send.HideEmail = true;
+
+ var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SaveSendAsync(send));
+ Assert.Contains("Due to an Enterprise Policy, you are not allowed to hide your email address from recipients when creating or editing a Send.", exception.Message);
+ }
+
+ [Theory]
+ [BitAutoData(SendType.File)]
+ [BitAutoData(SendType.Text)]
+ public async Task SaveSendAsync_DisableHideEmail_Applies_ButEmailNotHidden_Success_vNext(SendType sendType,
+ SutProvider sutProvider, [NewUserSendCustomize] Send send)
+ {
+ send.Type = sendType;
+ SaveSendAsync_Setup_vNext(sutProvider, send, new SendPolicyRequirement { DisableHideEmail = true });
+ send.HideEmail = false;
+
+ await sutProvider.Sut.SaveSendAsync(send);
+
+ await sutProvider.GetDependency().Received(1).CreateAsync(send);
+ }
+
+ [Theory]
+ [BitAutoData(SendType.File)]
+ [BitAutoData(SendType.Text)]
+ public async Task SaveSendAsync_DisableHideEmail_DoesntApply_Success_vNext(SendType sendType,
+ SutProvider sutProvider, [NewUserSendCustomize] Send send)
+ {
+ send.Type = sendType;
+ SaveSendAsync_Setup_vNext(sutProvider, send, new SendPolicyRequirement());
+ send.HideEmail = true;
+
+ await sutProvider.Sut.SaveSendAsync(send);
+
+ await sutProvider.GetDependency().Received(1).CreateAsync(send);
+ }
+
[Theory]
[BitAutoData]
public async Task SaveSendAsync_ExistingSend_Updates(SutProvider sutProvider,
From 2b1db97d5c3438b9dd909fa06892248653437295 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rui=20Tom=C3=A9?=
<108268980+r-tome@users.noreply.github.com>
Date: Mon, 24 Feb 2025 11:40:53 +0000
Subject: [PATCH 06/26] [PM-18085] Add Manage property to UserCipherDetails
(#5390)
* Add Manage permission to UserCipherDetails and CipherDetails_ReadByIdUserId
* Add Manage property to CipherDetails and UserCipherDetailsQuery
* Add integration test for CipherRepository Manage permission rules
* Update CipherDetails_ReadWithoutOrganizationsByUserId to include Manage permission
* Refactor UserCipherDetailsQuery to include detailed permission and organization properties
* Refactor CipherRepositoryTests to improve test organization and readability
- Split large test method into smaller, focused methods
- Added helper methods for creating test data and performing assertions
- Improved test coverage for cipher permissions in different scenarios
- Maintained existing test logic while enhancing code structure
* Refactor CipherRepositoryTests to consolidate cipher permission tests
- Removed redundant helper methods for permission assertions
- Simplified test methods for GetCipherPermissionsForOrganizationAsync, GetManyByUserIdAsync, and GetByIdAsync
- Maintained existing test coverage for cipher manage permissions
- Improved code readability and reduced code duplication
* Add integration test for CipherRepository group collection manage permissions
- Added new test method GetCipherPermissionsForOrganizationAsync_ManageProperty_RespectsCollectionGroupRules
- Implemented helper method CreateCipherInOrganizationCollectionWithGroup to support group-based collection permission testing
- Verified manage permissions are correctly applied based on group collection access settings
* Add @Manage parameter to Cipher stored procedures
- Updated CipherDetails_Create, CipherDetails_CreateWithCollections, and CipherDetails_Update stored procedures
- Added @Manage parameter with comment "-- not used"
- Included new stored procedure implementations in migration script
- Consistent with previous work on adding Manage property to cipher details
* Update UserCipherDetails functions to reorder Manage and ViewPassword columns
* Reorder Manage and ViewPassword properties in cipher details queries
* Bump date in migration script
---
src/Core/Vault/Models/Data/CipherDetails.cs | 2 +
.../Queries/UserCipherDetailsQuery.cs | 51 ++-
.../Vault/Repositories/CipherRepository.cs | 1 +
.../Vault/dbo/Functions/UserCipherDetails.sql | 6 +
.../Cipher/CipherDetails_Create.sql | 3 +-
.../CipherDetails_CreateWithCollections.sql | 5 +-
.../Cipher/CipherDetails_ReadByIdUserId.sql | 3 +-
...tails_ReadWithoutOrganizationsByUserId.sql | 1 +
.../Cipher/CipherDetails_Update.sql | 5 +-
.../Repositories/CipherRepositoryTests.cs | 271 +++++++++++++++
.../2025-02-19_00_UserCipherDetailsManage.sql | 309 ++++++++++++++++++
11 files changed, 645 insertions(+), 12 deletions(-)
create mode 100644 util/Migrator/DbScripts/2025-02-19_00_UserCipherDetailsManage.sql
diff --git a/src/Core/Vault/Models/Data/CipherDetails.cs b/src/Core/Vault/Models/Data/CipherDetails.cs
index 716b49ca4f..e0ece1efec 100644
--- a/src/Core/Vault/Models/Data/CipherDetails.cs
+++ b/src/Core/Vault/Models/Data/CipherDetails.cs
@@ -8,6 +8,7 @@ public class CipherDetails : CipherOrganizationDetails
public bool Favorite { get; set; }
public bool Edit { get; set; }
public bool ViewPassword { get; set; }
+ public bool Manage { get; set; }
public CipherDetails() { }
@@ -53,6 +54,7 @@ public class CipherDetailsWithCollections : CipherDetails
Favorite = cipher.Favorite;
Edit = cipher.Edit;
ViewPassword = cipher.ViewPassword;
+ Manage = cipher.Manage;
CollectionIds = collectionCiphersGroupDict.TryGetValue(Id, out var value)
? value.Select(cc => cc.CollectionId)
diff --git a/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs b/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs
index fdfb9a1bc9..507849f51b 100644
--- a/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs
+++ b/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs
@@ -50,11 +50,49 @@ public class UserCipherDetailsQuery : IQuery
where (cu == null ? (Guid?)null : cu.CollectionId) != null || (cg == null ? (Guid?)null : cg.CollectionId) != null
- select c;
+ select new
+ {
+ c.Id,
+ c.UserId,
+ c.OrganizationId,
+ c.Type,
+ c.Data,
+ c.Attachments,
+ c.CreationDate,
+ c.RevisionDate,
+ c.DeletedDate,
+ c.Favorites,
+ c.Folders,
+ Edit = cu == null ? (cg != null && cg.ReadOnly == false) : cu.ReadOnly == false,
+ ViewPassword = cu == null ? (cg != null && cg.HidePasswords == false) : cu.HidePasswords == false,
+ Manage = cu == null ? (cg != null && cg.Manage == true) : cu.Manage == true,
+ OrganizationUseTotp = o.UseTotp,
+ c.Reprompt,
+ c.Key
+ };
var query2 = from c in dbContext.Ciphers
where c.UserId == _userId
- select c;
+ select new
+ {
+ c.Id,
+ c.UserId,
+ c.OrganizationId,
+ c.Type,
+ c.Data,
+ c.Attachments,
+ c.CreationDate,
+ c.RevisionDate,
+ c.DeletedDate,
+ c.Favorites,
+ c.Folders,
+ Edit = true,
+ ViewPassword = true,
+ Manage = true,
+ OrganizationUseTotp = false,
+ c.Reprompt,
+ c.Key
+ };
var union = query.Union(query2).Select(c => new CipherDetails
{
@@ -68,11 +106,12 @@ public class UserCipherDetailsQuery : IQuery
RevisionDate = c.RevisionDate,
DeletedDate = c.DeletedDate,
Favorite = _userId.HasValue && c.Favorites != null && c.Favorites.ToLowerInvariant().Contains($"\"{_userId}\":true"),
- FolderId = GetFolderId(_userId, c),
- Edit = true,
+ FolderId = GetFolderId(_userId, new Cipher { Id = c.Id, Folders = c.Folders }),
+ Edit = c.Edit,
Reprompt = c.Reprompt,
- ViewPassword = true,
- OrganizationUseTotp = false,
+ ViewPassword = c.ViewPassword,
+ Manage = c.Manage,
+ OrganizationUseTotp = c.OrganizationUseTotp,
Key = c.Key
});
return union;
diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs
index 6a4ffb4b35..9c91609b1b 100644
--- a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs
+++ b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs
@@ -432,6 +432,7 @@ public class CipherRepository : Repository c.Id == manageCipher.Id);
+ Assert.NotNull(managePermission);
+ Assert.True(managePermission.Manage, "Collection with Manage=true should grant Manage permission");
+
+ var nonManagePermission = permissions.FirstOrDefault(c => c.Id == nonManageCipher.Id);
+ Assert.NotNull(nonManagePermission);
+ Assert.False(nonManagePermission.Manage, "Collection with Manage=false should not grant Manage permission");
+ }
+
+ [DatabaseTheory, DatabaseData]
+ public async Task GetCipherPermissionsForOrganizationAsync_ManageProperty_RespectsCollectionGroupRules(
+ ICipherRepository cipherRepository,
+ IUserRepository userRepository,
+ ICollectionCipherRepository collectionCipherRepository,
+ ICollectionRepository collectionRepository,
+ IOrganizationRepository organizationRepository,
+ IOrganizationUserRepository organizationUserRepository,
+ IGroupRepository groupRepository)
+ {
+ var (user, organization, orgUser) = await CreateTestUserAndOrganization(userRepository, organizationRepository, organizationUserRepository);
+
+ var group = await groupRepository.CreateAsync(new Group
+ {
+ OrganizationId = organization.Id,
+ Name = "Test Group",
+ });
+ await groupRepository.UpdateUsersAsync(group.Id, new[] { orgUser.Id });
+
+ var (manageCipher, nonManageCipher) = await CreateCipherInOrganizationCollectionWithGroup(
+ organization, group, cipherRepository, collectionRepository, collectionCipherRepository, groupRepository);
+
+ var permissions = await cipherRepository.GetCipherPermissionsForOrganizationAsync(organization.Id, user.Id);
+ Assert.Equal(2, permissions.Count);
+
+ var managePermission = permissions.FirstOrDefault(c => c.Id == manageCipher.Id);
+ Assert.NotNull(managePermission);
+ Assert.True(managePermission.Manage, "Collection with Group Manage=true should grant Manage permission");
+
+ var nonManagePermission = permissions.FirstOrDefault(c => c.Id == nonManageCipher.Id);
+ Assert.NotNull(nonManagePermission);
+ Assert.False(nonManagePermission.Manage, "Collection with Group Manage=false should not grant Manage permission");
+ }
+
+ [DatabaseTheory, DatabaseData]
+ public async Task GetManyByUserIdAsync_ManageProperty_RespectsCollectionAndOwnershipRules(
+ ICipherRepository cipherRepository,
+ IUserRepository userRepository,
+ ICollectionCipherRepository collectionCipherRepository,
+ ICollectionRepository collectionRepository,
+ IOrganizationRepository organizationRepository,
+ IOrganizationUserRepository organizationUserRepository)
+ {
+ var (user, organization, orgUser) = await CreateTestUserAndOrganization(userRepository, organizationRepository, organizationUserRepository);
+
+ var manageCipher = await CreateCipherInOrganizationCollection(
+ organization, orgUser, cipherRepository, collectionRepository, collectionCipherRepository,
+ hasManagePermission: true, "Manage Collection");
+
+ var nonManageCipher = await CreateCipherInOrganizationCollection(
+ organization, orgUser, cipherRepository, collectionRepository, collectionCipherRepository,
+ hasManagePermission: false, "Non-Manage Collection");
+
+ var personalCipher = await CreatePersonalCipher(user, cipherRepository);
+
+ var userCiphers = await cipherRepository.GetManyByUserIdAsync(user.Id);
+ Assert.Equal(3, userCiphers.Count);
+
+ var managePermission = userCiphers.FirstOrDefault(c => c.Id == manageCipher.Id);
+ Assert.NotNull(managePermission);
+ Assert.True(managePermission.Manage, "Collection with Manage=true should grant Manage permission");
+
+ var nonManagePermission = userCiphers.FirstOrDefault(c => c.Id == nonManageCipher.Id);
+ Assert.NotNull(nonManagePermission);
+ Assert.False(nonManagePermission.Manage, "Collection with Manage=false should not grant Manage permission");
+
+ var personalPermission = userCiphers.FirstOrDefault(c => c.Id == personalCipher.Id);
+ Assert.NotNull(personalPermission);
+ Assert.True(personalPermission.Manage, "Personal ciphers should always have Manage permission");
+ }
+
+ [DatabaseTheory, DatabaseData]
+ public async Task GetByIdAsync_ManageProperty_RespectsCollectionAndOwnershipRules(
+ ICipherRepository cipherRepository,
+ IUserRepository userRepository,
+ ICollectionCipherRepository collectionCipherRepository,
+ ICollectionRepository collectionRepository,
+ IOrganizationRepository organizationRepository,
+ IOrganizationUserRepository organizationUserRepository)
+ {
+ var (user, organization, orgUser) = await CreateTestUserAndOrganization(userRepository, organizationRepository, organizationUserRepository);
+
+ var manageCipher = await CreateCipherInOrganizationCollection(
+ organization, orgUser, cipherRepository, collectionRepository, collectionCipherRepository,
+ hasManagePermission: true, "Manage Collection");
+
+ var nonManageCipher = await CreateCipherInOrganizationCollection(
+ organization, orgUser, cipherRepository, collectionRepository, collectionCipherRepository,
+ hasManagePermission: false, "Non-Manage Collection");
+
+ var personalCipher = await CreatePersonalCipher(user, cipherRepository);
+
+ var manageDetails = await cipherRepository.GetByIdAsync(manageCipher.Id, user.Id);
+ Assert.NotNull(manageDetails);
+ Assert.True(manageDetails.Manage, "Collection with Manage=true should grant Manage permission");
+
+ var nonManageDetails = await cipherRepository.GetByIdAsync(nonManageCipher.Id, user.Id);
+ Assert.NotNull(nonManageDetails);
+ Assert.False(nonManageDetails.Manage, "Collection with Manage=false should not grant Manage permission");
+
+ var personalDetails = await cipherRepository.GetByIdAsync(personalCipher.Id, user.Id);
+ Assert.NotNull(personalDetails);
+ Assert.True(personalDetails.Manage, "Personal ciphers should always have Manage permission");
+ }
+
+ private async Task<(User user, Organization org, OrganizationUser orgUser)> CreateTestUserAndOrganization(
+ IUserRepository userRepository,
+ IOrganizationRepository organizationRepository,
+ IOrganizationUserRepository organizationUserRepository)
+ {
+ var user = await userRepository.CreateAsync(new User
+ {
+ Name = "Test User",
+ Email = $"test+{Guid.NewGuid()}@email.com",
+ ApiKey = "TEST",
+ SecurityStamp = "stamp",
+ });
+
+ var organization = await organizationRepository.CreateAsync(new Organization
+ {
+ Name = "Test Organization",
+ BillingEmail = user.Email,
+ Plan = "Test"
+ });
+
+ var orgUser = await organizationUserRepository.CreateAsync(new OrganizationUser
+ {
+ UserId = user.Id,
+ OrganizationId = organization.Id,
+ Status = OrganizationUserStatusType.Confirmed,
+ Type = OrganizationUserType.Owner,
+ });
+
+ return (user, organization, orgUser);
+ }
+
+ private async Task CreateCipherInOrganizationCollection(
+ Organization organization,
+ OrganizationUser orgUser,
+ ICipherRepository cipherRepository,
+ ICollectionRepository collectionRepository,
+ ICollectionCipherRepository collectionCipherRepository,
+ bool hasManagePermission,
+ string collectionName)
+ {
+ var collection = await collectionRepository.CreateAsync(new Collection
+ {
+ Name = collectionName,
+ OrganizationId = organization.Id,
+ });
+
+ var cipher = await cipherRepository.CreateAsync(new Cipher
+ {
+ Type = CipherType.Login,
+ OrganizationId = organization.Id,
+ Data = ""
+ });
+
+ await collectionCipherRepository.UpdateCollectionsForAdminAsync(cipher.Id, organization.Id,
+ new List { collection.Id });
+
+ await collectionRepository.UpdateUsersAsync(collection.Id, new List
+ {
+ new() { Id = orgUser.Id, HidePasswords = false, ReadOnly = false, Manage = hasManagePermission }
+ });
+
+ return cipher;
+ }
+
+ private async Task<(Cipher manageCipher, Cipher nonManageCipher)> CreateCipherInOrganizationCollectionWithGroup(
+ Organization organization,
+ Group group,
+ ICipherRepository cipherRepository,
+ ICollectionRepository collectionRepository,
+ ICollectionCipherRepository collectionCipherRepository,
+ IGroupRepository groupRepository)
+ {
+ var manageCollection = await collectionRepository.CreateAsync(new Collection
+ {
+ Name = "Group Manage Collection",
+ OrganizationId = organization.Id,
+ });
+
+ var nonManageCollection = await collectionRepository.CreateAsync(new Collection
+ {
+ Name = "Group Non-Manage Collection",
+ OrganizationId = organization.Id,
+ });
+
+ var manageCipher = await cipherRepository.CreateAsync(new Cipher
+ {
+ Type = CipherType.Login,
+ OrganizationId = organization.Id,
+ Data = ""
+ });
+
+ var nonManageCipher = await cipherRepository.CreateAsync(new Cipher
+ {
+ Type = CipherType.Login,
+ OrganizationId = organization.Id,
+ Data = ""
+ });
+
+ await collectionCipherRepository.UpdateCollectionsForAdminAsync(manageCipher.Id, organization.Id,
+ new List { manageCollection.Id });
+ await collectionCipherRepository.UpdateCollectionsForAdminAsync(nonManageCipher.Id, organization.Id,
+ new List { nonManageCollection.Id });
+
+ await groupRepository.ReplaceAsync(group,
+ new[]
+ {
+ new CollectionAccessSelection
+ {
+ Id = manageCollection.Id,
+ HidePasswords = false,
+ ReadOnly = false,
+ Manage = true
+ },
+ new CollectionAccessSelection
+ {
+ Id = nonManageCollection.Id,
+ HidePasswords = false,
+ ReadOnly = false,
+ Manage = false
+ }
+ });
+
+ return (manageCipher, nonManageCipher);
+ }
+
+ private async Task CreatePersonalCipher(User user, ICipherRepository cipherRepository)
+ {
+ return await cipherRepository.CreateAsync(new Cipher
+ {
+ Type = CipherType.Login,
+ UserId = user.Id,
+ Data = ""
+ });
+ }
}
diff --git a/util/Migrator/DbScripts/2025-02-19_00_UserCipherDetailsManage.sql b/util/Migrator/DbScripts/2025-02-19_00_UserCipherDetailsManage.sql
new file mode 100644
index 0000000000..c6420ff13f
--- /dev/null
+++ b/util/Migrator/DbScripts/2025-02-19_00_UserCipherDetailsManage.sql
@@ -0,0 +1,309 @@
+CREATE OR ALTER FUNCTION [dbo].[UserCipherDetails](@UserId UNIQUEIDENTIFIER)
+RETURNS TABLE
+AS RETURN
+WITH [CTE] AS (
+ SELECT
+ [Id],
+ [OrganizationId]
+ FROM
+ [OrganizationUser]
+ WHERE
+ [UserId] = @UserId
+ AND [Status] = 2 -- Confirmed
+)
+SELECT
+ C.*,
+ CASE
+ WHEN COALESCE(CU.[ReadOnly], CG.[ReadOnly], 0) = 0
+ THEN 1
+ ELSE 0
+ END [Edit],
+ CASE
+ WHEN COALESCE(CU.[HidePasswords], CG.[HidePasswords], 0) = 0
+ THEN 1
+ ELSE 0
+ END [ViewPassword],
+ CASE
+ WHEN COALESCE(CU.[Manage], CG.[Manage], 0) = 1
+ THEN 1
+ ELSE 0
+ END [Manage],
+ CASE
+ WHEN O.[UseTotp] = 1
+ THEN 1
+ ELSE 0
+ END [OrganizationUseTotp]
+FROM
+ [dbo].[CipherDetails](@UserId) C
+INNER JOIN
+ [CTE] OU ON C.[UserId] IS NULL AND C.[OrganizationId] IN (SELECT [OrganizationId] FROM [CTE])
+INNER JOIN
+ [dbo].[Organization] O ON O.[Id] = OU.[OrganizationId] AND O.[Id] = C.[OrganizationId] AND O.[Enabled] = 1
+LEFT JOIN
+ [dbo].[CollectionCipher] CC ON CC.[CipherId] = C.[Id]
+LEFT JOIN
+ [dbo].[CollectionUser] CU ON CU.[CollectionId] = CC.[CollectionId] AND CU.[OrganizationUserId] = OU.[Id]
+LEFT JOIN
+ [dbo].[GroupUser] GU ON CU.[CollectionId] IS NULL AND GU.[OrganizationUserId] = OU.[Id]
+LEFT JOIN
+ [dbo].[Group] G ON G.[Id] = GU.[GroupId]
+LEFT JOIN
+ [dbo].[CollectionGroup] CG ON CG.[CollectionId] = CC.[CollectionId] AND CG.[GroupId] = GU.[GroupId]
+WHERE
+ CU.[CollectionId] IS NOT NULL
+ OR CG.[CollectionId] IS NOT NULL
+
+UNION ALL
+
+SELECT
+ *,
+ 1 [Edit],
+ 1 [ViewPassword],
+ 1 [Manage],
+ 0 [OrganizationUseTotp]
+FROM
+ [dbo].[CipherDetails](@UserId)
+WHERE
+ [UserId] = @UserId
+GO
+
+CREATE OR ALTER PROCEDURE [dbo].[CipherDetails_ReadByIdUserId]
+ @Id UNIQUEIDENTIFIER,
+ @UserId UNIQUEIDENTIFIER
+AS
+BEGIN
+ SET NOCOUNT ON
+
+SELECT
+ [Id],
+ [UserId],
+ [OrganizationId],
+ [Type],
+ [Data],
+ [Attachments],
+ [CreationDate],
+ [RevisionDate],
+ [Favorite],
+ [FolderId],
+ [DeletedDate],
+ [Reprompt],
+ [Key],
+ [OrganizationUseTotp],
+ MAX ([Edit]) AS [Edit],
+ MAX ([ViewPassword]) AS [ViewPassword],
+ MAX ([Manage]) AS [Manage]
+ FROM
+ [dbo].[UserCipherDetails](@UserId)
+ WHERE
+ [Id] = @Id
+ GROUP BY
+ [Id],
+ [UserId],
+ [OrganizationId],
+ [Type],
+ [Data],
+ [Attachments],
+ [CreationDate],
+ [RevisionDate],
+ [Favorite],
+ [FolderId],
+ [DeletedDate],
+ [Reprompt],
+ [Key],
+ [OrganizationUseTotp]
+END
+GO
+
+CREATE OR ALTER PROCEDURE [dbo].[CipherDetails_ReadWithoutOrganizationsByUserId]
+ @UserId UNIQUEIDENTIFIER
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ SELECT
+ *,
+ 1 [Edit],
+ 1 [ViewPassword],
+ 1 [Manage],
+ 0 [OrganizationUseTotp]
+ FROM
+ [dbo].[CipherDetails](@UserId)
+ WHERE
+ [UserId] = @UserId
+END
+GO
+
+CREATE OR ALTER PROCEDURE [dbo].[CipherDetails_Create]
+ @Id UNIQUEIDENTIFIER,
+ @UserId UNIQUEIDENTIFIER,
+ @OrganizationId UNIQUEIDENTIFIER,
+ @Type TINYINT,
+ @Data NVARCHAR(MAX),
+ @Favorites NVARCHAR(MAX), -- not used
+ @Folders NVARCHAR(MAX), -- not used
+ @Attachments NVARCHAR(MAX), -- not used
+ @CreationDate DATETIME2(7),
+ @RevisionDate DATETIME2(7),
+ @FolderId UNIQUEIDENTIFIER,
+ @Favorite BIT,
+ @Edit BIT, -- not used
+ @ViewPassword BIT, -- not used
+ @Manage BIT, -- not used
+ @OrganizationUseTotp BIT, -- not used
+ @DeletedDate DATETIME2(7),
+ @Reprompt TINYINT,
+ @Key VARCHAR(MAX) = NULL
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ DECLARE @UserIdKey VARCHAR(50) = CONCAT('"', @UserId, '"')
+ DECLARE @UserIdPath VARCHAR(50) = CONCAT('$.', @UserIdKey)
+
+ INSERT INTO [dbo].[Cipher]
+ (
+ [Id],
+ [UserId],
+ [OrganizationId],
+ [Type],
+ [Data],
+ [Favorites],
+ [Folders],
+ [CreationDate],
+ [RevisionDate],
+ [DeletedDate],
+ [Reprompt],
+ [Key]
+ )
+ VALUES
+ (
+ @Id,
+ CASE WHEN @OrganizationId IS NULL THEN @UserId ELSE NULL END,
+ @OrganizationId,
+ @Type,
+ @Data,
+ CASE WHEN @Favorite = 1 THEN CONCAT('{', @UserIdKey, ':true}') ELSE NULL END,
+ CASE WHEN @FolderId IS NOT NULL THEN CONCAT('{', @UserIdKey, ':"', @FolderId, '"', '}') ELSE NULL END,
+ @CreationDate,
+ @RevisionDate,
+ @DeletedDate,
+ @Reprompt,
+ @Key
+ )
+
+ IF @OrganizationId IS NOT NULL
+ BEGIN
+ EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId
+ END
+ ELSE IF @UserId IS NOT NULL
+ BEGIN
+ EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
+ END
+END
+GO
+
+CREATE OR ALTER PROCEDURE [dbo].[CipherDetails_CreateWithCollections]
+ @Id UNIQUEIDENTIFIER,
+ @UserId UNIQUEIDENTIFIER,
+ @OrganizationId UNIQUEIDENTIFIER,
+ @Type TINYINT,
+ @Data NVARCHAR(MAX),
+ @Favorites NVARCHAR(MAX), -- not used
+ @Folders NVARCHAR(MAX), -- not used
+ @Attachments NVARCHAR(MAX), -- not used
+ @CreationDate DATETIME2(7),
+ @RevisionDate DATETIME2(7),
+ @FolderId UNIQUEIDENTIFIER,
+ @Favorite BIT,
+ @Edit BIT, -- not used
+ @ViewPassword BIT, -- not used
+ @Manage BIT, -- not used
+ @OrganizationUseTotp BIT, -- not used
+ @DeletedDate DATETIME2(7),
+ @Reprompt TINYINT,
+ @Key VARCHAR(MAX) = NULL,
+ @CollectionIds AS [dbo].[GuidIdArray] READONLY
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ EXEC [dbo].[CipherDetails_Create] @Id, @UserId, @OrganizationId, @Type, @Data, @Favorites, @Folders,
+ @Attachments, @CreationDate, @RevisionDate, @FolderId, @Favorite, @Edit, @ViewPassword, @Manage,
+ @OrganizationUseTotp, @DeletedDate, @Reprompt, @Key
+
+ DECLARE @UpdateCollectionsSuccess INT
+ EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds
+END
+GO
+
+CREATE OR ALTER PROCEDURE [dbo].[CipherDetails_Update]
+ @Id UNIQUEIDENTIFIER,
+ @UserId UNIQUEIDENTIFIER,
+ @OrganizationId UNIQUEIDENTIFIER,
+ @Type TINYINT,
+ @Data NVARCHAR(MAX),
+ @Favorites NVARCHAR(MAX), -- not used
+ @Folders NVARCHAR(MAX), -- not used
+ @Attachments NVARCHAR(MAX),
+ @CreationDate DATETIME2(7),
+ @RevisionDate DATETIME2(7),
+ @FolderId UNIQUEIDENTIFIER,
+ @Favorite BIT,
+ @Edit BIT, -- not used
+ @ViewPassword BIT, -- not used
+ @Manage BIT, -- not used
+ @OrganizationUseTotp BIT, -- not used
+ @DeletedDate DATETIME2(2),
+ @Reprompt TINYINT,
+ @Key VARCHAR(MAX) = NULL
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ DECLARE @UserIdKey VARCHAR(50) = CONCAT('"', @UserId, '"')
+ DECLARE @UserIdPath VARCHAR(50) = CONCAT('$.', @UserIdKey)
+
+ UPDATE
+ [dbo].[Cipher]
+ SET
+ [UserId] = CASE WHEN @OrganizationId IS NULL THEN @UserId ELSE NULL END,
+ [OrganizationId] = @OrganizationId,
+ [Type] = @Type,
+ [Data] = @Data,
+ [Folders] =
+ CASE
+ WHEN @FolderId IS NOT NULL AND [Folders] IS NULL THEN
+ CONCAT('{', @UserIdKey, ':"', @FolderId, '"', '}')
+ WHEN @FolderId IS NOT NULL THEN
+ JSON_MODIFY([Folders], @UserIdPath, CAST(@FolderId AS VARCHAR(50)))
+ ELSE
+ JSON_MODIFY([Folders], @UserIdPath, NULL)
+ END,
+ [Favorites] =
+ CASE
+ WHEN @Favorite = 1 AND [Favorites] IS NULL THEN
+ CONCAT('{', @UserIdKey, ':true}')
+ WHEN @Favorite = 1 THEN
+ JSON_MODIFY([Favorites], @UserIdPath, CAST(1 AS BIT))
+ ELSE
+ JSON_MODIFY([Favorites], @UserIdPath, NULL)
+ END,
+ [Attachments] = @Attachments,
+ [Reprompt] = @Reprompt,
+ [CreationDate] = @CreationDate,
+ [RevisionDate] = @RevisionDate,
+ [DeletedDate] = @DeletedDate,
+ [Key] = @Key
+ WHERE
+ [Id] = @Id
+
+ IF @OrganizationId IS NOT NULL
+ BEGIN
+ EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId
+ END
+ ELSE IF @UserId IS NOT NULL
+ BEGIN
+ EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
+ END
+END
+GO
From 6bc579f51ea3930444981778024abf272c26ba5c Mon Sep 17 00:00:00 2001
From: Github Actions
Date: Mon, 24 Feb 2025 12:32:27 +0000
Subject: [PATCH 07/26] Bumped version to 2025.2.1
---
Directory.Build.props | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Directory.Build.props b/Directory.Build.props
index c797513c63..4b56322adb 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -3,7 +3,7 @@
net8.0
- 2025.2.0
+ 2025.2.1
Bit.$(MSBuildProjectName)
enable
From 6ca98df721aacccfe9b79e4855c110d882b74fee Mon Sep 17 00:00:00 2001
From: Jimmy Vo
Date: Mon, 24 Feb 2025 10:42:04 -0500
Subject: [PATCH 08/26] Ac/pm 17449/add managed user validation to email token
(#5437)
---
.../Auth/Controllers/AccountsController.cs | 7 ++++-
src/Core/Exceptions/BadRequestException.cs | 14 +++++++++-
src/Core/Services/IUserService.cs | 10 +++++++
.../Services/Implementations/UserService.cs | 4 +--
.../Controllers/AccountsControllerTests.cs | 28 ++++++++++++++-----
5 files changed, 52 insertions(+), 11 deletions(-)
diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs
index f3af49e6c3..176bc183b6 100644
--- a/src/Api/Auth/Controllers/AccountsController.cs
+++ b/src/Api/Auth/Controllers/AccountsController.cs
@@ -149,6 +149,12 @@ public class AccountsController : Controller
throw new BadRequestException("MasterPasswordHash", "Invalid password.");
}
+ var managedUserValidationResult = await _userService.ValidateManagedUserDomainAsync(user, model.NewEmail);
+
+ if (!managedUserValidationResult.Succeeded)
+ {
+ throw new BadRequestException(managedUserValidationResult.Errors);
+ }
await _userService.InitiateEmailChangeAsync(user, model.NewEmail);
}
@@ -167,7 +173,6 @@ public class AccountsController : Controller
throw new BadRequestException("You cannot change your email when using Key Connector.");
}
-
var result = await _userService.ChangeEmailAsync(user, model.MasterPasswordHash, model.NewEmail,
model.NewMasterPasswordHash, model.Token, model.Key);
if (result.Succeeded)
diff --git a/src/Core/Exceptions/BadRequestException.cs b/src/Core/Exceptions/BadRequestException.cs
index e7268b6c55..042f853a57 100644
--- a/src/Core/Exceptions/BadRequestException.cs
+++ b/src/Core/Exceptions/BadRequestException.cs
@@ -1,4 +1,5 @@
-using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace Bit.Core.Exceptions;
@@ -29,5 +30,16 @@ public class BadRequestException : Exception
ModelState = modelState;
}
+ public BadRequestException(IEnumerable identityErrors)
+ : base("The model state is invalid.")
+ {
+ ModelState = new ModelStateDictionary();
+
+ foreach (var error in identityErrors)
+ {
+ ModelState.AddModelError(error.Code, error.Description);
+ }
+ }
+
public ModelStateDictionary ModelState { get; set; }
}
diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs
index 2ac7796547..b6a1d1f05b 100644
--- a/src/Core/Services/IUserService.cs
+++ b/src/Core/Services/IUserService.cs
@@ -136,6 +136,16 @@ public interface IUserService
///
Task IsManagedByAnyOrganizationAsync(Guid userId);
+ ///
+ /// Verify whether the new email domain meets the requirements for managed users.
+ ///
+ ///
+ ///
+ ///
+ /// IdentityResult
+ ///
+ Task ValidateManagedUserDomainAsync(User user, string newEmail);
+
///
/// Gets the organizations that manage the user.
///
diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs
index 2374d8f4e1..e419b832a7 100644
--- a/src/Core/Services/Implementations/UserService.cs
+++ b/src/Core/Services/Implementations/UserService.cs
@@ -545,7 +545,7 @@ public class UserService : UserManager, IUserService, IDisposable
return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch());
}
- var managedUserValidationResult = await ValidateManagedUserAsync(user, newEmail);
+ var managedUserValidationResult = await ValidateManagedUserDomainAsync(user, newEmail);
if (!managedUserValidationResult.Succeeded)
{
@@ -617,7 +617,7 @@ public class UserService : UserManager, IUserService, IDisposable
return IdentityResult.Success;
}
- private async Task ValidateManagedUserAsync(User user, string newEmail)
+ public async Task ValidateManagedUserDomainAsync(User user, string newEmail)
{
var managingOrganizations = await GetOrganizationsManagingUserAsync(user.Id);
diff --git a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs
index 8bdb14bf78..6a9862b3d6 100644
--- a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs
+++ b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs
@@ -134,29 +134,43 @@ public class AccountsControllerTests : IDisposable
[Fact]
public async Task PostEmailToken_ShouldInitiateEmailChange()
{
+ // Arrange
var user = GenerateExampleUser();
ConfigureUserServiceToReturnValidPrincipalFor(user);
ConfigureUserServiceToAcceptPasswordFor(user);
- var newEmail = "example@user.com";
+ const string newEmail = "example@user.com";
+ _userService.ValidateManagedUserDomainAsync(user, newEmail).Returns(IdentityResult.Success);
+ // Act
await _sut.PostEmailToken(new EmailTokenRequestModel { NewEmail = newEmail });
+ // Assert
await _userService.Received(1).InitiateEmailChangeAsync(user, newEmail);
}
[Fact]
- public async Task PostEmailToken_WithAccountDeprovisioningEnabled_WhenUserIsNotManagedByAnOrganization_ShouldInitiateEmailChange()
+ public async Task PostEmailToken_WhenValidateManagedUserDomainAsyncFails_ShouldReturnError()
{
+ // Arrange
var user = GenerateExampleUser();
ConfigureUserServiceToReturnValidPrincipalFor(user);
ConfigureUserServiceToAcceptPasswordFor(user);
- _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true);
- _userService.IsManagedByAnyOrganizationAsync(user.Id).Returns(false);
- var newEmail = "example@user.com";
- await _sut.PostEmailToken(new EmailTokenRequestModel { NewEmail = newEmail });
+ const string newEmail = "example@user.com";
- await _userService.Received(1).InitiateEmailChangeAsync(user, newEmail);
+ _userService.ValidateManagedUserDomainAsync(user, newEmail)
+ .Returns(IdentityResult.Failed(new IdentityError
+ {
+ Code = "TestFailure",
+ Description = "This is a test."
+ }));
+
+
+ // Act
+ // Assert
+ await Assert.ThrowsAsync(
+ () => _sut.PostEmailToken(new EmailTokenRequestModel { NewEmail = newEmail })
+ );
}
[Fact]
From 0f10ca52b4886dc1afa3f624ddb5d7f342cb5c1e Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Mon, 24 Feb 2025 15:45:39 -0500
Subject: [PATCH 09/26] [deps] Auth: Lock file maintenance (#5301)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
bitwarden_license/src/Sso/package-lock.json | 204 ++++++++++----------
src/Admin/package-lock.json | 204 ++++++++++----------
2 files changed, 214 insertions(+), 194 deletions(-)
diff --git a/bitwarden_license/src/Sso/package-lock.json b/bitwarden_license/src/Sso/package-lock.json
index a0e7b767cc..1105d74cf1 100644
--- a/bitwarden_license/src/Sso/package-lock.json
+++ b/bitwarden_license/src/Sso/package-lock.json
@@ -98,9 +98,9 @@
}
},
"node_modules/@parcel/watcher": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.0.tgz",
- "integrity": "sha512-i0GV1yJnm2n3Yq1qw6QrUrd/LI9bE8WEBOTtOkpCXHHdyN3TAGgqAK/DAT05z4fq2x04cARXt2pDmjWjL92iTQ==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
+ "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -119,25 +119,25 @@
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
- "@parcel/watcher-android-arm64": "2.5.0",
- "@parcel/watcher-darwin-arm64": "2.5.0",
- "@parcel/watcher-darwin-x64": "2.5.0",
- "@parcel/watcher-freebsd-x64": "2.5.0",
- "@parcel/watcher-linux-arm-glibc": "2.5.0",
- "@parcel/watcher-linux-arm-musl": "2.5.0",
- "@parcel/watcher-linux-arm64-glibc": "2.5.0",
- "@parcel/watcher-linux-arm64-musl": "2.5.0",
- "@parcel/watcher-linux-x64-glibc": "2.5.0",
- "@parcel/watcher-linux-x64-musl": "2.5.0",
- "@parcel/watcher-win32-arm64": "2.5.0",
- "@parcel/watcher-win32-ia32": "2.5.0",
- "@parcel/watcher-win32-x64": "2.5.0"
+ "@parcel/watcher-android-arm64": "2.5.1",
+ "@parcel/watcher-darwin-arm64": "2.5.1",
+ "@parcel/watcher-darwin-x64": "2.5.1",
+ "@parcel/watcher-freebsd-x64": "2.5.1",
+ "@parcel/watcher-linux-arm-glibc": "2.5.1",
+ "@parcel/watcher-linux-arm-musl": "2.5.1",
+ "@parcel/watcher-linux-arm64-glibc": "2.5.1",
+ "@parcel/watcher-linux-arm64-musl": "2.5.1",
+ "@parcel/watcher-linux-x64-glibc": "2.5.1",
+ "@parcel/watcher-linux-x64-musl": "2.5.1",
+ "@parcel/watcher-win32-arm64": "2.5.1",
+ "@parcel/watcher-win32-ia32": "2.5.1",
+ "@parcel/watcher-win32-x64": "2.5.1"
}
},
"node_modules/@parcel/watcher-android-arm64": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.0.tgz",
- "integrity": "sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
+ "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
"cpu": [
"arm64"
],
@@ -156,9 +156,9 @@
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.0.tgz",
- "integrity": "sha512-hyZ3TANnzGfLpRA2s/4U1kbw2ZI4qGxaRJbBH2DCSREFfubMswheh8TeiC1sGZ3z2jUf3s37P0BBlrD3sjVTUw==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
+ "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
"cpu": [
"arm64"
],
@@ -177,9 +177,9 @@
}
},
"node_modules/@parcel/watcher-darwin-x64": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.0.tgz",
- "integrity": "sha512-9rhlwd78saKf18fT869/poydQK8YqlU26TMiNg7AIu7eBp9adqbJZqmdFOsbZ5cnLp5XvRo9wcFmNHgHdWaGYA==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
+ "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
"cpu": [
"x64"
],
@@ -198,9 +198,9 @@
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.0.tgz",
- "integrity": "sha512-syvfhZzyM8kErg3VF0xpV8dixJ+RzbUaaGaeb7uDuz0D3FK97/mZ5AJQ3XNnDsXX7KkFNtyQyFrXZzQIcN49Tw==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
+ "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
"cpu": [
"x64"
],
@@ -219,9 +219,9 @@
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.0.tgz",
- "integrity": "sha512-0VQY1K35DQET3dVYWpOaPFecqOT9dbuCfzjxoQyif1Wc574t3kOSkKevULddcR9znz1TcklCE7Ht6NIxjvTqLA==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
+ "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
"cpu": [
"arm"
],
@@ -240,9 +240,9 @@
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.0.tgz",
- "integrity": "sha512-6uHywSIzz8+vi2lAzFeltnYbdHsDm3iIB57d4g5oaB9vKwjb6N6dRIgZMujw4nm5r6v9/BQH0noq6DzHrqr2pA==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
+ "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
"cpu": [
"arm"
],
@@ -261,9 +261,9 @@
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.0.tgz",
- "integrity": "sha512-BfNjXwZKxBy4WibDb/LDCriWSKLz+jJRL3cM/DllnHH5QUyoiUNEp3GmL80ZqxeumoADfCCP19+qiYiC8gUBjA==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
+ "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
"cpu": [
"arm64"
],
@@ -282,9 +282,9 @@
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.0.tgz",
- "integrity": "sha512-S1qARKOphxfiBEkwLUbHjCY9BWPdWnW9j7f7Hb2jPplu8UZ3nes7zpPOW9bkLbHRvWM0WDTsjdOTUgW0xLBN1Q==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
+ "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
"cpu": [
"arm64"
],
@@ -303,9 +303,9 @@
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.0.tgz",
- "integrity": "sha512-d9AOkusyXARkFD66S6zlGXyzx5RvY+chTP9Jp0ypSTC9d4lzyRs9ovGf/80VCxjKddcUvnsGwCHWuF2EoPgWjw==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
+ "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
"cpu": [
"x64"
],
@@ -324,9 +324,9 @@
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.0.tgz",
- "integrity": "sha512-iqOC+GoTDoFyk/VYSFHwjHhYrk8bljW6zOhPuhi5t9ulqiYq1togGJB5e3PwYVFFfeVgc6pbz3JdQyDoBszVaA==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
+ "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
"cpu": [
"x64"
],
@@ -345,9 +345,9 @@
}
},
"node_modules/@parcel/watcher-win32-arm64": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.0.tgz",
- "integrity": "sha512-twtft1d+JRNkM5YbmexfcH/N4znDtjgysFaV9zvZmmJezQsKpkfLYJ+JFV3uygugK6AtIM2oADPkB2AdhBrNig==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
+ "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
"cpu": [
"arm64"
],
@@ -366,9 +366,9 @@
}
},
"node_modules/@parcel/watcher-win32-ia32": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.0.tgz",
- "integrity": "sha512-+rgpsNRKwo8A53elqbbHXdOMtY/tAtTzManTWShB5Kk54N8Q9mzNWV7tV+IbGueCbcj826MfWGU3mprWtuf1TA==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
+ "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
"cpu": [
"ia32"
],
@@ -387,9 +387,9 @@
}
},
"node_modules/@parcel/watcher-win32-x64": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.0.tgz",
- "integrity": "sha512-lPrxve92zEHdgeff3aiu4gDOIt4u7sJYha6wbdEZDCDUhtjTsOMiaJzG5lMY4GkWH8p0fMmO2Ppq5G5XXG+DQw==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
+ "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
"cpu": [
"x64"
],
@@ -455,9 +455,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "22.10.2",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz",
- "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==",
+ "version": "22.13.5",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz",
+ "integrity": "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -781,9 +781,9 @@
}
},
"node_modules/browserslist": {
- "version": "4.24.3",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.3.tgz",
- "integrity": "sha512-1CPmv8iobE2fyRMV97dAcMVegvvWKxmq94hkLiAkUGwKVTyDLw33K+ZxiFrREKmmps4rIw6grcCFCnTMSZ/YiA==",
+ "version": "4.24.4",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
+ "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==",
"dev": true,
"funding": [
{
@@ -821,9 +821,9 @@
"license": "MIT"
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001690",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001690.tgz",
- "integrity": "sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w==",
+ "version": "1.0.30001700",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz",
+ "integrity": "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==",
"dev": true,
"funding": [
{
@@ -975,16 +975,16 @@
}
},
"node_modules/electron-to-chromium": {
- "version": "1.5.75",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.75.tgz",
- "integrity": "sha512-Lf3++DumRE/QmweGjU+ZcKqQ+3bKkU/qjaKYhIJKEOhgIO9Xs6IiAQFkfFoj+RhgDk4LUeNsLo6plExHqSyu6Q==",
+ "version": "1.5.103",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.103.tgz",
+ "integrity": "sha512-P6+XzIkfndgsrjROJWfSvVEgNHtPgbhVyTkwLjUM2HU/h7pZRORgaTlHqfAikqxKmdJMLW8fftrdGWbd/Ds0FA==",
"dev": true,
"license": "ISC"
},
"node_modules/enhanced-resolve": {
- "version": "5.18.0",
- "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.0.tgz",
- "integrity": "sha512-0/r0MySGYG8YqlayBZ6MuCfECmHFdJ5qyPh8s8wa5Hnm6SaFLSK1VYCbj+NKp090Nm1caZhD+QTnmxO7esYGyQ==",
+ "version": "5.18.1",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
+ "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1009,9 +1009,9 @@
}
},
"node_modules/es-module-lexer": {
- "version": "1.5.4",
- "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz",
- "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==",
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz",
+ "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==",
"dev": true,
"license": "MIT"
},
@@ -1114,10 +1114,20 @@
"license": "MIT"
},
"node_modules/fast-uri": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz",
- "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==",
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
+ "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==",
"dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
"license": "BSD-3-Clause"
},
"node_modules/fastest-levenshtein": {
@@ -1632,9 +1642,9 @@
}
},
"node_modules/postcss": {
- "version": "8.4.49",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
- "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
+ "version": "8.5.3",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
+ "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
"dev": true,
"funding": [
{
@@ -1652,7 +1662,7 @@
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.7",
+ "nanoid": "^3.3.8",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -1724,9 +1734,9 @@
}
},
"node_modules/postcss-selector-parser": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.0.0.tgz",
- "integrity": "sha512-9RbEr1Y7FFfptd/1eEdntyjMwLeghW1bHX9GWjXo19vx4ytPQhANltvVxDggzJl7mnWM+dX28kb6cyS/4iQjlQ==",
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
+ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1765,13 +1775,13 @@
}
},
"node_modules/readdirp": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz",
- "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==",
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"engines": {
- "node": ">= 14.16.0"
+ "node": ">= 14.18.0"
},
"funding": {
"type": "individual",
@@ -1949,9 +1959,9 @@
}
},
"node_modules/semver": {
- "version": "7.6.3",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
- "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
+ "version": "7.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
+ "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
"dev": true,
"license": "ISC",
"bin": {
@@ -2078,9 +2088,9 @@
}
},
"node_modules/terser": {
- "version": "5.37.0",
- "resolved": "https://registry.npmjs.org/terser/-/terser-5.37.0.tgz",
- "integrity": "sha512-B8wRRkmre4ERucLM/uXx4MOV5cbnOlVAqUst+1+iLKPI0dOgFO28f84ptoQt9HEI537PMzfYa/d+GEPKTRXmYA==",
+ "version": "5.39.0",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz",
+ "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
@@ -2153,9 +2163,9 @@
"license": "MIT"
},
"node_modules/update-browserslist-db": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz",
- "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==",
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz",
+ "integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==",
"dev": true,
"funding": [
{
@@ -2174,7 +2184,7 @@
"license": "MIT",
"dependencies": {
"escalade": "^3.2.0",
- "picocolors": "^1.1.0"
+ "picocolors": "^1.1.1"
},
"bin": {
"update-browserslist-db": "cli.js"
diff --git a/src/Admin/package-lock.json b/src/Admin/package-lock.json
index 152edd6fc9..6f3298df5b 100644
--- a/src/Admin/package-lock.json
+++ b/src/Admin/package-lock.json
@@ -99,9 +99,9 @@
}
},
"node_modules/@parcel/watcher": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.0.tgz",
- "integrity": "sha512-i0GV1yJnm2n3Yq1qw6QrUrd/LI9bE8WEBOTtOkpCXHHdyN3TAGgqAK/DAT05z4fq2x04cARXt2pDmjWjL92iTQ==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
+ "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -120,25 +120,25 @@
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
- "@parcel/watcher-android-arm64": "2.5.0",
- "@parcel/watcher-darwin-arm64": "2.5.0",
- "@parcel/watcher-darwin-x64": "2.5.0",
- "@parcel/watcher-freebsd-x64": "2.5.0",
- "@parcel/watcher-linux-arm-glibc": "2.5.0",
- "@parcel/watcher-linux-arm-musl": "2.5.0",
- "@parcel/watcher-linux-arm64-glibc": "2.5.0",
- "@parcel/watcher-linux-arm64-musl": "2.5.0",
- "@parcel/watcher-linux-x64-glibc": "2.5.0",
- "@parcel/watcher-linux-x64-musl": "2.5.0",
- "@parcel/watcher-win32-arm64": "2.5.0",
- "@parcel/watcher-win32-ia32": "2.5.0",
- "@parcel/watcher-win32-x64": "2.5.0"
+ "@parcel/watcher-android-arm64": "2.5.1",
+ "@parcel/watcher-darwin-arm64": "2.5.1",
+ "@parcel/watcher-darwin-x64": "2.5.1",
+ "@parcel/watcher-freebsd-x64": "2.5.1",
+ "@parcel/watcher-linux-arm-glibc": "2.5.1",
+ "@parcel/watcher-linux-arm-musl": "2.5.1",
+ "@parcel/watcher-linux-arm64-glibc": "2.5.1",
+ "@parcel/watcher-linux-arm64-musl": "2.5.1",
+ "@parcel/watcher-linux-x64-glibc": "2.5.1",
+ "@parcel/watcher-linux-x64-musl": "2.5.1",
+ "@parcel/watcher-win32-arm64": "2.5.1",
+ "@parcel/watcher-win32-ia32": "2.5.1",
+ "@parcel/watcher-win32-x64": "2.5.1"
}
},
"node_modules/@parcel/watcher-android-arm64": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.0.tgz",
- "integrity": "sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
+ "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
"cpu": [
"arm64"
],
@@ -157,9 +157,9 @@
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.0.tgz",
- "integrity": "sha512-hyZ3TANnzGfLpRA2s/4U1kbw2ZI4qGxaRJbBH2DCSREFfubMswheh8TeiC1sGZ3z2jUf3s37P0BBlrD3sjVTUw==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
+ "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
"cpu": [
"arm64"
],
@@ -178,9 +178,9 @@
}
},
"node_modules/@parcel/watcher-darwin-x64": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.0.tgz",
- "integrity": "sha512-9rhlwd78saKf18fT869/poydQK8YqlU26TMiNg7AIu7eBp9adqbJZqmdFOsbZ5cnLp5XvRo9wcFmNHgHdWaGYA==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
+ "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
"cpu": [
"x64"
],
@@ -199,9 +199,9 @@
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.0.tgz",
- "integrity": "sha512-syvfhZzyM8kErg3VF0xpV8dixJ+RzbUaaGaeb7uDuz0D3FK97/mZ5AJQ3XNnDsXX7KkFNtyQyFrXZzQIcN49Tw==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
+ "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
"cpu": [
"x64"
],
@@ -220,9 +220,9 @@
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.0.tgz",
- "integrity": "sha512-0VQY1K35DQET3dVYWpOaPFecqOT9dbuCfzjxoQyif1Wc574t3kOSkKevULddcR9znz1TcklCE7Ht6NIxjvTqLA==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
+ "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
"cpu": [
"arm"
],
@@ -241,9 +241,9 @@
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.0.tgz",
- "integrity": "sha512-6uHywSIzz8+vi2lAzFeltnYbdHsDm3iIB57d4g5oaB9vKwjb6N6dRIgZMujw4nm5r6v9/BQH0noq6DzHrqr2pA==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
+ "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
"cpu": [
"arm"
],
@@ -262,9 +262,9 @@
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.0.tgz",
- "integrity": "sha512-BfNjXwZKxBy4WibDb/LDCriWSKLz+jJRL3cM/DllnHH5QUyoiUNEp3GmL80ZqxeumoADfCCP19+qiYiC8gUBjA==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
+ "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
"cpu": [
"arm64"
],
@@ -283,9 +283,9 @@
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.0.tgz",
- "integrity": "sha512-S1qARKOphxfiBEkwLUbHjCY9BWPdWnW9j7f7Hb2jPplu8UZ3nes7zpPOW9bkLbHRvWM0WDTsjdOTUgW0xLBN1Q==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
+ "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
"cpu": [
"arm64"
],
@@ -304,9 +304,9 @@
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.0.tgz",
- "integrity": "sha512-d9AOkusyXARkFD66S6zlGXyzx5RvY+chTP9Jp0ypSTC9d4lzyRs9ovGf/80VCxjKddcUvnsGwCHWuF2EoPgWjw==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
+ "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
"cpu": [
"x64"
],
@@ -325,9 +325,9 @@
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.0.tgz",
- "integrity": "sha512-iqOC+GoTDoFyk/VYSFHwjHhYrk8bljW6zOhPuhi5t9ulqiYq1togGJB5e3PwYVFFfeVgc6pbz3JdQyDoBszVaA==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
+ "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
"cpu": [
"x64"
],
@@ -346,9 +346,9 @@
}
},
"node_modules/@parcel/watcher-win32-arm64": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.0.tgz",
- "integrity": "sha512-twtft1d+JRNkM5YbmexfcH/N4znDtjgysFaV9zvZmmJezQsKpkfLYJ+JFV3uygugK6AtIM2oADPkB2AdhBrNig==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
+ "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
"cpu": [
"arm64"
],
@@ -367,9 +367,9 @@
}
},
"node_modules/@parcel/watcher-win32-ia32": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.0.tgz",
- "integrity": "sha512-+rgpsNRKwo8A53elqbbHXdOMtY/tAtTzManTWShB5Kk54N8Q9mzNWV7tV+IbGueCbcj826MfWGU3mprWtuf1TA==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
+ "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
"cpu": [
"ia32"
],
@@ -388,9 +388,9 @@
}
},
"node_modules/@parcel/watcher-win32-x64": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.0.tgz",
- "integrity": "sha512-lPrxve92zEHdgeff3aiu4gDOIt4u7sJYha6wbdEZDCDUhtjTsOMiaJzG5lMY4GkWH8p0fMmO2Ppq5G5XXG+DQw==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
+ "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
"cpu": [
"x64"
],
@@ -456,9 +456,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "22.10.2",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz",
- "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==",
+ "version": "22.13.5",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz",
+ "integrity": "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -782,9 +782,9 @@
}
},
"node_modules/browserslist": {
- "version": "4.24.3",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.3.tgz",
- "integrity": "sha512-1CPmv8iobE2fyRMV97dAcMVegvvWKxmq94hkLiAkUGwKVTyDLw33K+ZxiFrREKmmps4rIw6grcCFCnTMSZ/YiA==",
+ "version": "4.24.4",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
+ "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==",
"dev": true,
"funding": [
{
@@ -822,9 +822,9 @@
"license": "MIT"
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001690",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001690.tgz",
- "integrity": "sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w==",
+ "version": "1.0.30001700",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz",
+ "integrity": "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==",
"dev": true,
"funding": [
{
@@ -976,16 +976,16 @@
}
},
"node_modules/electron-to-chromium": {
- "version": "1.5.75",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.75.tgz",
- "integrity": "sha512-Lf3++DumRE/QmweGjU+ZcKqQ+3bKkU/qjaKYhIJKEOhgIO9Xs6IiAQFkfFoj+RhgDk4LUeNsLo6plExHqSyu6Q==",
+ "version": "1.5.103",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.103.tgz",
+ "integrity": "sha512-P6+XzIkfndgsrjROJWfSvVEgNHtPgbhVyTkwLjUM2HU/h7pZRORgaTlHqfAikqxKmdJMLW8fftrdGWbd/Ds0FA==",
"dev": true,
"license": "ISC"
},
"node_modules/enhanced-resolve": {
- "version": "5.18.0",
- "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.0.tgz",
- "integrity": "sha512-0/r0MySGYG8YqlayBZ6MuCfECmHFdJ5qyPh8s8wa5Hnm6SaFLSK1VYCbj+NKp090Nm1caZhD+QTnmxO7esYGyQ==",
+ "version": "5.18.1",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
+ "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1010,9 +1010,9 @@
}
},
"node_modules/es-module-lexer": {
- "version": "1.5.4",
- "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz",
- "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==",
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz",
+ "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==",
"dev": true,
"license": "MIT"
},
@@ -1115,10 +1115,20 @@
"license": "MIT"
},
"node_modules/fast-uri": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz",
- "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==",
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
+ "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==",
"dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
"license": "BSD-3-Clause"
},
"node_modules/fastest-levenshtein": {
@@ -1633,9 +1643,9 @@
}
},
"node_modules/postcss": {
- "version": "8.4.49",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
- "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
+ "version": "8.5.3",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
+ "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
"dev": true,
"funding": [
{
@@ -1653,7 +1663,7 @@
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.7",
+ "nanoid": "^3.3.8",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -1725,9 +1735,9 @@
}
},
"node_modules/postcss-selector-parser": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.0.0.tgz",
- "integrity": "sha512-9RbEr1Y7FFfptd/1eEdntyjMwLeghW1bHX9GWjXo19vx4ytPQhANltvVxDggzJl7mnWM+dX28kb6cyS/4iQjlQ==",
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
+ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1766,13 +1776,13 @@
}
},
"node_modules/readdirp": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz",
- "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==",
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"engines": {
- "node": ">= 14.16.0"
+ "node": ">= 14.18.0"
},
"funding": {
"type": "individual",
@@ -1950,9 +1960,9 @@
}
},
"node_modules/semver": {
- "version": "7.6.3",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
- "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
+ "version": "7.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
+ "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
"dev": true,
"license": "ISC",
"bin": {
@@ -2079,9 +2089,9 @@
}
},
"node_modules/terser": {
- "version": "5.37.0",
- "resolved": "https://registry.npmjs.org/terser/-/terser-5.37.0.tgz",
- "integrity": "sha512-B8wRRkmre4ERucLM/uXx4MOV5cbnOlVAqUst+1+iLKPI0dOgFO28f84ptoQt9HEI537PMzfYa/d+GEPKTRXmYA==",
+ "version": "5.39.0",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz",
+ "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
@@ -2162,9 +2172,9 @@
"license": "MIT"
},
"node_modules/update-browserslist-db": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz",
- "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==",
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz",
+ "integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==",
"dev": true,
"funding": [
{
@@ -2183,7 +2193,7 @@
"license": "MIT",
"dependencies": {
"escalade": "^3.2.0",
- "picocolors": "^1.1.0"
+ "picocolors": "^1.1.1"
},
"bin": {
"update-browserslist-db": "cli.js"
From d15c1faa74ff5151e02b6c4ffe0254c385783eea Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rui=20Tom=C3=A9?=
<108268980+r-tome@users.noreply.github.com>
Date: Tue, 25 Feb 2025 14:57:30 +0000
Subject: [PATCH 10/26] [PM-12491] Create Organization disable command (#5348)
* Add command interface and implementation for disabling organizations
* Register organization disable command for dependency injection
* Add unit tests for OrganizationDisableCommand
* Refactor subscription handlers to use IOrganizationDisableCommand for disabling organizations
* Remove DisableAsync method from IOrganizationService and its implementation in OrganizationService
* Remove IOrganizationService dependency from SubscriptionDeletedHandler
* Remove commented TODO for sending email to owners in OrganizationDisableCommand
---
.../SubscriptionDeletedHandler.cs | 11 +--
.../SubscriptionUpdatedHandler.cs | 7 +-
.../Interfaces/IOrganizationDisableCommand.cs | 14 ++++
.../OrganizationDisableCommand.cs | 33 ++++++++
.../Services/IOrganizationService.cs | 1 -
.../Implementations/OrganizationService.cs | 14 ----
...OrganizationServiceCollectionExtensions.cs | 4 +
.../OrganizationDisableCommandTests.cs | 79 +++++++++++++++++++
8 files changed, 141 insertions(+), 22 deletions(-)
create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationDisableCommand.cs
create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDisableCommand.cs
create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationDisableCommandTests.cs
diff --git a/src/Billing/Services/Implementations/SubscriptionDeletedHandler.cs b/src/Billing/Services/Implementations/SubscriptionDeletedHandler.cs
index 26a1c30c14..8155928453 100644
--- a/src/Billing/Services/Implementations/SubscriptionDeletedHandler.cs
+++ b/src/Billing/Services/Implementations/SubscriptionDeletedHandler.cs
@@ -1,4 +1,5 @@
using Bit.Billing.Constants;
+using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.Services;
using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
@@ -6,20 +7,20 @@ namespace Bit.Billing.Services.Implementations;
public class SubscriptionDeletedHandler : ISubscriptionDeletedHandler
{
private readonly IStripeEventService _stripeEventService;
- private readonly IOrganizationService _organizationService;
private readonly IUserService _userService;
private readonly IStripeEventUtilityService _stripeEventUtilityService;
+ private readonly IOrganizationDisableCommand _organizationDisableCommand;
public SubscriptionDeletedHandler(
IStripeEventService stripeEventService,
- IOrganizationService organizationService,
IUserService userService,
- IStripeEventUtilityService stripeEventUtilityService)
+ IStripeEventUtilityService stripeEventUtilityService,
+ IOrganizationDisableCommand organizationDisableCommand)
{
_stripeEventService = stripeEventService;
- _organizationService = organizationService;
_userService = userService;
_stripeEventUtilityService = stripeEventUtilityService;
+ _organizationDisableCommand = organizationDisableCommand;
}
///
@@ -44,7 +45,7 @@ public class SubscriptionDeletedHandler : ISubscriptionDeletedHandler
subscription.CancellationDetails.Comment != providerMigrationCancellationComment &&
!subscription.CancellationDetails.Comment.Contains(addedToProviderCancellationComment))
{
- await _organizationService.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
+ await _organizationDisableCommand.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
}
else if (userId.HasValue)
{
diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs
index 4e142f8cae..35a16ae74f 100644
--- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs
+++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs
@@ -26,6 +26,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
private readonly ISchedulerFactory _schedulerFactory;
private readonly IFeatureService _featureService;
private readonly IOrganizationEnableCommand _organizationEnableCommand;
+ private readonly IOrganizationDisableCommand _organizationDisableCommand;
public SubscriptionUpdatedHandler(
IStripeEventService stripeEventService,
@@ -38,7 +39,8 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
IOrganizationRepository organizationRepository,
ISchedulerFactory schedulerFactory,
IFeatureService featureService,
- IOrganizationEnableCommand organizationEnableCommand)
+ IOrganizationEnableCommand organizationEnableCommand,
+ IOrganizationDisableCommand organizationDisableCommand)
{
_stripeEventService = stripeEventService;
_stripeEventUtilityService = stripeEventUtilityService;
@@ -51,6 +53,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
_schedulerFactory = schedulerFactory;
_featureService = featureService;
_organizationEnableCommand = organizationEnableCommand;
+ _organizationDisableCommand = organizationDisableCommand;
}
///
@@ -67,7 +70,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired
when organizationId.HasValue:
{
- await _organizationService.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
+ await _organizationDisableCommand.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
if (subscription.Status == StripeSubscriptionStatus.Unpaid &&
subscription.LatestInvoice is { BillingReason: "subscription_cycle" or "subscription_create" })
{
diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationDisableCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationDisableCommand.cs
new file mode 100644
index 0000000000..d15e9537e6
--- /dev/null
+++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationDisableCommand.cs
@@ -0,0 +1,14 @@
+namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
+
+///
+/// Command interface for disabling organizations.
+///
+public interface IOrganizationDisableCommand
+{
+ ///
+ /// Disables an organization with an optional expiration date.
+ ///
+ /// The unique identifier of the organization to disable.
+ /// Optional date when the disable status should expire.
+ Task DisableAsync(Guid organizationId, DateTime? expirationDate);
+}
diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDisableCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDisableCommand.cs
new file mode 100644
index 0000000000..63f80032b8
--- /dev/null
+++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDisableCommand.cs
@@ -0,0 +1,33 @@
+using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
+using Bit.Core.Repositories;
+using Bit.Core.Services;
+
+namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
+
+public class OrganizationDisableCommand : IOrganizationDisableCommand
+{
+ private readonly IOrganizationRepository _organizationRepository;
+ private readonly IApplicationCacheService _applicationCacheService;
+
+ public OrganizationDisableCommand(
+ IOrganizationRepository organizationRepository,
+ IApplicationCacheService applicationCacheService)
+ {
+ _organizationRepository = organizationRepository;
+ _applicationCacheService = applicationCacheService;
+ }
+
+ public async Task DisableAsync(Guid organizationId, DateTime? expirationDate)
+ {
+ var organization = await _organizationRepository.GetByIdAsync(organizationId);
+ if (organization is { Enabled: true })
+ {
+ organization.Enabled = false;
+ organization.ExpirationDate = expirationDate;
+ organization.RevisionDate = DateTime.UtcNow;
+
+ await _organizationRepository.ReplaceAsync(organization);
+ await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
+ }
+ }
+}
diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs
index 683fbe9902..dacb2ab162 100644
--- a/src/Core/AdminConsole/Services/IOrganizationService.cs
+++ b/src/Core/AdminConsole/Services/IOrganizationService.cs
@@ -28,7 +28,6 @@ public interface IOrganizationService
///
Task<(Organization organization, OrganizationUser organizationUser)> SignUpAsync(OrganizationLicense license, User owner,
string ownerKey, string collectionName, string publicKey, string privateKey);
- Task DisableAsync(Guid organizationId, DateTime? expirationDate);
Task UpdateExpirationDateAsync(Guid organizationId, DateTime? expirationDate);
Task UpdateAsync(Organization organization, bool updateBilling = false, EventType eventType = EventType.Organization_Updated);
Task UpdateTwoFactorProviderAsync(Organization organization, TwoFactorProviderType type);
diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs
index 284c11cc78..14cf89a246 100644
--- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs
+++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs
@@ -686,20 +686,6 @@ public class OrganizationService : IOrganizationService
}
}
- public async Task DisableAsync(Guid organizationId, DateTime? expirationDate)
- {
- var org = await GetOrgById(organizationId);
- if (org != null && org.Enabled)
- {
- org.Enabled = false;
- org.ExpirationDate = expirationDate;
- org.RevisionDate = DateTime.UtcNow;
- await ReplaceAndUpdateCacheAsync(org);
-
- // TODO: send email to owners?
- }
- }
-
public async Task UpdateExpirationDateAsync(Guid organizationId, DateTime? expirationDate)
{
var org = await GetOrgById(organizationId);
diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs
index 7db514887c..232e04fbd0 100644
--- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs
+++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs
@@ -55,6 +55,7 @@ public static class OrganizationServiceCollectionExtensions
services.AddOrganizationSignUpCommands();
services.AddOrganizationDeleteCommands();
services.AddOrganizationEnableCommands();
+ services.AddOrganizationDisableCommands();
services.AddOrganizationAuthCommands();
services.AddOrganizationUserCommands();
services.AddOrganizationUserCommandsQueries();
@@ -73,6 +74,9 @@ public static class OrganizationServiceCollectionExtensions
private static void AddOrganizationEnableCommands(this IServiceCollection services) =>
services.AddScoped();
+ private static void AddOrganizationDisableCommands(this IServiceCollection services) =>
+ services.AddScoped();
+
private static void AddOrganizationConnectionCommands(this IServiceCollection services)
{
services.AddScoped();
diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationDisableCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationDisableCommandTests.cs
new file mode 100644
index 0000000000..9e77a56b93
--- /dev/null
+++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationDisableCommandTests.cs
@@ -0,0 +1,79 @@
+using Bit.Core.AdminConsole.Entities;
+using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
+using Bit.Core.Repositories;
+using Bit.Core.Services;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Organizations;
+
+[SutProviderCustomize]
+public class OrganizationDisableCommandTests
+{
+ [Theory, BitAutoData]
+ public async Task DisableAsync_WhenOrganizationEnabled_DisablesSuccessfully(
+ Organization organization,
+ DateTime expirationDate,
+ SutProvider sutProvider)
+ {
+ organization.Enabled = true;
+ sutProvider.GetDependency()
+ .GetByIdAsync(organization.Id)
+ .Returns(organization);
+
+ await sutProvider.Sut.DisableAsync(organization.Id, expirationDate);
+
+ Assert.False(organization.Enabled);
+ Assert.Equal(expirationDate, organization.ExpirationDate);
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .ReplaceAsync(organization);
+ await sutProvider.GetDependency()
+ .Received(1)
+ .UpsertOrganizationAbilityAsync(organization);
+ }
+
+ [Theory, BitAutoData]
+ public async Task DisableAsync_WhenOrganizationNotFound_DoesNothing(
+ Guid organizationId,
+ DateTime expirationDate,
+ SutProvider sutProvider)
+ {
+ sutProvider.GetDependency()
+ .GetByIdAsync(organizationId)
+ .Returns((Organization)null);
+
+ await sutProvider.Sut.DisableAsync(organizationId, expirationDate);
+
+ await sutProvider.GetDependency()
+ .DidNotReceive()
+ .ReplaceAsync(Arg.Any());
+ await sutProvider.GetDependency()
+ .DidNotReceive()
+ .UpsertOrganizationAbilityAsync(Arg.Any());
+ }
+
+ [Theory, BitAutoData]
+ public async Task DisableAsync_WhenOrganizationAlreadyDisabled_DoesNothing(
+ Organization organization,
+ DateTime expirationDate,
+ SutProvider sutProvider)
+ {
+ organization.Enabled = false;
+ sutProvider.GetDependency()
+ .GetByIdAsync(organization.Id)
+ .Returns(organization);
+
+ await sutProvider.Sut.DisableAsync(organization.Id, expirationDate);
+
+ await sutProvider.GetDependency()
+ .DidNotReceive()
+ .ReplaceAsync(Arg.Any());
+ await sutProvider.GetDependency()
+ .DidNotReceive()
+ .UpsertOrganizationAbilityAsync(Arg.Any());
+ }
+}
From 66feebd35833e622d56ccbf09dcff3b61e869b4f Mon Sep 17 00:00:00 2001
From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com>
Date: Tue, 25 Feb 2025 16:32:19 +0100
Subject: [PATCH 11/26] [PM-13127]Breadcrumb event logs (#5430)
* Rename the feature flag to lowercase
* Rename the feature flag to epic
---------
Signed-off-by: Cy Okeke
---
src/Core/Constants.cs | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs
index 879e8365fc..a862978722 100644
--- a/src/Core/Constants.cs
+++ b/src/Core/Constants.cs
@@ -173,6 +173,7 @@ public static class FeatureFlagKeys
public const string RecoveryCodeLogin = "pm-17128-recovery-code-login";
public const string PM3503_MobileAnonAddySelfHostAlias = "anon-addy-self-host-alias";
public const string AndroidImportLoginsFlow = "import-logins-flow";
+ public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features";
public static List GetAllKeys()
{
From 622ef902ed882ffd62ccce21e7eae73e2a71350d Mon Sep 17 00:00:00 2001
From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com>
Date: Tue, 25 Feb 2025 13:36:12 -0500
Subject: [PATCH 12/26] [PM-18578] Don't enable automatic tax for non-taxable
non-US businesses during `invoice.upcoming` (#5443)
* Only enable automatic tax for US subscriptions or EU subscriptions that are taxable.
* Run dotnet format
---
.../Implementations/UpcomingInvoiceHandler.cs | 216 +++++++++---------
.../Billing/Extensions/CustomerExtensions.cs | 9 +
2 files changed, 113 insertions(+), 112 deletions(-)
diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs
index 409bd0d18b..5315195c59 100644
--- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs
+++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs
@@ -1,6 +1,7 @@
-using Bit.Billing.Constants;
-using Bit.Core.AdminConsole.Entities;
+using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
+using Bit.Core.Billing.Constants;
+using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Repositories;
@@ -11,94 +12,66 @@ using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
-public class UpcomingInvoiceHandler : IUpcomingInvoiceHandler
+public class UpcomingInvoiceHandler(
+ ILogger logger,
+ IMailService mailService,
+ IOrganizationRepository organizationRepository,
+ IProviderRepository providerRepository,
+ IStripeFacade stripeFacade,
+ IStripeEventService stripeEventService,
+ IStripeEventUtilityService stripeEventUtilityService,
+ IUserRepository userRepository,
+ IValidateSponsorshipCommand validateSponsorshipCommand)
+ : IUpcomingInvoiceHandler
{
- private readonly ILogger _logger;
- private readonly IStripeEventService _stripeEventService;
- private readonly IUserService _userService;
- private readonly IStripeFacade _stripeFacade;
- private readonly IMailService _mailService;
- private readonly IProviderRepository _providerRepository;
- private readonly IValidateSponsorshipCommand _validateSponsorshipCommand;
- private readonly IOrganizationRepository _organizationRepository;
- private readonly IStripeEventUtilityService _stripeEventUtilityService;
-
- public UpcomingInvoiceHandler(
- ILogger logger,
- IStripeEventService stripeEventService,
- IUserService userService,
- IStripeFacade stripeFacade,
- IMailService mailService,
- IProviderRepository providerRepository,
- IValidateSponsorshipCommand validateSponsorshipCommand,
- IOrganizationRepository organizationRepository,
- IStripeEventUtilityService stripeEventUtilityService)
- {
- _logger = logger;
- _stripeEventService = stripeEventService;
- _userService = userService;
- _stripeFacade = stripeFacade;
- _mailService = mailService;
- _providerRepository = providerRepository;
- _validateSponsorshipCommand = validateSponsorshipCommand;
- _organizationRepository = organizationRepository;
- _stripeEventUtilityService = stripeEventUtilityService;
- }
-
- ///
- /// Handles the event type from Stripe.
- ///
- ///
- ///
public async Task HandleAsync(Event parsedEvent)
{
- var invoice = await _stripeEventService.GetInvoice(parsedEvent);
+ var invoice = await stripeEventService.GetInvoice(parsedEvent);
+
if (string.IsNullOrEmpty(invoice.SubscriptionId))
{
- _logger.LogWarning("Received 'invoice.upcoming' Event with ID '{eventId}' that did not include a Subscription ID", parsedEvent.Id);
+ logger.LogInformation("Received 'invoice.upcoming' Event with ID '{eventId}' that did not include a Subscription ID", parsedEvent.Id);
return;
}
- var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);
-
- if (subscription == null)
+ var subscription = await stripeFacade.GetSubscription(invoice.SubscriptionId, new SubscriptionGetOptions
{
- throw new Exception(
- $"Received null Subscription from Stripe for ID '{invoice.SubscriptionId}' while processing Event with ID '{parsedEvent.Id}'");
- }
+ Expand = ["customer.tax", "customer.tax_ids"]
+ });
- var updatedSubscription = await TryEnableAutomaticTaxAsync(subscription);
-
- var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(updatedSubscription.Metadata);
-
- var invoiceLineItemDescriptions = invoice.Lines.Select(i => i.Description).ToList();
+ var (organizationId, userId, providerId) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
if (organizationId.HasValue)
{
- if (_stripeEventUtilityService.IsSponsoredSubscription(updatedSubscription))
- {
- var sponsorshipIsValid =
- await _validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value);
- if (!sponsorshipIsValid)
- {
- // If the sponsorship is invalid, then the subscription was updated to use the regular families plan
- // price. Given that this is the case, we need the new invoice amount
- subscription = await _stripeFacade.GetSubscription(subscription.Id,
- new SubscriptionGetOptions { Expand = ["latest_invoice"] });
+ var organization = await organizationRepository.GetByIdAsync(organizationId.Value);
- invoice = subscription.LatestInvoice;
- invoiceLineItemDescriptions = invoice.Lines.Select(i => i.Description).ToList();
- }
- }
-
- var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);
-
- if (organization == null || !OrgPlanForInvoiceNotifications(organization))
+ if (organization == null)
{
return;
}
- await SendEmails(new List { organization.BillingEmail });
+ await TryEnableAutomaticTaxAsync(subscription);
+
+ if (!HasAnnualPlan(organization))
+ {
+ return;
+ }
+
+ if (stripeEventUtilityService.IsSponsoredSubscription(subscription))
+ {
+ var sponsorshipIsValid = await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value);
+
+ if (!sponsorshipIsValid)
+ {
+ /*
+ * If the sponsorship is invalid, then the subscription was updated to use the regular families plan
+ * price. Given that this is the case, we need the new invoice amount
+ */
+ invoice = await stripeFacade.GetInvoice(subscription.LatestInvoiceId);
+ }
+ }
+
+ await SendUpcomingInvoiceEmailsAsync(new List { organization.BillingEmail }, invoice);
/*
* TODO: https://bitwarden.atlassian.net/browse/PM-4862
@@ -113,66 +86,85 @@ public class UpcomingInvoiceHandler : IUpcomingInvoiceHandler
}
else if (userId.HasValue)
{
- var user = await _userService.GetUserByIdAsync(userId.Value);
+ var user = await userRepository.GetByIdAsync(userId.Value);
- if (user?.Premium == true)
+ if (user == null)
{
- await SendEmails(new List { user.Email });
+ return;
+ }
+
+ await TryEnableAutomaticTaxAsync(subscription);
+
+ if (user.Premium)
+ {
+ await SendUpcomingInvoiceEmailsAsync(new List { user.Email }, invoice);
}
}
else if (providerId.HasValue)
{
- var provider = await _providerRepository.GetByIdAsync(providerId.Value);
+ var provider = await providerRepository.GetByIdAsync(providerId.Value);
if (provider == null)
{
- _logger.LogError(
- "Received invoice.Upcoming webhook ({EventID}) for Provider ({ProviderID}) that does not exist",
- parsedEvent.Id,
- providerId.Value);
-
return;
}
- await SendEmails(new List { provider.BillingEmail });
+ await TryEnableAutomaticTaxAsync(subscription);
+ await SendUpcomingInvoiceEmailsAsync(new List { provider.BillingEmail }, invoice);
}
+ }
+
+ private async Task SendUpcomingInvoiceEmailsAsync(IEnumerable emails, Invoice invoice)
+ {
+ var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
+
+ var items = invoice.Lines.Select(i => i.Description).ToList();
+
+ if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0)
+ {
+ await mailService.SendInvoiceUpcoming(
+ validEmails,
+ invoice.AmountDue / 100M,
+ invoice.NextPaymentAttempt.Value,
+ items,
+ true);
+ }
+ }
+
+ private async Task TryEnableAutomaticTaxAsync(Subscription subscription)
+ {
+ if (subscription.AutomaticTax.Enabled ||
+ !subscription.Customer.HasBillingLocation() ||
+ IsNonTaxableNonUSBusinessUseSubscription(subscription))
+ {
+ return;
+ }
+
+ await stripeFacade.UpdateSubscription(subscription.Id,
+ new SubscriptionUpdateOptions
+ {
+ DefaultTaxRates = [],
+ AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
+ });
return;
- /*
- * Sends emails to the given email addresses.
- */
- async Task SendEmails(IEnumerable emails)
+ bool IsNonTaxableNonUSBusinessUseSubscription(Subscription localSubscription)
{
- var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
-
- if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0)
+ var familyPriceIds = new List
{
- await _mailService.SendInvoiceUpcoming(
- validEmails,
- invoice.AmountDue / 100M,
- invoice.NextPaymentAttempt.Value,
- invoiceLineItemDescriptions,
- true);
- }
+ // TODO: Replace with the PricingClient
+ StaticStore.GetPlan(PlanType.FamiliesAnnually2019).PasswordManager.StripePlanId,
+ StaticStore.GetPlan(PlanType.FamiliesAnnually).PasswordManager.StripePlanId
+ };
+
+ return localSubscription.Customer.Address.Country != "US" &&
+ localSubscription.Metadata.ContainsKey(StripeConstants.MetadataKeys.OrganizationId) &&
+ !localSubscription.Items.Select(item => item.Price.Id).Intersect(familyPriceIds).Any() &&
+ !localSubscription.Customer.TaxIds.Any();
}
}
- private async Task TryEnableAutomaticTaxAsync(Subscription subscription)
- {
- var customerGetOptions = new CustomerGetOptions { Expand = ["tax"] };
- var customer = await _stripeFacade.GetCustomer(subscription.CustomerId, customerGetOptions);
-
- var subscriptionUpdateOptions = new SubscriptionUpdateOptions();
-
- if (!subscriptionUpdateOptions.EnableAutomaticTax(customer, subscription))
- {
- return subscription;
- }
-
- return await _stripeFacade.UpdateSubscription(subscription.Id, subscriptionUpdateOptions);
- }
-
- private static bool OrgPlanForInvoiceNotifications(Organization org) => StaticStore.GetPlan(org.PlanType).IsAnnual;
+ private static bool HasAnnualPlan(Organization org) => StaticStore.GetPlan(org.PlanType).IsAnnual;
}
diff --git a/src/Core/Billing/Extensions/CustomerExtensions.cs b/src/Core/Billing/Extensions/CustomerExtensions.cs
index 62f1a5055c..1847abb0ad 100644
--- a/src/Core/Billing/Extensions/CustomerExtensions.cs
+++ b/src/Core/Billing/Extensions/CustomerExtensions.cs
@@ -5,6 +5,15 @@ namespace Bit.Core.Billing.Extensions;
public static class CustomerExtensions
{
+ public static bool HasBillingLocation(this Customer customer)
+ => customer is
+ {
+ Address:
+ {
+ Country: not null and not "",
+ PostalCode: not null and not ""
+ }
+ };
///
/// Determines if a Stripe customer supports automatic tax
From 492a3d64843b7c37796113ebb7228130c0d03a64 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 25 Feb 2025 21:42:40 -0500
Subject: [PATCH 13/26] [deps] Platform: Update azure azure-sdk-for-net
monorepo (#4815)
* [deps] Platform: Update azure azure-sdk-for-net monorepo
* fix: fixing tests for package downgrade
---------
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Ike Kottlowski
---
src/Api/Api.csproj | 3 ++-
src/Core/Core.csproj | 12 ++++++------
2 files changed, 8 insertions(+), 7 deletions(-)
diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj
index 6505fdab5b..44b0c7e969 100644
--- a/src/Api/Api.csproj
+++ b/src/Api/Api.csproj
@@ -35,7 +35,8 @@
-
+
+
diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj
index 860cf33298..6d681a3638 100644
--- a/src/Core/Core.csproj
+++ b/src/Core/Core.csproj
@@ -25,18 +25,18 @@
-
-
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
-
-
+
+
+
+
From dd78361aa49f59a772d58e830d8c4b8e2fa148c2 Mon Sep 17 00:00:00 2001
From: Ike <137194738+ike-kottlowski@users.noreply.github.com>
Date: Wed, 26 Feb 2025 09:44:35 -0500
Subject: [PATCH 14/26] Revert "[deps] Platform: Update azure azure-sdk-for-net
monorepo (#4815)" (#5447)
This reverts commit 492a3d64843b7c37796113ebb7228130c0d03a64.
---
src/Api/Api.csproj | 3 +--
src/Core/Core.csproj | 12 ++++++------
2 files changed, 7 insertions(+), 8 deletions(-)
diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj
index 44b0c7e969..6505fdab5b 100644
--- a/src/Api/Api.csproj
+++ b/src/Api/Api.csproj
@@ -35,8 +35,7 @@
-
-
+
diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj
index 6d681a3638..860cf33298 100644
--- a/src/Core/Core.csproj
+++ b/src/Core/Core.csproj
@@ -25,18 +25,18 @@
-
-
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
-
-
+
+
+
+
From 4a4d256fd9bdbb00e9f3bb786f785d28706e1d7b Mon Sep 17 00:00:00 2001
From: Matt Gibson
Date: Wed, 26 Feb 2025 13:48:51 -0800
Subject: [PATCH 15/26] [PM-16787] Web push enablement for server (#5395)
* Allow for binning of comb IDs by date and value
* Introduce notification hub pool
* Replace device type sharding with comb + range sharding
* Fix proxy interface
* Use enumerable services for multiServiceNotificationHub
* Fix push interface usage
* Fix push notification service dependencies
* Fix push notification keys
* Fixup documentation
* Remove deprecated settings
* Fix tests
* PascalCase method names
* Remove unused request model properties
* Remove unused setting
* Improve DateFromComb precision
* Prefer readonly service enumerable
* Pascal case template holes
* Name TryParse methods TryParse
* Apply suggestions from code review
Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
* Include preferred push technology in config response
SignalR will be the fallback, but clients should attempt web push first if offered and available to the client.
* Register web push devices
* Working signing and content encrypting
* update to RFC-8291 and RFC-8188
* Notification hub is now working, no need to create our own
* Fix body
* Flip Success Check
* use nifty json attribute
* Remove vapid private key
This is only needed to encrypt data for transmission along webpush -- it's handled by NotificationHub for us
* Add web push feature flag to control config response
* Update src/Core/NotificationHub/NotificationHubConnection.cs
Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
* Update src/Core/NotificationHub/NotificationHubConnection.cs
Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
* fixup! Update src/Core/NotificationHub/NotificationHubConnection.cs
* Move to platform ownership
* Remove debugging extension
* Remove unused dependencies
* Set json content directly
* Name web push registration data
* Fix FCM type typo
* Determine specific feature flag from set of flags
* Fixup merged tests
* Fixup tests
* Code quality suggestions
* Fix merged tests
* Fix test
---------
Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
---
src/Api/Controllers/ConfigController.cs | 2 +-
src/Api/Controllers/DevicesController.cs | 13 ++
src/Api/Models/Request/DeviceRequestModels.cs | 21 ++
.../Models/Response/ConfigResponseModel.cs | 32 ++-
.../Push/Controllers/PushController.cs | 6 +-
src/Api/Platform/Push/PushTechnologyType.cs | 11 ++
src/Core/Constants.cs | 1 +
.../NotificationHub/INotificationHubPool.cs | 1 +
.../NotificationHubConnection.cs | 46 ++++-
.../NotificationHub/NotificationHubPool.cs | 15 +-
.../NotificationHubPushRegistrationService.cs | 184 +++++++++++++-----
.../NotificationHub/PushRegistrationData.cs | 50 +++++
.../Push/Services/IPushRegistrationService.cs | 4 +-
.../Services/NoopPushRegistrationService.cs | 3 +-
.../Services/RelayPushRegistrationService.cs | 5 +-
src/Core/Services/IDeviceService.cs | 2 +
.../Services/Implementations/DeviceService.cs | 19 +-
src/Core/Settings/GlobalSettings.cs | 7 +
src/Core/Settings/IGlobalSettings.cs | 1 +
src/Core/Settings/IWebPushSettings.cs | 6 +
.../Push/Controllers/PushControllerTests.cs | 11 +-
.../NotificationHubConnectionTests.cs | 3 +-
...ficationHubPushRegistrationServiceTests.cs | 10 +-
test/Core.Test/Services/DeviceServiceTests.cs | 9 +-
.../Factories/WebApplicationFactoryBase.cs | 4 +
25 files changed, 383 insertions(+), 83 deletions(-)
create mode 100644 src/Api/Platform/Push/PushTechnologyType.cs
create mode 100644 src/Core/NotificationHub/PushRegistrationData.cs
create mode 100644 src/Core/Settings/IWebPushSettings.cs
diff --git a/src/Api/Controllers/ConfigController.cs b/src/Api/Controllers/ConfigController.cs
index 7699c6b115..9f38a644c2 100644
--- a/src/Api/Controllers/ConfigController.cs
+++ b/src/Api/Controllers/ConfigController.cs
@@ -23,6 +23,6 @@ public class ConfigController : Controller
[HttpGet("")]
public ConfigResponseModel GetConfigs()
{
- return new ConfigResponseModel(_globalSettings, _featureService.GetAll());
+ return new ConfigResponseModel(_featureService, _globalSettings);
}
}
diff --git a/src/Api/Controllers/DevicesController.cs b/src/Api/Controllers/DevicesController.cs
index aab898cd62..02eb2d36d5 100644
--- a/src/Api/Controllers/DevicesController.cs
+++ b/src/Api/Controllers/DevicesController.cs
@@ -186,6 +186,19 @@ public class DevicesController : Controller
await _deviceService.SaveAsync(model.ToDevice(device));
}
+ [HttpPut("identifier/{identifier}/web-push-auth")]
+ [HttpPost("identifier/{identifier}/web-push-auth")]
+ public async Task PutWebPushAuth(string identifier, [FromBody] WebPushAuthRequestModel model)
+ {
+ var device = await _deviceRepository.GetByIdentifierAsync(identifier, _userService.GetProperUserId(User).Value);
+ if (device == null)
+ {
+ throw new NotFoundException();
+ }
+
+ await _deviceService.SaveAsync(model.ToData(), device);
+ }
+
[AllowAnonymous]
[HttpPut("identifier/{identifier}/clear-token")]
[HttpPost("identifier/{identifier}/clear-token")]
diff --git a/src/Api/Models/Request/DeviceRequestModels.cs b/src/Api/Models/Request/DeviceRequestModels.cs
index 60f17bd0ee..99465501d9 100644
--- a/src/Api/Models/Request/DeviceRequestModels.cs
+++ b/src/Api/Models/Request/DeviceRequestModels.cs
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Entities;
using Bit.Core.Enums;
+using Bit.Core.NotificationHub;
using Bit.Core.Utilities;
namespace Bit.Api.Models.Request;
@@ -37,6 +38,26 @@ public class DeviceRequestModel
}
}
+public class WebPushAuthRequestModel
+{
+ [Required]
+ public string Endpoint { get; set; }
+ [Required]
+ public string P256dh { get; set; }
+ [Required]
+ public string Auth { get; set; }
+
+ public WebPushRegistrationData ToData()
+ {
+ return new WebPushRegistrationData
+ {
+ Endpoint = Endpoint,
+ P256dh = P256dh,
+ Auth = Auth
+ };
+ }
+}
+
public class DeviceTokenRequestModel
{
[StringLength(255)]
diff --git a/src/Api/Models/Response/ConfigResponseModel.cs b/src/Api/Models/Response/ConfigResponseModel.cs
index 7328f1d164..4571089295 100644
--- a/src/Api/Models/Response/ConfigResponseModel.cs
+++ b/src/Api/Models/Response/ConfigResponseModel.cs
@@ -1,4 +1,7 @@
-using Bit.Core.Models.Api;
+using Bit.Core;
+using Bit.Core.Enums;
+using Bit.Core.Models.Api;
+using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
@@ -11,6 +14,7 @@ public class ConfigResponseModel : ResponseModel
public ServerConfigResponseModel Server { get; set; }
public EnvironmentConfigResponseModel Environment { get; set; }
public IDictionary FeatureStates { get; set; }
+ public PushSettings Push { get; set; }
public ServerSettingsResponseModel Settings { get; set; }
public ConfigResponseModel() : base("config")
@@ -23,8 +27,9 @@ public class ConfigResponseModel : ResponseModel
}
public ConfigResponseModel(
- IGlobalSettings globalSettings,
- IDictionary featureStates) : base("config")
+ IFeatureService featureService,
+ IGlobalSettings globalSettings
+ ) : base("config")
{
Version = AssemblyHelpers.GetVersion();
GitHash = AssemblyHelpers.GetGitHash();
@@ -37,7 +42,9 @@ public class ConfigResponseModel : ResponseModel
Notifications = globalSettings.BaseServiceUri.Notifications,
Sso = globalSettings.BaseServiceUri.Sso
};
- FeatureStates = featureStates;
+ FeatureStates = featureService.GetAll();
+ var webPushEnabled = FeatureStates.TryGetValue(FeatureFlagKeys.WebPush, out var webPushEnabledValue) ? (bool)webPushEnabledValue : false;
+ Push = PushSettings.Build(webPushEnabled, globalSettings);
Settings = new ServerSettingsResponseModel
{
DisableUserRegistration = globalSettings.DisableUserRegistration
@@ -61,6 +68,23 @@ public class EnvironmentConfigResponseModel
public string Sso { get; set; }
}
+public class PushSettings
+{
+ public PushTechnologyType PushTechnology { get; private init; }
+ public string VapidPublicKey { get; private init; }
+
+ public static PushSettings Build(bool webPushEnabled, IGlobalSettings globalSettings)
+ {
+ var vapidPublicKey = webPushEnabled ? globalSettings.WebPush.VapidPublicKey : null;
+ var pushTechnology = vapidPublicKey != null ? PushTechnologyType.WebPush : PushTechnologyType.SignalR;
+ return new()
+ {
+ VapidPublicKey = vapidPublicKey,
+ PushTechnology = pushTechnology
+ };
+ }
+}
+
public class ServerSettingsResponseModel
{
public bool DisableUserRegistration { get; set; }
diff --git a/src/Api/Platform/Push/Controllers/PushController.cs b/src/Api/Platform/Push/Controllers/PushController.cs
index f88fa4aa9e..28641a86cf 100644
--- a/src/Api/Platform/Push/Controllers/PushController.cs
+++ b/src/Api/Platform/Push/Controllers/PushController.cs
@@ -1,6 +1,7 @@
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Models.Api;
+using Bit.Core.NotificationHub;
using Bit.Core.Platform.Push;
using Bit.Core.Settings;
using Bit.Core.Utilities;
@@ -42,9 +43,8 @@ public class PushController : Controller
public async Task RegisterAsync([FromBody] PushRegistrationRequestModel model)
{
CheckUsage();
- await _pushRegistrationService.CreateOrUpdateRegistrationAsync(model.PushToken, Prefix(model.DeviceId),
- Prefix(model.UserId), Prefix(model.Identifier), model.Type, model.OrganizationIds.Select(Prefix),
- model.InstallationId);
+ await _pushRegistrationService.CreateOrUpdateRegistrationAsync(new PushRegistrationData(model.PushToken), Prefix(model.DeviceId),
+ Prefix(model.UserId), Prefix(model.Identifier), model.Type, model.OrganizationIds.Select(Prefix), model.InstallationId);
}
[HttpPost("delete")]
diff --git a/src/Api/Platform/Push/PushTechnologyType.cs b/src/Api/Platform/Push/PushTechnologyType.cs
new file mode 100644
index 0000000000..cc89abacaa
--- /dev/null
+++ b/src/Api/Platform/Push/PushTechnologyType.cs
@@ -0,0 +1,11 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Bit.Core.Enums;
+
+public enum PushTechnologyType
+{
+ [Display(Name = "SignalR")]
+ SignalR = 0,
+ [Display(Name = "WebPush")]
+ WebPush = 1,
+}
diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs
index a862978722..82ceb817e3 100644
--- a/src/Core/Constants.cs
+++ b/src/Core/Constants.cs
@@ -172,6 +172,7 @@ public static class FeatureFlagKeys
public const string AndroidMutualTls = "mutual-tls";
public const string RecoveryCodeLogin = "pm-17128-recovery-code-login";
public const string PM3503_MobileAnonAddySelfHostAlias = "anon-addy-self-host-alias";
+ public const string WebPush = "web-push";
public const string AndroidImportLoginsFlow = "import-logins-flow";
public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features";
diff --git a/src/Core/NotificationHub/INotificationHubPool.cs b/src/Core/NotificationHub/INotificationHubPool.cs
index 18bae98bc6..3981598118 100644
--- a/src/Core/NotificationHub/INotificationHubPool.cs
+++ b/src/Core/NotificationHub/INotificationHubPool.cs
@@ -4,6 +4,7 @@ namespace Bit.Core.NotificationHub;
public interface INotificationHubPool
{
+ NotificationHubConnection ConnectionFor(Guid comb);
INotificationHubClient ClientFor(Guid comb);
INotificationHubProxy AllClients { get; }
}
diff --git a/src/Core/NotificationHub/NotificationHubConnection.cs b/src/Core/NotificationHub/NotificationHubConnection.cs
index 3a1437f70c..a68134450e 100644
--- a/src/Core/NotificationHub/NotificationHubConnection.cs
+++ b/src/Core/NotificationHub/NotificationHubConnection.cs
@@ -1,11 +1,20 @@
-using Bit.Core.Settings;
+using System.Security.Cryptography;
+using System.Text;
+using System.Web;
+using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.Azure.NotificationHubs;
-class NotificationHubConnection
+namespace Bit.Core.NotificationHub;
+
+public class NotificationHubConnection
{
public string HubName { get; init; }
public string ConnectionString { get; init; }
+ private Lazy _parsedConnectionString;
+ public Uri Endpoint => _parsedConnectionString.Value.Endpoint;
+ private string SasKey => _parsedConnectionString.Value.SharedAccessKey;
+ private string SasKeyName => _parsedConnectionString.Value.SharedAccessKeyName;
public bool EnableSendTracing { get; init; }
private NotificationHubClient _hubClient;
///
@@ -95,7 +104,38 @@ class NotificationHubConnection
return RegistrationStartDate < queryTime;
}
- private NotificationHubConnection() { }
+ public HttpRequestMessage CreateRequest(HttpMethod method, string pathUri, params string[] queryParameters)
+ {
+ var uriBuilder = new UriBuilder(Endpoint)
+ {
+ Scheme = "https",
+ Path = $"{HubName}/{pathUri.TrimStart('/')}",
+ Query = string.Join('&', [.. queryParameters, "api-version=2015-01"]),
+ };
+
+ var result = new HttpRequestMessage(method, uriBuilder.Uri);
+ result.Headers.Add("Authorization", GenerateSasToken(uriBuilder.Uri));
+ result.Headers.Add("TrackingId", Guid.NewGuid().ToString());
+ return result;
+ }
+
+ private string GenerateSasToken(Uri uri)
+ {
+ string targetUri = Uri.EscapeDataString(uri.ToString().ToLower()).ToLower();
+ long expires = DateTime.UtcNow.AddMinutes(1).Ticks / TimeSpan.TicksPerSecond;
+ string stringToSign = targetUri + "\n" + expires;
+
+ using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(SasKey)))
+ {
+ var signature = Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(stringToSign)));
+ return $"SharedAccessSignature sr={targetUri}&sig={HttpUtility.UrlEncode(signature)}&se={expires}&skn={SasKeyName}";
+ }
+ }
+
+ private NotificationHubConnection()
+ {
+ _parsedConnectionString = new(() => new NotificationHubConnectionStringBuilder(ConnectionString));
+ }
///
/// Creates a new NotificationHubConnection from the given settings.
diff --git a/src/Core/NotificationHub/NotificationHubPool.cs b/src/Core/NotificationHub/NotificationHubPool.cs
index 8993ee2b8e..6b48e82f88 100644
--- a/src/Core/NotificationHub/NotificationHubPool.cs
+++ b/src/Core/NotificationHub/NotificationHubPool.cs
@@ -44,6 +44,18 @@ public class NotificationHubPool : INotificationHubPool
///
/// Thrown when no notification hub is found for a given comb.
public INotificationHubClient ClientFor(Guid comb)
+ {
+ var resolvedConnection = ConnectionFor(comb);
+ return resolvedConnection.HubClient;
+ }
+
+ ///
+ /// Gets the NotificationHubConnection for the given comb ID.
+ ///
+ ///
+ ///
+ /// Thrown when no notification hub is found for a given comb.
+ public NotificationHubConnection ConnectionFor(Guid comb)
{
var possibleConnections = _connections.Where(c => c.RegistrationEnabled(comb)).ToArray();
if (possibleConnections.Length == 0)
@@ -55,7 +67,8 @@ public class NotificationHubPool : INotificationHubPool
}
var resolvedConnection = possibleConnections[CoreHelpers.BinForComb(comb, possibleConnections.Length)];
_logger.LogTrace("Resolved notification hub for comb {Comb} out of {HubCount} hubs.\n{ConnectionInfo}", comb, possibleConnections.Length, resolvedConnection.LogString);
- return resolvedConnection.HubClient;
+ return resolvedConnection;
+
}
public INotificationHubProxy AllClients { get { return new NotificationHubClientProxy(_clients); } }
diff --git a/src/Core/NotificationHub/NotificationHubPushRegistrationService.cs b/src/Core/NotificationHub/NotificationHubPushRegistrationService.cs
index 9793c8198a..f44fcf91a0 100644
--- a/src/Core/NotificationHub/NotificationHubPushRegistrationService.cs
+++ b/src/Core/NotificationHub/NotificationHubPushRegistrationService.cs
@@ -1,82 +1,131 @@
-using Bit.Core.Enums;
+using System.Diagnostics.CodeAnalysis;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.Azure.NotificationHubs;
+using Microsoft.Extensions.Logging;
namespace Bit.Core.NotificationHub;
public class NotificationHubPushRegistrationService : IPushRegistrationService
{
+ private static readonly JsonSerializerOptions webPushSerializationOptions = new()
+ {
+ Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
+ };
private readonly IInstallationDeviceRepository _installationDeviceRepository;
private readonly INotificationHubPool _notificationHubPool;
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly ILogger _logger;
public NotificationHubPushRegistrationService(
IInstallationDeviceRepository installationDeviceRepository,
- INotificationHubPool notificationHubPool)
+ INotificationHubPool notificationHubPool,
+ IHttpClientFactory httpClientFactory,
+ ILogger logger)
{
_installationDeviceRepository = installationDeviceRepository;
_notificationHubPool = notificationHubPool;
+ _httpClientFactory = httpClientFactory;
+ _logger = logger;
}
- public async Task CreateOrUpdateRegistrationAsync(string pushToken, string deviceId, string userId,
+ public async Task CreateOrUpdateRegistrationAsync(PushRegistrationData data, string deviceId, string userId,
string identifier, DeviceType type, IEnumerable organizationIds, Guid installationId)
{
- if (string.IsNullOrWhiteSpace(pushToken))
- {
- return;
- }
-
+ var orgIds = organizationIds.ToList();
+ var clientType = DeviceTypes.ToClientType(type);
var installation = new Installation
{
InstallationId = deviceId,
- PushChannel = pushToken,
+ PushChannel = data.Token,
+ Tags = new List
+ {
+ $"userId:{userId}",
+ $"clientType:{clientType}"
+ }.Concat(orgIds.Select(organizationId => $"organizationId:{organizationId}")).ToList(),
Templates = new Dictionary()
};
- var clientType = DeviceTypes.ToClientType(type);
-
- installation.Tags = new List { $"userId:{userId}", $"clientType:{clientType}" };
-
if (!string.IsNullOrWhiteSpace(identifier))
{
installation.Tags.Add("deviceIdentifier:" + identifier);
}
- var organizationIdsList = organizationIds.ToList();
- foreach (var organizationId in organizationIdsList)
- {
- installation.Tags.Add($"organizationId:{organizationId}");
- }
-
if (installationId != Guid.Empty)
{
installation.Tags.Add($"installationId:{installationId}");
}
- string payloadTemplate = null, messageTemplate = null, badgeMessageTemplate = null;
+ if (data.Token != null)
+ {
+ await CreateOrUpdateMobileRegistrationAsync(installation, userId, identifier, clientType, orgIds, type, installationId);
+ }
+ else if (data.WebPush != null)
+ {
+ await CreateOrUpdateWebRegistrationAsync(data.WebPush.Value.Endpoint, data.WebPush.Value.P256dh, data.WebPush.Value.Auth, installation, userId, identifier, clientType, orgIds, installationId);
+ }
+
+ if (InstallationDeviceEntity.IsInstallationDeviceId(deviceId))
+ {
+ await _installationDeviceRepository.UpsertAsync(new InstallationDeviceEntity(deviceId));
+ }
+ }
+
+ private async Task CreateOrUpdateMobileRegistrationAsync(Installation installation, string userId,
+ string identifier, ClientType clientType, List organizationIds, DeviceType type, Guid installationId)
+ {
+ if (string.IsNullOrWhiteSpace(installation.PushChannel))
+ {
+ return;
+ }
+
switch (type)
{
case DeviceType.Android:
- payloadTemplate = "{\"message\":{\"data\":{\"type\":\"$(type)\",\"payload\":\"$(payload)\"}}}";
- messageTemplate = "{\"message\":{\"data\":{\"type\":\"$(type)\"}," +
- "\"notification\":{\"title\":\"$(title)\",\"body\":\"$(message)\"}}}";
+ installation.Templates.Add(BuildInstallationTemplate("payload",
+ "{\"message\":{\"data\":{\"type\":\"$(type)\",\"payload\":\"$(payload)\"}}}",
+ userId, identifier, clientType, organizationIds, installationId));
+ installation.Templates.Add(BuildInstallationTemplate("message",
+ "{\"message\":{\"data\":{\"type\":\"$(type)\"}," +
+ "\"notification\":{\"title\":\"$(title)\",\"body\":\"$(message)\"}}}",
+ userId, identifier, clientType, organizationIds, installationId));
+ installation.Templates.Add(BuildInstallationTemplate("badgeMessage",
+ "{\"message\":{\"data\":{\"type\":\"$(type)\"}," +
+ "\"notification\":{\"title\":\"$(title)\",\"body\":\"$(message)\"}}}",
+ userId, identifier, clientType, organizationIds, installationId));
installation.Platform = NotificationPlatform.FcmV1;
break;
case DeviceType.iOS:
- payloadTemplate = "{\"data\":{\"type\":\"#(type)\",\"payload\":\"$(payload)\"}," +
- "\"aps\":{\"content-available\":1}}";
- messageTemplate = "{\"data\":{\"type\":\"#(type)\"}," +
- "\"aps\":{\"alert\":\"$(message)\",\"badge\":null,\"content-available\":1}}";
- badgeMessageTemplate = "{\"data\":{\"type\":\"#(type)\"}," +
- "\"aps\":{\"alert\":\"$(message)\",\"badge\":\"#(badge)\",\"content-available\":1}}";
-
+ installation.Templates.Add(BuildInstallationTemplate("payload",
+ "{\"data\":{\"type\":\"#(type)\",\"payload\":\"$(payload)\"}," +
+ "\"aps\":{\"content-available\":1}}",
+ userId, identifier, clientType, organizationIds, installationId));
+ installation.Templates.Add(BuildInstallationTemplate("message",
+ "{\"data\":{\"type\":\"#(type)\"}," +
+ "\"aps\":{\"alert\":\"$(message)\",\"badge\":null,\"content-available\":1}}", userId, identifier, clientType, organizationIds, installationId));
+ installation.Templates.Add(BuildInstallationTemplate("badgeMessage",
+ "{\"data\":{\"type\":\"#(type)\"}," +
+ "\"aps\":{\"alert\":\"$(message)\",\"badge\":\"#(badge)\",\"content-available\":1}}",
+ userId, identifier, clientType, organizationIds, installationId));
installation.Platform = NotificationPlatform.Apns;
break;
case DeviceType.AndroidAmazon:
- payloadTemplate = "{\"data\":{\"type\":\"#(type)\",\"payload\":\"$(payload)\"}}";
- messageTemplate = "{\"data\":{\"type\":\"#(type)\",\"message\":\"$(message)\"}}";
+ installation.Templates.Add(BuildInstallationTemplate("payload",
+ "{\"data\":{\"type\":\"#(type)\",\"payload\":\"$(payload)\"}}",
+ userId, identifier, clientType, organizationIds, installationId));
+ installation.Templates.Add(BuildInstallationTemplate("message",
+ "{\"data\":{\"type\":\"#(type)\",\"message\":\"$(message)\"}}",
+ userId, identifier, clientType, organizationIds, installationId));
+ installation.Templates.Add(BuildInstallationTemplate("badgeMessage",
+ "{\"data\":{\"type\":\"#(type)\",\"message\":\"$(message)\"}}",
+ userId, identifier, clientType, organizationIds, installationId));
installation.Platform = NotificationPlatform.Adm;
break;
@@ -84,28 +133,62 @@ public class NotificationHubPushRegistrationService : IPushRegistrationService
break;
}
- BuildInstallationTemplate(installation, "payload", payloadTemplate, userId, identifier, clientType,
- organizationIdsList, installationId);
- BuildInstallationTemplate(installation, "message", messageTemplate, userId, identifier, clientType,
- organizationIdsList, installationId);
- BuildInstallationTemplate(installation, "badgeMessage", badgeMessageTemplate ?? messageTemplate,
- userId, identifier, clientType, organizationIdsList, installationId);
-
- await ClientFor(GetComb(deviceId)).CreateOrUpdateInstallationAsync(installation);
- if (InstallationDeviceEntity.IsInstallationDeviceId(deviceId))
- {
- await _installationDeviceRepository.UpsertAsync(new InstallationDeviceEntity(deviceId));
- }
+ await ClientFor(GetComb(installation.InstallationId)).CreateOrUpdateInstallationAsync(installation);
}
- private void BuildInstallationTemplate(Installation installation, string templateId, string templateBody,
- string userId, string identifier, ClientType clientType, List organizationIds, Guid installationId)
+ private async Task CreateOrUpdateWebRegistrationAsync(string endpoint, string p256dh, string auth, Installation installation, string userId,
+ string identifier, ClientType clientType, List organizationIds, Guid installationId)
{
- if (templateBody == null)
+ // The Azure SDK is currently lacking support for web push registrations.
+ // We need to use the REST API directly.
+
+ if (string.IsNullOrWhiteSpace(endpoint) || string.IsNullOrWhiteSpace(p256dh) || string.IsNullOrWhiteSpace(auth))
{
return;
}
+ installation.Templates.Add(BuildInstallationTemplate("payload",
+ "{\"data\":{\"type\":\"#(type)\",\"payload\":\"$(payload)\"}}",
+ userId, identifier, clientType, organizationIds, installationId));
+ installation.Templates.Add(BuildInstallationTemplate("message",
+ "{\"data\":{\"type\":\"#(type)\",\"message\":\"$(message)\"}}",
+ userId, identifier, clientType, organizationIds, installationId));
+ installation.Templates.Add(BuildInstallationTemplate("badgeMessage",
+ "{\"data\":{\"type\":\"#(type)\",\"message\":\"$(message)\"}}",
+ userId, identifier, clientType, organizationIds, installationId));
+
+ var content = new
+ {
+ installationId = installation.InstallationId,
+ pushChannel = new
+ {
+ endpoint,
+ p256dh,
+ auth
+ },
+ platform = "browser",
+ tags = installation.Tags,
+ templates = installation.Templates
+ };
+
+ var client = _httpClientFactory.CreateClient("NotificationHub");
+ var request = ConnectionFor(GetComb(installation.InstallationId)).CreateRequest(HttpMethod.Put, $"installations/{installation.InstallationId}");
+ request.Content = JsonContent.Create(content, new MediaTypeHeaderValue("application/json"), webPushSerializationOptions);
+ var response = await client.SendAsync(request);
+ var body = await response.Content.ReadAsStringAsync();
+ if (!response.IsSuccessStatusCode)
+ {
+ _logger.LogWarning("Web push registration failed: {Response}", body);
+ }
+ else
+ {
+ _logger.LogInformation("Web push registration success: {Response}", body);
+ }
+ }
+
+ private static KeyValuePair BuildInstallationTemplate(string templateId, [StringSyntax(StringSyntaxAttribute.Json)] string templateBody,
+ string userId, string identifier, ClientType clientType, List organizationIds, Guid installationId)
+ {
var fullTemplateId = $"template:{templateId}";
var template = new InstallationTemplate
@@ -132,7 +215,7 @@ public class NotificationHubPushRegistrationService : IPushRegistrationService
template.Tags.Add($"installationId:{installationId}");
}
- installation.Templates.Add(fullTemplateId, template);
+ return new KeyValuePair(fullTemplateId, template);
}
public async Task DeleteRegistrationAsync(string deviceId)
@@ -213,6 +296,11 @@ public class NotificationHubPushRegistrationService : IPushRegistrationService
return _notificationHubPool.ClientFor(deviceId);
}
+ private NotificationHubConnection ConnectionFor(Guid deviceId)
+ {
+ return _notificationHubPool.ConnectionFor(deviceId);
+ }
+
private Guid GetComb(string deviceId)
{
var deviceIdString = deviceId;
diff --git a/src/Core/NotificationHub/PushRegistrationData.cs b/src/Core/NotificationHub/PushRegistrationData.cs
new file mode 100644
index 0000000000..0cdf981ee2
--- /dev/null
+++ b/src/Core/NotificationHub/PushRegistrationData.cs
@@ -0,0 +1,50 @@
+namespace Bit.Core.NotificationHub;
+
+public struct WebPushRegistrationData : IEquatable
+{
+ public string Endpoint { get; init; }
+ public string P256dh { get; init; }
+ public string Auth { get; init; }
+
+ public bool Equals(WebPushRegistrationData other)
+ {
+ return Endpoint == other.Endpoint && P256dh == other.P256dh && Auth == other.Auth;
+ }
+
+ public override int GetHashCode()
+ {
+ return HashCode.Combine(Endpoint, P256dh, Auth);
+ }
+}
+
+public class PushRegistrationData : IEquatable
+{
+ public string Token { get; set; }
+ public WebPushRegistrationData? WebPush { get; set; }
+ public PushRegistrationData(string token)
+ {
+ Token = token;
+ }
+
+ public PushRegistrationData(string Endpoint, string P256dh, string Auth) : this(new WebPushRegistrationData
+ {
+ Endpoint = Endpoint,
+ P256dh = P256dh,
+ Auth = Auth
+ })
+ { }
+
+ public PushRegistrationData(WebPushRegistrationData webPush)
+ {
+ WebPush = webPush;
+ }
+ public bool Equals(PushRegistrationData other)
+ {
+ return Token == other.Token && WebPush.Equals(other.WebPush);
+ }
+
+ public override int GetHashCode()
+ {
+ return HashCode.Combine(Token, WebPush.GetHashCode());
+ }
+}
diff --git a/src/Core/Platform/Push/Services/IPushRegistrationService.cs b/src/Core/Platform/Push/Services/IPushRegistrationService.cs
index 0f2a28700b..469cd2577b 100644
--- a/src/Core/Platform/Push/Services/IPushRegistrationService.cs
+++ b/src/Core/Platform/Push/Services/IPushRegistrationService.cs
@@ -1,11 +1,11 @@
using Bit.Core.Enums;
+using Bit.Core.NotificationHub;
namespace Bit.Core.Platform.Push;
public interface IPushRegistrationService
{
- Task CreateOrUpdateRegistrationAsync(string pushToken, string deviceId, string userId,
- string identifier, DeviceType type, IEnumerable organizationIds, Guid installationId);
+ Task CreateOrUpdateRegistrationAsync(PushRegistrationData data, string deviceId, string userId, string identifier, DeviceType type, IEnumerable organizationIds, Guid installationId);
Task DeleteRegistrationAsync(string deviceId);
Task AddUserRegistrationOrganizationAsync(IEnumerable deviceIds, string organizationId);
Task DeleteUserRegistrationOrganizationAsync(IEnumerable deviceIds, string organizationId);
diff --git a/src/Core/Platform/Push/Services/NoopPushRegistrationService.cs b/src/Core/Platform/Push/Services/NoopPushRegistrationService.cs
index ac6f8a814b..9a7674232a 100644
--- a/src/Core/Platform/Push/Services/NoopPushRegistrationService.cs
+++ b/src/Core/Platform/Push/Services/NoopPushRegistrationService.cs
@@ -1,4 +1,5 @@
using Bit.Core.Enums;
+using Bit.Core.NotificationHub;
namespace Bit.Core.Platform.Push.Internal;
@@ -9,7 +10,7 @@ public class NoopPushRegistrationService : IPushRegistrationService
return Task.FromResult(0);
}
- public Task CreateOrUpdateRegistrationAsync(string pushToken, string deviceId, string userId,
+ public Task CreateOrUpdateRegistrationAsync(PushRegistrationData pushRegistrationData, string deviceId, string userId,
string identifier, DeviceType type, IEnumerable organizationIds, Guid installationId)
{
return Task.FromResult(0);
diff --git a/src/Core/Platform/Push/Services/RelayPushRegistrationService.cs b/src/Core/Platform/Push/Services/RelayPushRegistrationService.cs
index 58a34c15c5..1a3843d05a 100644
--- a/src/Core/Platform/Push/Services/RelayPushRegistrationService.cs
+++ b/src/Core/Platform/Push/Services/RelayPushRegistrationService.cs
@@ -1,6 +1,7 @@
using Bit.Core.Enums;
using Bit.Core.IdentityServer;
using Bit.Core.Models.Api;
+using Bit.Core.NotificationHub;
using Bit.Core.Services;
using Bit.Core.Settings;
using Microsoft.Extensions.Logging;
@@ -24,14 +25,14 @@ public class RelayPushRegistrationService : BaseIdentityClientService, IPushRegi
{
}
- public async Task CreateOrUpdateRegistrationAsync(string pushToken, string deviceId, string userId,
+ public async Task CreateOrUpdateRegistrationAsync(PushRegistrationData pushData, string deviceId, string userId,
string identifier, DeviceType type, IEnumerable organizationIds, Guid installationId)
{
var requestModel = new PushRegistrationRequestModel
{
DeviceId = deviceId,
Identifier = identifier,
- PushToken = pushToken,
+ PushToken = pushData.Token,
Type = type,
UserId = userId,
OrganizationIds = organizationIds,
diff --git a/src/Core/Services/IDeviceService.cs b/src/Core/Services/IDeviceService.cs
index b5f3a0b8f1..cd055f8b46 100644
--- a/src/Core/Services/IDeviceService.cs
+++ b/src/Core/Services/IDeviceService.cs
@@ -1,10 +1,12 @@
using Bit.Core.Auth.Models.Api.Request;
using Bit.Core.Entities;
+using Bit.Core.NotificationHub;
namespace Bit.Core.Services;
public interface IDeviceService
{
+ Task SaveAsync(WebPushRegistrationData webPush, Device device);
Task SaveAsync(Device device);
Task ClearTokenAsync(Device device);
Task DeactivateAsync(Device device);
diff --git a/src/Core/Services/Implementations/DeviceService.cs b/src/Core/Services/Implementations/DeviceService.cs
index c8b0134932..99523d8e5e 100644
--- a/src/Core/Services/Implementations/DeviceService.cs
+++ b/src/Core/Services/Implementations/DeviceService.cs
@@ -3,6 +3,7 @@ using Bit.Core.Auth.Utilities;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
+using Bit.Core.NotificationHub;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Settings;
@@ -28,9 +29,19 @@ public class DeviceService : IDeviceService
_globalSettings = globalSettings;
}
+ public async Task SaveAsync(WebPushRegistrationData webPush, Device device)
+ {
+ await SaveAsync(new PushRegistrationData(webPush.Endpoint, webPush.P256dh, webPush.Auth), device);
+ }
+
public async Task SaveAsync(Device device)
{
- if (device.Id == default(Guid))
+ await SaveAsync(new PushRegistrationData(device.PushToken), device);
+ }
+
+ private async Task SaveAsync(PushRegistrationData data, Device device)
+ {
+ if (device.Id == default)
{
await _deviceRepository.CreateAsync(device);
}
@@ -45,9 +56,9 @@ public class DeviceService : IDeviceService
OrganizationUserStatusType.Confirmed))
.Select(ou => ou.OrganizationId.ToString());
- await _pushRegistrationService.CreateOrUpdateRegistrationAsync(device.PushToken, device.Id.ToString(),
- device.UserId.ToString(), device.Identifier, device.Type, organizationIdsString,
- _globalSettings.Installation.Id);
+ await _pushRegistrationService.CreateOrUpdateRegistrationAsync(data, device.Id.ToString(),
+ device.UserId.ToString(), device.Identifier, device.Type, organizationIdsString, _globalSettings.Installation.Id);
+
}
public async Task ClearTokenAsync(Device device)
diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs
index dbfc8543a3..6bb76eb50a 100644
--- a/src/Core/Settings/GlobalSettings.cs
+++ b/src/Core/Settings/GlobalSettings.cs
@@ -83,6 +83,8 @@ public class GlobalSettings : IGlobalSettings
public virtual IDomainVerificationSettings DomainVerification { get; set; } = new DomainVerificationSettings();
public virtual ILaunchDarklySettings LaunchDarkly { get; set; } = new LaunchDarklySettings();
public virtual string DevelopmentDirectory { get; set; }
+ public virtual IWebPushSettings WebPush { get; set; } = new WebPushSettings();
+
public virtual bool EnableEmailVerification { get; set; }
public virtual string KdfDefaultHashKey { get; set; }
public virtual string PricingUri { get; set; }
@@ -677,4 +679,9 @@ public class GlobalSettings : IGlobalSettings
public virtual IConnectionStringSettings Redis { get; set; } = new ConnectionStringSettings();
public virtual IConnectionStringSettings Cosmos { get; set; } = new ConnectionStringSettings();
}
+
+ public class WebPushSettings : IWebPushSettings
+ {
+ public string VapidPublicKey { get; set; }
+ }
}
diff --git a/src/Core/Settings/IGlobalSettings.cs b/src/Core/Settings/IGlobalSettings.cs
index b89df8abf5..411014ea32 100644
--- a/src/Core/Settings/IGlobalSettings.cs
+++ b/src/Core/Settings/IGlobalSettings.cs
@@ -27,5 +27,6 @@ public interface IGlobalSettings
string DatabaseProvider { get; set; }
GlobalSettings.SqlSettings SqlServer { get; set; }
string DevelopmentDirectory { get; set; }
+ IWebPushSettings WebPush { get; set; }
GlobalSettings.EventLoggingSettings EventLogging { get; set; }
}
diff --git a/src/Core/Settings/IWebPushSettings.cs b/src/Core/Settings/IWebPushSettings.cs
new file mode 100644
index 0000000000..d63bec23f5
--- /dev/null
+++ b/src/Core/Settings/IWebPushSettings.cs
@@ -0,0 +1,6 @@
+namespace Bit.Core.Settings;
+
+public interface IWebPushSettings
+{
+ public string VapidPublicKey { get; set; }
+}
diff --git a/test/Api.Test/Platform/Push/Controllers/PushControllerTests.cs b/test/Api.Test/Platform/Push/Controllers/PushControllerTests.cs
index 70e1e83edb..b796a445ae 100644
--- a/test/Api.Test/Platform/Push/Controllers/PushControllerTests.cs
+++ b/test/Api.Test/Platform/Push/Controllers/PushControllerTests.cs
@@ -4,6 +4,7 @@ using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Api;
+using Bit.Core.NotificationHub;
using Bit.Core.Platform.Push;
using Bit.Core.Settings;
using Bit.Test.Common.AutoFixture;
@@ -248,7 +249,7 @@ public class PushControllerTests
Assert.Equal("Not correctly configured for push relays.", exception.Message);
await sutProvider.GetDependency().Received(0)
- .CreateOrUpdateRegistrationAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(),
+ .CreateOrUpdateRegistrationAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(),
Arg.Any(), Arg.Any>(), Arg.Any());
}
@@ -265,7 +266,7 @@ public class PushControllerTests
var expectedDeviceId = $"{installationId}_{deviceId}";
var expectedOrganizationId = $"{installationId}_{organizationId}";
- await sutProvider.Sut.RegisterAsync(new PushRegistrationRequestModel
+ var model = new PushRegistrationRequestModel
{
DeviceId = deviceId.ToString(),
PushToken = "test-push-token",
@@ -274,10 +275,12 @@ public class PushControllerTests
Identifier = identifier.ToString(),
OrganizationIds = [organizationId.ToString()],
InstallationId = installationId
- });
+ };
+
+ await sutProvider.Sut.RegisterAsync(model);
await sutProvider.GetDependency().Received(1)
- .CreateOrUpdateRegistrationAsync("test-push-token", expectedDeviceId, expectedUserId,
+ .CreateOrUpdateRegistrationAsync(Arg.Is(data => data.Equals(new PushRegistrationData(model.PushToken))), expectedDeviceId, expectedUserId,
expectedIdentifier, DeviceType.Android, Arg.Do>(organizationIds =>
{
var organizationIdsList = organizationIds.ToList();
diff --git a/test/Core.Test/NotificationHub/NotificationHubConnectionTests.cs b/test/Core.Test/NotificationHub/NotificationHubConnectionTests.cs
index 0d7382b3cc..fc76e5c1b7 100644
--- a/test/Core.Test/NotificationHub/NotificationHubConnectionTests.cs
+++ b/test/Core.Test/NotificationHub/NotificationHubConnectionTests.cs
@@ -1,4 +1,5 @@
-using Bit.Core.Settings;
+using Bit.Core.NotificationHub;
+using Bit.Core.Settings;
using Bit.Core.Utilities;
using Xunit;
diff --git a/test/Core.Test/NotificationHub/NotificationHubPushRegistrationServiceTests.cs b/test/Core.Test/NotificationHub/NotificationHubPushRegistrationServiceTests.cs
index 77551f53e7..b30cd3dda8 100644
--- a/test/Core.Test/NotificationHub/NotificationHubPushRegistrationServiceTests.cs
+++ b/test/Core.Test/NotificationHub/NotificationHubPushRegistrationServiceTests.cs
@@ -19,7 +19,7 @@ public class NotificationHubPushRegistrationServiceTests
SutProvider sutProvider, Guid deviceId, Guid userId, Guid identifier,
Guid organizationId, Guid installationId)
{
- await sutProvider.Sut.CreateOrUpdateRegistrationAsync(pushToken, deviceId.ToString(), userId.ToString(),
+ await sutProvider.Sut.CreateOrUpdateRegistrationAsync(new PushRegistrationData(pushToken), deviceId.ToString(), userId.ToString(),
identifier.ToString(), DeviceType.Android, [organizationId.ToString()], installationId);
sutProvider.GetDependency()
@@ -39,7 +39,7 @@ public class NotificationHubPushRegistrationServiceTests
var pushToken = "test push token";
- await sutProvider.Sut.CreateOrUpdateRegistrationAsync(pushToken, deviceId.ToString(), userId.ToString(),
+ await sutProvider.Sut.CreateOrUpdateRegistrationAsync(new PushRegistrationData(pushToken), deviceId.ToString(), userId.ToString(),
identifierNull ? null : identifier.ToString(), DeviceType.Android,
partOfOrganizationId ? [organizationId.ToString()] : [],
installationIdNull ? Guid.Empty : installationId);
@@ -115,7 +115,7 @@ public class NotificationHubPushRegistrationServiceTests
var pushToken = "test push token";
- await sutProvider.Sut.CreateOrUpdateRegistrationAsync(pushToken, deviceId.ToString(), userId.ToString(),
+ await sutProvider.Sut.CreateOrUpdateRegistrationAsync(new PushRegistrationData(pushToken), deviceId.ToString(), userId.ToString(),
identifierNull ? null : identifier.ToString(), DeviceType.iOS,
partOfOrganizationId ? [organizationId.ToString()] : [],
installationIdNull ? Guid.Empty : installationId);
@@ -191,7 +191,7 @@ public class NotificationHubPushRegistrationServiceTests
var pushToken = "test push token";
- await sutProvider.Sut.CreateOrUpdateRegistrationAsync(pushToken, deviceId.ToString(), userId.ToString(),
+ await sutProvider.Sut.CreateOrUpdateRegistrationAsync(new PushRegistrationData(pushToken), deviceId.ToString(), userId.ToString(),
identifierNull ? null : identifier.ToString(), DeviceType.AndroidAmazon,
partOfOrganizationId ? [organizationId.ToString()] : [],
installationIdNull ? Guid.Empty : installationId);
@@ -268,7 +268,7 @@ public class NotificationHubPushRegistrationServiceTests
var pushToken = "test push token";
- await sutProvider.Sut.CreateOrUpdateRegistrationAsync(pushToken, deviceId.ToString(), userId.ToString(),
+ await sutProvider.Sut.CreateOrUpdateRegistrationAsync(new PushRegistrationData(pushToken), deviceId.ToString(), userId.ToString(),
identifier.ToString(), deviceType, [organizationId.ToString()], installationId);
sutProvider.GetDependency()
diff --git a/test/Core.Test/Services/DeviceServiceTests.cs b/test/Core.Test/Services/DeviceServiceTests.cs
index 95a93cf4e8..b454a0c04b 100644
--- a/test/Core.Test/Services/DeviceServiceTests.cs
+++ b/test/Core.Test/Services/DeviceServiceTests.cs
@@ -4,6 +4,7 @@ using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
+using Bit.Core.NotificationHub;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
@@ -43,13 +44,13 @@ public class DeviceServiceTests
Name = "test device",
Type = DeviceType.Android,
UserId = userId,
- PushToken = "testtoken",
+ PushToken = "testToken",
Identifier = "testid"
};
await deviceService.SaveAsync(device);
Assert.True(device.RevisionDate - DateTime.UtcNow < TimeSpan.FromSeconds(1));
- await pushRepo.Received(1).CreateOrUpdateRegistrationAsync("testtoken", id.ToString(),
+ await pushRepo.Received(1).CreateOrUpdateRegistrationAsync(Arg.Is(v => v.Token == "testToken"), id.ToString(),
userId.ToString(), "testid", DeviceType.Android,
Arg.Do>(organizationIds =>
{
@@ -84,12 +85,12 @@ public class DeviceServiceTests
Name = "test device",
Type = DeviceType.Android,
UserId = userId,
- PushToken = "testtoken",
+ PushToken = "testToken",
Identifier = "testid"
};
await deviceService.SaveAsync(device);
- await pushRepo.Received(1).CreateOrUpdateRegistrationAsync("testtoken",
+ await pushRepo.Received(1).CreateOrUpdateRegistrationAsync(Arg.Is(v => v.Token == "testToken"),
Arg.Do(id => Guid.TryParse(id, out var _)), userId.ToString(), "testid", DeviceType.Android,
Arg.Do>(organizationIds =>
{
diff --git a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs
index 7c7f790cdc..c1089608da 100644
--- a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs
+++ b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs
@@ -163,6 +163,10 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory
// New Device Verification
{ "globalSettings:disableEmailNewDevice", "false" },
+
+ // Web push notifications
+ { "globalSettings:webPush:vapidPublicKey", "BGBtAM0bU3b5jsB14IjBYarvJZ6rWHilASLudTTYDDBi7a-3kebo24Yus_xYeOMZ863flAXhFAbkL6GVSrxgErg" },
+ { "globalSettings:launchDarkly:flagValues:web-push", "true" },
});
});
From bd66f06bd99856985e4587e6cd8094e4bb9ae392 Mon Sep 17 00:00:00 2001
From: Matt Gibson
Date: Wed, 26 Feb 2025 14:41:24 -0800
Subject: [PATCH 16/26] Prefer record to implementing IEquatable (#5449)
---
.../NotificationHub/PushRegistrationData.cs | 23 ++-----------------
.../Push/Controllers/PushControllerTests.cs | 2 +-
2 files changed, 3 insertions(+), 22 deletions(-)
diff --git a/src/Core/NotificationHub/PushRegistrationData.cs b/src/Core/NotificationHub/PushRegistrationData.cs
index 0cdf981ee2..20e1cf0936 100644
--- a/src/Core/NotificationHub/PushRegistrationData.cs
+++ b/src/Core/NotificationHub/PushRegistrationData.cs
@@ -1,23 +1,13 @@
namespace Bit.Core.NotificationHub;
-public struct WebPushRegistrationData : IEquatable
+public record struct WebPushRegistrationData
{
public string Endpoint { get; init; }
public string P256dh { get; init; }
public string Auth { get; init; }
-
- public bool Equals(WebPushRegistrationData other)
- {
- return Endpoint == other.Endpoint && P256dh == other.P256dh && Auth == other.Auth;
- }
-
- public override int GetHashCode()
- {
- return HashCode.Combine(Endpoint, P256dh, Auth);
- }
}
-public class PushRegistrationData : IEquatable
+public record class PushRegistrationData
{
public string Token { get; set; }
public WebPushRegistrationData? WebPush { get; set; }
@@ -38,13 +28,4 @@ public class PushRegistrationData : IEquatable
{
WebPush = webPush;
}
- public bool Equals(PushRegistrationData other)
- {
- return Token == other.Token && WebPush.Equals(other.WebPush);
- }
-
- public override int GetHashCode()
- {
- return HashCode.Combine(Token, WebPush.GetHashCode());
- }
}
diff --git a/test/Api.Test/Platform/Push/Controllers/PushControllerTests.cs b/test/Api.Test/Platform/Push/Controllers/PushControllerTests.cs
index b796a445ae..399913a0c4 100644
--- a/test/Api.Test/Platform/Push/Controllers/PushControllerTests.cs
+++ b/test/Api.Test/Platform/Push/Controllers/PushControllerTests.cs
@@ -280,7 +280,7 @@ public class PushControllerTests
await sutProvider.Sut.RegisterAsync(model);
await sutProvider.GetDependency().Received(1)
- .CreateOrUpdateRegistrationAsync(Arg.Is(data => data.Equals(new PushRegistrationData(model.PushToken))), expectedDeviceId, expectedUserId,
+ .CreateOrUpdateRegistrationAsync(Arg.Is(data => data == new PushRegistrationData(model.PushToken)), expectedDeviceId, expectedUserId,
expectedIdentifier, DeviceType.Android, Arg.Do>(organizationIds =>
{
var organizationIdsList = organizationIds.ToList();
From a2e665cb96c883e7eba7a2568b9811913615df32 Mon Sep 17 00:00:00 2001
From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com>
Date: Thu, 27 Feb 2025 07:55:46 -0500
Subject: [PATCH 17/26] [PM-16684] Integrate Pricing Service behind FF (#5276)
* Remove gRPC and convert PricingClient to HttpClient wrapper
* Add PlanType.GetProductTier extension
Many instances of StaticStore use are just to get the ProductTierType of a PlanType, but this can be derived from the PlanType itself without having to fetch the entire plan.
* Remove invocations of the StaticStore in non-Test code
* Deprecate StaticStore entry points
* Run dotnet format
* Matt's feedback
* Run dotnet format
* Rui's feedback
* Run dotnet format
* Replacements since approval
* Run dotnet format
---
.../RemoveOrganizationFromProviderCommand.cs | 11 +-
.../AdminConsole/Services/ProviderService.cs | 26 ++-
.../Billing/ProviderBillingService.cs | 31 +--
.../Queries/Projects/MaxProjectsQuery.cs | 1 +
...oveOrganizationFromProviderCommandTests.cs | 3 +
.../Services/ProviderServiceTests.cs | 7 +
.../Billing/ProviderBillingServiceTests.cs | 83 +++++++
.../Controllers/OrganizationsController.cs | 17 +-
.../Models/OrganizationEditModel.cs | 8 +-
.../OrganizationUsersController.cs | 10 +-
.../Controllers/OrganizationsController.cs | 25 +-
.../OrganizationResponseModel.cs | 23 +-
.../ProfileOrganizationResponseModel.cs | 5 +-
...rofileProviderOrganizationResponseModel.cs | 6 +-
.../OrganizationBillingController.cs | 4 +-
.../Controllers/OrganizationsController.cs | 37 +--
.../Controllers/ProviderBillingController.cs | 16 +-
.../Responses/ProviderSubscriptionResponse.cs | 18 +-
.../Controllers/OrganizationController.cs | 9 +-
...anizationSubscriptionUpdateRequestModel.cs | 9 +-
src/Api/Controllers/PlansController.cs | 10 +-
...elfHostedOrganizationLicensesController.cs | 3 +-
...tsManagerSubscriptionUpdateRequestModel.cs | 5 +-
src/Api/Models/Response/PlanResponseModel.cs | 11 +-
.../Controllers/ServiceAccountsController.cs | 10 +-
.../PaymentSucceededHandler.cs | 24 +-
.../Implementations/ProviderEventService.cs | 17 +-
.../SubscriptionUpdatedHandler.cs | 43 ++--
.../Implementations/UpcomingInvoiceHandler.cs | 26 +--
.../UpdateOrganizationUserCommand.cs | 12 +-
.../CloudOrganizationSignUpCommand.cs | 6 +-
.../Implementations/OrganizationService.cs | 46 ++--
src/Core/Billing/Constants/StripeConstants.cs | 1 +
.../Billing/Extensions/BillingExtensions.cs | 11 +
.../Extensions/ServiceCollectionExtensions.cs | 3 +-
.../Implementations/OrganizationMigrator.cs | 9 +-
.../Billing/Models/ConfiguredProviderPlan.cs | 19 +-
.../Billing/Models/OrganizationMetadata.cs | 15 +-
.../Billing/Models/Sales/OrganizationSale.cs | 4 +-
.../Billing/Models/Sales/SubscriptionSetup.cs | 4 +-
src/Core/Billing/Pricing/IPricingClient.cs | 26 +++
.../JSON/FreeOrScalableDTOJsonConverter.cs | 35 +++
.../JSON/PurchasableDTOJsonConverter.cs | 40 ++++
.../Pricing/JSON/TypeReadingJsonConverter.cs | 28 +++
src/Core/Billing/Pricing/Models/FeatureDTO.cs | 9 +
src/Core/Billing/Pricing/Models/PlanDTO.cs | 27 +++
.../Billing/Pricing/Models/PurchasableDTO.cs | 73 ++++++
src/Core/Billing/Pricing/PlanAdapter.cs | 153 ++++++------
src/Core/Billing/Pricing/PricingClient.cs | 88 +++++--
.../Pricing/Protos/password-manager.proto | 92 --------
.../Pricing/ServiceCollectionExtensions.cs | 21 ++
.../OrganizationBillingService.cs | 88 +++----
src/Core/Core.csproj | 12 +-
.../Business/CompleteSubscriptionUpdate.cs | 23 +-
.../Business/ProviderSubscriptionUpdate.cs | 7 +-
.../SecretsManagerSubscriptionUpdate.cs | 12 +-
src/Core/Models/Business/SubscriptionInfo.cs | 1 -
.../Cloud/CloudSyncSponsorshipsCommand.cs | 4 +-
.../Cloud/SetUpSponsorshipCommand.cs | 6 +-
.../Cloud/ValidateSponsorshipCommand.cs | 7 +-
.../CreateSponsorshipCommand.cs | 6 +-
.../AddSecretsManagerSubscriptionCommand.cs | 17 +-
.../UpgradeOrganizationPlanCommand.cs | 18 +-
.../Implementations/StripePaymentService.cs | 22 +-
src/Core/Utilities/StaticStore.cs | 6 +-
.../OrganizationsControllerTests.cs | 6 +-
.../OrganizationsControllerTests.cs | 6 +-
.../ProviderBillingControllerTests.cs | 6 +
.../ServiceAccountsControllerTests.cs | 4 +
.../Services/ProviderEventServiceTests.cs | 24 +-
.../CloudOrganizationSignUpCommandTests.cs | 20 +-
.../Services/OrganizationServiceTests.cs | 18 +-
.../OrganizationBillingServiceTests.cs | 47 +---
.../CompleteSubscriptionUpdateTests.cs | 12 +-
.../SecretsManagerSubscriptionUpdateTests.cs | 55 +++--
...dSecretsManagerSubscriptionCommandTests.cs | 10 +-
...eSecretsManagerSubscriptionCommandTests.cs | 217 +++++++++---------
.../UpgradeOrganizationPlanCommandTests.cs | 16 ++
78 files changed, 1178 insertions(+), 712 deletions(-)
create mode 100644 src/Core/Billing/Pricing/JSON/FreeOrScalableDTOJsonConverter.cs
create mode 100644 src/Core/Billing/Pricing/JSON/PurchasableDTOJsonConverter.cs
create mode 100644 src/Core/Billing/Pricing/JSON/TypeReadingJsonConverter.cs
create mode 100644 src/Core/Billing/Pricing/Models/FeatureDTO.cs
create mode 100644 src/Core/Billing/Pricing/Models/PlanDTO.cs
create mode 100644 src/Core/Billing/Pricing/Models/PurchasableDTO.cs
delete mode 100644 src/Core/Billing/Pricing/Protos/password-manager.proto
create mode 100644 src/Core/Billing/Pricing/ServiceCollectionExtensions.cs
diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs
index ce0c0c9335..d2acdac079 100644
--- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs
+++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs
@@ -5,12 +5,12 @@ using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
+using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
-using Bit.Core.Utilities;
using Stripe;
namespace Bit.Commercial.Core.AdminConsole.Providers;
@@ -27,6 +27,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
private readonly IProviderBillingService _providerBillingService;
private readonly ISubscriberService _subscriberService;
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
+ private readonly IPricingClient _pricingClient;
public RemoveOrganizationFromProviderCommand(
IEventService eventService,
@@ -38,7 +39,8 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
IFeatureService featureService,
IProviderBillingService providerBillingService,
ISubscriberService subscriberService,
- IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery)
+ IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
+ IPricingClient pricingClient)
{
_eventService = eventService;
_mailService = mailService;
@@ -50,6 +52,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
_providerBillingService = providerBillingService;
_subscriberService = subscriberService;
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
+ _pricingClient = pricingClient;
}
public async Task RemoveOrganizationFromProvider(
@@ -110,7 +113,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
Email = organization.BillingEmail
});
- var plan = StaticStore.GetPlan(organization.PlanType).PasswordManager;
+ var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
var subscriptionCreateOptions = new SubscriptionCreateOptions
{
@@ -124,7 +127,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
},
OffSession = true,
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
- Items = [new SubscriptionItemOptions { Price = plan.StripeSeatPlanId, Quantity = organization.Seats }]
+ Items = [new SubscriptionItemOptions { Price = plan.PasswordManager.StripeSeatPlanId, Quantity = organization.Seats }]
};
var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs
index 864466ad45..799b57dc5a 100644
--- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs
+++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs
@@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Enums;
+using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Entities;
@@ -50,6 +51,7 @@ public class ProviderService : IProviderService
private readonly IDataProtectorTokenFactory _providerDeleteTokenDataFactory;
private readonly IApplicationCacheService _applicationCacheService;
private readonly IProviderBillingService _providerBillingService;
+ private readonly IPricingClient _pricingClient;
public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository,
IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository,
@@ -58,7 +60,7 @@ public class ProviderService : IProviderService
IOrganizationRepository organizationRepository, GlobalSettings globalSettings,
ICurrentContext currentContext, IStripeAdapter stripeAdapter, IFeatureService featureService,
IDataProtectorTokenFactory providerDeleteTokenDataFactory,
- IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService)
+ IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService, IPricingClient pricingClient)
{
_providerRepository = providerRepository;
_providerUserRepository = providerUserRepository;
@@ -77,6 +79,7 @@ public class ProviderService : IProviderService
_providerDeleteTokenDataFactory = providerDeleteTokenDataFactory;
_applicationCacheService = applicationCacheService;
_providerBillingService = providerBillingService;
+ _pricingClient = pricingClient;
}
public async Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo = null)
@@ -452,30 +455,31 @@ public class ProviderService : IProviderService
if (!string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
{
- var subscriptionItem = await GetSubscriptionItemAsync(organization.GatewaySubscriptionId,
- GetStripeSeatPlanId(organization.PlanType));
+ var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
+
+ var subscriptionItem = await GetSubscriptionItemAsync(
+ organization.GatewaySubscriptionId,
+ plan.PasswordManager.StripeSeatPlanId);
+
var extractedPlanType = PlanTypeMappings(organization);
+ var extractedPlan = await _pricingClient.GetPlanOrThrow(extractedPlanType);
+
if (subscriptionItem != null)
{
- await UpdateSubscriptionAsync(subscriptionItem, GetStripeSeatPlanId(extractedPlanType), organization);
+ await UpdateSubscriptionAsync(subscriptionItem, extractedPlan.PasswordManager.StripeSeatPlanId, organization);
}
}
await _organizationRepository.UpsertAsync(organization);
}
- private async Task GetSubscriptionItemAsync(string subscriptionId, string oldPlanId)
+ private async Task GetSubscriptionItemAsync(string subscriptionId, string oldPlanId)
{
var subscriptionDetails = await _stripeAdapter.SubscriptionGetAsync(subscriptionId);
return subscriptionDetails.Items.Data.FirstOrDefault(item => item.Price.Id == oldPlanId);
}
- private static string GetStripeSeatPlanId(PlanType planType)
- {
- return StaticStore.GetPlan(planType).PasswordManager.StripeSeatPlanId;
- }
-
- private async Task UpdateSubscriptionAsync(Stripe.SubscriptionItem subscriptionItem, string extractedPlanType, Organization organization)
+ private async Task UpdateSubscriptionAsync(SubscriptionItem subscriptionItem, string extractedPlanType, Organization organization)
{
try
{
diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs
index 7b10793283..b637cf37ef 100644
--- a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs
+++ b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs
@@ -10,6 +10,7 @@ using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models;
+using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
@@ -32,6 +33,7 @@ public class ProviderBillingService(
ILogger logger,
IOrganizationRepository organizationRepository,
IPaymentService paymentService,
+ IPricingClient pricingClient,
IProviderInvoiceItemRepository providerInvoiceItemRepository,
IProviderOrganizationRepository providerOrganizationRepository,
IProviderPlanRepository providerPlanRepository,
@@ -77,8 +79,7 @@ public class ProviderBillingService(
var managedPlanType = await GetManagedPlanTypeAsync(provider, organization);
- // TODO: Replace with PricingClient
- var plan = StaticStore.GetPlan(managedPlanType);
+ var plan = await pricingClient.GetPlanOrThrow(managedPlanType);
organization.Plan = plan.Name;
organization.PlanType = plan.Type;
organization.MaxCollections = plan.PasswordManager.MaxCollections;
@@ -154,7 +155,8 @@ public class ProviderBillingService(
return;
}
- var oldPlanConfiguration = StaticStore.GetPlan(plan.PlanType);
+ var oldPlanConfiguration = await pricingClient.GetPlanOrThrow(plan.PlanType);
+ var newPlanConfiguration = await pricingClient.GetPlanOrThrow(command.NewPlan);
plan.PlanType = command.NewPlan;
await providerPlanRepository.ReplaceAsync(plan);
@@ -178,7 +180,7 @@ public class ProviderBillingService(
[
new SubscriptionItemOptions
{
- Price = StaticStore.GetPlan(command.NewPlan).PasswordManager.StripeProviderPortalSeatPlanId,
+ Price = newPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId,
Quantity = oldSubscriptionItem!.Quantity
},
new SubscriptionItemOptions
@@ -204,7 +206,7 @@ public class ProviderBillingService(
throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
}
organization.PlanType = command.NewPlan;
- organization.Plan = StaticStore.GetPlan(command.NewPlan).Name;
+ organization.Plan = newPlanConfiguration.Name;
await organizationRepository.ReplaceAsync(organization);
}
}
@@ -347,7 +349,7 @@ public class ProviderBillingService(
{
var (organization, _) = pair;
- var planName = DerivePlanName(provider, organization);
+ var planName = await DerivePlanName(provider, organization);
var addable = new AddableOrganization(
organization.Id,
@@ -368,7 +370,7 @@ public class ProviderBillingService(
return addable with { Disabled = requiresPurchase };
}));
- string DerivePlanName(Provider localProvider, Organization localOrganization)
+ async Task DerivePlanName(Provider localProvider, Organization localOrganization)
{
if (localProvider.Type == ProviderType.Msp)
{
@@ -380,8 +382,7 @@ public class ProviderBillingService(
};
}
- // TODO: Replace with PricingClient
- var plan = StaticStore.GetPlan(localOrganization.PlanType);
+ var plan = await pricingClient.GetPlanOrThrow(localOrganization.PlanType);
return plan.Name;
}
}
@@ -568,7 +569,7 @@ public class ProviderBillingService(
foreach (var providerPlan in providerPlans)
{
- var plan = StaticStore.GetPlan(providerPlan.PlanType);
+ var plan = await pricingClient.GetPlanOrThrow(providerPlan.PlanType);
if (!providerPlan.IsConfigured())
{
@@ -652,8 +653,10 @@ public class ProviderBillingService(
if (providerPlan.SeatMinimum != newPlanConfiguration.SeatsMinimum)
{
- var priceId = StaticStore.GetPlan(newPlanConfiguration.Plan).PasswordManager
- .StripeProviderPortalSeatPlanId;
+ var newPlan = await pricingClient.GetPlanOrThrow(newPlanConfiguration.Plan);
+
+ var priceId = newPlan.PasswordManager.StripeProviderPortalSeatPlanId;
+
var subscriptionItem = subscription.Items.First(item => item.Price.Id == priceId);
if (providerPlan.PurchasedSeats == 0)
@@ -717,7 +720,7 @@ public class ProviderBillingService(
ProviderPlan providerPlan,
int newlyAssignedSeats) => async (currentlySubscribedSeats, newlySubscribedSeats) =>
{
- var plan = StaticStore.GetPlan(providerPlan.PlanType);
+ var plan = await pricingClient.GetPlanOrThrow(providerPlan.PlanType);
await paymentService.AdjustSeats(
provider,
@@ -741,7 +744,7 @@ public class ProviderBillingService(
var providerOrganizations =
await providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id);
- var plan = StaticStore.GetPlan(planType);
+ var plan = await pricingClient.GetPlanOrThrow(planType);
return providerOrganizations
.Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed)
diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs
index ee7bc398fe..d9a7d4a2ce 100644
--- a/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs
+++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs
@@ -28,6 +28,7 @@ public class MaxProjectsQuery : IMaxProjectsQuery
throw new NotFoundException();
}
+ // TODO: PRICING -> https://bitwarden.atlassian.net/browse/PM-17122
var plan = StaticStore.GetPlan(org.PlanType);
if (plan?.SecretsManager == null)
{
diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs
index f45ab75046..2debd521a5 100644
--- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs
+++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs
@@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
+using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@@ -205,6 +206,8 @@ public class RemoveOrganizationFromProviderCommandTests
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
+ sutProvider.GetDependency().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan);
+
sutProvider.GetDependency().HasConfirmedOwnersExceptAsync(
providerOrganization.OrganizationId,
[],
diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs
index 2883c9d7e3..d2d82f47de 100644
--- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs
+++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs
@@ -7,6 +7,7 @@ using Bit.Core.AdminConsole.Models.Business.Provider;
using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
+using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Entities;
@@ -550,8 +551,14 @@ public class ProviderServiceTests
organization.PlanType = PlanType.EnterpriseMonthly;
organization.Plan = "Enterprise (Monthly)";
+ sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType)
+ .Returns(StaticStore.GetPlan(organization.PlanType));
+
var expectedPlanType = PlanType.EnterpriseMonthly2020;
+ sutProvider.GetDependency().GetPlanOrThrow(expectedPlanType)
+ .Returns(StaticStore.GetPlan(expectedPlanType));
+
var expectedPlanId = "2020-enterprise-org-seat-monthly";
sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider);
diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs
index 3739603a2d..2fbd09a213 100644
--- a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs
+++ b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs
@@ -9,6 +9,7 @@ using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
+using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
@@ -128,6 +129,9 @@ public class ProviderBillingServiceTests
.GetByIdAsync(Arg.Is(p => p == providerPlanId))
.Returns(existingPlan);
+ sutProvider.GetDependency().GetPlanOrThrow(existingPlan.PlanType)
+ .Returns(StaticStore.GetPlan(existingPlan.PlanType));
+
var stripeAdapter = sutProvider.GetDependency();
stripeAdapter.ProviderSubscriptionGetAsync(
Arg.Is(provider.GatewaySubscriptionId),
@@ -156,6 +160,9 @@ public class ProviderBillingServiceTests
var command =
new ChangeProviderPlanCommand(providerPlanId, PlanType.EnterpriseMonthly, provider.GatewaySubscriptionId);
+ sutProvider.GetDependency().GetPlanOrThrow(command.NewPlan)
+ .Returns(StaticStore.GetPlan(command.NewPlan));
+
// Act
await sutProvider.Sut.ChangePlan(command);
@@ -390,6 +397,12 @@ public class ProviderBillingServiceTests
}
};
+ foreach (var plan in providerPlans)
+ {
+ sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType)
+ .Returns(StaticStore.GetPlan(plan.PlanType));
+ }
+
sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans);
// 50 seats currently assigned with a seat minimum of 100
@@ -451,6 +464,12 @@ public class ProviderBillingServiceTests
}
};
+ foreach (var plan in providerPlans)
+ {
+ sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType)
+ .Returns(StaticStore.GetPlan(plan.PlanType));
+ }
+
var providerPlan = providerPlans.First();
sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans);
@@ -515,6 +534,12 @@ public class ProviderBillingServiceTests
}
};
+ foreach (var plan in providerPlans)
+ {
+ sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType)
+ .Returns(StaticStore.GetPlan(plan.PlanType));
+ }
+
var providerPlan = providerPlans.First();
sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans);
@@ -579,6 +604,12 @@ public class ProviderBillingServiceTests
}
};
+ foreach (var plan in providerPlans)
+ {
+ sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType)
+ .Returns(StaticStore.GetPlan(plan.PlanType));
+ }
+
var providerPlan = providerPlans.First();
sutProvider.GetDependency