From d0c72a34f12baadede2c191a814315f153872b4a Mon Sep 17 00:00:00 2001 From: Opeyemi Date: Mon, 16 Dec 2024 14:21:05 +0000 Subject: [PATCH 01/19] Update SH Unified Build trigger (#5154) * Update SH Unified Build trigger * make value a boolean --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 420b9b63750a..eb644fe8b5e2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -580,6 +580,7 @@ jobs: ref: 'main', inputs: { server_branch: process.env.GITHUB_REF + is_workflow_call: true } }); From 8994d1d7dd019e857d68e8ae46262f8daea82058 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 16 Dec 2024 15:11:56 +0000 Subject: [PATCH 02/19] [deps] Tools: Update aws-sdk-net monorepo (#5126) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> --- src/Core/Core.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index fd4d8cc7e124..9049f94dcfb9 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -21,8 +21,8 @@ - - + + From c446ac86fead55e6d7b762ca44ea814d9d53e80b Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Mon, 16 Dec 2024 07:57:56 -0800 Subject: [PATCH 03/19] [PM-12512] Add Endpoint to allow users to request a new device otp (#5146) feat(NewDeviceVerification): Added a resend new device OTP endpoint and method for the IUserService as well as wrote test for new methods for the user service. --- .../Auth/Controllers/AccountsController.cs | 8 + ...henticatedSecretVerificatioRequestModel.cs | 12 ++ src/Core/Services/IUserService.cs | 2 +- .../Services/Implementations/UserService.cs | 16 +- test/Core.Test/Services/UserServiceTests.cs | 171 ++++++++++++++---- 5 files changed, 171 insertions(+), 38 deletions(-) create mode 100644 src/Api/Auth/Models/Request/Accounts/UnauthenticatedSecretVerificatioRequestModel.cs diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index a94e170cbbab..1c08ce4f7394 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -961,6 +961,14 @@ public async Task VerifyOTP([FromBody] VerifyOTPRequestModel model) } } + [RequireFeature(FeatureFlagKeys.NewDeviceVerification)] + [AllowAnonymous] + [HttpPost("resend-new-device-otp")] + public async Task ResendNewDeviceOtpAsync([FromBody] UnauthenticatedSecretVerificatioRequestModel request) + { + await _userService.ResendNewDeviceVerificationEmail(request.Email, request.Secret); + } + private async Task> GetOrganizationIdsManagingUserAsync(Guid userId) { var organizationManagingUser = await _userService.GetOrganizationsManagingUserAsync(userId); diff --git a/src/Api/Auth/Models/Request/Accounts/UnauthenticatedSecretVerificatioRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/UnauthenticatedSecretVerificatioRequestModel.cs new file mode 100644 index 000000000000..629896b8c4aa --- /dev/null +++ b/src/Api/Auth/Models/Request/Accounts/UnauthenticatedSecretVerificatioRequestModel.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Utilities; + +namespace Bit.Api.Auth.Models.Request.Accounts; + +public class UnauthenticatedSecretVerificatioRequestModel : SecretVerificationRequestModel +{ + [Required] + [StrictEmailAddress] + [StringLength(256)] + public string Email { get; set; } +} diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index 65bec5ea9f78..f0ba5352662e 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -76,7 +76,7 @@ Task UpdatePasswordHash(User user, string newPassword, Task SendOTPAsync(User user); Task VerifyOTPAsync(User user, string token); Task VerifySecretAsync(User user, string secret, bool isSettingMFA = false); - + Task ResendNewDeviceVerificationEmail(string email, string secret); void SetTwoFactorProvider(User user, TwoFactorProviderType type, bool setEnabled = true); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 2bc81959b9b3..cb17d6e26b45 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -1407,7 +1407,7 @@ public async Task RotateApiKeyAsync(User user) public async Task SendOTPAsync(User user) { - if (user.Email == null) + if (string.IsNullOrEmpty(user.Email)) { throw new BadRequestException("No user email."); } @@ -1450,6 +1450,20 @@ public async Task VerifySecretAsync(User user, string secret, bool isSetti return isVerified; } + public async Task ResendNewDeviceVerificationEmail(string email, string secret) + { + var user = await _userRepository.GetByEmailAsync(email); + if (user == null) + { + return; + } + + if (await VerifySecretAsync(user, secret)) + { + await SendOTPAsync(user); + } + } + private async Task SendAppropriateWelcomeEmailAsync(User user, string initiationPath) { var isFromMarketingWebsite = initiationPath.Contains("Secrets Manager trial"); diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs index de2a5187171c..e44609c6d64c 100644 --- a/test/Core.Test/Services/UserServiceTests.cs +++ b/test/Core.Test/Services/UserServiceTests.cs @@ -13,6 +13,7 @@ using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Data.Organizations.OrganizationUsers; @@ -240,42 +241,7 @@ public async Task VerifySecretAsync_Works( }); // HACK: SutProvider is being weird about not injecting the IPasswordHasher that I configured - var sut = new UserService( - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency>(), - sutProvider.GetDependency>(), - sutProvider.GetDependency>(), - sutProvider.GetDependency>>(), - sutProvider.GetDependency>>(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency>>(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - new FakeDataProtectorTokenFactory(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency() - ); + var sut = RebuildSut(sutProvider); var actualIsVerified = await sut.VerifySecretAsync(user, secret); @@ -522,6 +488,99 @@ await sutProvider.GetDependency() .SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(organization2.DisplayName(), user.Email); } + [Theory, BitAutoData] + public async Task ResendNewDeviceVerificationEmail_UserNull_SendOTPAsyncNotCalled( + SutProvider sutProvider, string email, string secret) + { + sutProvider.GetDependency() + .GetByEmailAsync(email) + .Returns(null as User); + + await sutProvider.Sut.ResendNewDeviceVerificationEmail(email, secret); + + await sutProvider.GetDependency() + .DidNotReceive() + .SendOTPEmailAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ResendNewDeviceVerificationEmail_SecretNotValid_SendOTPAsyncNotCalled( + SutProvider sutProvider, string email, string secret) + { + sutProvider.GetDependency() + .GetByEmailAsync(email) + .Returns(null as User); + + await sutProvider.Sut.ResendNewDeviceVerificationEmail(email, secret); + + await sutProvider.GetDependency() + .DidNotReceive() + .SendOTPEmailAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ResendNewDeviceVerificationEmail_SendsToken_Success( + SutProvider sutProvider, User user) + { + // Arrange + var testPassword = "test_password"; + var tokenProvider = SetupFakeTokenProvider(sutProvider, user); + SetupUserAndDevice(user, true); + + // Setup the fake password verification + var substitutedUserPasswordStore = Substitute.For>(); + substitutedUserPasswordStore + .GetPasswordHashAsync(user, Arg.Any()) + .Returns((ci) => + { + return Task.FromResult("hashed_test_password"); + }); + + sutProvider.SetDependency>(substitutedUserPasswordStore, "store"); + + sutProvider.GetDependency>("passwordHasher") + .VerifyHashedPassword(user, "hashed_test_password", testPassword) + .Returns((ci) => + { + return PasswordVerificationResult.Success; + }); + + sutProvider.GetDependency() + .GetByEmailAsync(user.Email) + .Returns(user); + + // HACK: SutProvider is being weird about not injecting the IPasswordHasher that I configured + var sut = RebuildSut(sutProvider); + + await sut.ResendNewDeviceVerificationEmail(user.Email, testPassword); + + await sutProvider.GetDependency() + .Received(1) + .SendOTPEmailAsync(user.Email, Arg.Any()); + } + + [Theory] + [BitAutoData("")] + [BitAutoData("null")] + public async Task SendOTPAsync_UserEmailNull_ThrowsBadRequest( + string email, + SutProvider sutProvider, User user) + { + user.Email = email == "null" ? null : ""; + var expectedMessage = "No user email."; + try + { + await sutProvider.Sut.SendOTPAsync(user); + } + catch (BadRequestException ex) + { + Assert.Equal(ex.Message, expectedMessage); + await sutProvider.GetDependency() + .DidNotReceive() + .SendOTPEmailAsync(Arg.Any(), Arg.Any()); + } + } + private static void SetupUserAndDevice(User user, bool shouldHavePassword) { @@ -573,4 +632,44 @@ private static IUserTwoFactorTokenProvider SetupFakeTokenProvider(SutProvi return fakeUserTwoFactorProvider; } + + private IUserService RebuildSut(SutProvider sutProvider) + { + return new UserService( + sutProvider.GetDependency(), + sutProvider.GetDependency(), + sutProvider.GetDependency(), + sutProvider.GetDependency(), + sutProvider.GetDependency(), + sutProvider.GetDependency(), + sutProvider.GetDependency>(), + sutProvider.GetDependency>(), + sutProvider.GetDependency>(), + sutProvider.GetDependency>>(), + sutProvider.GetDependency>>(), + sutProvider.GetDependency(), + sutProvider.GetDependency(), + sutProvider.GetDependency(), + sutProvider.GetDependency>>(), + sutProvider.GetDependency(), + sutProvider.GetDependency(), + sutProvider.GetDependency(), + sutProvider.GetDependency(), + sutProvider.GetDependency(), + sutProvider.GetDependency(), + sutProvider.GetDependency(), + sutProvider.GetDependency(), + sutProvider.GetDependency(), + sutProvider.GetDependency(), + sutProvider.GetDependency(), + sutProvider.GetDependency(), + sutProvider.GetDependency(), + sutProvider.GetDependency(), + new FakeDataProtectorTokenFactory(), + sutProvider.GetDependency(), + sutProvider.GetDependency(), + sutProvider.GetDependency(), + sutProvider.GetDependency() + ); + } } From d88a103fbc8baa60147eaf377d05eeeb87b1eb7a Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Mon, 16 Dec 2024 17:11:37 +0100 Subject: [PATCH 04/19] Move CSVHelper under billing ownership (#5156) Co-authored-by: Daniel James Smith --- .github/renovate.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/renovate.json b/.github/renovate.json index 5779b28edb41..4ae3cc19d8b4 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -63,6 +63,7 @@ "BitPay.Light", "Braintree", "coverlet.collector", + "CsvHelper", "FluentAssertions", "Kralizek.AutoFixture.Extensions.MockHttp", "Microsoft.AspNetCore.Mvc.Testing", From 7637cbe12ac67006db73573f3eb9a1010a7cb153 Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Mon, 16 Dec 2024 12:01:09 -0600 Subject: [PATCH 05/19] [PM-13362] Add private key regeneration endpoint (#4929) * Add new RegenerateUserAsymmetricKeysCommand * add new command tests * Add regen controller * Add regen controller tests * add feature flag * Add push notification to sync new asymmetric keys to other devices --- .../AccountsKeyManagementController.cs | 50 +++++ .../Requests/KeyRegenerationRequestModel.cs | 23 ++ src/Core/Constants.cs | 1 + .../IRegenerateUserAsymmetricKeysCommand.cs | 13 ++ .../RegenerateUserAsymmetricKeysCommand.cs | 71 +++++++ ...eyManagementServiceCollectionExtensions.cs | 18 ++ .../Utilities/ServiceCollectionExtensions.cs | 2 + .../Helpers/LoginHelper.cs | 6 + .../AccountsKeyManagementControllerTests.cs | 164 +++++++++++++++ .../AccountsKeyManagementControllerTests.cs | 96 +++++++++ ...egenerateUserAsymmetricKeysCommandTests.cs | 197 ++++++++++++++++++ 11 files changed, 641 insertions(+) create mode 100644 src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs create mode 100644 src/Api/KeyManagement/Models/Requests/KeyRegenerationRequestModel.cs create mode 100644 src/Core/KeyManagement/Commands/Interfaces/IRegenerateUserAsymmetricKeysCommand.cs create mode 100644 src/Core/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommand.cs create mode 100644 src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs create mode 100644 test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs create mode 100644 test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs create mode 100644 test/Core.Test/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommandTests.cs diff --git a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs new file mode 100644 index 000000000000..b8d5e3094931 --- /dev/null +++ b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs @@ -0,0 +1,50 @@ +#nullable enable +using Bit.Api.KeyManagement.Models.Requests; +using Bit.Core; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Commands.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.KeyManagement.Controllers; + +[Route("accounts/key-management")] +[Authorize("Application")] +public class AccountsKeyManagementController : Controller +{ + private readonly IEmergencyAccessRepository _emergencyAccessRepository; + private readonly IFeatureService _featureService; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IRegenerateUserAsymmetricKeysCommand _regenerateUserAsymmetricKeysCommand; + private readonly IUserService _userService; + + public AccountsKeyManagementController(IUserService userService, + IFeatureService featureService, + IOrganizationUserRepository organizationUserRepository, + IEmergencyAccessRepository emergencyAccessRepository, + IRegenerateUserAsymmetricKeysCommand regenerateUserAsymmetricKeysCommand) + { + _userService = userService; + _featureService = featureService; + _regenerateUserAsymmetricKeysCommand = regenerateUserAsymmetricKeysCommand; + _organizationUserRepository = organizationUserRepository; + _emergencyAccessRepository = emergencyAccessRepository; + } + + [HttpPost("regenerate-keys")] + public async Task RegenerateKeysAsync([FromBody] KeyRegenerationRequestModel request) + { + if (!_featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration)) + { + throw new NotFoundException(); + } + + var user = await _userService.GetUserByPrincipalAsync(User) ?? throw new UnauthorizedAccessException(); + var usersOrganizationAccounts = await _organizationUserRepository.GetManyByUserAsync(user.Id); + var designatedEmergencyAccess = await _emergencyAccessRepository.GetManyDetailsByGranteeIdAsync(user.Id); + await _regenerateUserAsymmetricKeysCommand.RegenerateKeysAsync(request.ToUserAsymmetricKeys(user.Id), + usersOrganizationAccounts, designatedEmergencyAccess); + } +} diff --git a/src/Api/KeyManagement/Models/Requests/KeyRegenerationRequestModel.cs b/src/Api/KeyManagement/Models/Requests/KeyRegenerationRequestModel.cs new file mode 100644 index 000000000000..495d13cccd95 --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/KeyRegenerationRequestModel.cs @@ -0,0 +1,23 @@ +#nullable enable +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Utilities; + +namespace Bit.Api.KeyManagement.Models.Requests; + +public class KeyRegenerationRequestModel +{ + public required string UserPublicKey { get; set; } + + [EncryptedString] + public required string UserKeyEncryptedUserPrivateKey { get; set; } + + public UserAsymmetricKeys ToUserAsymmetricKeys(Guid userId) + { + return new UserAsymmetricKeys + { + UserId = userId, + PublicKey = UserPublicKey, + UserKeyEncryptedPrivateKey = UserKeyEncryptedUserPrivateKey, + }; + } +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index df0abfb4b999..2c315b257848 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -160,6 +160,7 @@ public static class FeatureFlagKeys public const string PM12443RemovePagingLogic = "pm-12443-remove-paging-logic"; public const string SelfHostLicenseRefactor = "pm-11516-self-host-license-refactor"; public const string PromoteProviderServiceUserTool = "pm-15128-promote-provider-service-user-tool"; + public const string PrivateKeyRegeneration = "pm-12241-private-key-regeneration"; public static List GetAllKeys() { diff --git a/src/Core/KeyManagement/Commands/Interfaces/IRegenerateUserAsymmetricKeysCommand.cs b/src/Core/KeyManagement/Commands/Interfaces/IRegenerateUserAsymmetricKeysCommand.cs new file mode 100644 index 000000000000..d7ad7e3959eb --- /dev/null +++ b/src/Core/KeyManagement/Commands/Interfaces/IRegenerateUserAsymmetricKeysCommand.cs @@ -0,0 +1,13 @@ +#nullable enable +using Bit.Core.Auth.Models.Data; +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Core.KeyManagement.Commands.Interfaces; + +public interface IRegenerateUserAsymmetricKeysCommand +{ + Task RegenerateKeysAsync(UserAsymmetricKeys userAsymmetricKeys, + ICollection usersOrganizationAccounts, + ICollection designatedEmergencyAccess); +} diff --git a/src/Core/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommand.cs b/src/Core/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommand.cs new file mode 100644 index 000000000000..a54223f685b2 --- /dev/null +++ b/src/Core/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommand.cs @@ -0,0 +1,71 @@ +#nullable enable +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Commands.Interfaces; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.KeyManagement.Repositories; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.KeyManagement.Commands; + +public class RegenerateUserAsymmetricKeysCommand : IRegenerateUserAsymmetricKeysCommand +{ + private readonly ICurrentContext _currentContext; + private readonly ILogger _logger; + private readonly IUserAsymmetricKeysRepository _userAsymmetricKeysRepository; + private readonly IPushNotificationService _pushService; + + public RegenerateUserAsymmetricKeysCommand( + ICurrentContext currentContext, + IUserAsymmetricKeysRepository userAsymmetricKeysRepository, + IPushNotificationService pushService, + ILogger logger) + { + _currentContext = currentContext; + _logger = logger; + _userAsymmetricKeysRepository = userAsymmetricKeysRepository; + _pushService = pushService; + } + + public async Task RegenerateKeysAsync(UserAsymmetricKeys userAsymmetricKeys, + ICollection usersOrganizationAccounts, + ICollection designatedEmergencyAccess) + { + var userId = _currentContext.UserId; + if (!userId.HasValue || + userAsymmetricKeys.UserId != userId.Value || + usersOrganizationAccounts.Any(ou => ou.UserId != userId) || + designatedEmergencyAccess.Any(dea => dea.GranteeId != userId)) + { + throw new NotFoundException(); + } + + var inOrganizations = usersOrganizationAccounts.Any(ou => + ou.Status is OrganizationUserStatusType.Confirmed or OrganizationUserStatusType.Revoked); + var hasDesignatedEmergencyAccess = designatedEmergencyAccess.Any(x => + x.Status is EmergencyAccessStatusType.Confirmed or EmergencyAccessStatusType.RecoveryApproved + or EmergencyAccessStatusType.RecoveryInitiated); + + _logger.LogInformation( + "User asymmetric keys regeneration requested. UserId: {userId} OrganizationMembership: {inOrganizations} DesignatedEmergencyAccess: {hasDesignatedEmergencyAccess} DeviceType: {deviceType}", + userAsymmetricKeys.UserId, inOrganizations, hasDesignatedEmergencyAccess, _currentContext.DeviceType); + + // For now, don't regenerate asymmetric keys for user's with organization membership and designated emergency access. + if (inOrganizations || hasDesignatedEmergencyAccess) + { + throw new BadRequestException("Key regeneration not supported for this user."); + } + + await _userAsymmetricKeysRepository.RegenerateUserAsymmetricKeysAsync(userAsymmetricKeys); + _logger.LogInformation( + "User's asymmetric keys regenerated. UserId: {userId} OrganizationMembership: {inOrganizations} DesignatedEmergencyAccess: {hasDesignatedEmergencyAccess} DeviceType: {deviceType}", + userAsymmetricKeys.UserId, inOrganizations, hasDesignatedEmergencyAccess, _currentContext.DeviceType); + + await _pushService.PushSyncSettingsAsync(userId.Value); + } +} diff --git a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs new file mode 100644 index 000000000000..102630c7e6b7 --- /dev/null +++ b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs @@ -0,0 +1,18 @@ +using Bit.Core.KeyManagement.Commands; +using Bit.Core.KeyManagement.Commands.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.KeyManagement; + +public static class KeyManagementServiceCollectionExtensions +{ + public static void AddKeyManagementServices(this IServiceCollection services) + { + services.AddKeyManagementCommands(); + } + + private static void AddKeyManagementCommands(this IServiceCollection services) + { + services.AddScoped(); + } +} diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 7585739d82ca..c757f163e92d 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -26,6 +26,7 @@ using Bit.Core.HostedServices; using Bit.Core.Identity; using Bit.Core.IdentityServer; +using Bit.Core.KeyManagement; using Bit.Core.NotificationHub; using Bit.Core.OrganizationFeatures; using Bit.Core.Repositories; @@ -120,6 +121,7 @@ public static void AddBaseServices(this IServiceCollection services, IGlobalSett services.AddScoped(); services.AddVaultServices(); services.AddReportingServices(); + services.AddKeyManagementServices(); } public static void AddTokenizers(this IServiceCollection services) diff --git a/test/Api.IntegrationTest/Helpers/LoginHelper.cs b/test/Api.IntegrationTest/Helpers/LoginHelper.cs index d6ce911bd0fe..1f5eb725d956 100644 --- a/test/Api.IntegrationTest/Helpers/LoginHelper.cs +++ b/test/Api.IntegrationTest/Helpers/LoginHelper.cs @@ -16,6 +16,12 @@ public LoginHelper(ApiApplicationFactory factory, HttpClient client) _client = client; } + public async Task LoginAsync(string email) + { + var tokens = await _factory.LoginAsync(email); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Token); + } + public async Task LoginWithOrganizationApiKeyAsync(Guid organizationId) { var (clientId, apiKey) = await GetOrganizationApiKey(_factory, organizationId); diff --git a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs new file mode 100644 index 000000000000..ec7ca3746010 --- /dev/null +++ b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -0,0 +1,164 @@ +using System.Net; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Api.KeyManagement.Models.Requests; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Billing.Enums; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Api.IntegrationTest.KeyManagement.Controllers; + +public class AccountsKeyManagementControllerTests : IClassFixture, IAsyncLifetime +{ + private static readonly string _mockEncryptedString = + "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk="; + + private readonly HttpClient _client; + private readonly IEmergencyAccessRepository _emergencyAccessRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly ApiApplicationFactory _factory; + private readonly LoginHelper _loginHelper; + private readonly IUserRepository _userRepository; + private string _ownerEmail = null!; + + public AccountsKeyManagementControllerTests(ApiApplicationFactory factory) + { + _factory = factory; + _factory.UpdateConfiguration("globalSettings:launchDarkly:flagValues:pm-12241-private-key-regeneration", + "true"); + _client = factory.CreateClient(); + _loginHelper = new LoginHelper(_factory, _client); + _userRepository = _factory.GetService(); + _emergencyAccessRepository = _factory.GetService(); + _organizationUserRepository = _factory.GetService(); + } + + public async Task InitializeAsync() + { + _ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(_ownerEmail); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + [Theory] + [BitAutoData] + public async Task RegenerateKeysAsync_FeatureFlagTurnedOff_NotFound(KeyRegenerationRequestModel request) + { + // Localize factory to inject a false value for the feature flag. + var localFactory = new ApiApplicationFactory(); + localFactory.UpdateConfiguration("globalSettings:launchDarkly:flagValues:pm-12241-private-key-regeneration", + "false"); + var localClient = localFactory.CreateClient(); + var localEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + var localLoginHelper = new LoginHelper(localFactory, localClient); + await localFactory.LoginWithNewAccount(localEmail); + await localLoginHelper.LoginAsync(localEmail); + + request.UserKeyEncryptedUserPrivateKey = _mockEncryptedString; + + var response = await localClient.PostAsJsonAsync("/accounts/key-management/regenerate-keys", request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Theory] + [BitAutoData] + public async Task RegenerateKeysAsync_NotLoggedIn_Unauthorized(KeyRegenerationRequestModel request) + { + request.UserKeyEncryptedUserPrivateKey = _mockEncryptedString; + + var response = await _client.PostAsJsonAsync("/accounts/key-management/regenerate-keys", request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Theory] + [BitAutoData(OrganizationUserStatusType.Confirmed, EmergencyAccessStatusType.Confirmed)] + [BitAutoData(OrganizationUserStatusType.Confirmed, EmergencyAccessStatusType.RecoveryApproved)] + [BitAutoData(OrganizationUserStatusType.Confirmed, EmergencyAccessStatusType.RecoveryInitiated)] + [BitAutoData(OrganizationUserStatusType.Revoked, EmergencyAccessStatusType.Confirmed)] + [BitAutoData(OrganizationUserStatusType.Revoked, EmergencyAccessStatusType.RecoveryApproved)] + [BitAutoData(OrganizationUserStatusType.Revoked, EmergencyAccessStatusType.RecoveryInitiated)] + [BitAutoData(OrganizationUserStatusType.Confirmed, null)] + [BitAutoData(OrganizationUserStatusType.Revoked, null)] + [BitAutoData(OrganizationUserStatusType.Invited, EmergencyAccessStatusType.Confirmed)] + [BitAutoData(OrganizationUserStatusType.Invited, EmergencyAccessStatusType.RecoveryApproved)] + [BitAutoData(OrganizationUserStatusType.Invited, EmergencyAccessStatusType.RecoveryInitiated)] + public async Task RegenerateKeysAsync_UserInOrgOrHasDesignatedEmergencyAccess_ThrowsBadRequest( + OrganizationUserStatusType organizationUserStatus, + EmergencyAccessStatusType? emergencyAccessStatus, + KeyRegenerationRequestModel request) + { + if (organizationUserStatus is OrganizationUserStatusType.Confirmed or OrganizationUserStatusType.Revoked) + { + await CreateOrganizationUserAsync(organizationUserStatus); + } + + if (emergencyAccessStatus != null) + { + await CreateDesignatedEmergencyAccessAsync(emergencyAccessStatus.Value); + } + + await _loginHelper.LoginAsync(_ownerEmail); + request.UserKeyEncryptedUserPrivateKey = _mockEncryptedString; + + var response = await _client.PostAsJsonAsync("/accounts/key-management/regenerate-keys", request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Theory] + [BitAutoData] + public async Task RegenerateKeysAsync_Success(KeyRegenerationRequestModel request) + { + await _loginHelper.LoginAsync(_ownerEmail); + request.UserKeyEncryptedUserPrivateKey = _mockEncryptedString; + + var response = await _client.PostAsJsonAsync("/accounts/key-management/regenerate-keys", request); + response.EnsureSuccessStatusCode(); + + var user = await _userRepository.GetByEmailAsync(_ownerEmail); + Assert.NotNull(user); + Assert.Equal(request.UserPublicKey, user.PublicKey); + Assert.Equal(request.UserKeyEncryptedUserPrivateKey, user.PrivateKey); + } + + private async Task CreateOrganizationUserAsync(OrganizationUserStatusType organizationUserStatus) + { + var (_, organizationUser) = await OrganizationTestHelpers.SignUpAsync(_factory, + PlanType.EnterpriseAnnually, _ownerEmail, passwordManagerSeats: 10, + paymentMethod: PaymentMethodType.Card); + organizationUser.Status = organizationUserStatus; + await _organizationUserRepository.ReplaceAsync(organizationUser); + } + + private async Task CreateDesignatedEmergencyAccessAsync(EmergencyAccessStatusType emergencyAccessStatus) + { + var tempEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(tempEmail); + + var tempUser = await _userRepository.GetByEmailAsync(tempEmail); + var user = await _userRepository.GetByEmailAsync(_ownerEmail); + var emergencyAccess = new EmergencyAccess + { + GrantorId = tempUser!.Id, + GranteeId = user!.Id, + KeyEncrypted = _mockEncryptedString, + Status = emergencyAccessStatus, + Type = EmergencyAccessType.View, + WaitTimeDays = 10, + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow + }; + await _emergencyAccessRepository.CreateAsync(emergencyAccess); + } +} diff --git a/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs new file mode 100644 index 000000000000..2615697ad374 --- /dev/null +++ b/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -0,0 +1,96 @@ +#nullable enable +using System.Security.Claims; +using Bit.Api.KeyManagement.Controllers; +using Bit.Api.KeyManagement.Models.Requests; +using Bit.Core; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Commands.Interfaces; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; + +namespace Bit.Api.Test.KeyManagement.Controllers; + +[ControllerCustomize(typeof(AccountsKeyManagementController))] +[SutProviderCustomize] +[JsonDocumentCustomize] +public class AccountsKeyManagementControllerTests +{ + [Theory] + [BitAutoData] + public async Task RegenerateKeysAsync_FeatureFlagOff_Throws( + SutProvider sutProvider, + KeyRegenerationRequestModel data) + { + sutProvider.GetDependency().IsEnabled(Arg.Is(FeatureFlagKeys.PrivateKeyRegeneration)) + .Returns(false); + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).ReturnsNull(); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RegenerateKeysAsync(data)); + + await sutProvider.GetDependency().ReceivedWithAnyArgs(0) + .GetManyByUserAsync(Arg.Any()); + await sutProvider.GetDependency().ReceivedWithAnyArgs(0) + .GetManyDetailsByGranteeIdAsync(Arg.Any()); + await sutProvider.GetDependency().ReceivedWithAnyArgs(0) + .RegenerateKeysAsync(Arg.Any(), + Arg.Any>(), + Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task RegenerateKeysAsync_UserNull_Throws(SutProvider sutProvider, + KeyRegenerationRequestModel data) + { + sutProvider.GetDependency().IsEnabled(Arg.Is(FeatureFlagKeys.PrivateKeyRegeneration)) + .Returns(true); + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).ReturnsNull(); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RegenerateKeysAsync(data)); + + await sutProvider.GetDependency().ReceivedWithAnyArgs(0) + .GetManyByUserAsync(Arg.Any()); + await sutProvider.GetDependency().ReceivedWithAnyArgs(0) + .GetManyDetailsByGranteeIdAsync(Arg.Any()); + await sutProvider.GetDependency().ReceivedWithAnyArgs(0) + .RegenerateKeysAsync(Arg.Any(), + Arg.Any>(), + Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task RegenerateKeysAsync_Success(SutProvider sutProvider, + KeyRegenerationRequestModel data, User user, ICollection orgUsers, + ICollection accessDetails) + { + sutProvider.GetDependency().IsEnabled(Arg.Is(FeatureFlagKeys.PrivateKeyRegeneration)) + .Returns(true); + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user); + sutProvider.GetDependency().GetManyByUserAsync(Arg.Is(user.Id)).Returns(orgUsers); + sutProvider.GetDependency().GetManyDetailsByGranteeIdAsync(Arg.Is(user.Id)) + .Returns(accessDetails); + + await sutProvider.Sut.RegenerateKeysAsync(data); + + await sutProvider.GetDependency().Received(1) + .GetManyByUserAsync(Arg.Is(user.Id)); + await sutProvider.GetDependency().Received(1) + .GetManyDetailsByGranteeIdAsync(Arg.Is(user.Id)); + await sutProvider.GetDependency().Received(1) + .RegenerateKeysAsync( + Arg.Is(u => + u.UserId == user.Id && u.PublicKey == data.UserPublicKey && + u.UserKeyEncryptedPrivateKey == data.UserKeyEncryptedUserPrivateKey), + Arg.Is(orgUsers), + Arg.Is(accessDetails)); + } +} diff --git a/test/Core.Test/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommandTests.cs b/test/Core.Test/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommandTests.cs new file mode 100644 index 000000000000..3388956156e7 --- /dev/null +++ b/test/Core.Test/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommandTests.cs @@ -0,0 +1,197 @@ +#nullable enable +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Commands; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.KeyManagement.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; + +namespace Bit.Core.Test.KeyManagement.Commands; + +[SutProviderCustomize] +public class RegenerateUserAsymmetricKeysCommandTests +{ + [Theory] + [BitAutoData] + public async Task RegenerateKeysAsync_NoCurrentContext_NotFoundException( + SutProvider sutProvider, + UserAsymmetricKeys userAsymmetricKeys) + { + sutProvider.GetDependency().UserId.ReturnsNullForAnyArgs(); + var usersOrganizationAccounts = new List(); + var designatedEmergencyAccess = new List(); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RegenerateKeysAsync(userAsymmetricKeys, + usersOrganizationAccounts, designatedEmergencyAccess)); + } + + [Theory] + [BitAutoData] + public async Task RegenerateKeysAsync_UserHasNoSharedAccess_Success( + SutProvider sutProvider, + UserAsymmetricKeys userAsymmetricKeys) + { + sutProvider.GetDependency().UserId.ReturnsForAnyArgs(userAsymmetricKeys.UserId); + var usersOrganizationAccounts = new List(); + var designatedEmergencyAccess = new List(); + + await sutProvider.Sut.RegenerateKeysAsync(userAsymmetricKeys, + usersOrganizationAccounts, designatedEmergencyAccess); + + await sutProvider.GetDependency() + .Received(1) + .RegenerateUserAsymmetricKeysAsync(Arg.Is(userAsymmetricKeys)); + await sutProvider.GetDependency() + .Received(1) + .PushSyncSettingsAsync(Arg.Is(userAsymmetricKeys.UserId)); + } + + [Theory] + [BitAutoData(false, false, true)] + [BitAutoData(false, true, false)] + [BitAutoData(false, true, true)] + [BitAutoData(true, false, false)] + [BitAutoData(true, false, true)] + [BitAutoData(true, true, false)] + [BitAutoData(true, true, true)] + public async Task RegenerateKeysAsync_UserIdMisMatch_NotFoundException( + bool userAsymmetricKeysMismatch, + bool orgMismatch, + bool emergencyAccessMismatch, + SutProvider sutProvider, + UserAsymmetricKeys userAsymmetricKeys, + ICollection usersOrganizationAccounts, + ICollection designatedEmergencyAccess) + { + sutProvider.GetDependency().UserId + .ReturnsForAnyArgs(userAsymmetricKeysMismatch ? new Guid() : userAsymmetricKeys.UserId); + + if (!orgMismatch) + { + usersOrganizationAccounts = + SetupOrganizationUserAccounts(userAsymmetricKeys.UserId, usersOrganizationAccounts); + } + + if (!emergencyAccessMismatch) + { + designatedEmergencyAccess = SetupEmergencyAccess(userAsymmetricKeys.UserId, designatedEmergencyAccess); + } + + await Assert.ThrowsAsync(() => sutProvider.Sut.RegenerateKeysAsync(userAsymmetricKeys, + usersOrganizationAccounts, designatedEmergencyAccess)); + + await sutProvider.GetDependency() + .ReceivedWithAnyArgs(0) + .RegenerateUserAsymmetricKeysAsync(Arg.Any()); + await sutProvider.GetDependency() + .ReceivedWithAnyArgs(0) + .PushSyncSettingsAsync(Arg.Any()); + } + + [Theory] + [BitAutoData(OrganizationUserStatusType.Confirmed)] + [BitAutoData(OrganizationUserStatusType.Revoked)] + public async Task RegenerateKeysAsync_UserInOrganizations_BadRequestException( + OrganizationUserStatusType organizationUserStatus, + SutProvider sutProvider, + UserAsymmetricKeys userAsymmetricKeys, + ICollection usersOrganizationAccounts) + { + sutProvider.GetDependency().UserId.ReturnsForAnyArgs(userAsymmetricKeys.UserId); + usersOrganizationAccounts = CreateInOrganizationAccounts(userAsymmetricKeys.UserId, organizationUserStatus, + usersOrganizationAccounts); + var designatedEmergencyAccess = new List(); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RegenerateKeysAsync(userAsymmetricKeys, + usersOrganizationAccounts, designatedEmergencyAccess)); + + await sutProvider.GetDependency() + .ReceivedWithAnyArgs(0) + .RegenerateUserAsymmetricKeysAsync(Arg.Any()); + await sutProvider.GetDependency() + .ReceivedWithAnyArgs(0) + .PushSyncSettingsAsync(Arg.Any()); + } + + [Theory] + [BitAutoData(EmergencyAccessStatusType.Confirmed)] + [BitAutoData(EmergencyAccessStatusType.RecoveryApproved)] + [BitAutoData(EmergencyAccessStatusType.RecoveryInitiated)] + public async Task RegenerateKeysAsync_UserHasDesignatedEmergencyAccess_BadRequestException( + EmergencyAccessStatusType statusType, + SutProvider sutProvider, + UserAsymmetricKeys userAsymmetricKeys, + ICollection designatedEmergencyAccess) + { + sutProvider.GetDependency().UserId.ReturnsForAnyArgs(userAsymmetricKeys.UserId); + designatedEmergencyAccess = + CreateDesignatedEmergencyAccess(userAsymmetricKeys.UserId, statusType, designatedEmergencyAccess); + var usersOrganizationAccounts = new List(); + + + await Assert.ThrowsAsync(() => sutProvider.Sut.RegenerateKeysAsync(userAsymmetricKeys, + usersOrganizationAccounts, designatedEmergencyAccess)); + + await sutProvider.GetDependency() + .ReceivedWithAnyArgs(0) + .RegenerateUserAsymmetricKeysAsync(Arg.Any()); + await sutProvider.GetDependency() + .ReceivedWithAnyArgs(0) + .PushSyncSettingsAsync(Arg.Any()); + } + + private static ICollection CreateInOrganizationAccounts(Guid userId, + OrganizationUserStatusType organizationUserStatus, ICollection organizationUserAccounts) + { + foreach (var organizationUserAccount in organizationUserAccounts) + { + organizationUserAccount.UserId = userId; + organizationUserAccount.Status = organizationUserStatus; + } + + return organizationUserAccounts; + } + + private static ICollection CreateDesignatedEmergencyAccess(Guid userId, + EmergencyAccessStatusType status, ICollection designatedEmergencyAccess) + { + foreach (var designated in designatedEmergencyAccess) + { + designated.GranteeId = userId; + designated.Status = status; + } + + return designatedEmergencyAccess; + } + + private static ICollection SetupOrganizationUserAccounts(Guid userId, + ICollection organizationUserAccounts) + { + foreach (var organizationUserAccount in organizationUserAccounts) + { + organizationUserAccount.UserId = userId; + } + + return organizationUserAccounts; + } + + private static ICollection SetupEmergencyAccess(Guid userId, + ICollection emergencyAccessDetails) + { + foreach (var emergencyAccessDetail in emergencyAccessDetails) + { + emergencyAccessDetail.GranteeId = userId; + } + + return emergencyAccessDetails; + } +} From b907935edaf0542cc2d8915b076d99e5b398cf62 Mon Sep 17 00:00:00 2001 From: Robyn MacCallum Date: Mon, 16 Dec 2024 16:18:33 -0500 Subject: [PATCH 06/19] Add Authenticator sync flags (#5159) * Add Authenticator sync flags * Fix whitespace --- src/Core/Constants.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 2c315b257848..86e49fa6cf72 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -161,6 +161,8 @@ public static class FeatureFlagKeys public const string SelfHostLicenseRefactor = "pm-11516-self-host-license-refactor"; public const string PromoteProviderServiceUserTool = "pm-15128-promote-provider-service-user-tool"; public const string PrivateKeyRegeneration = "pm-12241-private-key-regeneration"; + public const string AuthenticatorSynciOS = "enable-authenticator-sync-ios"; + public const string AuthenticatorSyncAndroid = "enable-authenticator-sync-android"; public static List GetAllKeys() { From ecbfc056830e41822e4567721be0a8bc51fc0436 Mon Sep 17 00:00:00 2001 From: aj-bw <81774843+aj-bw@users.noreply.github.com> Date: Tue, 17 Dec 2024 08:32:37 -0500 Subject: [PATCH 07/19] QA-689/BEEEP-public-api-GET-subscription-details (#5041) * added GET operation to org subscription endpoint * adding back removed using statement * addressing unused import and lint warnings * whitespace lint fix * successful local format * add NotSelfHostOnly attribute * add endpoint summary and return details --- .../Controllers/OrganizationController.cs | 44 +++++++++++++++++++ ...anizationSubscriptionUpdateRequestModel.cs | 0 ...izationSubscriptionDetailsResponseModel.cs | 32 ++++++++++++++ 3 files changed, 76 insertions(+) rename src/Api/Billing/Public/Models/{ => Request}/OrganizationSubscriptionUpdateRequestModel.cs (100%) create mode 100644 src/Api/Billing/Public/Models/Response/OrganizationSubscriptionDetailsResponseModel.cs diff --git a/src/Api/Billing/Public/Controllers/OrganizationController.cs b/src/Api/Billing/Public/Controllers/OrganizationController.cs index c696f2af5065..7fcd94acd38d 100644 --- a/src/Api/Billing/Public/Controllers/OrganizationController.cs +++ b/src/Api/Billing/Public/Controllers/OrganizationController.cs @@ -1,4 +1,5 @@ using System.Net; +using Bit.Api.Billing.Public.Models; using Bit.Api.Models.Public.Response; using Bit.Core.Context; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; @@ -35,6 +36,49 @@ public OrganizationController( _logger = logger; } + /// + /// Retrieves the subscription details for the current organization. + /// + /// + /// Returns an object containing the subscription details if successful. + /// + [HttpGet("subscription")] + [SelfHosted(NotSelfHostedOnly = true)] + [ProducesResponseType(typeof(OrganizationSubscriptionDetailsResponseModel), (int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.NotFound)] + public async Task GetSubscriptionAsync() + { + try + { + var organizationId = _currentContext.OrganizationId.Value; + var organization = await _organizationRepository.GetByIdAsync(organizationId); + + var subscriptionDetails = new OrganizationSubscriptionDetailsResponseModel + { + PasswordManager = new PasswordManagerSubscriptionDetails + { + Seats = organization.Seats, + MaxAutoScaleSeats = organization.MaxAutoscaleSeats, + Storage = organization.MaxStorageGb + }, + SecretsManager = new SecretsManagerSubscriptionDetails + { + Seats = organization.SmSeats, + MaxAutoScaleSeats = organization.MaxAutoscaleSmSeats, + ServiceAccounts = organization.SmServiceAccounts, + MaxAutoScaleServiceAccounts = organization.MaxAutoscaleSmServiceAccounts + } + }; + + return Ok(subscriptionDetails); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unhandled error while retrieving the subscription details"); + return StatusCode(500, new { Message = "An error occurred while retrieving the subscription details." }); + } + } + /// /// Update the organization's current subscription for Password Manager and/or Secrets Manager. /// diff --git a/src/Api/Billing/Public/Models/OrganizationSubscriptionUpdateRequestModel.cs b/src/Api/Billing/Public/Models/Request/OrganizationSubscriptionUpdateRequestModel.cs similarity index 100% rename from src/Api/Billing/Public/Models/OrganizationSubscriptionUpdateRequestModel.cs rename to src/Api/Billing/Public/Models/Request/OrganizationSubscriptionUpdateRequestModel.cs diff --git a/src/Api/Billing/Public/Models/Response/OrganizationSubscriptionDetailsResponseModel.cs b/src/Api/Billing/Public/Models/Response/OrganizationSubscriptionDetailsResponseModel.cs new file mode 100644 index 000000000000..09aa7decc17d --- /dev/null +++ b/src/Api/Billing/Public/Models/Response/OrganizationSubscriptionDetailsResponseModel.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Billing.Public.Models; + +public class OrganizationSubscriptionDetailsResponseModel : IValidatableObject +{ + public PasswordManagerSubscriptionDetails PasswordManager { get; set; } + public SecretsManagerSubscriptionDetails SecretsManager { get; set; } + public IEnumerable Validate(ValidationContext validationContext) + { + if (PasswordManager == null && SecretsManager == null) + { + yield return new ValidationResult("At least one of PasswordManager or SecretsManager must be provided."); + } + + yield return ValidationResult.Success; + } +} +public class PasswordManagerSubscriptionDetails +{ + public int? Seats { get; set; } + public int? MaxAutoScaleSeats { get; set; } + public short? Storage { get; set; } +} + +public class SecretsManagerSubscriptionDetails +{ + public int? Seats { get; set; } + public int? MaxAutoScaleSeats { get; set; } + public int? ServiceAccounts { get; set; } + public int? MaxAutoScaleServiceAccounts { get; set; } +} From 16488091d24f275885cab4777b9764f5847298c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ch=C4=99ci=C5=84ski?= Date: Tue, 17 Dec 2024 16:45:02 +0100 Subject: [PATCH 08/19] Remove is_workflow_call input from build workflow (#5161) --- .github/workflows/build.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index eb644fe8b5e2..420b9b63750a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -580,7 +580,6 @@ jobs: ref: 'main', inputs: { server_branch: process.env.GITHUB_REF - is_workflow_call: true } }); From b75c63c2c6ac335747958c0e72e2d4143a3520c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Tue, 17 Dec 2024 15:57:31 +0000 Subject: [PATCH 09/19] [PM-15957] Fix: Domain Claim fails to enable Single Organization Policy, sends no emails and Revokes all users (#5147) * Add JSON-based stored procedure for updating account revision dates and modify existing procedure to use it * Refactor SingleOrgPolicyValidator to revoke only non-compliant organization users and update related tests --- .../SingleOrgPolicyValidator.cs | 11 +++- ...OrganizationUser_SetStatusForUsersById.sql | 2 +- ...tRevisionDateByOrganizationUserIdsJson.sql | 33 ++++++++++ .../SingleOrgPolicyValidatorTests.cs | 38 +++++++++-- ...2-11-00_BumpAccountRevisionDateJsonIds.sql | 64 +++++++++++++++++++ 5 files changed, 140 insertions(+), 8 deletions(-) create mode 100644 src/Sql/dbo/Stored Procedures/User_BumpAccountRevisionDateByOrganizationUserIdsJson.sql create mode 100644 util/Migrator/DbScripts/2024-12-11-00_BumpAccountRevisionDateJsonIds.sql diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs index 050949ee7f91..a37deef3eb78 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs @@ -97,15 +97,22 @@ private async Task RevokeNonCompliantUsersAsync(Guid organizationId, IActingUser return; } + var allRevocableUserOrgs = await _organizationUserRepository.GetManyByManyUsersAsync( + currentActiveRevocableOrganizationUsers.Select(ou => ou.UserId!.Value)); + var usersToRevoke = currentActiveRevocableOrganizationUsers.Where(ou => + allRevocableUserOrgs.Any(uo => uo.UserId == ou.UserId && + uo.OrganizationId != organizationId && + uo.Status != OrganizationUserStatusType.Invited)).ToList(); + var commandResult = await _revokeNonCompliantOrganizationUserCommand.RevokeNonCompliantOrganizationUsersAsync( - new RevokeOrganizationUsersRequest(organizationId, currentActiveRevocableOrganizationUsers, performedBy)); + new RevokeOrganizationUsersRequest(organizationId, usersToRevoke, performedBy)); if (commandResult.HasErrors) { throw new BadRequestException(string.Join(", ", commandResult.ErrorMessages)); } - await Task.WhenAll(currentActiveRevocableOrganizationUsers.Select(x => + await Task.WhenAll(usersToRevoke.Select(x => _mailService.SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(organization.DisplayName(), x.Email))); } diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_SetStatusForUsersById.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_SetStatusForUsersById.sql index 95ed5a3155d0..18b876775e2c 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_SetStatusForUsersById.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_SetStatusForUsersById.sql @@ -24,6 +24,6 @@ BEGIN SET [Status] = @Status WHERE [Id] IN (SELECT Id from @ParsedIds) - EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationUserIds] @OrganizationUserIds + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationUserIdsJson] @OrganizationUserIds END diff --git a/src/Sql/dbo/Stored Procedures/User_BumpAccountRevisionDateByOrganizationUserIdsJson.sql b/src/Sql/dbo/Stored Procedures/User_BumpAccountRevisionDateByOrganizationUserIdsJson.sql new file mode 100644 index 000000000000..6e4119d86447 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/User_BumpAccountRevisionDateByOrganizationUserIdsJson.sql @@ -0,0 +1,33 @@ +CREATE PROCEDURE [dbo].[User_BumpAccountRevisionDateByOrganizationUserIdsJson] + @OrganizationUserIds NVARCHAR(MAX) +AS +BEGIN + SET NOCOUNT ON + + CREATE TABLE #UserIds + ( + UserId UNIQUEIDENTIFIER NOT NULL + ); + + INSERT INTO #UserIds (UserId) + SELECT + OU.UserId + FROM + [dbo].[OrganizationUser] OU + INNER JOIN + (SELECT [value] as Id FROM OPENJSON(@OrganizationUserIds)) AS OUIds + ON OUIds.Id = OU.Id + WHERE + OU.[Status] = 2 -- Confirmed + + UPDATE + U + SET + U.[AccountRevisionDate] = GETUTCDATE() + FROM + [dbo].[User] U + INNER JOIN + #UserIds ON U.[Id] = #UserIds.[UserId] + + DROP TABLE #UserIds +END diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidatorTests.cs index 0731920757a6..d2809102aae2 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidatorTests.cs @@ -75,6 +75,7 @@ public async Task OnSaveSideEffectsAsync_RevokesNonCompliantUsers( var compliantUser1 = new OrganizationUserUserDetails { + Id = Guid.NewGuid(), OrganizationId = organization.Id, Type = OrganizationUserType.User, Status = OrganizationUserStatusType.Confirmed, @@ -84,6 +85,7 @@ public async Task OnSaveSideEffectsAsync_RevokesNonCompliantUsers( var compliantUser2 = new OrganizationUserUserDetails { + Id = Guid.NewGuid(), OrganizationId = organization.Id, Type = OrganizationUserType.User, Status = OrganizationUserStatusType.Confirmed, @@ -93,6 +95,7 @@ public async Task OnSaveSideEffectsAsync_RevokesNonCompliantUsers( var nonCompliantUser = new OrganizationUserUserDetails { + Id = Guid.NewGuid(), OrganizationId = organization.Id, Type = OrganizationUserType.User, Status = OrganizationUserStatusType.Confirmed, @@ -106,6 +109,7 @@ public async Task OnSaveSideEffectsAsync_RevokesNonCompliantUsers( var otherOrganizationUser = new OrganizationUser { + Id = Guid.NewGuid(), OrganizationId = new Guid(), UserId = nonCompliantUserId, Status = OrganizationUserStatusType.Confirmed @@ -129,11 +133,20 @@ public async Task OnSaveSideEffectsAsync_RevokesNonCompliantUsers( await sutProvider.GetDependency() .Received(1) - .RevokeNonCompliantOrganizationUsersAsync(Arg.Any()); + .RevokeNonCompliantOrganizationUsersAsync( + Arg.Is(r => + r.OrganizationId == organization.Id && + r.OrganizationUsers.Count() == 1 && + r.OrganizationUsers.First().Id == nonCompliantUser.Id)); + await sutProvider.GetDependency() + .DidNotReceive() + .SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(organization.DisplayName(), compliantUser1.Email); + await sutProvider.GetDependency() + .DidNotReceive() + .SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(organization.DisplayName(), compliantUser2.Email); await sutProvider.GetDependency() .Received(1) - .SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(organization.DisplayName(), - "user3@example.com"); + .SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(organization.DisplayName(), nonCompliantUser.Email); } [Theory, BitAutoData] @@ -148,6 +161,7 @@ public async Task OnSaveSideEffectsAsync_RemovesNonCompliantUsers( var compliantUser1 = new OrganizationUserUserDetails { + Id = Guid.NewGuid(), OrganizationId = organization.Id, Type = OrganizationUserType.User, Status = OrganizationUserStatusType.Confirmed, @@ -157,6 +171,7 @@ public async Task OnSaveSideEffectsAsync_RemovesNonCompliantUsers( var compliantUser2 = new OrganizationUserUserDetails { + Id = Guid.NewGuid(), OrganizationId = organization.Id, Type = OrganizationUserType.User, Status = OrganizationUserStatusType.Confirmed, @@ -166,6 +181,7 @@ public async Task OnSaveSideEffectsAsync_RemovesNonCompliantUsers( var nonCompliantUser = new OrganizationUserUserDetails { + Id = Guid.NewGuid(), OrganizationId = organization.Id, Type = OrganizationUserType.User, Status = OrganizationUserStatusType.Confirmed, @@ -179,6 +195,7 @@ public async Task OnSaveSideEffectsAsync_RemovesNonCompliantUsers( var otherOrganizationUser = new OrganizationUser { + Id = Guid.NewGuid(), OrganizationId = new Guid(), UserId = nonCompliantUserId, Status = OrganizationUserStatusType.Confirmed @@ -200,13 +217,24 @@ public async Task OnSaveSideEffectsAsync_RemovesNonCompliantUsers( await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy); + await sutProvider.GetDependency() + .DidNotReceive() + .RemoveUserAsync(policyUpdate.OrganizationId, compliantUser1.Id, savingUserId); + await sutProvider.GetDependency() + .DidNotReceive() + .RemoveUserAsync(policyUpdate.OrganizationId, compliantUser2.Id, savingUserId); await sutProvider.GetDependency() .Received(1) .RemoveUserAsync(policyUpdate.OrganizationId, nonCompliantUser.Id, savingUserId); + await sutProvider.GetDependency() + .DidNotReceive() + .SendOrganizationUserRemovedForPolicySingleOrgEmailAsync(organization.DisplayName(), compliantUser1.Email); + await sutProvider.GetDependency() + .DidNotReceive() + .SendOrganizationUserRemovedForPolicySingleOrgEmailAsync(organization.DisplayName(), compliantUser2.Email); await sutProvider.GetDependency() .Received(1) - .SendOrganizationUserRemovedForPolicySingleOrgEmailAsync(organization.DisplayName(), - "user3@example.com"); + .SendOrganizationUserRemovedForPolicySingleOrgEmailAsync(organization.DisplayName(), nonCompliantUser.Email); } [Theory, BitAutoData] diff --git a/util/Migrator/DbScripts/2024-12-11-00_BumpAccountRevisionDateJsonIds.sql b/util/Migrator/DbScripts/2024-12-11-00_BumpAccountRevisionDateJsonIds.sql new file mode 100644 index 000000000000..11d1d75a31c2 --- /dev/null +++ b/util/Migrator/DbScripts/2024-12-11-00_BumpAccountRevisionDateJsonIds.sql @@ -0,0 +1,64 @@ +CREATE OR ALTER PROCEDURE [dbo].[User_BumpAccountRevisionDateByOrganizationUserIdsJson] + @OrganizationUserIds NVARCHAR(MAX) +AS +BEGIN + SET NOCOUNT ON + + CREATE TABLE #UserIds + ( + UserId UNIQUEIDENTIFIER NOT NULL + ); + + INSERT INTO #UserIds (UserId) + SELECT + OU.UserId + FROM + [dbo].[OrganizationUser] OU + INNER JOIN + (SELECT [value] as Id FROM OPENJSON(@OrganizationUserIds)) AS OUIds + ON OUIds.Id = OU.Id + WHERE + OU.[Status] = 2 -- Confirmed + + UPDATE + U + SET + U.[AccountRevisionDate] = GETUTCDATE() + FROM + [dbo].[User] U + INNER JOIN + #UserIds ON U.[Id] = #UserIds.[UserId] + + DROP TABLE #UserIds +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_SetStatusForUsersById] + @OrganizationUserIds AS NVARCHAR(MAX), + @Status SMALLINT +AS +BEGIN + SET NOCOUNT ON + + -- Declare a table variable to hold the parsed JSON data + DECLARE @ParsedIds TABLE (Id UNIQUEIDENTIFIER); + + -- Parse the JSON input into the table variable + INSERT INTO @ParsedIds (Id) + SELECT value + FROM OPENJSON(@OrganizationUserIds); + + -- Check if the input table is empty + IF (SELECT COUNT(1) FROM @ParsedIds) < 1 + BEGIN + RETURN(-1); + END + + UPDATE + [dbo].[OrganizationUser] + SET [Status] = @Status + WHERE [Id] IN (SELECT Id from @ParsedIds) + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationUserIdsJson] @OrganizationUserIds +END +GO From 2e8f2df9428edf19a272141a8d36dc7ddad20ed4 Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Tue, 17 Dec 2024 08:59:39 -0800 Subject: [PATCH 10/19] feat(NewDeviceVerification) : (#5153) feat(NewDeviceVerification) : Added constat for the cache key in Bit.Core because the cache key format needs to be shared between the Identity Server and the MVC Admin project. Updated DeviceValidator class to handle checking cache for user information to allow pass through. Updated and Added tests to handle new flow. --- src/Core/Constants.cs | 2 +- .../RequestValidators/DeviceValidator.cs | 18 ++++++++- .../IdentityServer/DeviceValidatorTests.cs | 38 ++++++++++++++++++- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 86e49fa6cf72..9b51b12d6296 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -57,7 +57,7 @@ public static class AuthConstants public static readonly RangeConstant ARGON2_ITERATIONS = new(2, 10, 3); public static readonly RangeConstant ARGON2_MEMORY = new(15, 1024, 64); public static readonly RangeConstant ARGON2_PARALLELISM = new(1, 16, 4); - + public static readonly string NewDeviceVerificationExceptionCacheKeyFormat = "NewDeviceVerificationException_{0}"; } public class RangeConstant diff --git a/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs b/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs index 2a048bcb2aab..d59417bfa72d 100644 --- a/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs @@ -10,6 +10,7 @@ using Bit.Core.Settings; using Bit.Identity.IdentityServer.Enums; using Duende.IdentityServer.Validation; +using Microsoft.Extensions.Caching.Distributed; namespace Bit.Identity.IdentityServer.RequestValidators; @@ -20,6 +21,8 @@ public class DeviceValidator( IMailService mailService, ICurrentContext currentContext, IUserService userService, + IDistributedCache distributedCache, + ILogger logger, IFeatureService featureService) : IDeviceValidator { private readonly IDeviceService _deviceService = deviceService; @@ -28,6 +31,8 @@ public class DeviceValidator( private readonly IMailService _mailService = mailService; private readonly ICurrentContext _currentContext = currentContext; private readonly IUserService _userService = userService; + private readonly IDistributedCache distributedCache = distributedCache; + private readonly ILogger _logger = logger; private readonly IFeatureService _featureService = featureService; public async Task ValidateRequestDeviceAsync(ValidatedTokenRequest request, CustomValidatorRequestContext context) @@ -67,7 +72,6 @@ public async Task ValidateRequestDeviceAsync(ValidatedTokenRequest request !context.SsoRequired && _globalSettings.EnableNewDeviceVerification) { - // We only want to return early if the device is invalid or there is an error var validationResult = await HandleNewDeviceVerificationAsync(context.User, request); if (validationResult != DeviceValidationResultType.Success) { @@ -121,6 +125,18 @@ private async Task HandleNewDeviceVerificationAsync( return DeviceValidationResultType.InvalidUser; } + // CS exception flow + // Check cache for user information + var cacheKey = string.Format(AuthConstants.NewDeviceVerificationExceptionCacheKeyFormat, user.Id.ToString()); + var cacheValue = await distributedCache.GetAsync(cacheKey); + if (cacheValue != null) + { + // if found in cache return success result and remove from cache + await distributedCache.RemoveAsync(cacheKey); + _logger.LogInformation("New device verification exception for user {UserId} found in cache", user.Id); + return DeviceValidationResultType.Success; + } + // parse request for NewDeviceOtp to validate var newDeviceOtp = request.Raw["NewDeviceOtp"]?.ToString(); // we only check null here since an empty OTP will be considered an incorrect OTP diff --git a/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs b/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs index 304715b68cb1..105267ea305d 100644 --- a/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs @@ -10,6 +10,8 @@ using Bit.Identity.IdentityServer.RequestValidators; using Bit.Test.Common.AutoFixture.Attributes; using Duende.IdentityServer.Validation; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; using AuthFixtures = Bit.Identity.Test.AutoFixture; @@ -24,6 +26,8 @@ public class DeviceValidatorTests private readonly IMailService _mailService; private readonly ICurrentContext _currentContext; private readonly IUserService _userService; + private readonly IDistributedCache _distributedCache; + private readonly Logger _logger; private readonly IFeatureService _featureService; private readonly DeviceValidator _sut; @@ -35,6 +39,8 @@ public DeviceValidatorTests() _mailService = Substitute.For(); _currentContext = Substitute.For(); _userService = Substitute.For(); + _distributedCache = Substitute.For(); + _logger = new Logger(Substitute.For()); _featureService = Substitute.For(); _sut = new DeviceValidator( _deviceService, @@ -43,6 +49,8 @@ public DeviceValidatorTests() _mailService, _currentContext, _userService, + _distributedCache, + _logger, _featureService); } @@ -51,7 +59,7 @@ public async void GetKnownDeviceAsync_UserNull_ReturnsFalse( Device device) { // Arrange - // AutoData arrages + // AutoData arranges // Act var result = await _sut.GetKnownDeviceAsync(null, device); @@ -421,6 +429,30 @@ public async void HandleNewDeviceVerificationAsync_UserNull_ContextModified_Retu Assert.Equal(expectedErrorMessage, actualResponse.Message); } + [Theory, BitAutoData] + public async void HandleNewDeviceVerificationAsync_UserHasCacheValue_ReturnsSuccess( + CustomValidatorRequestContext context, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request) + { + // Arrange + ArrangeForHandleNewDeviceVerificationTest(context, request); + _featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification).Returns(true); + _globalSettings.EnableNewDeviceVerification = true; + _distributedCache.GetAsync(Arg.Any()).Returns([1]); + + // Act + var result = await _sut.ValidateRequestDeviceAsync(request, context); + + // Assert + await _userService.Received(0).SendOTPAsync(context.User); + await _deviceService.Received(1).SaveAsync(Arg.Any()); + + Assert.True(result); + Assert.False(context.CustomResponse.ContainsKey("ErrorModel")); + Assert.Equal(context.User.Id, context.Device.UserId); + Assert.NotNull(context.Device); + } + [Theory, BitAutoData] public async void HandleNewDeviceVerificationAsync_NewDeviceOtpValid_ReturnsSuccess( CustomValidatorRequestContext context, @@ -430,6 +462,7 @@ public async void HandleNewDeviceVerificationAsync_NewDeviceOtpValid_ReturnsSucc ArrangeForHandleNewDeviceVerificationTest(context, request); _featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification).Returns(true); _globalSettings.EnableNewDeviceVerification = true; + _distributedCache.GetAsync(Arg.Any()).Returns(null as byte[]); var newDeviceOtp = "123456"; request.Raw.Add("NewDeviceOtp", newDeviceOtp); @@ -461,6 +494,7 @@ public async void HandleNewDeviceVerificationAsync_NewDeviceOtpInvalid_ReturnsIn ArrangeForHandleNewDeviceVerificationTest(context, request); _featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification).Returns(true); _globalSettings.EnableNewDeviceVerification = true; + _distributedCache.GetAsync(Arg.Any()).Returns(null as byte[]); request.Raw.Add("NewDeviceOtp", newDeviceOtp); @@ -489,6 +523,7 @@ public async void HandleNewDeviceVerificationAsync_UserHasNoDevices_ReturnsSucce ArrangeForHandleNewDeviceVerificationTest(context, request); _featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification).Returns(true); _globalSettings.EnableNewDeviceVerification = true; + _distributedCache.GetAsync(Arg.Any()).Returns([1]); _deviceRepository.GetManyByUserIdAsync(context.User.Id).Returns([]); // Act @@ -515,6 +550,7 @@ public async void HandleNewDeviceVerificationAsync_NewDeviceOtpEmpty_UserHasDevi _featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification).Returns(true); _globalSettings.EnableNewDeviceVerification = true; _deviceRepository.GetManyByUserIdAsync(context.User.Id).Returns([new Device()]); + _distributedCache.GetAsync(Arg.Any()).Returns(null as byte[]); // Act var result = await _sut.ValidateRequestDeviceAsync(request, context); From eb9a061e6f84bbdb124b37db6895825a2bd4797c Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Tue, 17 Dec 2024 12:44:08 -0500 Subject: [PATCH 11/19] [pm-15123] Add delete permissions for CS and Billing. (#5145) --- src/Admin/Utilities/RolePermissionMapping.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Admin/Utilities/RolePermissionMapping.cs b/src/Admin/Utilities/RolePermissionMapping.cs index 81da3fcf383e..9cee571abad7 100644 --- a/src/Admin/Utilities/RolePermissionMapping.cs +++ b/src/Admin/Utilities/RolePermissionMapping.cs @@ -114,6 +114,7 @@ public static class RolePermissionMapping Permission.User_Billing_LaunchGateway, Permission.Org_List_View, Permission.Org_OrgInformation_View, + Permission.Org_Delete, Permission.Org_GeneralDetails_View, Permission.Org_BusinessInformation_View, Permission.Org_BillingInformation_View, @@ -156,6 +157,7 @@ public static class RolePermissionMapping Permission.Org_Billing_View, Permission.Org_Billing_Edit, Permission.Org_Billing_LaunchGateway, + Permission.Org_Delete, Permission.Provider_Edit, Permission.Provider_View, Permission.Provider_List_View, From de2dc243fc81fe4966941c6ca7fc712fc0592904 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 18 Dec 2024 14:18:37 +0100 Subject: [PATCH 12/19] [deps] Tools: Update MailKit to 4.9.0 (#5133) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Core/Core.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 9049f94dcfb9..43068a4ac0a2 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -34,7 +34,7 @@ - + From 21fcfcd5e855b72928f853f771c4980a5f9ccbc9 Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> Date: Wed, 18 Dec 2024 15:59:50 +0100 Subject: [PATCH 13/19] [PM-10563] Notification Center API (#4852) * PM-10563: Notification Center API * PM-10563: continuation token hack * PM-10563: Resolving merge conflicts * PM-10563: Unit Tests * PM-10563: Paging simplification by page number and size in database * PM-10563: Request validation * PM-10563: Read, Deleted status filters change * PM-10563: Plural name for tests * PM-10563: Request validation to always for int type * PM-10563: Continuation Token returns null on response when no more records available * PM-10563: Integration tests for GET * PM-10563: Mark notification read, deleted commands date typos fix * PM-10563: Integration tests for PATCH read, deleted * PM-10563: Request, Response models tests * PM-10563: EditorConfig compliance * PM-10563: Extracting to const * PM-10563: Update db migration script date * PM-10563: Update migration script date --- .../Controllers/NotificationsController.cs | 71 +++ .../Request/NotificationFilterRequestModel.cs | 41 ++ .../Response/NotificationResponseModel.cs | 46 ++ ...cationCenterServiceCollectionExtensions.cs | 29 + ...etNotificationStatusDetailsForUserQuery.cs | 7 +- ...etNotificationStatusDetailsForUserQuery.cs | 4 +- .../Repositories/INotificationRepository.cs | 10 +- .../Repositories/NotificationRepository.cs | 28 +- .../Repositories/NotificationRepository.cs | 28 +- .../Utilities/ServiceCollectionExtensions.cs | 2 + .../Notification_ReadByUserIdAndStatus.sql | 15 +- .../NotificationsControllerTests.cs | 582 ++++++++++++++++++ .../NotificationsControllerTests.cs | 202 ++++++ .../NotificationFilterRequestModelTests.cs | 93 +++ .../NotificationResponseModelTests.cs | 43 ++ .../NotificationStatusDetailsFixtures.cs | 34 +- ...tificationStatusDetailsForUserQueryTest.cs | 37 +- ...4-12-18_00_AddPagingToNotificationRead.sql | 39 ++ 18 files changed, 1272 insertions(+), 39 deletions(-) create mode 100644 src/Api/NotificationCenter/Controllers/NotificationsController.cs create mode 100644 src/Api/NotificationCenter/Models/Request/NotificationFilterRequestModel.cs create mode 100644 src/Api/NotificationCenter/Models/Response/NotificationResponseModel.cs create mode 100644 src/Core/NotificationCenter/NotificationCenterServiceCollectionExtensions.cs create mode 100644 test/Api.IntegrationTest/NotificationCenter/Controllers/NotificationsControllerTests.cs create mode 100644 test/Api.Test/NotificationCenter/Controllers/NotificationsControllerTests.cs create mode 100644 test/Api.Test/NotificationCenter/Models/Request/NotificationFilterRequestModelTests.cs create mode 100644 test/Api.Test/NotificationCenter/Models/Response/NotificationResponseModelTests.cs create mode 100644 util/Migrator/DbScripts/2024-12-18_00_AddPagingToNotificationRead.sql diff --git a/src/Api/NotificationCenter/Controllers/NotificationsController.cs b/src/Api/NotificationCenter/Controllers/NotificationsController.cs new file mode 100644 index 000000000000..9dc1505cb8d6 --- /dev/null +++ b/src/Api/NotificationCenter/Controllers/NotificationsController.cs @@ -0,0 +1,71 @@ +#nullable enable +using Bit.Api.Models.Response; +using Bit.Api.NotificationCenter.Models.Request; +using Bit.Api.NotificationCenter.Models.Response; +using Bit.Core.Models.Data; +using Bit.Core.NotificationCenter.Commands.Interfaces; +using Bit.Core.NotificationCenter.Models.Filter; +using Bit.Core.NotificationCenter.Queries.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.NotificationCenter.Controllers; + +[Route("notifications")] +[Authorize("Application")] +public class NotificationsController : Controller +{ + private readonly IGetNotificationStatusDetailsForUserQuery _getNotificationStatusDetailsForUserQuery; + private readonly IMarkNotificationDeletedCommand _markNotificationDeletedCommand; + private readonly IMarkNotificationReadCommand _markNotificationReadCommand; + + public NotificationsController( + IGetNotificationStatusDetailsForUserQuery getNotificationStatusDetailsForUserQuery, + IMarkNotificationDeletedCommand markNotificationDeletedCommand, + IMarkNotificationReadCommand markNotificationReadCommand) + { + _getNotificationStatusDetailsForUserQuery = getNotificationStatusDetailsForUserQuery; + _markNotificationDeletedCommand = markNotificationDeletedCommand; + _markNotificationReadCommand = markNotificationReadCommand; + } + + [HttpGet("")] + public async Task> ListAsync( + [FromQuery] NotificationFilterRequestModel filter) + { + var pageOptions = new PageOptions + { + ContinuationToken = filter.ContinuationToken, + PageSize = filter.PageSize + }; + + var notificationStatusFilter = new NotificationStatusFilter + { + Read = filter.ReadStatusFilter, + Deleted = filter.DeletedStatusFilter + }; + + var notificationStatusDetailsPagedResult = + await _getNotificationStatusDetailsForUserQuery.GetByUserIdStatusFilterAsync(notificationStatusFilter, + pageOptions); + + var responses = notificationStatusDetailsPagedResult.Data + .Select(n => new NotificationResponseModel(n)) + .ToList(); + + return new ListResponseModel(responses, + notificationStatusDetailsPagedResult.ContinuationToken); + } + + [HttpPatch("{id}/delete")] + public async Task MarkAsDeletedAsync([FromRoute] Guid id) + { + await _markNotificationDeletedCommand.MarkDeletedAsync(id); + } + + [HttpPatch("{id}/read")] + public async Task MarkAsReadAsync([FromRoute] Guid id) + { + await _markNotificationReadCommand.MarkReadAsync(id); + } +} diff --git a/src/Api/NotificationCenter/Models/Request/NotificationFilterRequestModel.cs b/src/Api/NotificationCenter/Models/Request/NotificationFilterRequestModel.cs new file mode 100644 index 000000000000..9c6252b6db24 --- /dev/null +++ b/src/Api/NotificationCenter/Models/Request/NotificationFilterRequestModel.cs @@ -0,0 +1,41 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.NotificationCenter.Models.Request; + +public class NotificationFilterRequestModel : IValidatableObject +{ + /// + /// Filters notifications by read status. When not set, includes notifications without a status. + /// + public bool? ReadStatusFilter { get; set; } + + /// + /// Filters notifications by deleted status. When not set, includes notifications without a status. + /// + public bool? DeletedStatusFilter { get; set; } + + /// + /// A cursor for use in pagination. + /// + [StringLength(9)] + public string? ContinuationToken { get; set; } + + /// + /// The number of items to return in a single page. + /// Default 10. Minimum 10, maximum 1000. + /// + [Range(10, 1000)] + public int PageSize { get; set; } = 10; + + public IEnumerable Validate(ValidationContext validationContext) + { + if (!string.IsNullOrWhiteSpace(ContinuationToken) && + (!int.TryParse(ContinuationToken, out var pageNumber) || pageNumber <= 0)) + { + yield return new ValidationResult( + "Continuation token must be a positive, non zero integer.", + [nameof(ContinuationToken)]); + } + } +} diff --git a/src/Api/NotificationCenter/Models/Response/NotificationResponseModel.cs b/src/Api/NotificationCenter/Models/Response/NotificationResponseModel.cs new file mode 100644 index 000000000000..1ebed87de2db --- /dev/null +++ b/src/Api/NotificationCenter/Models/Response/NotificationResponseModel.cs @@ -0,0 +1,46 @@ +#nullable enable +using Bit.Core.Models.Api; +using Bit.Core.NotificationCenter.Enums; +using Bit.Core.NotificationCenter.Models.Data; + +namespace Bit.Api.NotificationCenter.Models.Response; + +public class NotificationResponseModel : ResponseModel +{ + private const string _objectName = "notification"; + + public NotificationResponseModel(NotificationStatusDetails notificationStatusDetails, string obj = _objectName) + : base(obj) + { + if (notificationStatusDetails == null) + { + throw new ArgumentNullException(nameof(notificationStatusDetails)); + } + + Id = notificationStatusDetails.Id; + Priority = notificationStatusDetails.Priority; + Title = notificationStatusDetails.Title; + Body = notificationStatusDetails.Body; + Date = notificationStatusDetails.RevisionDate; + ReadDate = notificationStatusDetails.ReadDate; + DeletedDate = notificationStatusDetails.DeletedDate; + } + + public NotificationResponseModel() : base(_objectName) + { + } + + public Guid Id { get; set; } + + public Priority Priority { get; set; } + + public string? Title { get; set; } + + public string? Body { get; set; } + + public DateTime Date { get; set; } + + public DateTime? ReadDate { get; set; } + + public DateTime? DeletedDate { get; set; } +} diff --git a/src/Core/NotificationCenter/NotificationCenterServiceCollectionExtensions.cs b/src/Core/NotificationCenter/NotificationCenterServiceCollectionExtensions.cs new file mode 100644 index 000000000000..fe41ebc5c353 --- /dev/null +++ b/src/Core/NotificationCenter/NotificationCenterServiceCollectionExtensions.cs @@ -0,0 +1,29 @@ +#nullable enable +using Bit.Core.NotificationCenter.Authorization; +using Bit.Core.NotificationCenter.Commands; +using Bit.Core.NotificationCenter.Commands.Interfaces; +using Bit.Core.NotificationCenter.Queries; +using Bit.Core.NotificationCenter.Queries.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.NotificationCenter; + +public static class NotificationCenterServiceCollectionExtensions +{ + public static void AddNotificationCenterServices(this IServiceCollection services) + { + // Authorization Handlers + services.AddScoped(); + services.AddScoped(); + // Commands + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + // Queries + services.AddScoped(); + services.AddScoped(); + } +} diff --git a/src/Core/NotificationCenter/Queries/GetNotificationStatusDetailsForUserQuery.cs b/src/Core/NotificationCenter/Queries/GetNotificationStatusDetailsForUserQuery.cs index 0a783a59ba40..235c2c6ed010 100644 --- a/src/Core/NotificationCenter/Queries/GetNotificationStatusDetailsForUserQuery.cs +++ b/src/Core/NotificationCenter/Queries/GetNotificationStatusDetailsForUserQuery.cs @@ -1,6 +1,7 @@ #nullable enable using Bit.Core.Context; using Bit.Core.Exceptions; +using Bit.Core.Models.Data; using Bit.Core.NotificationCenter.Models.Data; using Bit.Core.NotificationCenter.Models.Filter; using Bit.Core.NotificationCenter.Queries.Interfaces; @@ -21,8 +22,8 @@ public GetNotificationStatusDetailsForUserQuery(ICurrentContext currentContext, _notificationRepository = notificationRepository; } - public async Task> GetByUserIdStatusFilterAsync( - NotificationStatusFilter statusFilter) + public async Task> GetByUserIdStatusFilterAsync( + NotificationStatusFilter statusFilter, PageOptions pageOptions) { if (!_currentContext.UserId.HasValue) { @@ -33,6 +34,6 @@ public async Task> GetByUserIdStatusFilte // Note: only returns the user's notifications - no authorization check needed return await _notificationRepository.GetByUserIdAndStatusAsync(_currentContext.UserId.Value, clientType, - statusFilter); + statusFilter, pageOptions); } } diff --git a/src/Core/NotificationCenter/Queries/Interfaces/IGetNotificationStatusDetailsForUserQuery.cs b/src/Core/NotificationCenter/Queries/Interfaces/IGetNotificationStatusDetailsForUserQuery.cs index 456a0e9400ea..fd6c0b5e6366 100644 --- a/src/Core/NotificationCenter/Queries/Interfaces/IGetNotificationStatusDetailsForUserQuery.cs +++ b/src/Core/NotificationCenter/Queries/Interfaces/IGetNotificationStatusDetailsForUserQuery.cs @@ -1,4 +1,5 @@ #nullable enable +using Bit.Core.Models.Data; using Bit.Core.NotificationCenter.Models.Data; using Bit.Core.NotificationCenter.Models.Filter; @@ -6,5 +7,6 @@ namespace Bit.Core.NotificationCenter.Queries.Interfaces; public interface IGetNotificationStatusDetailsForUserQuery { - Task> GetByUserIdStatusFilterAsync(NotificationStatusFilter statusFilter); + Task> GetByUserIdStatusFilterAsync(NotificationStatusFilter statusFilter, + PageOptions pageOptions); } diff --git a/src/Core/NotificationCenter/Repositories/INotificationRepository.cs b/src/Core/NotificationCenter/Repositories/INotificationRepository.cs index 2c3faed91430..21604ed169e8 100644 --- a/src/Core/NotificationCenter/Repositories/INotificationRepository.cs +++ b/src/Core/NotificationCenter/Repositories/INotificationRepository.cs @@ -1,5 +1,6 @@ #nullable enable using Bit.Core.Enums; +using Bit.Core.Models.Data; using Bit.Core.NotificationCenter.Entities; using Bit.Core.NotificationCenter.Models.Data; using Bit.Core.NotificationCenter.Models.Filter; @@ -22,10 +23,13 @@ public interface INotificationRepository : IRepository /// If both and /// are not set, includes notifications without a status. /// + /// + /// Pagination options. + /// /// - /// Ordered by priority (highest to lowest) and creation date (descending). + /// Paged results ordered by priority (descending, highest to lowest) and creation date (descending). /// Includes all fields from and /// - Task> GetByUserIdAndStatusAsync(Guid userId, ClientType clientType, - NotificationStatusFilter? statusFilter); + Task> GetByUserIdAndStatusAsync(Guid userId, ClientType clientType, + NotificationStatusFilter? statusFilter, PageOptions pageOptions); } diff --git a/src/Infrastructure.Dapper/NotificationCenter/Repositories/NotificationRepository.cs b/src/Infrastructure.Dapper/NotificationCenter/Repositories/NotificationRepository.cs index f70c50f49f26..b6843d980102 100644 --- a/src/Infrastructure.Dapper/NotificationCenter/Repositories/NotificationRepository.cs +++ b/src/Infrastructure.Dapper/NotificationCenter/Repositories/NotificationRepository.cs @@ -1,6 +1,7 @@ #nullable enable using System.Data; using Bit.Core.Enums; +using Bit.Core.Models.Data; using Bit.Core.NotificationCenter.Entities; using Bit.Core.NotificationCenter.Models.Data; using Bit.Core.NotificationCenter.Models.Filter; @@ -24,16 +25,35 @@ public NotificationRepository(string connectionString, string readOnlyConnection { } - public async Task> GetByUserIdAndStatusAsync(Guid userId, - ClientType clientType, NotificationStatusFilter? statusFilter) + public async Task> GetByUserIdAndStatusAsync(Guid userId, + ClientType clientType, NotificationStatusFilter? statusFilter, PageOptions pageOptions) { await using var connection = new SqlConnection(ConnectionString); + if (!int.TryParse(pageOptions.ContinuationToken, out var pageNumber)) + { + pageNumber = 1; + } + var results = await connection.QueryAsync( "[dbo].[Notification_ReadByUserIdAndStatus]", - new { UserId = userId, ClientType = clientType, statusFilter?.Read, statusFilter?.Deleted }, + new + { + UserId = userId, + ClientType = clientType, + statusFilter?.Read, + statusFilter?.Deleted, + PageNumber = pageNumber, + pageOptions.PageSize + }, commandType: CommandType.StoredProcedure); - return results.ToList(); + var data = results.ToList(); + + return new PagedResult + { + Data = data, + ContinuationToken = data.Count < pageOptions.PageSize ? null : (pageNumber + 1).ToString() + }; } } diff --git a/src/Infrastructure.EntityFramework/NotificationCenter/Repositories/NotificationRepository.cs b/src/Infrastructure.EntityFramework/NotificationCenter/Repositories/NotificationRepository.cs index a413e78748ae..5d1071f26c0d 100644 --- a/src/Infrastructure.EntityFramework/NotificationCenter/Repositories/NotificationRepository.cs +++ b/src/Infrastructure.EntityFramework/NotificationCenter/Repositories/NotificationRepository.cs @@ -1,6 +1,7 @@ #nullable enable using AutoMapper; using Bit.Core.Enums; +using Bit.Core.Models.Data; using Bit.Core.NotificationCenter.Models.Data; using Bit.Core.NotificationCenter.Models.Filter; using Bit.Core.NotificationCenter.Repositories; @@ -36,28 +37,41 @@ public NotificationRepository(IServiceScopeFactory serviceScopeFactory, IMapper return Mapper.Map>(notifications); } - public async Task> GetByUserIdAndStatusAsync(Guid userId, - ClientType clientType, NotificationStatusFilter? statusFilter) + public async Task> GetByUserIdAndStatusAsync(Guid userId, + ClientType clientType, NotificationStatusFilter? statusFilter, PageOptions pageOptions) { await using var scope = ServiceScopeFactory.CreateAsyncScope(); var dbContext = GetDatabaseContext(scope); + if (!int.TryParse(pageOptions.ContinuationToken, out var pageNumber)) + { + pageNumber = 1; + } + var notificationStatusDetailsViewQuery = new NotificationStatusDetailsViewQuery(userId, clientType); var query = notificationStatusDetailsViewQuery.Run(dbContext); if (statusFilter != null && (statusFilter.Read != null || statusFilter.Deleted != null)) { query = from n in query - where statusFilter.Read == null || - (statusFilter.Read == true ? n.ReadDate != null : n.ReadDate == null) || - statusFilter.Deleted == null || - (statusFilter.Deleted == true ? n.DeletedDate != null : n.DeletedDate == null) + where (statusFilter.Read == null || + (statusFilter.Read == true ? n.ReadDate != null : n.ReadDate == null)) && + (statusFilter.Deleted == null || + (statusFilter.Deleted == true ? n.DeletedDate != null : n.DeletedDate == null)) select n; } - return await query + var results = await query .OrderByDescending(n => n.Priority) .ThenByDescending(n => n.CreationDate) + .Skip(pageOptions.PageSize * (pageNumber - 1)) + .Take(pageOptions.PageSize) .ToListAsync(); + + return new PagedResult + { + Data = results, + ContinuationToken = results.Count < pageOptions.PageSize ? null : (pageNumber + 1).ToString() + }; } } diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index c757f163e92d..85bd0301c38b 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -27,6 +27,7 @@ using Bit.Core.Identity; using Bit.Core.IdentityServer; using Bit.Core.KeyManagement; +using Bit.Core.NotificationCenter; using Bit.Core.NotificationHub; using Bit.Core.OrganizationFeatures; using Bit.Core.Repositories; @@ -122,6 +123,7 @@ public static void AddBaseServices(this IServiceCollection services, IGlobalSett services.AddVaultServices(); services.AddReportingServices(); services.AddKeyManagementServices(); + services.AddNotificationCenterServices(); } public static void AddTokenizers(this IServiceCollection services) diff --git a/src/Sql/NotificationCenter/dbo/Stored Procedures/Notification_ReadByUserIdAndStatus.sql b/src/Sql/NotificationCenter/dbo/Stored Procedures/Notification_ReadByUserIdAndStatus.sql index b98f85f73c01..72efda2012da 100644 --- a/src/Sql/NotificationCenter/dbo/Stored Procedures/Notification_ReadByUserIdAndStatus.sql +++ b/src/Sql/NotificationCenter/dbo/Stored Procedures/Notification_ReadByUserIdAndStatus.sql @@ -2,7 +2,9 @@ CREATE PROCEDURE [dbo].[Notification_ReadByUserIdAndStatus] @UserId UNIQUEIDENTIFIER, @ClientType TINYINT, @Read BIT, - @Deleted BIT + @Deleted BIT, + @PageNumber INT = 1, + @PageSize INT = 10 AS BEGIN SET NOCOUNT ON @@ -21,13 +23,14 @@ BEGIN AND ou.[OrganizationId] IS NOT NULL)) AND ((@Read IS NULL AND @Deleted IS NULL) OR (n.[NotificationStatusUserId] IS NOT NULL - AND ((@Read IS NULL + AND (@Read IS NULL OR IIF((@Read = 1 AND n.[ReadDate] IS NOT NULL) OR (@Read = 0 AND n.[ReadDate] IS NULL), 1, 0) = 1) - OR (@Deleted IS NULL - OR IIF((@Deleted = 1 AND n.[DeletedDate] IS NOT NULL) OR - (@Deleted = 0 AND n.[DeletedDate] IS NULL), - 1, 0) = 1)))) + AND (@Deleted IS NULL + OR IIF((@Deleted = 1 AND n.[DeletedDate] IS NOT NULL) OR + (@Deleted = 0 AND n.[DeletedDate] IS NULL), + 1, 0) = 1))) ORDER BY [Priority] DESC, n.[CreationDate] DESC + OFFSET @PageSize * (@PageNumber - 1) ROWS FETCH NEXT @PageSize ROWS ONLY END diff --git a/test/Api.IntegrationTest/NotificationCenter/Controllers/NotificationsControllerTests.cs b/test/Api.IntegrationTest/NotificationCenter/Controllers/NotificationsControllerTests.cs new file mode 100644 index 000000000000..6d487c5d8fcf --- /dev/null +++ b/test/Api.IntegrationTest/NotificationCenter/Controllers/NotificationsControllerTests.cs @@ -0,0 +1,582 @@ +using System.Net; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Api.Models.Response; +using Bit.Api.NotificationCenter.Models.Response; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Api; +using Bit.Core.NotificationCenter.Entities; +using Bit.Core.NotificationCenter.Enums; +using Bit.Core.NotificationCenter.Repositories; +using Bit.Core.Repositories; +using Xunit; + +namespace Bit.Api.IntegrationTest.NotificationCenter.Controllers; + +public class NotificationsControllerTests : IClassFixture, IAsyncLifetime +{ + private static readonly string _mockEncryptedBody = + "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk="; + + private static readonly string _mockEncryptedTitle = + "2.06CDSJjTZaigYHUuswIq5A==|trxgZl2RCkYrrmCvGE9WNA==|w5p05eI5wsaYeSyWtsAPvBX63vj798kIMxBTfSB0BQg="; + + private static readonly Random _random = new(); + + private static TimeSpan OneMinuteTimeSpan => TimeSpan.FromMinutes(1); + + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + private readonly LoginHelper _loginHelper; + private readonly INotificationRepository _notificationRepository; + private readonly INotificationStatusRepository _notificationStatusRepository; + private readonly IUserRepository _userRepository; + private Organization _organization = null!; + private OrganizationUser _organizationUserOwner = null!; + private string _ownerEmail = null!; + private List<(Notification, NotificationStatus?)> _notificationsWithStatuses = null!; + + public NotificationsControllerTests(ApiApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + _loginHelper = new LoginHelper(_factory, _client); + _notificationRepository = _factory.GetService(); + _notificationStatusRepository = _factory.GetService(); + _userRepository = _factory.GetService(); + } + + public async Task InitializeAsync() + { + // Create the owner account + _ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(_ownerEmail); + + // Create the organization + (_organization, _organizationUserOwner) = await OrganizationTestHelpers.SignUpAsync(_factory, + plan: PlanType.EnterpriseAnnually, ownerEmail: _ownerEmail, passwordManagerSeats: 10, + paymentMethod: PaymentMethodType.Card); + + _notificationsWithStatuses = await CreateNotificationsWithStatusesAsync(); + } + + public Task DisposeAsync() + { + _client.Dispose(); + + foreach (var (notification, _) in _notificationsWithStatuses) + { + _notificationRepository.DeleteAsync(notification); + } + + return Task.CompletedTask; + } + + [Theory] + [InlineData("invalid")] + [InlineData("-1")] + [InlineData("0")] + public async Task ListAsync_RequestValidationContinuationInvalidNumber_BadRequest(string continuationToken) + { + await _loginHelper.LoginAsync(_ownerEmail); + + var response = await _client.GetAsync($"/notifications?continuationToken={continuationToken}"); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.Contains("ContinuationToken", result.ValidationErrors); + Assert.Contains("Continuation token must be a positive, non zero integer.", + result.ValidationErrors["ContinuationToken"]); + } + + [Fact] + public async Task ListAsync_RequestValidationContinuationTokenMaxLengthExceeded_BadRequest() + { + await _loginHelper.LoginAsync(_ownerEmail); + + var response = await _client.GetAsync("/notifications?continuationToken=1234567890"); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.Contains("ContinuationToken", result.ValidationErrors); + Assert.Contains("The field ContinuationToken must be a string with a maximum length of 9.", + result.ValidationErrors["ContinuationToken"]); + } + + [Theory] + [InlineData("9")] + [InlineData("1001")] + public async Task ListAsync_RequestValidationPageSizeInvalidRange_BadRequest(string pageSize) + { + await _loginHelper.LoginAsync(_ownerEmail); + + var response = await _client.GetAsync($"/notifications?pageSize={pageSize}"); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.Contains("PageSize", result.ValidationErrors); + Assert.Contains("The field PageSize must be between 10 and 1000.", + result.ValidationErrors["PageSize"]); + } + + [Fact] + public async Task ListAsync_NotLoggedIn_Unauthorized() + { + var response = await _client.GetAsync("/notifications"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Theory] + [InlineData(null, null, "2", 10)] + [InlineData(10, null, "2", 10)] + [InlineData(10, 2, "3", 10)] + [InlineData(10, 3, null, 0)] + [InlineData(15, null, "2", 15)] + [InlineData(15, 2, null, 5)] + [InlineData(20, null, "2", 20)] + [InlineData(20, 2, null, 0)] + [InlineData(1000, null, null, 20)] + public async Task ListAsync_PaginationFilter_ReturnsNextPageOfNotificationsCorrectOrder( + int? pageSize, int? pageNumber, string? expectedContinuationToken, int expectedCount) + { + var pageSizeWithDefault = pageSize ?? 10; + + await _loginHelper.LoginAsync(_ownerEmail); + + var skip = pageNumber == null ? 0 : (pageNumber.Value - 1) * pageSizeWithDefault; + + var notificationsInOrder = _notificationsWithStatuses.OrderByDescending(e => e.Item1.Priority) + .ThenByDescending(e => e.Item1.CreationDate) + .Skip(skip) + .Take(pageSizeWithDefault) + .ToList(); + + var url = "/notifications"; + if (pageNumber != null) + { + url += $"?continuationToken={pageNumber}"; + } + + if (pageSize != null) + { + url += url.Contains('?') ? "&" : "?"; + url += $"pageSize={pageSize}"; + } + + var response = await _client.GetAsync(url); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(result?.Data); + Assert.InRange(result.Data.Count(), 0, pageSizeWithDefault); + Assert.Equal(expectedCount, notificationsInOrder.Count); + Assert.Equal(notificationsInOrder.Count, result.Data.Count()); + AssertNotificationResponseModels(result.Data, notificationsInOrder); + + Assert.Equal(expectedContinuationToken, result.ContinuationToken); + } + + [Theory] + [InlineData(null, null)] + [InlineData(null, false)] + [InlineData(null, true)] + [InlineData(false, null)] + [InlineData(true, null)] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task ListAsync_ReadStatusDeletedStatusFilter_ReturnsFilteredNotificationsCorrectOrder( + bool? readStatusFilter, bool? deletedStatusFilter) + { + await _loginHelper.LoginAsync(_ownerEmail); + var notificationsInOrder = _notificationsWithStatuses.FindAll(e => + (readStatusFilter == null || readStatusFilter == (e.Item2?.ReadDate != null)) && + (deletedStatusFilter == null || deletedStatusFilter == (e.Item2?.DeletedDate != null))) + .OrderByDescending(e => e.Item1.Priority) + .ThenByDescending(e => e.Item1.CreationDate) + .Take(10) + .ToList(); + + var url = "/notifications"; + if (readStatusFilter != null) + { + url += $"?readStatusFilter={readStatusFilter}"; + } + + if (deletedStatusFilter != null) + { + url += url.Contains('?') ? "&" : "?"; + url += $"deletedStatusFilter={deletedStatusFilter}"; + } + + var response = await _client.GetAsync(url); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(result?.Data); + Assert.InRange(result.Data.Count(), 0, 10); + Assert.Equal(notificationsInOrder.Count, result.Data.Count()); + AssertNotificationResponseModels(result.Data, notificationsInOrder); + } + + [Fact] + private async void MarkAsDeletedAsync_NotLoggedIn_Unauthorized() + { + var url = $"/notifications/{Guid.NewGuid().ToString()}/delete"; + var response = await _client.PatchAsync(url, new StringContent("")); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + private async void MarkAsDeletedAsync_NonExistentNotificationId_NotFound() + { + await _loginHelper.LoginAsync(_ownerEmail); + + var url = $"/notifications/{Guid.NewGuid()}/delete"; + var response = await _client.PatchAsync(url, new StringContent("")); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + private async void MarkAsDeletedAsync_UserIdNotMatching_NotFound() + { + var email = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(email); + var user = (await _userRepository.GetByEmailAsync(email))!; + var notifications = await CreateNotificationsAsync(user.Id); + + await _loginHelper.LoginAsync(_ownerEmail); + + var url = $"/notifications/{notifications[0].Id}/delete"; + var response = await _client.PatchAsync(url, new StringContent("")); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + private async void MarkAsDeletedAsync_OrganizationIdNotMatchingUserNotPartOfOrganization_NotFound() + { + var email = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(email); + var user = (await _userRepository.GetByEmailAsync(email))!; + var notifications = await CreateNotificationsAsync(user.Id, _organization.Id); + + await _loginHelper.LoginAsync(email); + + var url = $"/notifications/{notifications[0].Id}/delete"; + var response = await _client.PatchAsync(url, new StringContent("")); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + private async void MarkAsDeletedAsync_OrganizationIdNotMatchingUserPartOfDifferentOrganization_NotFound() + { + var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, + plan: PlanType.EnterpriseAnnually, ownerEmail: _ownerEmail, passwordManagerSeats: 10, + paymentMethod: PaymentMethodType.Card); + var email = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(email); + var user = (await _userRepository.GetByEmailAsync(email))!; + await OrganizationTestHelpers.CreateUserAsync(_factory, organization.Id, email, OrganizationUserType.User); + var notifications = await CreateNotificationsAsync(user.Id, _organization.Id); + + await _loginHelper.LoginAsync(email); + + var url = $"/notifications/{notifications[0].Id}/delete"; + var response = await _client.PatchAsync(url, new StringContent("")); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + private async void MarkAsDeletedAsync_NotificationStatusNotExisting_Created() + { + var notifications = await CreateNotificationsAsync(_organizationUserOwner.UserId); + + await _loginHelper.LoginAsync(_ownerEmail); + + var url = $"/notifications/{notifications[0].Id}/delete"; + var response = await _client.PatchAsync(url, new StringContent("")); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var notificationStatus = await _notificationStatusRepository.GetByNotificationIdAndUserIdAsync( + notifications[0].Id, _organizationUserOwner.UserId!.Value); + Assert.NotNull(notificationStatus); + Assert.NotNull(notificationStatus.DeletedDate); + Assert.Equal(DateTime.UtcNow, notificationStatus.DeletedDate.Value, OneMinuteTimeSpan); + Assert.Null(notificationStatus.ReadDate); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + private async void MarkAsDeletedAsync_NotificationStatusExisting_Updated(bool deletedDateNull) + { + var notifications = await CreateNotificationsAsync(_organizationUserOwner.UserId); + await _notificationStatusRepository.CreateAsync(new NotificationStatus + { + NotificationId = notifications[0].Id, + UserId = _organizationUserOwner.UserId!.Value, + ReadDate = null, + DeletedDate = deletedDateNull ? null : DateTime.UtcNow - TimeSpan.FromMinutes(_random.Next(3600)) + }); + + await _loginHelper.LoginAsync(_ownerEmail); + + var url = $"/notifications/{notifications[0].Id}/delete"; + var response = await _client.PatchAsync(url, new StringContent("")); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var notificationStatus = await _notificationStatusRepository.GetByNotificationIdAndUserIdAsync( + notifications[0].Id, _organizationUserOwner.UserId!.Value); + Assert.NotNull(notificationStatus); + Assert.NotNull(notificationStatus.DeletedDate); + Assert.Equal(DateTime.UtcNow, notificationStatus.DeletedDate.Value, OneMinuteTimeSpan); + Assert.Null(notificationStatus.ReadDate); + } + + [Fact] + private async void MarkAsReadAsync_NotLoggedIn_Unauthorized() + { + var url = $"/notifications/{Guid.NewGuid().ToString()}/read"; + var response = await _client.PatchAsync(url, new StringContent("")); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + private async void MarkAsReadAsync_NonExistentNotificationId_NotFound() + { + await _loginHelper.LoginAsync(_ownerEmail); + + var url = $"/notifications/{Guid.NewGuid()}/read"; + var response = await _client.PatchAsync(url, new StringContent("")); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + private async void MarkAsReadAsync_UserIdNotMatching_NotFound() + { + var email = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(email); + var user = (await _userRepository.GetByEmailAsync(email))!; + var notifications = await CreateNotificationsAsync(user.Id); + + await _loginHelper.LoginAsync(_ownerEmail); + + var url = $"/notifications/{notifications[0].Id}/read"; + var response = await _client.PatchAsync(url, new StringContent("")); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + private async void MarkAsReadAsync_OrganizationIdNotMatchingUserNotPartOfOrganization_NotFound() + { + var email = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(email); + var user = (await _userRepository.GetByEmailAsync(email))!; + var notifications = await CreateNotificationsAsync(user.Id, _organization.Id); + + await _loginHelper.LoginAsync(email); + + var url = $"/notifications/{notifications[0].Id}/read"; + var response = await _client.PatchAsync(url, new StringContent("")); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + private async void MarkAsReadAsync_OrganizationIdNotMatchingUserPartOfDifferentOrganization_NotFound() + { + var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, + plan: PlanType.EnterpriseAnnually, ownerEmail: _ownerEmail, passwordManagerSeats: 10, + paymentMethod: PaymentMethodType.Card); + var email = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(email); + var user = (await _userRepository.GetByEmailAsync(email))!; + await OrganizationTestHelpers.CreateUserAsync(_factory, organization.Id, email, OrganizationUserType.User); + var notifications = await CreateNotificationsAsync(user.Id, _organization.Id); + + await _loginHelper.LoginAsync(email); + + var url = $"/notifications/{notifications[0].Id}/read"; + var response = await _client.PatchAsync(url, new StringContent("")); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + private async void MarkAsReadAsync_NotificationStatusNotExisting_Created() + { + var notifications = await CreateNotificationsAsync(_organizationUserOwner.UserId); + + await _loginHelper.LoginAsync(_ownerEmail); + + var url = $"/notifications/{notifications[0].Id}/read"; + var response = await _client.PatchAsync(url, new StringContent("")); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var notificationStatus = await _notificationStatusRepository.GetByNotificationIdAndUserIdAsync( + notifications[0].Id, _organizationUserOwner.UserId!.Value); + Assert.NotNull(notificationStatus); + Assert.NotNull(notificationStatus.ReadDate); + Assert.Equal(DateTime.UtcNow, notificationStatus.ReadDate.Value, OneMinuteTimeSpan); + Assert.Null(notificationStatus.DeletedDate); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + private async void MarkAsReadAsync_NotificationStatusExisting_Updated(bool readDateNull) + { + var notifications = await CreateNotificationsAsync(_organizationUserOwner.UserId); + await _notificationStatusRepository.CreateAsync(new NotificationStatus + { + NotificationId = notifications[0].Id, + UserId = _organizationUserOwner.UserId!.Value, + ReadDate = readDateNull ? null : DateTime.UtcNow - TimeSpan.FromMinutes(_random.Next(3600)), + DeletedDate = null + }); + + await _loginHelper.LoginAsync(_ownerEmail); + + var url = $"/notifications/{notifications[0].Id}/read"; + var response = await _client.PatchAsync(url, new StringContent("")); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var notificationStatus = await _notificationStatusRepository.GetByNotificationIdAndUserIdAsync( + notifications[0].Id, _organizationUserOwner.UserId!.Value); + Assert.NotNull(notificationStatus); + Assert.NotNull(notificationStatus.ReadDate); + Assert.Equal(DateTime.UtcNow, notificationStatus.ReadDate.Value, OneMinuteTimeSpan); + Assert.Null(notificationStatus.DeletedDate); + } + + private static void AssertNotificationResponseModels( + IEnumerable notificationResponseModels, + List<(Notification, NotificationStatus?)> expectedNotificationsWithStatuses) + { + var i = 0; + foreach (var notificationResponseModel in notificationResponseModels) + { + Assert.Contains(expectedNotificationsWithStatuses, e => e.Item1.Id == notificationResponseModel.Id); + var (expectedNotification, expectedNotificationStatus) = expectedNotificationsWithStatuses[i]; + Assert.NotNull(expectedNotification); + Assert.Equal(expectedNotification.Priority, notificationResponseModel.Priority); + Assert.Equal(expectedNotification.Title, notificationResponseModel.Title); + Assert.Equal(expectedNotification.Body, notificationResponseModel.Body); + Assert.Equal(expectedNotification.RevisionDate, notificationResponseModel.Date); + if (expectedNotificationStatus != null) + { + Assert.Equal(expectedNotificationStatus.ReadDate, notificationResponseModel.ReadDate); + Assert.Equal(expectedNotificationStatus.DeletedDate, notificationResponseModel.DeletedDate); + } + else + { + Assert.Null(notificationResponseModel.ReadDate); + Assert.Null(notificationResponseModel.DeletedDate); + } + + Assert.Equal("notification", notificationResponseModel.Object); + i++; + } + } + + private async Task> CreateNotificationsWithStatusesAsync() + { + var userId = (Guid)_organizationUserOwner.UserId!; + + var globalNotifications = await CreateNotificationsAsync(); + var userWithoutOrganizationNotifications = await CreateNotificationsAsync(userId: userId); + var organizationWithoutUserNotifications = await CreateNotificationsAsync(organizationId: _organization.Id); + var userPartOrOrganizationNotifications = await CreateNotificationsAsync(userId: userId, + organizationId: _organization.Id); + + var globalNotificationWithStatuses = await CreateNotificationStatusesAsync(globalNotifications, userId); + var userWithoutOrganizationNotificationWithStatuses = + await CreateNotificationStatusesAsync(userWithoutOrganizationNotifications, userId); + var organizationWithoutUserNotificationWithStatuses = + await CreateNotificationStatusesAsync(organizationWithoutUserNotifications, userId); + var userPartOrOrganizationNotificationWithStatuses = + await CreateNotificationStatusesAsync(userPartOrOrganizationNotifications, userId); + + return new List> + { + globalNotificationWithStatuses, + userWithoutOrganizationNotificationWithStatuses, + organizationWithoutUserNotificationWithStatuses, + userPartOrOrganizationNotificationWithStatuses + } + .SelectMany(n => n) + .ToList(); + } + + private async Task> CreateNotificationsAsync(Guid? userId = null, Guid? organizationId = null, + int numberToCreate = 5) + { + var priorities = Enum.GetValues(); + var clientTypes = Enum.GetValues(); + + var notifications = new List(); + + foreach (var clientType in clientTypes) + { + for (var i = 0; i < numberToCreate; i++) + { + var notification = new Notification + { + Global = userId == null && organizationId == null, + UserId = userId, + OrganizationId = organizationId, + Title = _mockEncryptedTitle, + Body = _mockEncryptedBody, + Priority = (Priority)priorities.GetValue(_random.Next(priorities.Length))!, + ClientType = clientType, + CreationDate = DateTime.UtcNow - TimeSpan.FromMinutes(_random.Next(3600)), + RevisionDate = DateTime.UtcNow - TimeSpan.FromMinutes(_random.Next(3600)) + }; + + notification = await _notificationRepository.CreateAsync(notification); + + notifications.Add(notification); + } + } + + return notifications; + } + + private async Task> CreateNotificationStatusesAsync( + List notifications, Guid userId) + { + var readDateNotificationStatus = await _notificationStatusRepository.CreateAsync(new NotificationStatus + { + NotificationId = notifications[0].Id, + UserId = userId, + ReadDate = DateTime.UtcNow - TimeSpan.FromMinutes(_random.Next(3600)), + DeletedDate = null + }); + + var deletedDateNotificationStatus = await _notificationStatusRepository.CreateAsync(new NotificationStatus + { + NotificationId = notifications[1].Id, + UserId = userId, + ReadDate = null, + DeletedDate = DateTime.UtcNow - TimeSpan.FromMinutes(_random.Next(3600)) + }); + + var readDateAndDeletedDateNotificationStatus = await _notificationStatusRepository.CreateAsync( + new NotificationStatus + { + NotificationId = notifications[2].Id, + UserId = userId, + ReadDate = DateTime.UtcNow - TimeSpan.FromMinutes(_random.Next(3600)), + DeletedDate = DateTime.UtcNow - TimeSpan.FromMinutes(_random.Next(3600)) + }); + + return + [ + (notifications[0], readDateNotificationStatus), + (notifications[1], deletedDateNotificationStatus), + (notifications[2], readDateAndDeletedDateNotificationStatus), + (notifications[3], null), + (notifications[4], null) + ]; + } +} diff --git a/test/Api.Test/NotificationCenter/Controllers/NotificationsControllerTests.cs b/test/Api.Test/NotificationCenter/Controllers/NotificationsControllerTests.cs new file mode 100644 index 000000000000..b8b21ef41930 --- /dev/null +++ b/test/Api.Test/NotificationCenter/Controllers/NotificationsControllerTests.cs @@ -0,0 +1,202 @@ +#nullable enable +using Bit.Api.NotificationCenter.Controllers; +using Bit.Api.NotificationCenter.Models.Request; +using Bit.Core.Models.Data; +using Bit.Core.NotificationCenter.Commands.Interfaces; +using Bit.Core.NotificationCenter.Models.Data; +using Bit.Core.NotificationCenter.Models.Filter; +using Bit.Core.NotificationCenter.Queries.Interfaces; +using Bit.Core.Test.NotificationCenter.AutoFixture; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.NotificationCenter.Controllers; + +[ControllerCustomize(typeof(NotificationsController))] +[SutProviderCustomize] +public class NotificationsControllerTests +{ + [Theory] + [BitAutoData([null, null])] + [BitAutoData([null, false])] + [BitAutoData([null, true])] + [BitAutoData(false, null)] + [BitAutoData(true, null)] + [BitAutoData(false, false)] + [BitAutoData(false, true)] + [BitAutoData(true, false)] + [BitAutoData(true, true)] + [NotificationStatusDetailsListCustomize(5)] + public async Task ListAsync_StatusFilter_ReturnedMatchingNotifications(bool? readStatusFilter, bool? deletedStatusFilter, + SutProvider sutProvider, + IEnumerable notificationStatusDetailsEnumerable) + { + var notificationStatusDetailsList = notificationStatusDetailsEnumerable + .OrderByDescending(n => n.Priority) + .ThenByDescending(n => n.CreationDate) + .ToList(); + + sutProvider.GetDependency() + .GetByUserIdStatusFilterAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult { Data = notificationStatusDetailsList }); + + var expectedNotificationStatusDetailsMap = notificationStatusDetailsList + .Take(10) + .ToDictionary(n => n.Id); + + var listResponse = await sutProvider.Sut.ListAsync(new NotificationFilterRequestModel + { + ReadStatusFilter = readStatusFilter, + DeletedStatusFilter = deletedStatusFilter + }); + + Assert.Equal("list", listResponse.Object); + Assert.Equal(5, listResponse.Data.Count()); + Assert.All(listResponse.Data, notificationResponseModel => + { + Assert.Equal("notification", notificationResponseModel.Object); + Assert.True(expectedNotificationStatusDetailsMap.ContainsKey(notificationResponseModel.Id)); + var expectedNotificationStatusDetails = expectedNotificationStatusDetailsMap[notificationResponseModel.Id]; + Assert.NotNull(expectedNotificationStatusDetails); + Assert.Equal(expectedNotificationStatusDetails.Id, notificationResponseModel.Id); + Assert.Equal(expectedNotificationStatusDetails.Priority, notificationResponseModel.Priority); + Assert.Equal(expectedNotificationStatusDetails.Title, notificationResponseModel.Title); + Assert.Equal(expectedNotificationStatusDetails.Body, notificationResponseModel.Body); + Assert.Equal(expectedNotificationStatusDetails.RevisionDate, notificationResponseModel.Date); + Assert.Equal(expectedNotificationStatusDetails.ReadDate, notificationResponseModel.ReadDate); + Assert.Equal(expectedNotificationStatusDetails.DeletedDate, notificationResponseModel.DeletedDate); + }); + Assert.Null(listResponse.ContinuationToken); + + await sutProvider.GetDependency() + .Received(1) + .GetByUserIdStatusFilterAsync(Arg.Is(filter => + filter.Read == readStatusFilter && filter.Deleted == deletedStatusFilter), + Arg.Is(pageOptions => + pageOptions.ContinuationToken == null && pageOptions.PageSize == 10)); + } + + [Theory] + [BitAutoData] + [NotificationStatusDetailsListCustomize(19)] + public async Task ListAsync_PagingRequestNoContinuationToken_ReturnedFirst10MatchingNotifications( + SutProvider sutProvider, + IEnumerable notificationStatusDetailsEnumerable) + { + var notificationStatusDetailsList = notificationStatusDetailsEnumerable + .OrderByDescending(n => n.Priority) + .ThenByDescending(n => n.CreationDate) + .ToList(); + + sutProvider.GetDependency() + .GetByUserIdStatusFilterAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult + { Data = notificationStatusDetailsList.Take(10).ToList(), ContinuationToken = "2" }); + + var expectedNotificationStatusDetailsMap = notificationStatusDetailsList + .Take(10) + .ToDictionary(n => n.Id); + + var listResponse = await sutProvider.Sut.ListAsync(new NotificationFilterRequestModel()); + + Assert.Equal("list", listResponse.Object); + Assert.Equal(10, listResponse.Data.Count()); + Assert.All(listResponse.Data, notificationResponseModel => + { + Assert.Equal("notification", notificationResponseModel.Object); + Assert.True(expectedNotificationStatusDetailsMap.ContainsKey(notificationResponseModel.Id)); + var expectedNotificationStatusDetails = expectedNotificationStatusDetailsMap[notificationResponseModel.Id]; + Assert.NotNull(expectedNotificationStatusDetails); + Assert.Equal(expectedNotificationStatusDetails.Id, notificationResponseModel.Id); + Assert.Equal(expectedNotificationStatusDetails.Priority, notificationResponseModel.Priority); + Assert.Equal(expectedNotificationStatusDetails.Title, notificationResponseModel.Title); + Assert.Equal(expectedNotificationStatusDetails.Body, notificationResponseModel.Body); + Assert.Equal(expectedNotificationStatusDetails.RevisionDate, notificationResponseModel.Date); + Assert.Equal(expectedNotificationStatusDetails.ReadDate, notificationResponseModel.ReadDate); + Assert.Equal(expectedNotificationStatusDetails.DeletedDate, notificationResponseModel.DeletedDate); + }); + Assert.Equal("2", listResponse.ContinuationToken); + + await sutProvider.GetDependency() + .Received(1) + .GetByUserIdStatusFilterAsync(Arg.Any(), + Arg.Is(pageOptions => + pageOptions.ContinuationToken == null && pageOptions.PageSize == 10)); + } + + [Theory] + [BitAutoData] + [NotificationStatusDetailsListCustomize(19)] + public async Task ListAsync_PagingRequestUsingContinuationToken_ReturnedLast9MatchingNotifications( + SutProvider sutProvider, + IEnumerable notificationStatusDetailsEnumerable) + { + var notificationStatusDetailsList = notificationStatusDetailsEnumerable + .OrderByDescending(n => n.Priority) + .ThenByDescending(n => n.CreationDate) + .ToList(); + + sutProvider.GetDependency() + .GetByUserIdStatusFilterAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult + { Data = notificationStatusDetailsList.Skip(10).ToList() }); + + var expectedNotificationStatusDetailsMap = notificationStatusDetailsList + .Skip(10) + .ToDictionary(n => n.Id); + + var listResponse = await sutProvider.Sut.ListAsync(new NotificationFilterRequestModel { ContinuationToken = "2" }); + + Assert.Equal("list", listResponse.Object); + Assert.Equal(9, listResponse.Data.Count()); + Assert.All(listResponse.Data, notificationResponseModel => + { + Assert.Equal("notification", notificationResponseModel.Object); + Assert.True(expectedNotificationStatusDetailsMap.ContainsKey(notificationResponseModel.Id)); + var expectedNotificationStatusDetails = expectedNotificationStatusDetailsMap[notificationResponseModel.Id]; + Assert.NotNull(expectedNotificationStatusDetails); + Assert.Equal(expectedNotificationStatusDetails.Id, notificationResponseModel.Id); + Assert.Equal(expectedNotificationStatusDetails.Priority, notificationResponseModel.Priority); + Assert.Equal(expectedNotificationStatusDetails.Title, notificationResponseModel.Title); + Assert.Equal(expectedNotificationStatusDetails.Body, notificationResponseModel.Body); + Assert.Equal(expectedNotificationStatusDetails.RevisionDate, notificationResponseModel.Date); + Assert.Equal(expectedNotificationStatusDetails.ReadDate, notificationResponseModel.ReadDate); + Assert.Equal(expectedNotificationStatusDetails.DeletedDate, notificationResponseModel.DeletedDate); + }); + Assert.Null(listResponse.ContinuationToken); + + await sutProvider.GetDependency() + .Received(1) + .GetByUserIdStatusFilterAsync(Arg.Any(), + Arg.Is(pageOptions => + pageOptions.ContinuationToken == "2" && pageOptions.PageSize == 10)); + } + + [Theory] + [BitAutoData] + public async Task MarkAsDeletedAsync_NotificationId_MarkedAsDeleted( + SutProvider sutProvider, + Guid notificationId) + { + await sutProvider.Sut.MarkAsDeletedAsync(notificationId); + + await sutProvider.GetDependency() + .Received(1) + .MarkDeletedAsync(notificationId); + } + + [Theory] + [BitAutoData] + public async Task MarkAsReadAsync_NotificationId_MarkedAsRead( + SutProvider sutProvider, + Guid notificationId) + { + await sutProvider.Sut.MarkAsReadAsync(notificationId); + + await sutProvider.GetDependency() + .Received(1) + .MarkReadAsync(notificationId); + } +} diff --git a/test/Api.Test/NotificationCenter/Models/Request/NotificationFilterRequestModelTests.cs b/test/Api.Test/NotificationCenter/Models/Request/NotificationFilterRequestModelTests.cs new file mode 100644 index 000000000000..8b72d13e712e --- /dev/null +++ b/test/Api.Test/NotificationCenter/Models/Request/NotificationFilterRequestModelTests.cs @@ -0,0 +1,93 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; +using Bit.Api.NotificationCenter.Models.Request; +using Xunit; + +namespace Bit.Api.Test.NotificationCenter.Models.Request; + +public class NotificationFilterRequestModelTests +{ + [Theory] + [InlineData("invalid")] + [InlineData("-1")] + [InlineData("0")] + public void Validate_ContinuationTokenInvalidNumber_Invalid(string continuationToken) + { + var model = new NotificationFilterRequestModel + { + ContinuationToken = continuationToken, + }; + var result = Validate(model); + Assert.Single(result); + Assert.Contains("Continuation token must be a positive, non zero integer.", result[0].ErrorMessage); + Assert.Contains("ContinuationToken", result[0].MemberNames); + } + + [Fact] + public void Validate_ContinuationTokenMaxLengthExceeded_Invalid() + { + var model = new NotificationFilterRequestModel + { + ContinuationToken = "1234567890" + }; + var result = Validate(model); + Assert.Single(result); + Assert.Contains("The field ContinuationToken must be a string with a maximum length of 9.", + result[0].ErrorMessage); + Assert.Contains("ContinuationToken", result[0].MemberNames); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("1")] + [InlineData("123456789")] + public void Validate_ContinuationTokenCorrect_Valid(string? continuationToken) + { + var model = new NotificationFilterRequestModel + { + ContinuationToken = continuationToken + }; + var result = Validate(model); + Assert.Empty(result); + } + + [Theory] + [InlineData(9)] + [InlineData(1001)] + public void Validate_PageSizeInvalidRange_Invalid(int pageSize) + { + var model = new NotificationFilterRequestModel + { + PageSize = pageSize + }; + var result = Validate(model); + Assert.Single(result); + Assert.Contains("The field PageSize must be between 10 and 1000.", result[0].ErrorMessage); + Assert.Contains("PageSize", result[0].MemberNames); + } + + [Theory] + [InlineData(null)] + [InlineData(10)] + [InlineData(1000)] + public void Validate_PageSizeCorrect_Valid(int? pageSize) + { + var model = pageSize == null + ? new NotificationFilterRequestModel() + : new NotificationFilterRequestModel + { + PageSize = pageSize.Value + }; + var result = Validate(model); + Assert.Empty(result); + } + + private static List Validate(NotificationFilterRequestModel model) + { + var results = new List(); + Validator.TryValidateObject(model, new ValidationContext(model), results, true); + return results; + } +} diff --git a/test/Api.Test/NotificationCenter/Models/Response/NotificationResponseModelTests.cs b/test/Api.Test/NotificationCenter/Models/Response/NotificationResponseModelTests.cs new file mode 100644 index 000000000000..f0dfc03fec28 --- /dev/null +++ b/test/Api.Test/NotificationCenter/Models/Response/NotificationResponseModelTests.cs @@ -0,0 +1,43 @@ +#nullable enable +using Bit.Api.NotificationCenter.Models.Response; +using Bit.Core.Enums; +using Bit.Core.NotificationCenter.Enums; +using Bit.Core.NotificationCenter.Models.Data; +using Xunit; + +namespace Bit.Api.Test.NotificationCenter.Models.Response; + +public class NotificationResponseModelTests +{ + [Fact] + public void Constructor_NotificationStatusDetailsNull_CorrectFields() + { + Assert.Throws(() => new NotificationResponseModel(null!)); + } + + [Fact] + public void Constructor_NotificationStatusDetails_CorrectFields() + { + var notificationStatusDetails = new NotificationStatusDetails + { + Id = Guid.NewGuid(), + Global = true, + Priority = Priority.High, + ClientType = ClientType.All, + Title = "Test Title", + Body = "Test Body", + RevisionDate = DateTime.UtcNow - TimeSpan.FromMinutes(3), + ReadDate = DateTime.UtcNow - TimeSpan.FromMinutes(1), + DeletedDate = DateTime.UtcNow, + }; + var model = new NotificationResponseModel(notificationStatusDetails); + + Assert.Equal(model.Id, notificationStatusDetails.Id); + Assert.Equal(model.Priority, notificationStatusDetails.Priority); + Assert.Equal(model.Title, notificationStatusDetails.Title); + Assert.Equal(model.Body, notificationStatusDetails.Body); + Assert.Equal(model.Date, notificationStatusDetails.RevisionDate); + Assert.Equal(model.ReadDate, notificationStatusDetails.ReadDate); + Assert.Equal(model.DeletedDate, notificationStatusDetails.DeletedDate); + } +} diff --git a/test/Core.Test/NotificationCenter/AutoFixture/NotificationStatusDetailsFixtures.cs b/test/Core.Test/NotificationCenter/AutoFixture/NotificationStatusDetailsFixtures.cs index 1e1d066d168e..71c9878f425d 100644 --- a/test/Core.Test/NotificationCenter/AutoFixture/NotificationStatusDetailsFixtures.cs +++ b/test/Core.Test/NotificationCenter/AutoFixture/NotificationStatusDetailsFixtures.cs @@ -9,9 +9,32 @@ public class NotificationStatusDetailsCustomization : ICustomization { public void Customize(IFixture fixture) { - fixture.Customize(composer => composer.With(n => n.Id, Guid.NewGuid()) - .With(n => n.UserId, Guid.NewGuid()) - .With(n => n.OrganizationId, Guid.NewGuid())); + fixture.Customize(composer => + { + return composer.With(n => n.Id, Guid.NewGuid()) + .With(n => n.UserId, Guid.NewGuid()) + .With(n => n.OrganizationId, Guid.NewGuid()); + }); + } +} + +public class NotificationStatusDetailsListCustomization(int count) : ICustomization +{ + public void Customize(IFixture fixture) + { + var customization = new NotificationStatusDetailsCustomization(); + fixture.Customize>(composer => composer.FromFactory(() => + { + var notifications = new List(); + for (var i = 0; i < count; i++) + { + customization.Customize(fixture); + var notificationStatusDetails = fixture.Create(); + notifications.Add(notificationStatusDetails); + } + + return notifications; + })); } } @@ -19,3 +42,8 @@ public class NotificationStatusDetailsCustomizeAttribute : BitCustomizeAttribute { public override ICustomization GetCustomization() => new NotificationStatusDetailsCustomization(); } + +public class NotificationStatusDetailsListCustomizeAttribute(int count) : BitCustomizeAttribute +{ + public override ICustomization GetCustomization() => new NotificationStatusDetailsListCustomization(count); +} diff --git a/test/Core.Test/NotificationCenter/Queries/GetNotificationStatusDetailsForUserQueryTest.cs b/test/Core.Test/NotificationCenter/Queries/GetNotificationStatusDetailsForUserQueryTest.cs index 7d9c26560613..d0c89a45d969 100644 --- a/test/Core.Test/NotificationCenter/Queries/GetNotificationStatusDetailsForUserQueryTest.cs +++ b/test/Core.Test/NotificationCenter/Queries/GetNotificationStatusDetailsForUserQueryTest.cs @@ -2,6 +2,7 @@ using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Data; using Bit.Core.NotificationCenter.Models.Data; using Bit.Core.NotificationCenter.Models.Filter; using Bit.Core.NotificationCenter.Queries; @@ -19,37 +20,49 @@ namespace Bit.Core.Test.NotificationCenter.Queries; public class GetNotificationStatusDetailsForUserQueryTest { private static void Setup(SutProvider sutProvider, - List notificationsStatusDetails, NotificationStatusFilter statusFilter, Guid? userId) + List notificationsStatusDetails, NotificationStatusFilter statusFilter, Guid? userId, + PageOptions pageOptions, string? continuationToken) { sutProvider.GetDependency().UserId.Returns(userId); - sutProvider.GetDependency().GetByUserIdAndStatusAsync( - userId.GetValueOrDefault(Guid.NewGuid()), Arg.Any(), statusFilter) - .Returns(notificationsStatusDetails); + sutProvider.GetDependency() + .GetByUserIdAndStatusAsync(userId.GetValueOrDefault(Guid.NewGuid()), Arg.Any(), statusFilter, + pageOptions) + .Returns(new PagedResult + { + Data = notificationsStatusDetails, + ContinuationToken = continuationToken + }); } [Theory] [BitAutoData] public async Task GetByUserIdStatusFilterAsync_NotLoggedIn_NotFoundException( SutProvider sutProvider, - List notificationsStatusDetails, NotificationStatusFilter notificationStatusFilter) + List notificationsStatusDetails, NotificationStatusFilter notificationStatusFilter, + PageOptions pageOptions, string? continuationToken) { - Setup(sutProvider, notificationsStatusDetails, notificationStatusFilter, userId: null); + Setup(sutProvider, notificationsStatusDetails, notificationStatusFilter, userId: null, pageOptions, + continuationToken); await Assert.ThrowsAsync(() => - sutProvider.Sut.GetByUserIdStatusFilterAsync(notificationStatusFilter)); + sutProvider.Sut.GetByUserIdStatusFilterAsync(notificationStatusFilter, pageOptions)); } [Theory] [BitAutoData] public async Task GetByUserIdStatusFilterAsync_NotificationsFound_Returned( SutProvider sutProvider, - List notificationsStatusDetails, NotificationStatusFilter notificationStatusFilter) + List notificationsStatusDetails, NotificationStatusFilter notificationStatusFilter, + PageOptions pageOptions, string? continuationToken) { - Setup(sutProvider, notificationsStatusDetails, notificationStatusFilter, Guid.NewGuid()); + Setup(sutProvider, notificationsStatusDetails, notificationStatusFilter, Guid.NewGuid(), pageOptions, + continuationToken); - var actualNotificationsStatusDetails = - await sutProvider.Sut.GetByUserIdStatusFilterAsync(notificationStatusFilter); + var actualNotificationsStatusDetailsPagedResult = + await sutProvider.Sut.GetByUserIdStatusFilterAsync(notificationStatusFilter, pageOptions); - Assert.Equal(notificationsStatusDetails, actualNotificationsStatusDetails); + Assert.NotNull(actualNotificationsStatusDetailsPagedResult); + Assert.Equal(notificationsStatusDetails, actualNotificationsStatusDetailsPagedResult.Data); + Assert.Equal(continuationToken, actualNotificationsStatusDetailsPagedResult.ContinuationToken); } } diff --git a/util/Migrator/DbScripts/2024-12-18_00_AddPagingToNotificationRead.sql b/util/Migrator/DbScripts/2024-12-18_00_AddPagingToNotificationRead.sql new file mode 100644 index 000000000000..21e19c193c03 --- /dev/null +++ b/util/Migrator/DbScripts/2024-12-18_00_AddPagingToNotificationRead.sql @@ -0,0 +1,39 @@ +-- Stored Procedure Notification_ReadByUserIdAndStatus + +CREATE OR ALTER PROCEDURE [dbo].[Notification_ReadByUserIdAndStatus] + @UserId UNIQUEIDENTIFIER, + @ClientType TINYINT, + @Read BIT, + @Deleted BIT, + @PageNumber INT = 1, + @PageSize INT = 10 +AS +BEGIN + SET NOCOUNT ON + + SELECT n.* + FROM [dbo].[NotificationStatusDetailsView] n + LEFT JOIN [dbo].[OrganizationUserView] ou ON n.[OrganizationId] = ou.[OrganizationId] + AND ou.[UserId] = @UserId + WHERE (n.[NotificationStatusUserId] IS NULL OR n.[NotificationStatusUserId] = @UserId) + AND [ClientType] IN (0, CASE WHEN @ClientType != 0 THEN @ClientType END) + AND ([Global] = 1 + OR (n.[UserId] = @UserId + AND (n.[OrganizationId] IS NULL + OR ou.[OrganizationId] IS NOT NULL)) + OR (n.[UserId] IS NULL + AND ou.[OrganizationId] IS NOT NULL)) + AND ((@Read IS NULL AND @Deleted IS NULL) + OR (n.[NotificationStatusUserId] IS NOT NULL + AND (@Read IS NULL + OR IIF((@Read = 1 AND n.[ReadDate] IS NOT NULL) OR + (@Read = 0 AND n.[ReadDate] IS NULL), + 1, 0) = 1) + AND (@Deleted IS NULL + OR IIF((@Deleted = 1 AND n.[DeletedDate] IS NOT NULL) OR + (@Deleted = 0 AND n.[DeletedDate] IS NULL), + 1, 0) = 1))) + ORDER BY [Priority] DESC, n.[CreationDate] DESC + OFFSET @PageSize * (@PageNumber - 1) ROWS FETCH NEXT @PageSize ROWS ONLY +END +GO From 322a07477a27c759e5b637662cde0a8001ffef80 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Wed, 18 Dec 2024 16:31:07 +0100 Subject: [PATCH 14/19] organization status changed code changes (#5113) * organization status changed code changes Signed-off-by: Cy Okeke * Add the push notification to subscriptionUpdated Signed-off-by: Cy Okeke * send notification using the SendPayloadToUser Signed-off-by: Cy Okeke * Change the implementation to send userId * Added new implementation for orgstatus sync * refactor the code and remove private methods --------- Signed-off-by: Cy Okeke --- .../Implementations/PaymentSucceededHandler.cs | 6 +++++- .../Implementations/SubscriptionUpdatedHandler.cs | 11 ++++++++++- src/Core/Enums/PushType.cs | 1 + src/Core/Models/PushNotification.cs | 6 ++++++ .../NotificationHubPushNotificationService.cs | 12 ++++++++++++ src/Core/Services/IPushNotificationService.cs | 4 +++- .../AzureQueuePushNotificationService.cs | 12 ++++++++++++ .../MultiServicePushNotificationService.cs | 9 ++++++++- .../NotificationsApiPushNotificationService.cs | 14 +++++++++++++- .../RelayPushNotificationService.cs | 14 +++++++++++++- .../NoopPushNotificationService.cs | 8 +++++++- src/Notifications/HubHelpers.cs | 7 +++++++ 12 files changed, 97 insertions(+), 7 deletions(-) diff --git a/src/Billing/Services/Implementations/PaymentSucceededHandler.cs b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs index 6aa8aa2b9f6f..49578187f983 100644 --- a/src/Billing/Services/Implementations/PaymentSucceededHandler.cs +++ b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs @@ -25,6 +25,7 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler private readonly ICurrentContext _currentContext; private readonly IUserRepository _userRepository; private readonly IStripeEventUtilityService _stripeEventUtilityService; + private readonly IPushNotificationService _pushNotificationService; public PaymentSucceededHandler( ILogger logger, @@ -37,7 +38,8 @@ public PaymentSucceededHandler( IUserRepository userRepository, IStripeEventUtilityService stripeEventUtilityService, IUserService userService, - IOrganizationService organizationService) + IOrganizationService organizationService, + IPushNotificationService pushNotificationService) { _logger = logger; _stripeEventService = stripeEventService; @@ -50,6 +52,7 @@ public PaymentSucceededHandler( _stripeEventUtilityService = stripeEventUtilityService; _userService = userService; _organizationService = organizationService; + _pushNotificationService = pushNotificationService; } /// @@ -140,6 +143,7 @@ await _referenceEventService.RaiseEventAsync(new ReferenceEvent await _organizationService.EnableAsync(organizationId.Value, subscription.CurrentPeriodEnd); var organization = await _organizationRepository.GetByIdAsync(organizationId.Value); + await _pushNotificationService.PushSyncOrganizationStatusAsync(organization); await _referenceEventService.RaiseEventAsync( new ReferenceEvent(ReferenceEventType.Rebilled, organization, _currentContext) diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index 4b4c9dcf4a40..d49b22b7fbe5 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -1,5 +1,6 @@ using Bit.Billing.Constants; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; +using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; using Stripe; @@ -15,6 +16,8 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler private readonly IStripeFacade _stripeFacade; private readonly IOrganizationSponsorshipRenewCommand _organizationSponsorshipRenewCommand; private readonly IUserService _userService; + private readonly IPushNotificationService _pushNotificationService; + private readonly IOrganizationRepository _organizationRepository; public SubscriptionUpdatedHandler( IStripeEventService stripeEventService, @@ -22,7 +25,9 @@ public SubscriptionUpdatedHandler( IOrganizationService organizationService, IStripeFacade stripeFacade, IOrganizationSponsorshipRenewCommand organizationSponsorshipRenewCommand, - IUserService userService) + IUserService userService, + IPushNotificationService pushNotificationService, + IOrganizationRepository organizationRepository) { _stripeEventService = stripeEventService; _stripeEventUtilityService = stripeEventUtilityService; @@ -30,6 +35,8 @@ public SubscriptionUpdatedHandler( _stripeFacade = stripeFacade; _organizationSponsorshipRenewCommand = organizationSponsorshipRenewCommand; _userService = userService; + _pushNotificationService = pushNotificationService; + _organizationRepository = organizationRepository; } /// @@ -70,6 +77,8 @@ public async Task HandleAsync(Event parsedEvent) case StripeSubscriptionStatus.Active when organizationId.HasValue: { await _organizationService.EnableAsync(organizationId.Value); + var organization = await _organizationRepository.GetByIdAsync(organizationId.Value); + await _pushNotificationService.PushSyncOrganizationStatusAsync(organization); break; } case StripeSubscriptionStatus.Active: diff --git a/src/Core/Enums/PushType.cs b/src/Core/Enums/PushType.cs index 9dbef7b8e274..2030b855e239 100644 --- a/src/Core/Enums/PushType.cs +++ b/src/Core/Enums/PushType.cs @@ -25,4 +25,5 @@ public enum PushType : byte AuthRequestResponse = 16, SyncOrganizations = 17, + SyncOrganizationStatusChanged = 18, } diff --git a/src/Core/Models/PushNotification.cs b/src/Core/Models/PushNotification.cs index 37b3b25c0d01..667080580ed1 100644 --- a/src/Core/Models/PushNotification.cs +++ b/src/Core/Models/PushNotification.cs @@ -50,3 +50,9 @@ public class AuthRequestPushNotification public Guid UserId { get; set; } public Guid Id { get; set; } } + +public class OrganizationStatusPushNotification +{ + public Guid OrganizationId { get; set; } + public bool Enabled { get; set; } +} diff --git a/src/Core/NotificationHub/NotificationHubPushNotificationService.cs b/src/Core/NotificationHub/NotificationHubPushNotificationService.cs index 6143676deffb..7438e812e0ba 100644 --- a/src/Core/NotificationHub/NotificationHubPushNotificationService.cs +++ b/src/Core/NotificationHub/NotificationHubPushNotificationService.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.RegularExpressions; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Entities; using Bit.Core.Context; using Bit.Core.Enums; @@ -226,6 +227,17 @@ public async Task SendPayloadToOrganizationAsync(string orgId, PushType type, ob } } + public async Task PushSyncOrganizationStatusAsync(Organization organization) + { + var message = new OrganizationStatusPushNotification + { + OrganizationId = organization.Id, + Enabled = organization.Enabled + }; + + await SendPayloadToOrganizationAsync(organization.Id, PushType.SyncOrganizationStatusChanged, message, false); + } + private string GetContextIdentifier(bool excludeCurrentContext) { if (!excludeCurrentContext) diff --git a/src/Core/Services/IPushNotificationService.cs b/src/Core/Services/IPushNotificationService.cs index 29a20239d1ac..6e2e47e27fa5 100644 --- a/src/Core/Services/IPushNotificationService.cs +++ b/src/Core/Services/IPushNotificationService.cs @@ -1,4 +1,5 @@ -using Bit.Core.Auth.Entities; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Entities; using Bit.Core.Enums; using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; @@ -27,4 +28,5 @@ public interface IPushNotificationService Task SendPayloadToUserAsync(string userId, PushType type, object payload, string identifier, string deviceId = null); Task SendPayloadToOrganizationAsync(string orgId, PushType type, object payload, string identifier, string deviceId = null); + Task PushSyncOrganizationStatusAsync(Organization organization); } diff --git a/src/Core/Services/Implementations/AzureQueuePushNotificationService.cs b/src/Core/Services/Implementations/AzureQueuePushNotificationService.cs index 1e4a7314c457..3daadebf3aa8 100644 --- a/src/Core/Services/Implementations/AzureQueuePushNotificationService.cs +++ b/src/Core/Services/Implementations/AzureQueuePushNotificationService.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Azure.Storage.Queues; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Entities; using Bit.Core.Context; using Bit.Core.Enums; @@ -221,4 +222,15 @@ public Task SendPayloadToOrganizationAsync(string orgId, PushType type, object p // Noop return Task.FromResult(0); } + + public async Task PushSyncOrganizationStatusAsync(Organization organization) + { + var message = new OrganizationStatusPushNotification + { + OrganizationId = organization.Id, + Enabled = organization.Enabled + }; + await SendMessageAsync(PushType.SyncOrganizationStatusChanged, message, false); + } + } diff --git a/src/Core/Services/Implementations/MultiServicePushNotificationService.cs b/src/Core/Services/Implementations/MultiServicePushNotificationService.cs index 00be72c980e0..185a11adbb80 100644 --- a/src/Core/Services/Implementations/MultiServicePushNotificationService.cs +++ b/src/Core/Services/Implementations/MultiServicePushNotificationService.cs @@ -1,4 +1,5 @@ -using Bit.Core.Auth.Entities; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Entities; using Bit.Core.Enums; using Bit.Core.Settings; using Bit.Core.Tools.Entities; @@ -144,6 +145,12 @@ public Task SendPayloadToOrganizationAsync(string orgId, PushType type, object p return Task.FromResult(0); } + public Task PushSyncOrganizationStatusAsync(Organization organization) + { + PushToServices((s) => s.PushSyncOrganizationStatusAsync(organization)); + return Task.FromResult(0); + } + private void PushToServices(Func pushFunc) { if (_services != null) diff --git a/src/Core/Services/Implementations/NotificationsApiPushNotificationService.cs b/src/Core/Services/Implementations/NotificationsApiPushNotificationService.cs index 9ec1eb31d4d1..feec75fbe086 100644 --- a/src/Core/Services/Implementations/NotificationsApiPushNotificationService.cs +++ b/src/Core/Services/Implementations/NotificationsApiPushNotificationService.cs @@ -1,4 +1,5 @@ -using Bit.Core.Auth.Entities; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Entities; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Models; @@ -227,4 +228,15 @@ public Task SendPayloadToOrganizationAsync(string orgId, PushType type, object p // Noop return Task.FromResult(0); } + + public async Task PushSyncOrganizationStatusAsync(Organization organization) + { + var message = new OrganizationStatusPushNotification + { + OrganizationId = organization.Id, + Enabled = organization.Enabled + }; + + await SendMessageAsync(PushType.SyncOrganizationStatusChanged, message, false); + } } diff --git a/src/Core/Services/Implementations/RelayPushNotificationService.cs b/src/Core/Services/Implementations/RelayPushNotificationService.cs index 6cfc0c0a6153..d72529677901 100644 --- a/src/Core/Services/Implementations/RelayPushNotificationService.cs +++ b/src/Core/Services/Implementations/RelayPushNotificationService.cs @@ -1,4 +1,5 @@ -using Bit.Core.Auth.Entities; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Entities; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.IdentityServer; @@ -251,4 +252,15 @@ public Task SendPayloadToOrganizationAsync(string orgId, PushType type, object p { throw new NotImplementedException(); } + + public async Task PushSyncOrganizationStatusAsync(Organization organization) + { + var message = new OrganizationStatusPushNotification + { + OrganizationId = organization.Id, + Enabled = organization.Enabled + }; + + await SendPayloadToOrganizationAsync(organization.Id, PushType.SyncOrganizationStatusChanged, message, false); + } } diff --git a/src/Core/Services/NoopImplementations/NoopPushNotificationService.cs b/src/Core/Services/NoopImplementations/NoopPushNotificationService.cs index d4eff93ef65e..b5e2616220d7 100644 --- a/src/Core/Services/NoopImplementations/NoopPushNotificationService.cs +++ b/src/Core/Services/NoopImplementations/NoopPushNotificationService.cs @@ -1,4 +1,5 @@ -using Bit.Core.Auth.Entities; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Entities; using Bit.Core.Enums; using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; @@ -88,6 +89,11 @@ public Task SendPayloadToOrganizationAsync(string orgId, PushType type, object p return Task.FromResult(0); } + public Task PushSyncOrganizationStatusAsync(Organization organization) + { + return Task.FromResult(0); + } + public Task PushAuthRequestAsync(AuthRequest authRequest) { return Task.FromResult(0); diff --git a/src/Notifications/HubHelpers.cs b/src/Notifications/HubHelpers.cs index 53edb7638919..ce2e6b24adce 100644 --- a/src/Notifications/HubHelpers.cs +++ b/src/Notifications/HubHelpers.cs @@ -85,6 +85,13 @@ await anonymousHubContext.Clients.Group(authRequestResponseNotification.Payload. await hubContext.Clients.User(authRequestNotification.Payload.UserId.ToString()) .SendAsync("ReceiveMessage", authRequestNotification, cancellationToken); break; + case PushType.SyncOrganizationStatusChanged: + var orgStatusNotification = + JsonSerializer.Deserialize>( + notificationJson, _deserializerOptions); + await hubContext.Clients.Group($"Organization_{orgStatusNotification.Payload.OrganizationId}") + .SendAsync("ReceiveMessage", orgStatusNotification, cancellationToken); + break; default: break; } From 4f50461521b329d97986b5b18bebbe20c0bd4a2b Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Wed, 18 Dec 2024 16:00:43 -0500 Subject: [PATCH 15/19] Remove link to missing file (#5166) --- bitwarden-server.sln | 1 - 1 file changed, 1 deletion(-) diff --git a/bitwarden-server.sln b/bitwarden-server.sln index ad643c43c3bf..75e7d7fade23 100644 --- a/bitwarden-server.sln +++ b/bitwarden-server.sln @@ -18,7 +18,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution .editorconfig = .editorconfig TRADEMARK_GUIDELINES.md = TRADEMARK_GUIDELINES.md SECURITY.md = SECURITY.md - NuGet.Config = NuGet.Config LICENSE_FAQ.md = LICENSE_FAQ.md LICENSE_BITWARDEN.txt = LICENSE_BITWARDEN.txt LICENSE_AGPL.txt = LICENSE_AGPL.txt From 2504f36bdc6c8fe7e78f3a5b2e4f402688b7a4cb Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Wed, 18 Dec 2024 15:36:50 -0600 Subject: [PATCH 16/19] PM-13227 Rename access insights to access intelligence (#5160) --- src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml index 23d2057d073b..cdc7608675b1 100644 --- a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml +++ b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml @@ -165,7 +165,7 @@
-

Access Insights

+

Access Intelligence

From 0b026404db70c8d43dcc80d0c071daa47060a0c8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 19 Dec 2024 13:43:44 +0100 Subject: [PATCH 17/19] [deps] Tools: Update Microsoft.Extensions.DependencyInjection to v9 (#5073) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> --- .../Infrastructure.IntegrationTest.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj b/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj index 159572f387da..724627cd2909 100644 --- a/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj +++ b/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj @@ -8,8 +8,8 @@ - - + + From cb7cbb630aba46050ef9c235a2a0e4608dda4d83 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 19 Dec 2024 12:57:03 +0000 Subject: [PATCH 18/19] [deps] Tools: Update Microsoft.Extensions.Configuration to v9 (#5072) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Core/Core.csproj | 4 ++-- .../Infrastructure.IntegrationTest.csproj | 2 +- test/IntegrationTestCommon/IntegrationTestCommon.csproj | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 43068a4ac0a2..317d74f53677 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -41,8 +41,8 @@ - - + + diff --git a/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj b/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj index 724627cd2909..417525f064b3 100644 --- a/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj +++ b/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj @@ -7,7 +7,7 @@ - + diff --git a/test/IntegrationTestCommon/IntegrationTestCommon.csproj b/test/IntegrationTestCommon/IntegrationTestCommon.csproj index 3e8e55524bde..2a65c4c364b7 100644 --- a/test/IntegrationTestCommon/IntegrationTestCommon.csproj +++ b/test/IntegrationTestCommon/IntegrationTestCommon.csproj @@ -6,7 +6,7 @@ - + From a3da5b2f0a0f0dfd7f636de3a2d56eb32c30ea13 Mon Sep 17 00:00:00 2001 From: Tom <144813356+ttalty@users.noreply.github.com> Date: Thu, 19 Dec 2024 11:00:47 -0500 Subject: [PATCH 19/19] Removing access intelligence server side feature flag (#5158) --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 9b51b12d6296..cc94cf3dee2c 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -141,7 +141,6 @@ public static class FeatureFlagKeys public const string TrialPayment = "PM-8163-trial-payment"; public const string RemoveServerVersionHeader = "remove-server-version-header"; public const string SecureOrgGroupDetails = "pm-3479-secure-org-group-details"; - public const string AccessIntelligence = "pm-13227-access-intelligence"; public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint"; public const string PM12275_MultiOrganizationEnterprises = "pm-12275-multi-organization-enterprises"; public const string GeneratorToolsModernization = "generator-tools-modernization";