From 01daad59424ef0747c351865b6549f31dd872adf Mon Sep 17 00:00:00 2001 From: bnagawiecki <107435978+bnagawiecki@users.noreply.github.com> Date: Mon, 7 Apr 2025 10:48:50 -0400 Subject: [PATCH 01/28] add feature flag for new desktop cipher forms (#5621) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 8889615dfa..9ba30786d1 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -197,6 +197,7 @@ public static class FeatureFlagKeys public const string RestrictProviderAccess = "restrict-provider-access"; public const string SecurityTasks = "security-tasks"; public const string CipherKeyEncryption = "cipher-key-encryption"; + public const string DesktopCipherForms = "pm-18520-desktop-cipher-forms"; public static List GetAllKeys() { From 1cf9ff34c1cc36d0139dbb61e27b8b542bf6e659 Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Mon, 7 Apr 2025 11:26:06 -0500 Subject: [PATCH 02/28] PM-17921 change the GenerateAccessData method to process lists in parallel (#5552) * PM-17921 change the GenerateAccessData method to process lists in parallel. * PM-17921 removing old method --- .../MemberAccessCipherDetailsQuery.cs | 128 +++++++++--------- 1 file changed, 63 insertions(+), 65 deletions(-) diff --git a/src/Core/Tools/ReportFeatures/MemberAccessCipherDetailsQuery.cs b/src/Core/Tools/ReportFeatures/MemberAccessCipherDetailsQuery.cs index a08359a84f..0c165a7dc2 100644 --- a/src/Core/Tools/ReportFeatures/MemberAccessCipherDetailsQuery.cs +++ b/src/Core/Tools/ReportFeatures/MemberAccessCipherDetailsQuery.cs @@ -1,4 +1,5 @@ -using Bit.Core.AdminConsole.Entities; +using System.Collections.Concurrent; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Entities; @@ -59,22 +60,21 @@ public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery var orgItems = await _organizationCiphersQuery.GetAllOrganizationCiphers(request.OrganizationId); var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers); - var memberAccessCipherDetails = GenerateAccessData( + var memberAccessCipherDetails = GenerateAccessDataParallel( orgGroups, orgCollectionsWithAccess, orgItems, organizationUsersTwoFactorEnabled, - orgAbility - ); + orgAbility); return memberAccessCipherDetails; } /// /// Generates a report for all members of an organization. Containing summary information - /// such as item, collection, and group counts. Including the cipherIds a member is assigned. + /// such as item, collection, and group counts. Including the cipherIds a member is assigned. /// Child collection includes detailed information on the user and group collections along - /// with their permissions. + /// with their permissions. /// /// Organization groups collection /// Collections for the organization and the groups/users and permissions @@ -82,72 +82,72 @@ public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery /// Organization users and two factor status /// Organization ability for account recovery status /// List of the MemberAccessCipherDetailsModel; - private IEnumerable GenerateAccessData( - ICollection orgGroups, - ICollection> orgCollectionsWithAccess, - IEnumerable orgItems, - IEnumerable<(OrganizationUserUserDetails user, bool twoFactorIsEnabled)> organizationUsersTwoFactorEnabled, - OrganizationAbility orgAbility) + private IEnumerable GenerateAccessDataParallel( + ICollection orgGroups, + ICollection> orgCollectionsWithAccess, + IEnumerable orgItems, + IEnumerable<(OrganizationUserUserDetails user, bool twoFactorIsEnabled)> organizationUsersTwoFactorEnabled, + OrganizationAbility orgAbility) { - var orgUsers = organizationUsersTwoFactorEnabled.Select(x => x.user); - // Create a dictionary to lookup the group names later. + var orgUsers = organizationUsersTwoFactorEnabled.Select(x => x.user).ToList(); var groupNameDictionary = orgGroups.ToDictionary(x => x.Id, x => x.Name); - - // Get collections grouped and into a dictionary for counts var collectionItems = orgItems .SelectMany(x => x.CollectionIds, (cipher, collectionId) => new { Cipher = cipher, CollectionId = collectionId }) .GroupBy(y => y.CollectionId, (key, ciphers) => new { CollectionId = key, Ciphers = ciphers }); - var itemLookup = collectionItems.ToDictionary(x => x.CollectionId.ToString(), x => x.Ciphers.Select(c => c.Cipher.Id.ToString())); + var itemLookup = collectionItems.ToDictionary(x => x.CollectionId.ToString(), x => x.Ciphers.Select(c => c.Cipher.Id.ToString()).ToList()); - // Loop through the org users and populate report and access data - var memberAccessCipherDetails = new List(); - foreach (var user in orgUsers) + var memberAccessCipherDetails = new ConcurrentBag(); + + Parallel.ForEach(orgUsers, user => { var groupAccessDetails = new List(); var userCollectionAccessDetails = new List(); + foreach (var tCollect in orgCollectionsWithAccess) { - var hasItems = itemLookup.TryGetValue(tCollect.Item1.Id.ToString(), out var items); - var collectionCiphers = hasItems ? items.Select(x => x) : null; - - var itemCounts = hasItems ? collectionCiphers.Count() : 0; - if (tCollect.Item2.Groups.Count() > 0) + if (itemLookup.TryGetValue(tCollect.Item1.Id.ToString(), out var items)) { + var itemCounts = items.Count; - var groupDetails = tCollect.Item2.Groups.Where((tCollectGroups) => user.Groups.Contains(tCollectGroups.Id)).Select(x => - new MemberAccessDetails - { - CollectionId = tCollect.Item1.Id, - CollectionName = tCollect.Item1.Name, - GroupId = x.Id, - GroupName = groupNameDictionary[x.Id], - ReadOnly = x.ReadOnly, - HidePasswords = x.HidePasswords, - Manage = x.Manage, - ItemCount = itemCounts, - CollectionCipherIds = items - }); + if (tCollect.Item2.Groups.Any()) + { + var groupDetails = tCollect.Item2.Groups + .Where(tCollectGroups => user.Groups.Contains(tCollectGroups.Id)) + .Select(x => new MemberAccessDetails + { + CollectionId = tCollect.Item1.Id, + CollectionName = tCollect.Item1.Name, + GroupId = x.Id, + GroupName = groupNameDictionary[x.Id], + ReadOnly = x.ReadOnly, + HidePasswords = x.HidePasswords, + Manage = x.Manage, + ItemCount = itemCounts, + CollectionCipherIds = items + }); - groupAccessDetails.AddRange(groupDetails); - } + groupAccessDetails.AddRange(groupDetails); + } - // All collections assigned to users and their permissions - if (tCollect.Item2.Users.Count() > 0) - { - var userCollectionDetails = tCollect.Item2.Users.Where((tCollectUser) => tCollectUser.Id == user.Id).Select(x => - new MemberAccessDetails - { - CollectionId = tCollect.Item1.Id, - CollectionName = tCollect.Item1.Name, - ReadOnly = x.ReadOnly, - HidePasswords = x.HidePasswords, - Manage = x.Manage, - ItemCount = itemCounts, - CollectionCipherIds = items - }); - userCollectionAccessDetails.AddRange(userCollectionDetails); + if (tCollect.Item2.Users.Any()) + { + var userCollectionDetails = tCollect.Item2.Users + .Where(tCollectUser => tCollectUser.Id == user.Id) + .Select(x => new MemberAccessDetails + { + CollectionId = tCollect.Item1.Id, + CollectionName = tCollect.Item1.Name, + ReadOnly = x.ReadOnly, + HidePasswords = x.HidePasswords, + Manage = x.Manage, + ItemCount = itemCounts, + CollectionCipherIds = items + }); + + userCollectionAccessDetails.AddRange(userCollectionDetails); + } } } @@ -156,7 +156,6 @@ public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery UserName = user.Name, Email = user.Email, TwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == user.Id).twoFactorIsEnabled, - // Both the user's ResetPasswordKey must be set and the organization can UseResetPassword AccountRecoveryEnabled = !string.IsNullOrEmpty(user.ResetPasswordKey) && orgAbility.UseResetPassword, UserGuid = user.Id, UsesKeyConnector = user.UsesKeyConnector @@ -169,9 +168,8 @@ public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery userAccessDetails.AddRange(userGroups); } - // There can be edge cases where groups don't have a collection var groupsWithoutCollections = user.Groups.Where(x => !userAccessDetails.Any(y => x == y.GroupId)); - if (groupsWithoutCollections.Count() > 0) + if (groupsWithoutCollections.Any()) { var emptyGroups = groupsWithoutCollections.Select(x => new MemberAccessDetails { @@ -189,20 +187,20 @@ public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery } report.AccessDetails = userAccessDetails; - var userCiphers = - report.AccessDetails - .Where(x => x.ItemCount > 0) - .SelectMany(y => y.CollectionCipherIds) - .Distinct(); + var userCiphers = report.AccessDetails + .Where(x => x.ItemCount > 0) + .SelectMany(y => y.CollectionCipherIds) + .Distinct(); report.CipherIds = userCiphers; report.TotalItemCount = userCiphers.Count(); - // Distinct items only var distinctItems = report.AccessDetails.Where(x => x.CollectionId.HasValue).Select(x => x.CollectionId).Distinct(); report.CollectionsCount = distinctItems.Count(); report.GroupsCount = report.AccessDetails.Select(x => x.GroupId).Where(y => y.HasValue).Distinct().Count(); + memberAccessCipherDetails.Add(report); - } + }); + return memberAccessCipherDetails; } } From a8403f3dc2a13e5b5469dd7ac857f6ac4e21d4dd Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Mon, 7 Apr 2025 14:10:36 -0400 Subject: [PATCH 03/28] [PM-19601] Introduce options for adding certificates to trust without root (#5609) * Introduce options for adding certificates to the X509ChainPolicy.CustomTrustStore Co-authored-by: tangowithfoxtrot * Add comments * Fix places I am still calling it TLS options * Format * Format from root * Add more tests * Add HTTP Tests * Format * Switch to empty builder * Remove unneeded helper * Configure logging only once --------- Co-authored-by: tangowithfoxtrot --- .../PostConfigureX509ChainOptions.cs | 96 ++++++ ...ustomizationServiceCollectionExtensions.cs | 53 +++ .../X509ChainOptions.cs | 73 ++++ .../MailKitSmtpMailDeliveryService.cs | 17 +- .../MailKitSmtpMailDeliveryServiceTests.cs | 61 ++-- test/Core.Test/Core.Test.csproj | 1 + ...izationServiceCollectionExtensionsTests.cs | 324 ++++++++++++++++++ .../Services/HandlebarsMailServiceTests.cs | 4 +- .../MailKitSmtpMailDeliveryServiceTests.cs | 7 +- 9 files changed, 610 insertions(+), 26 deletions(-) create mode 100644 src/Core/Platform/X509ChainCustomization/PostConfigureX509ChainOptions.cs create mode 100644 src/Core/Platform/X509ChainCustomization/X509ChainCustomizationServiceCollectionExtensions.cs create mode 100644 src/Core/Platform/X509ChainCustomization/X509ChainOptions.cs create mode 100644 test/Core.Test/Platform/X509ChainCustomization/X509ChainCustomizationServiceCollectionExtensionsTests.cs diff --git a/src/Core/Platform/X509ChainCustomization/PostConfigureX509ChainOptions.cs b/src/Core/Platform/X509ChainCustomization/PostConfigureX509ChainOptions.cs new file mode 100644 index 0000000000..963294e85f --- /dev/null +++ b/src/Core/Platform/X509ChainCustomization/PostConfigureX509ChainOptions.cs @@ -0,0 +1,96 @@ +#nullable enable + +using System.Security.Cryptography.X509Certificates; +using Bit.Core.Settings; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Bit.Core.Platform.X509ChainCustomization; + +internal sealed class PostConfigureX509ChainOptions : IPostConfigureOptions +{ + const string CertificateSearchPattern = "*.crt"; + + private readonly ILogger _logger; + private readonly IHostEnvironment _hostEnvironment; + private readonly GlobalSettings _globalSettings; + + public PostConfigureX509ChainOptions( + ILogger logger, + IHostEnvironment hostEnvironment, + GlobalSettings globalSettings) + { + _logger = logger; + _hostEnvironment = hostEnvironment; + _globalSettings = globalSettings; + } + + public void PostConfigure(string? name, X509ChainOptions options) + { + // We don't register or request a named instance of these options, + // so don't customize it. + if (name != Options.DefaultName) + { + return; + } + + // We only allow this setting to be configured on self host. + if (!_globalSettings.SelfHosted) + { + options.AdditionalCustomTrustCertificatesDirectory = null; + return; + } + + if (options.AdditionalCustomTrustCertificates != null) + { + // Additional certificates were added directly, this overwrites the need to + // read them from the directory. + _logger.LogInformation( + "Additional custom trust certificates were added directly, skipping loading them from '{Directory}'", + options.AdditionalCustomTrustCertificatesDirectory + ); + return; + } + + if (string.IsNullOrEmpty(options.AdditionalCustomTrustCertificatesDirectory)) + { + return; + } + + if (!Directory.Exists(options.AdditionalCustomTrustCertificatesDirectory)) + { + // The default directory is volume mounted via the default Bitwarden setup process. + // If the directory doesn't exist it could indicate a error in configuration but this + // directory is never expected in a normal development environment so lower the log + // level in that case. + var logLevel = _hostEnvironment.IsDevelopment() + ? LogLevel.Debug + : LogLevel.Warning; + _logger.Log( + logLevel, + "An additional custom trust certificate directory was given '{Directory}' but that directory does not exist.", + options.AdditionalCustomTrustCertificatesDirectory + ); + return; + } + + var certificates = new List(); + + foreach (var certFile in Directory.EnumerateFiles(options.AdditionalCustomTrustCertificatesDirectory, CertificateSearchPattern)) + { + certificates.Add(new X509Certificate2(certFile)); + } + + if (options.AdditionalCustomTrustCertificatesDirectory != X509ChainOptions.DefaultAdditionalCustomTrustCertificatesDirectory && certificates.Count == 0) + { + // They have intentionally given us a non-default directory but there weren't certificates, that is odd. + _logger.LogWarning( + "No additional custom trust certificates were found in '{Directory}'", + options.AdditionalCustomTrustCertificatesDirectory + ); + } + + options.AdditionalCustomTrustCertificates = certificates; + } +} diff --git a/src/Core/Platform/X509ChainCustomization/X509ChainCustomizationServiceCollectionExtensions.cs b/src/Core/Platform/X509ChainCustomization/X509ChainCustomizationServiceCollectionExtensions.cs new file mode 100644 index 0000000000..46bd5b37e6 --- /dev/null +++ b/src/Core/Platform/X509ChainCustomization/X509ChainCustomizationServiceCollectionExtensions.cs @@ -0,0 +1,53 @@ +using Bit.Core.Platform.X509ChainCustomization; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for setting up the ability to provide customization to how X509 chain validation works in an . +/// +public static class X509ChainCustomizationServiceCollectionExtensions +{ + /// + /// Configures X509ChainPolicy customization through the root level X509ChainOptions configuration section + /// and configures the primary to use custom certificate validation + /// when customized to do so. + /// + /// The . + /// The for additional chaining. + public static IServiceCollection AddX509ChainCustomization(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddOptions() + .BindConfiguration(nameof(X509ChainOptions)); + + // Use TryAddEnumerable to make sure `PostConfigureX509ChainOptions` isn't added multiple + // times even if this method is called multiple times. + services.TryAddEnumerable(ServiceDescriptor.Singleton, PostConfigureX509ChainOptions>()); + + services.AddHttpClient() + .ConfigureHttpClientDefaults(builder => + { + builder.ConfigurePrimaryHttpMessageHandler(sp => + { + var x509ChainOptions = sp.GetRequiredService>().Value; + + var handler = new HttpClientHandler(); + + if (x509ChainOptions.TryGetCustomRemoteCertificateValidationCallback(out var callback)) + { + handler.ServerCertificateCustomValidationCallback = (sender, certificate, chain, errors) => + { + return callback(certificate, chain, errors); + }; + } + + return handler; + }); + }); + + return services; + } +} diff --git a/src/Core/Platform/X509ChainCustomization/X509ChainOptions.cs b/src/Core/Platform/X509ChainCustomization/X509ChainOptions.cs new file mode 100644 index 0000000000..189a1087f5 --- /dev/null +++ b/src/Core/Platform/X509ChainCustomization/X509ChainOptions.cs @@ -0,0 +1,73 @@ +#nullable enable + +using System.Diagnostics.CodeAnalysis; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; + +namespace Bit.Core.Platform.X509ChainCustomization; + +/// +/// Allows for customization of the and access to a custom server certificate validator +/// if customization has been made. +/// +public sealed class X509ChainOptions +{ + // This is the directory that we historically used to allow certificates be added inside our container + // and then on start of the container we would move them to `/usr/local/share/ca-certificates/` and call + // `update-ca-certificates` but since that operation requires root we can't do it in a rootless container. + // Ref: https://github.com/bitwarden/server/blob/67d7d685a619a5fc413f8532dacb09681ee5c956/src/Api/entrypoint.sh#L38-L41 + public const string DefaultAdditionalCustomTrustCertificatesDirectory = "/etc/bitwarden/ca-certificates/"; + + /// + /// A directory where additional certificates should be read from and included in . + /// + /// + /// Only certificates suffixed with *.crt will be read. If is + /// set, then this directory will not be read from. + /// + public string? AdditionalCustomTrustCertificatesDirectory { get; set; } = DefaultAdditionalCustomTrustCertificatesDirectory; + + /// + /// A list of additional certificates that should be included in . + /// + /// + /// If this value is set manually, then will be ignored. + /// + public List? AdditionalCustomTrustCertificates { get; set; } + + /// + /// Attempts to retrieve a custom remote certificate validation callback. + /// + /// + /// Returns when we have custom remote certification that should be added, + /// when no custom validation is needed and the default validation callback should + /// be used instead. + /// + [MemberNotNullWhen(true, nameof(AdditionalCustomTrustCertificates))] + public bool TryGetCustomRemoteCertificateValidationCallback( + [MaybeNullWhen(false)] out Func callback) + { + callback = null; + if (AdditionalCustomTrustCertificates == null || AdditionalCustomTrustCertificates.Count == 0) + { + return false; + } + + // Ref: https://github.com/dotnet/runtime/issues/39835#issuecomment-663020581 + callback = (certificate, chain, errors) => + { + if (chain == null || certificate == null) + { + return false; + } + + chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + foreach (var additionalCertificate in AdditionalCustomTrustCertificates) + { + chain.ChainPolicy.CustomTrustStore.Add(additionalCertificate); + } + return chain.Build(certificate); + }; + return true; + } +} diff --git a/src/Core/Services/Implementations/MailKitSmtpMailDeliveryService.cs b/src/Core/Services/Implementations/MailKitSmtpMailDeliveryService.cs index dc4e89aa23..3a7cabd39e 100644 --- a/src/Core/Services/Implementations/MailKitSmtpMailDeliveryService.cs +++ b/src/Core/Services/Implementations/MailKitSmtpMailDeliveryService.cs @@ -1,7 +1,10 @@ -using Bit.Core.Settings; +using System.Security.Cryptography.X509Certificates; +using Bit.Core.Platform.X509ChainCustomization; +using Bit.Core.Settings; using Bit.Core.Utilities; using MailKit.Net.Smtp; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using MimeKit; namespace Bit.Core.Services; @@ -10,12 +13,14 @@ public class MailKitSmtpMailDeliveryService : IMailDeliveryService { private readonly GlobalSettings _globalSettings; private readonly ILogger _logger; + private readonly X509ChainOptions _x509ChainOptions; private readonly string _replyDomain; private readonly string _replyEmail; public MailKitSmtpMailDeliveryService( GlobalSettings globalSettings, - ILogger logger) + ILogger logger, + IOptions x509ChainOptions) { if (globalSettings.Mail?.Smtp?.Host == null) { @@ -31,6 +36,7 @@ public class MailKitSmtpMailDeliveryService : IMailDeliveryService _globalSettings = globalSettings; _logger = logger; + _x509ChainOptions = x509ChainOptions.Value; } public async Task SendEmailAsync(Models.Mail.MailMessage message) @@ -75,6 +81,13 @@ public class MailKitSmtpMailDeliveryService : IMailDeliveryService { client.ServerCertificateValidationCallback = (s, c, h, e) => true; } + else if (_x509ChainOptions.TryGetCustomRemoteCertificateValidationCallback(out var callback)) + { + client.ServerCertificateValidationCallback = (sender, cert, chain, errors) => + { + return callback(new X509Certificate2(cert), chain, errors); + }; + } if (!_globalSettings.Mail.Smtp.StartTls && !_globalSettings.Mail.Smtp.Ssl && _globalSettings.Mail.Smtp.Port == 25) diff --git a/test/Core.IntegrationTest/MailKitSmtpMailDeliveryServiceTests.cs b/test/Core.IntegrationTest/MailKitSmtpMailDeliveryServiceTests.cs index db2b945fda..38c18f26f9 100644 --- a/test/Core.IntegrationTest/MailKitSmtpMailDeliveryServiceTests.cs +++ b/test/Core.IntegrationTest/MailKitSmtpMailDeliveryServiceTests.cs @@ -1,11 +1,13 @@ using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Bit.Core.Models.Mail; +using Bit.Core.Platform.X509ChainCustomization; using Bit.Core.Services; using Bit.Core.Settings; using MailKit.Security; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; using Rnwood.SmtpServer; using Rnwood.SmtpServer.Extensions.Auth; using Xunit.Abstractions; @@ -14,6 +16,7 @@ namespace Bit.Core.IntegrationTest; public class MailKitSmtpMailDeliveryServiceTests { + private static int _loggingConfigured; private readonly X509Certificate2 _selfSignedCert; public MailKitSmtpMailDeliveryServiceTests(ITestOutputHelper testOutputHelper) @@ -30,13 +33,14 @@ public class MailKitSmtpMailDeliveryServiceTests return certRequest.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1)); } - private static async Task SaveCertAsync(string filePath, X509Certificate2 certificate) - { - await File.WriteAllBytesAsync(filePath, certificate.Export(X509ContentType.Cert)); - } - private static void ConfigureSmtpServerLogging(ITestOutputHelper testOutputHelper) { + // The logging in SmtpServer is configured statically so if we add it for each test it duplicates + // but we cant add the logger statically either because we need ITestOutputHelper + if (Interlocked.CompareExchange(ref _loggingConfigured, 1, 0) == 0) + { + return; + } // Unfortunately this package doesn't public expose its logging infrastructure // so we use private reflection to try and access it. try @@ -100,7 +104,8 @@ public class MailKitSmtpMailDeliveryServiceTests var mailKitDeliveryService = new MailKitSmtpMailDeliveryService( globalSettings, - NullLogger.Instance + NullLogger.Instance, + Options.Create(new X509ChainOptions()) ); await Assert.ThrowsAsync( @@ -113,7 +118,7 @@ public class MailKitSmtpMailDeliveryServiceTests ); } - [Fact(Skip = "Upcoming feature")] + [Fact] public async Task SendEmailAsync_SmtpServerUsingSelfSignedCert_CertInCustomLocation_Works() { // If an SMTP server is using a self signed cert we will in the future @@ -130,12 +135,18 @@ public class MailKitSmtpMailDeliveryServiceTests gs.Mail.Smtp.Ssl = true; }); - // TODO: Setup custom location and save self signed cert there. - // await SaveCertAsync("./my-location", _selfSignedCert); + var x509ChainOptions = new X509ChainOptions + { + AdditionalCustomTrustCertificates = + [ + _selfSignedCert, + ], + }; var mailKitDeliveryService = new MailKitSmtpMailDeliveryService( globalSettings, - NullLogger.Instance + NullLogger.Instance, + Options.Create(x509ChainOptions) ); var tcs = new TaskCompletionSource(); @@ -162,7 +173,7 @@ public class MailKitSmtpMailDeliveryServiceTests await tcs.Task; } - [Fact(Skip = "Upcoming feature")] + [Fact] public async Task SendEmailAsync_SmtpServerUsingSelfSignedCert_CertInCustomLocation_WithUnrelatedCerts_Works() { // If an SMTP server is using a self signed cert we will in the future @@ -179,15 +190,19 @@ public class MailKitSmtpMailDeliveryServiceTests gs.Mail.Smtp.Ssl = true; }); - // TODO: Setup custom location and save self signed cert there - // along with another self signed cert that is not related to - // the SMTP server. - // await SaveCertAsync("./my-location", _selfSignedCert); - // await SaveCertAsync("./my-location", CreateSelfSignedCert("example.com")); + var x509ChainOptions = new X509ChainOptions + { + AdditionalCustomTrustCertificates = + [ + _selfSignedCert, + CreateSelfSignedCert("example.com"), + ], + }; var mailKitDeliveryService = new MailKitSmtpMailDeliveryService( globalSettings, - NullLogger.Instance + NullLogger.Instance, + Options.Create(x509ChainOptions) ); var tcs = new TaskCompletionSource(); @@ -234,7 +249,8 @@ public class MailKitSmtpMailDeliveryServiceTests var mailKitDeliveryService = new MailKitSmtpMailDeliveryService( globalSettings, - NullLogger.Instance + NullLogger.Instance, + Options.Create(new X509ChainOptions()) ); var tcs = new TaskCompletionSource(); @@ -280,7 +296,8 @@ public class MailKitSmtpMailDeliveryServiceTests var mailKitDeliveryService = new MailKitSmtpMailDeliveryService( globalSettings, - NullLogger.Instance + NullLogger.Instance, + Options.Create(new X509ChainOptions()) ); var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); @@ -315,7 +332,8 @@ public class MailKitSmtpMailDeliveryServiceTests var mailKitDeliveryService = new MailKitSmtpMailDeliveryService( globalSettings, - NullLogger.Instance + NullLogger.Instance, + Options.Create(new X509ChainOptions()) ); var tcs = new TaskCompletionSource(); @@ -381,7 +399,8 @@ public class MailKitSmtpMailDeliveryServiceTests var mailKitDeliveryService = new MailKitSmtpMailDeliveryService( globalSettings, - NullLogger.Instance + NullLogger.Instance, + Options.Create(new X509ChainOptions()) ); var tcs = new TaskCompletionSource(); diff --git a/test/Core.Test/Core.Test.csproj b/test/Core.Test/Core.Test.csproj index baace97710..cc19c50c35 100644 --- a/test/Core.Test/Core.Test.csproj +++ b/test/Core.Test/Core.Test.csproj @@ -10,6 +10,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + diff --git a/test/Core.Test/Platform/X509ChainCustomization/X509ChainCustomizationServiceCollectionExtensionsTests.cs b/test/Core.Test/Platform/X509ChainCustomization/X509ChainCustomizationServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000000..2a4ed55489 --- /dev/null +++ b/test/Core.Test/Platform/X509ChainCustomization/X509ChainCustomizationServiceCollectionExtensionsTests.cs @@ -0,0 +1,324 @@ +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Bit.Core.Platform.X509ChainCustomization; +using Bit.Core.Settings; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Server.Kestrel.Https; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Platform.X509ChainCustomization; + +public class X509ChainCustomizationServiceCollectionExtensionsTests +{ + private static X509Certificate2 CreateSelfSignedCert(string commonName) + { + using var rsa = RSA.Create(2048); + var certRequest = new CertificateRequest($"CN={commonName}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return certRequest.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1)); + } + + [Fact] + public async Task OptionsPatternReturnsCachedValue() + { + var tempDir = Directory.CreateTempSubdirectory("certs"); + + var tempCert = Path.Combine(tempDir.FullName, "test.crt"); + await File.WriteAllBytesAsync(tempCert, CreateSelfSignedCert("localhost").Export(X509ContentType.Cert)); + + var services = CreateServices((gs, environment, config) => + { + config["X509ChainOptions:AdditionalCustomTrustCertificatesDirectory"] = tempDir.FullName; + }); + + // Create options once + var firstOptions = services.GetRequiredService>().Value; + + Assert.NotNull(firstOptions.AdditionalCustomTrustCertificates); + var cert = Assert.Single(firstOptions.AdditionalCustomTrustCertificates); + Assert.Equal("CN=localhost", cert.Subject); + + // Since the second resolution should have cached values, deleting the file during operation + // should have no impact. + File.Delete(tempCert); + + // This is expected to be a cached version and doesn't actually need to go and read the file system + var secondOptions = services.GetRequiredService>().Value; + Assert.Same(firstOptions, secondOptions); + + // This is the same reference as the first one so it shouldn't be different but just in case. + Assert.NotNull(secondOptions.AdditionalCustomTrustCertificates); + Assert.Single(secondOptions.AdditionalCustomTrustCertificates); + } + + [Fact] + public async Task DoesNotProvideCustomCallbackOnCloud() + { + var tempDir = Directory.CreateTempSubdirectory("certs"); + + var tempCert = Path.Combine(tempDir.FullName, "test.crt"); + await File.WriteAllBytesAsync(tempCert, CreateSelfSignedCert("localhost").Export(X509ContentType.Cert)); + + var options = CreateOptions((gs, environment, config) => + { + gs.SelfHosted = false; + + config["X509ChainOptions:AdditionalCustomTrustCertificatesDirectory"] = tempDir.FullName; + }); + + Assert.False(options.TryGetCustomRemoteCertificateValidationCallback(out _)); + } + + [Fact] + public async Task ManuallyAddingOptionsTakesPrecedence() + { + var tempDir = Directory.CreateTempSubdirectory("certs"); + + var tempCert = Path.Combine(tempDir.FullName, "test.crt"); + await File.WriteAllBytesAsync(tempCert, CreateSelfSignedCert("localhost").Export(X509ContentType.Cert)); + + var services = CreateServices((gs, environment, config) => + { + config["X509ChainOptions:AdditionalCustomTrustCertificatesDirectory"] = tempDir.FullName; + }, services => + { + services.Configure(options => + { + options.AdditionalCustomTrustCertificates = [CreateSelfSignedCert("example.com")]; + }); + }); + + var options = services.GetRequiredService>().Value; + + Assert.True(options.TryGetCustomRemoteCertificateValidationCallback(out var callback)); + var cert = Assert.Single(options.AdditionalCustomTrustCertificates); + Assert.Equal("CN=example.com", cert.Subject); + + var fakeLogCollector = services.GetFakeLogCollector(); + + Assert.Contains(fakeLogCollector.GetSnapshot(), + r => r.Message == $"Additional custom trust certificates were added directly, skipping loading them from '{tempDir}'"); + } + + [Fact] + public void NullCustomDirectory_SkipsTryingToLoad() + { + var services = CreateServices((gs, environment, config) => + { + config["X509ChainOptions:AdditionalCustomTrustCertificatesDirectory"] = null; + }); + + var options = services.GetRequiredService>().Value; + + Assert.False(options.TryGetCustomRemoteCertificateValidationCallback(out _)); + } + + [Theory] + [InlineData("Development", LogLevel.Debug)] + [InlineData("Production", LogLevel.Warning)] + public void CustomDirectoryDoesNotExist_Logs(string environment, LogLevel logLevel) + { + var fakeDir = "/fake/dir/that/does/not/exist"; + var services = CreateServices((gs, hostEnvironment, config) => + { + hostEnvironment.EnvironmentName = environment; + + config["X509ChainOptions:AdditionalCustomTrustCertificatesDirectory"] = fakeDir; + }); + + var options = services.GetRequiredService>().Value; + + Assert.False(options.TryGetCustomRemoteCertificateValidationCallback(out _)); + + var fakeLogCollector = services.GetFakeLogCollector(); + + Assert.Contains(fakeLogCollector.GetSnapshot(), + r => r.Message == $"An additional custom trust certificate directory was given '{fakeDir}' but that directory does not exist." + && r.Level == logLevel + ); + } + + [Fact] + public async Task NamedOptions_NotConfiguredAsync() + { + // To help make sure this fails for the right reason we should add certs to the directory + var tempDir = Directory.CreateTempSubdirectory("certs"); + + var tempCert = Path.Combine(tempDir.FullName, "test.crt"); + await File.WriteAllBytesAsync(tempCert, CreateSelfSignedCert("localhost").Export(X509ContentType.Cert)); + + var services = CreateServices((gs, environment, config) => + { + config["X509ChainOptions:AdditionalCustomTrustCertificatesDirectory"] = tempDir.FullName; + }); + + var options = services.GetRequiredService>(); + + var namedOptions = options.Get("SomeName"); + + Assert.Null(namedOptions.AdditionalCustomTrustCertificates); + } + + [Fact] + public void CustomLocation_NoCertificates_Logs() + { + var tempDir = Directory.CreateTempSubdirectory("certs"); + var services = CreateServices((gs, hostEnvironment, config) => + { + config["X509ChainOptions:AdditionalCustomTrustCertificatesDirectory"] = tempDir.FullName; + }); + + var options = services.GetRequiredService>().Value; + + Assert.False(options.TryGetCustomRemoteCertificateValidationCallback(out _)); + + var fakeLogCollector = services.GetFakeLogCollector(); + + Assert.Contains(fakeLogCollector.GetSnapshot(), + r => r.Message == $"No additional custom trust certificates were found in '{tempDir.FullName}'" + ); + } + + [Fact] + public async Task CallHttpWithSelfSignedCert_SelfSignedCertificateConfigured_Works() + { + var selfSignedCertificate = CreateSelfSignedCert("localhost"); + await using var app = await CreateServerAsync(55555, options => + { + options.ServerCertificate = selfSignedCertificate; + }); + + var services = CreateServices((gs, environment, config) => { }, services => + { + services.Configure(options => + { + options.AdditionalCustomTrustCertificates = [selfSignedCertificate]; + }); + }); + + var httpClient = services.GetRequiredService().CreateClient(); + + var response = await httpClient.GetStringAsync("https://localhost:55555"); + Assert.Equal("Hi", response); + } + + [Fact] + public async Task CallHttpWithSelfSignedCert_SelfSignedCertificateNotConfigured_Throws() + { + var selfSignedCertificate = CreateSelfSignedCert("localhost"); + await using var app = await CreateServerAsync(55556, options => + { + options.ServerCertificate = selfSignedCertificate; + }); + + var services = CreateServices((gs, environment, config) => { }, services => + { + services.Configure(options => + { + options.AdditionalCustomTrustCertificates = [CreateSelfSignedCert("example.com")]; + }); + }); + + var httpClient = services.GetRequiredService().CreateClient(); + + var requestException = await Assert.ThrowsAsync(async () => await httpClient.GetStringAsync("https://localhost:55556")); + Assert.NotNull(requestException.InnerException); + var authenticationException = Assert.IsAssignableFrom(requestException.InnerException); + Assert.Equal("The remote certificate was rejected by the provided RemoteCertificateValidationCallback.", authenticationException.Message); + } + + [Fact] + public async Task CallHttpWithSelfSignedCert_SelfSignedCertificateConfigured_WithExtraCert_Works() + { + var selfSignedCertificate = CreateSelfSignedCert("localhost"); + await using var app = await CreateServerAsync(55557, options => + { + options.ServerCertificate = selfSignedCertificate; + }); + + var services = CreateServices((gs, environment, config) => { }, services => + { + services.Configure(options => + { + options.AdditionalCustomTrustCertificates = [selfSignedCertificate, CreateSelfSignedCert("example.com")]; + }); + }); + + var httpClient = services.GetRequiredService().CreateClient(); + + var response = await httpClient.GetStringAsync("https://localhost:55557"); + Assert.Equal("Hi", response); + } + + private static async Task CreateServerAsync(int port, Action configure) + { + var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions()); + builder.Services.AddRoutingCore(); + builder.WebHost.UseKestrelCore() + .ConfigureKestrel(options => + { + options.ListenLocalhost(port, listenOptions => + { + listenOptions.UseHttps(httpsOptions => + { + configure(httpsOptions); + }); + }); + }); + + var app = builder.Build(); + + app.MapGet("/", () => "Hi"); + + await app.StartAsync(); + + return app; + } + + private static X509ChainOptions CreateOptions(Action> configure, Action? after = null) + { + var services = CreateServices(configure, after); + return services.GetRequiredService>().Value; + } + + private static IServiceProvider CreateServices(Action> configure, Action? after = null) + { + var globalSettings = new GlobalSettings + { + // A solid default for these tests as these settings aren't allowed to work in cloud. + SelfHosted = true, + }; + var hostEnvironment = Substitute.For(); + hostEnvironment.EnvironmentName = "Development"; + var config = new Dictionary(); + + configure(globalSettings, hostEnvironment, config); + + var services = new ServiceCollection(); + services.AddLogging(logging => + { + logging.SetMinimumLevel(LogLevel.Debug); + logging.AddFakeLogging(); + }); + services.AddSingleton(globalSettings); + services.AddSingleton(hostEnvironment); + services.AddSingleton( + new ConfigurationBuilder() + .AddInMemoryCollection(config) + .Build() + ); + + services.AddX509ChainCustomization(); + + after?.Invoke(services); + + return services.BuildServiceProvider(); + } +} diff --git a/test/Core.Test/Services/HandlebarsMailServiceTests.cs b/test/Core.Test/Services/HandlebarsMailServiceTests.cs index 89d9a211e0..35c2f8fe3b 100644 --- a/test/Core.Test/Services/HandlebarsMailServiceTests.cs +++ b/test/Core.Test/Services/HandlebarsMailServiceTests.cs @@ -4,9 +4,11 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Business; using Bit.Core.Entities; +using Bit.Core.Platform.X509ChainCustomization; using Bit.Core.Services; using Bit.Core.Settings; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using NSubstitute; using Xunit; @@ -136,7 +138,7 @@ public class HandlebarsMailServiceTests SiteName = "Bitwarden", }; - var mailDeliveryService = new MailKitSmtpMailDeliveryService(globalSettings, Substitute.For>()); + var mailDeliveryService = new MailKitSmtpMailDeliveryService(globalSettings, Substitute.For>(), Options.Create(new X509ChainOptions())); var handlebarsService = new HandlebarsMailService(globalSettings, mailDeliveryService, new BlockingMailEnqueuingService()); diff --git a/test/Core.Test/Services/MailKitSmtpMailDeliveryServiceTests.cs b/test/Core.Test/Services/MailKitSmtpMailDeliveryServiceTests.cs index 4e7e36fe02..06ee99dbef 100644 --- a/test/Core.Test/Services/MailKitSmtpMailDeliveryServiceTests.cs +++ b/test/Core.Test/Services/MailKitSmtpMailDeliveryServiceTests.cs @@ -1,6 +1,8 @@ -using Bit.Core.Services; +using Bit.Core.Platform.X509ChainCustomization; +using Bit.Core.Services; using Bit.Core.Settings; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using NSubstitute; using Xunit; @@ -23,7 +25,8 @@ public class MailKitSmtpMailDeliveryServiceTests _sut = new MailKitSmtpMailDeliveryService( _globalSettings, - _logger + _logger, + Options.Create(new X509ChainOptions()) ); } From 91fa02f8e6b65a5426bb6ecdbfae11fa8077b15d Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Mon, 7 Apr 2025 17:15:01 -0400 Subject: [PATCH 04/28] [PM-19811] fix ResetPasswordEnrolled check to handle empty and whitespace strings. (#5599) --- .../Models/Response/ProfileOrganizationResponseModel.cs | 2 +- .../Public/Models/Response/MemberResponseModel.cs | 4 ++-- .../Public/Models/Response/MemberResponseModelTests.cs | 9 +++++++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs index 3a901f11c4..28ec09e984 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs @@ -51,7 +51,7 @@ public class ProfileOrganizationResponseModel : ResponseModel SsoBound = !string.IsNullOrWhiteSpace(organization.SsoExternalId); Identifier = organization.Identifier; Permissions = CoreHelpers.LoadClassFromJsonData(organization.Permissions); - ResetPasswordEnrolled = organization.ResetPasswordKey != null; + ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(organization.ResetPasswordKey); UserId = organization.UserId; OrganizationUserId = organization.OrganizationUserId; ProviderId = organization.ProviderId; diff --git a/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs b/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs index 91e8788d01..933cda9dca 100644 --- a/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs +++ b/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs @@ -30,7 +30,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel Email = user.Email; Status = user.Status; Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c)); - ResetPasswordEnrolled = user.ResetPasswordKey != null; + ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(user.ResetPasswordKey); } [SetsRequiredMembers] @@ -49,7 +49,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel TwoFactorEnabled = twoFactorEnabled; Status = user.Status; Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c)); - ResetPasswordEnrolled = user.ResetPasswordKey != null; + ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(user.ResetPasswordKey); SsoExternalId = user.SsoExternalId; } diff --git a/test/Api.Test/AdminConsole/Public/Models/Response/MemberResponseModelTests.cs b/test/Api.Test/AdminConsole/Public/Models/Response/MemberResponseModelTests.cs index a9193258b8..468e7850fb 100644 --- a/test/Api.Test/AdminConsole/Public/Models/Response/MemberResponseModelTests.cs +++ b/test/Api.Test/AdminConsole/Public/Models/Response/MemberResponseModelTests.cs @@ -25,11 +25,16 @@ public class MemberResponseModelTests Assert.True(sut.ResetPasswordEnrolled); } - [Fact] - public void ResetPasswordEnrolled_ShouldBeFalse_WhenUserDoesNotHaveResetPasswordKey() + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ResetPasswordEnrolled_ShouldBeFalse_WhenResetPasswordKeyIsInvalid(string? resetPasswordKey) { // Arrange var user = Substitute.For(); + user.ResetPasswordKey = resetPasswordKey; + var collections = Substitute.For>(); // Act From 8beefbb4172621ece24aa505b05c2ad3164dcf4c Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Mon, 7 Apr 2025 21:36:08 -0400 Subject: [PATCH 05/28] [PM-19685] Remove email delay feature flag (#5622) * Remove email delay feature flag * Fixed reference. * Removed field from old registration method --- ...VerificationEmailForRegistrationCommand.cs | 18 ----------------- src/Core/Constants.cs | 1 - .../Controllers/AccountsController.cs | 20 +++++++------------ 3 files changed, 7 insertions(+), 32 deletions(-) diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs index 21a421b9d0..3f89e9ad0e 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs @@ -53,23 +53,10 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai var user = await _userRepository.GetByEmailAsync(email); var userExists = user != null; - // Delays enabled by default; flag must be enabled to remove the delays. - var delaysEnabled = !_featureService.IsEnabled(FeatureFlagKeys.EmailVerificationDisableTimingDelays); - if (!_globalSettings.EnableEmailVerification) { - if (userExists) { - - if (delaysEnabled) - { - // Add delay to prevent timing attacks - // Note: sub 140 ms feels responsive to users so we are using a random value between 100 - 130 ms - // as it should be long enough to prevent timing attacks but not too long to be noticeable to the user. - await Task.Delay(Random.Shared.Next(100, 130)); - } - throw new BadRequestException($"Email {email} is already taken"); } @@ -87,11 +74,6 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai await _mailService.SendRegistrationVerificationEmailAsync(email, token); } - if (delaysEnabled) - { - // Add random delay between 100ms-130ms to prevent timing attacks - await Task.Delay(Random.Shared.Next(100, 130)); - } // User exists but we will return a 200 regardless of whether the email was sent or not; so return null return null; } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 9ba30786d1..969c064c05 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -115,7 +115,6 @@ public static class FeatureFlagKeys public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; public const string DuoRedirect = "duo-redirect"; public const string EmailVerification = "email-verification"; - public const string EmailVerificationDisableTimingDelays = "email-verification-disable-timing-delays"; public const string DeviceTrustLogging = "pm-8285-device-trust-logging"; public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token"; public const string UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh"; diff --git a/src/Identity/Controllers/AccountsController.cs b/src/Identity/Controllers/AccountsController.cs index c840a7ddc5..9360da586c 100644 --- a/src/Identity/Controllers/AccountsController.cs +++ b/src/Identity/Controllers/AccountsController.cs @@ -121,8 +121,7 @@ public class AccountsController : Controller var user = model.ToUser(); var identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken(user, model.MasterPasswordHash, model.Token, model.OrganizationUserId); - // delaysEnabled false is only for the new registration with email verification process - return await ProcessRegistrationResult(identityResult, user, delaysEnabled: true); + return ProcessRegistrationResult(identityResult, user); } [HttpPost("register/send-verification-email")] @@ -188,7 +187,6 @@ public class AccountsController : Controller // Users will either have an emailed token or an email verification token - not both. IdentityResult identityResult = null; - var delaysEnabled = !_featureService.IsEnabled(FeatureFlagKeys.EmailVerificationDisableTimingDelays); switch (model.GetTokenType()) { @@ -197,32 +195,32 @@ public class AccountsController : Controller await _registerUserCommand.RegisterUserViaEmailVerificationToken(user, model.MasterPasswordHash, model.EmailVerificationToken); - return await ProcessRegistrationResult(identityResult, user, delaysEnabled); + return ProcessRegistrationResult(identityResult, user); break; case RegisterFinishTokenType.OrganizationInvite: identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken(user, model.MasterPasswordHash, model.OrgInviteToken, model.OrganizationUserId); - return await ProcessRegistrationResult(identityResult, user, delaysEnabled); + return ProcessRegistrationResult(identityResult, user); break; case RegisterFinishTokenType.OrgSponsoredFreeFamilyPlan: identityResult = await _registerUserCommand.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, model.MasterPasswordHash, model.OrgSponsoredFreeFamilyPlanToken); - return await ProcessRegistrationResult(identityResult, user, delaysEnabled); + return ProcessRegistrationResult(identityResult, user); break; case RegisterFinishTokenType.EmergencyAccessInvite: Debug.Assert(model.AcceptEmergencyAccessId.HasValue); identityResult = await _registerUserCommand.RegisterUserViaAcceptEmergencyAccessInviteToken(user, model.MasterPasswordHash, model.AcceptEmergencyAccessInviteToken, model.AcceptEmergencyAccessId.Value); - return await ProcessRegistrationResult(identityResult, user, delaysEnabled); + return ProcessRegistrationResult(identityResult, user); break; case RegisterFinishTokenType.ProviderInvite: Debug.Assert(model.ProviderUserId.HasValue); identityResult = await _registerUserCommand.RegisterUserViaProviderInviteToken(user, model.MasterPasswordHash, model.ProviderInviteToken, model.ProviderUserId.Value); - return await ProcessRegistrationResult(identityResult, user, delaysEnabled); + return ProcessRegistrationResult(identityResult, user); break; default: @@ -230,7 +228,7 @@ public class AccountsController : Controller } } - private async Task ProcessRegistrationResult(IdentityResult result, User user, bool delaysEnabled) + private RegisterResponseModel ProcessRegistrationResult(IdentityResult result, User user) { if (result.Succeeded) { @@ -243,10 +241,6 @@ public class AccountsController : Controller ModelState.AddModelError(string.Empty, error.Description); } - if (delaysEnabled) - { - await Task.Delay(Random.Shared.Next(100, 130)); - } throw new BadRequestException(ModelState); } From f732db4d2df1fa8fb6117fc3fce12d12fad19317 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Tue, 8 Apr 2025 12:33:44 +0200 Subject: [PATCH 06/28] Add xchacha20poly1305 enc type (#5470) --- src/Core/Enums/EncryptionType.cs | 4 ++++ src/Core/Utilities/EncryptedStringAttribute.cs | 1 + 2 files changed, 5 insertions(+) diff --git a/src/Core/Enums/EncryptionType.cs b/src/Core/Enums/EncryptionType.cs index 776ca99a93..52231e047c 100644 --- a/src/Core/Enums/EncryptionType.cs +++ b/src/Core/Enums/EncryptionType.cs @@ -4,9 +4,13 @@ // EncryptedStringAttribute public enum EncryptionType : byte { + // symmetric AesCbc256_B64 = 0, AesCbc128_HmacSha256_B64 = 1, AesCbc256_HmacSha256_B64 = 2, + XChaCha20Poly1305_B64 = 7, + + // asymmetric Rsa2048_OaepSha256_B64 = 3, Rsa2048_OaepSha1_B64 = 4, Rsa2048_OaepSha256_HmacSha256_B64 = 5, diff --git a/src/Core/Utilities/EncryptedStringAttribute.cs b/src/Core/Utilities/EncryptedStringAttribute.cs index 1fe06b4f58..9c59287df6 100644 --- a/src/Core/Utilities/EncryptedStringAttribute.cs +++ b/src/Core/Utilities/EncryptedStringAttribute.cs @@ -16,6 +16,7 @@ public class EncryptedStringAttribute : ValidationAttribute [EncryptionType.AesCbc256_B64] = 2, // iv|ct [EncryptionType.AesCbc128_HmacSha256_B64] = 3, // iv|ct|mac [EncryptionType.AesCbc256_HmacSha256_B64] = 3, // iv|ct|mac + [EncryptionType.XChaCha20Poly1305_B64] = 1, // cose bytes [EncryptionType.Rsa2048_OaepSha256_B64] = 1, // rsaCt [EncryptionType.Rsa2048_OaepSha1_B64] = 1, // rsaCt [EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64] = 2, // rsaCt|mac From 65f382ee675856067c666822c91c98e0eb72c163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Tue, 8 Apr 2025 15:26:00 +0100 Subject: [PATCH 07/28] [PM-17616] Remove feature flag for PushSyncOrgKeysOnRevokeRestore (#5616) --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 969c064c05..0eaa6cd85f 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -105,7 +105,6 @@ public static class FeatureFlagKeys public const string AccountDeprovisioning = "pm-10308-account-deprovisioning"; public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint"; public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission"; - public const string PushSyncOrgKeysOnRevokeRestore = "pm-17168-push-sync-org-keys-on-revoke-restore"; public const string PolicyRequirements = "pm-14439-policy-requirements"; public const string SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility"; public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast"; From f29b5c531fa64a56a85fbcc3200e5613c9f48cf0 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Tue, 8 Apr 2025 13:36:34 -0400 Subject: [PATCH 08/28] Include Root Certificates in Custom Trust Store (#5624) * Add new tests * Include root CA's in custom trust store --- .../X509ChainOptions.cs | 8 +++++ ...izationServiceCollectionExtensionsTests.cs | 35 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/Core/Platform/X509ChainCustomization/X509ChainOptions.cs b/src/Core/Platform/X509ChainCustomization/X509ChainOptions.cs index 189a1087f5..6cd06acf3c 100644 --- a/src/Core/Platform/X509ChainCustomization/X509ChainOptions.cs +++ b/src/Core/Platform/X509ChainCustomization/X509ChainOptions.cs @@ -53,6 +53,10 @@ public sealed class X509ChainOptions return false; } + // Do this outside of the callback so that we aren't opening the root store every request. + using var store = new X509Store(StoreName.Root, StoreLocation.LocalMachine, OpenFlags.ReadOnly); + var rootCertificates = store.Certificates; + // Ref: https://github.com/dotnet/runtime/issues/39835#issuecomment-663020581 callback = (certificate, chain, errors) => { @@ -62,6 +66,10 @@ public sealed class X509ChainOptions } chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + + // We want our additional certificates to be in addition to the machines root store. + chain.ChainPolicy.CustomTrustStore.AddRange(rootCertificates); + foreach (var additionalCertificate in AdditionalCustomTrustCertificates) { chain.ChainPolicy.CustomTrustStore.Add(additionalCertificate); diff --git a/test/Core.Test/Platform/X509ChainCustomization/X509ChainCustomizationServiceCollectionExtensionsTests.cs b/test/Core.Test/Platform/X509ChainCustomization/X509ChainCustomizationServiceCollectionExtensionsTests.cs index 2a4ed55489..cec8a2a39d 100644 --- a/test/Core.Test/Platform/X509ChainCustomization/X509ChainCustomizationServiceCollectionExtensionsTests.cs +++ b/test/Core.Test/Platform/X509ChainCustomization/X509ChainCustomizationServiceCollectionExtensionsTests.cs @@ -257,6 +257,41 @@ public class X509ChainCustomizationServiceCollectionExtensionsTests Assert.Equal("Hi", response); } + [Fact] + public async Task CallHttp_ReachingOutToServerTrustedThroughSystemCA() + { + var services = CreateServices((gs, environment, config) => { }, services => + { + services.Configure(options => + { + options.AdditionalCustomTrustCertificates = []; + }); + }); + + var httpClient = services.GetRequiredService().CreateClient(); + + var response = await httpClient.GetAsync("https://example.com"); + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task CallHttpWithCustomTrustForSelfSigned_ReachingOutToServerTrustedThroughSystemCA() + { + var selfSignedCertificate = CreateSelfSignedCert("localhost"); + var services = CreateServices((gs, environment, config) => { }, services => + { + services.Configure(options => + { + options.AdditionalCustomTrustCertificates = [selfSignedCertificate]; + }); + }); + + var httpClient = services.GetRequiredService().CreateClient(); + + var response = await httpClient.GetAsync("https://example.com"); + response.EnsureSuccessStatusCode(); + } + private static async Task CreateServerAsync(int port, Action configure) { var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions()); From f5f8d37d72ad4821c8c240df45d8cb69ddf05c7a Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Tue, 8 Apr 2025 11:13:47 -0700 Subject: [PATCH 09/28] [PM-18858] Use int.TryParse for plurality helper (#5625) --- .../Services/Implementations/HandlebarsMailService.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index a551342324..99bb8fa9dc 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -804,12 +804,10 @@ public class HandlebarsMailService : IMailService return; } - var numeric = parameters[0]; - var singularText = parameters[1].ToString(); - var pluralText = parameters[2].ToString(); - - if (numeric is int number) + if (int.TryParse(parameters[0].ToString(), out var number)) { + var singularText = parameters[1].ToString(); + var pluralText = parameters[2].ToString(); writer.WriteSafeString(number == 1 ? singularText : pluralText); } else From dcd62f00ba12a2a94d080b604c8744504e7ed11a Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Tue, 8 Apr 2025 14:38:44 -0500 Subject: [PATCH 10/28] [PM-15420] Managed to Claimed (#5594) * Renamed ManagedUserDomainClaimedEmails to ClaimedUserDomainClaimedEmails * Renamed method to improve clarity and consistency. Replaced `ValidateManagedUserDomainAsync` with `ValidateClaimedUserDomainAsync`. * Rename `GetOrganizationsManagingUserAsync` to `GetOrganizationsClaimingUserAsync`. This renaming clarifies the function's purpose, aligning its name with the concept of "claiming" rather than "managing" user associations. * Refactor variable naming in ValidateClaimedUserDomainAsync * Managed to claimed * Managed to claimed * Managed to claimed * Managing to Claiming * Managing to Claiming * Managing to Claiming * Managing to Claiming * Renamed DeleteManagedOrganizationUserAccountCommand to DeleteClaimedOrganizationUserAccountCommand * Renamed IDeleteManagedOrganizationUserAccountCommand to IDeleteClaimedOrganizationUserAccountCommand * Updated variable name * IsManagedBy to IsClaimedBy * Created new property. obsoleted old property and wired up for backward compatibility. * More Managed to Claimed renames. * Managed to Claimed * Fixing tests... :facepalm: * Got the rest of em * missed the test :facepalm: * fixed test. --- src/Admin/Controllers/UsersController.cs | 2 +- .../OrganizationUsersController.cs | 32 +++++----- .../Controllers/OrganizationsController.cs | 10 ++-- .../OrganizationUserResponseModel.cs | 30 +++++++--- .../ProfileOrganizationResponseModel.cs | 21 +++++-- .../Auth/Controllers/AccountsController.cs | 32 +++++----- .../Billing/Controllers/AccountsController.cs | 10 ++-- .../Controllers/OrganizationsController.cs | 4 +- .../Models/Response/ProfileResponseModel.cs | 4 +- .../Vault/Controllers/CiphersController.cs | 4 +- src/Api/Vault/Controllers/SyncController.cs | 6 +- .../Models/Response/SyncResponseModel.cs | 4 +- .../VerifyOrganizationDomainCommand.cs | 2 +- ...eClaimedOrganizationUserAccountCommand.cs} | 25 ++++---- ...GetOrganizationUsersClaimedStatusQuery.cs} | 10 ++-- ...eClaimedOrganizationUserAccountCommand.cs} | 2 +- ...GetOrganizationUsersClaimedStatusQuery.cs} | 10 ++-- .../RemoveOrganizationUserCommand.cs | 16 ++--- ...=> CannotDeleteClaimedAccountViewModel.cs} | 2 +- ...bs => CannotDeleteClaimedAccount.html.hbs} | 0 ...bs => CannotDeleteClaimedAccount.text.hbs} | 0 ...s.cs => ClaimedUserDomainClaimedEmails.cs} | 2 +- ...OrganizationServiceCollectionExtensions.cs | 4 +- src/Core/Services/IMailService.cs | 4 +- src/Core/Services/IUserService.cs | 10 ++-- .../Implementations/HandlebarsMailService.cs | 10 ++-- .../Services/Implementations/UserService.cs | 26 ++++---- .../NoopImplementations/NoopMailService.cs | 4 +- .../OrganizationUsersControllerTests.cs | 11 ++-- .../OrganizationsControllerTests.cs | 8 +-- .../Controllers/AccountsControllerTests.cs | 12 ++-- .../VerifyOrganizationDomainCommandTests.cs | 2 +- ...medOrganizationUserAccountCommandTests.cs} | 60 +++++++++---------- ...ganizationUsersClaimedStatusQueryTests.cs} | 18 +++--- .../RemoveOrganizationUserCommandTests.cs | 54 ++++++++--------- test/Core.Test/Services/UserServiceTests.cs | 16 ++--- 36 files changed, 245 insertions(+), 222 deletions(-) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/{DeleteManagedOrganizationUserAccountCommand.cs => DeleteClaimedOrganizationUserAccountCommand.cs} (89%) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/{GetOrganizationUsersManagementStatusQuery.cs => GetOrganizationUsersClaimedStatusQuery.cs} (82%) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/{IDeleteManagedOrganizationUserAccountCommand.cs => IDeleteClaimedOrganizationUserAccountCommand.cs} (92%) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/{IGetOrganizationUsersManagementStatusQuery.cs => IGetOrganizationUsersClaimedStatusQuery.cs} (67%) rename src/Core/Auth/Models/Mail/{CannotDeleteManagedAccountViewModel.cs => CannotDeleteClaimedAccountViewModel.cs} (53%) rename src/Core/MailTemplates/Handlebars/AdminConsole/{CannotDeleteManagedAccount.html.hbs => CannotDeleteClaimedAccount.html.hbs} (100%) rename src/Core/MailTemplates/Handlebars/AdminConsole/{CannotDeleteManagedAccount.text.hbs => CannotDeleteClaimedAccount.text.hbs} (100%) rename src/Core/Models/Data/Organizations/{ManagedUserDomainClaimedEmails.cs => ClaimedUserDomainClaimedEmails.cs} (66%) rename test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/{DeleteManagedOrganizationUserAccountCommandTests.cs => DeleteClaimedOrganizationUserAccountCommandTests.cs} (91%) rename test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/{GetOrganizationUsersManagementStatusQueryTests.cs => GetOrganizationUsersClaimedStatusQueryTests.cs} (80%) diff --git a/src/Admin/Controllers/UsersController.cs b/src/Admin/Controllers/UsersController.cs index cebb7d4b1e..71be19a041 100644 --- a/src/Admin/Controllers/UsersController.cs +++ b/src/Admin/Controllers/UsersController.cs @@ -184,7 +184,7 @@ public class UsersController : Controller private async Task AccountDeprovisioningEnabled(Guid userId) { return _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - ? await _userService.IsManagedByAnyOrganizationAsync(userId) + ? await _userService.IsClaimedByAnyOrganizationAsync(userId) : null; } } diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 5fd9109077..8a4cd54026 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -56,8 +56,8 @@ public class OrganizationUsersController : Controller private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; - private readonly IDeleteManagedOrganizationUserAccountCommand _deleteManagedOrganizationUserAccountCommand; - private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery; + private readonly IDeleteClaimedOrganizationUserAccountCommand _deleteClaimedOrganizationUserAccountCommand; + private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery; private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IFeatureService _featureService; private readonly IPricingClient _pricingClient; @@ -83,8 +83,8 @@ public class OrganizationUsersController : Controller IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IRemoveOrganizationUserCommand removeOrganizationUserCommand, - IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand, - IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery, + IDeleteClaimedOrganizationUserAccountCommand deleteClaimedOrganizationUserAccountCommand, + IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery, IPolicyRequirementQuery policyRequirementQuery, IFeatureService featureService, IPricingClient pricingClient, @@ -109,8 +109,8 @@ public class OrganizationUsersController : Controller _organizationUserUserDetailsQuery = organizationUserUserDetailsQuery; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _removeOrganizationUserCommand = removeOrganizationUserCommand; - _deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand; - _getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery; + _deleteClaimedOrganizationUserAccountCommand = deleteClaimedOrganizationUserAccountCommand; + _getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery; _policyRequirementQuery = policyRequirementQuery; _featureService = featureService; _pricingClient = pricingClient; @@ -127,11 +127,11 @@ public class OrganizationUsersController : Controller throw new NotFoundException(); } - var managedByOrganization = await GetManagedByOrganizationStatusAsync( + var claimedByOrganizationStatus = await GetClaimedByOrganizationStatusAsync( organizationUser.OrganizationId, [organizationUser.Id]); - var response = new OrganizationUserDetailsResponseModel(organizationUser, managedByOrganization[organizationUser.Id], collections); + var response = new OrganizationUserDetailsResponseModel(organizationUser, claimedByOrganizationStatus[organizationUser.Id], collections); if (includeGroups) { @@ -175,13 +175,13 @@ public class OrganizationUsersController : Controller } ); var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers); - var organizationUsersManagementStatus = await GetManagedByOrganizationStatusAsync(orgId, organizationUsers.Select(o => o.Id)); + var organizationUsersClaimedStatus = await GetClaimedByOrganizationStatusAsync(orgId, organizationUsers.Select(o => o.Id)); var responses = organizationUsers .Select(o => { var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == o.Id).twoFactorIsEnabled; - var managedByOrganization = organizationUsersManagementStatus[o.Id]; - var orgUser = new OrganizationUserUserDetailsResponseModel(o, userTwoFactorEnabled, managedByOrganization); + var claimedByOrganization = organizationUsersClaimedStatus[o.Id]; + var orgUser = new OrganizationUserUserDetailsResponseModel(o, userTwoFactorEnabled, claimedByOrganization); return orgUser; }); @@ -591,7 +591,7 @@ public class OrganizationUsersController : Controller throw new UnauthorizedAccessException(); } - await _deleteManagedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id); + await _deleteClaimedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id); } [RequireFeature(FeatureFlagKeys.AccountDeprovisioning)] @@ -610,7 +610,7 @@ public class OrganizationUsersController : Controller throw new UnauthorizedAccessException(); } - var results = await _deleteManagedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id); + var results = await _deleteClaimedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id); return new ListResponseModel(results.Select(r => new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage))); @@ -717,14 +717,14 @@ public class OrganizationUsersController : Controller new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2))); } - private async Task> GetManagedByOrganizationStatusAsync(Guid orgId, IEnumerable userIds) + private async Task> GetClaimedByOrganizationStatusAsync(Guid orgId, IEnumerable userIds) { if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) { return userIds.ToDictionary(kvp => kvp, kvp => false); } - var usersOrganizationManagementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(orgId, userIds); - return usersOrganizationManagementStatus; + var usersOrganizationClaimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(orgId, userIds); + return usersOrganizationClaimedStatus; } } diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 9fa9cb6672..6b7d031a00 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -140,10 +140,10 @@ public class OrganizationsController : Controller var organizations = await _organizationUserRepository.GetManyDetailsByUserAsync(userId, OrganizationUserStatusType.Confirmed); - var organizationManagingActiveUser = await _userService.GetOrganizationsManagingUserAsync(userId); - var organizationIdsManagingActiveUser = organizationManagingActiveUser.Select(o => o.Id); + var organizationsClaimingActiveUser = await _userService.GetOrganizationsClaimingUserAsync(userId); + var organizationIdsClaimingActiveUser = organizationsClaimingActiveUser.Select(o => o.Id); - var responses = organizations.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingActiveUser)); + var responses = organizations.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsClaimingActiveUser)); return new ListResponseModel(responses); } @@ -277,9 +277,9 @@ public class OrganizationsController : Controller } if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - && (await _userService.GetOrganizationsManagingUserAsync(user.Id)).Any(x => x.Id == id)) + && (await _userService.GetOrganizationsClaimingUserAsync(user.Id)).Any(x => x.Id == id)) { - throw new BadRequestException("Managed user account cannot leave managing organization. Contact your organization administrator for additional details."); + throw new BadRequestException("Claimed user account cannot leave claiming organization. Contact your organization administrator for additional details."); } await _removeOrganizationUserCommand.UserLeaveAsync(id, user.Id); diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs index 64dca73aaa..f9e5193045 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs @@ -66,24 +66,30 @@ public class OrganizationUserDetailsResponseModel : OrganizationUserResponseMode { public OrganizationUserDetailsResponseModel( OrganizationUser organizationUser, - bool managedByOrganization, + bool claimedByOrganization, IEnumerable collections) : base(organizationUser, "organizationUserDetails") { - ManagedByOrganization = managedByOrganization; + ClaimedByOrganization = claimedByOrganization; Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c)); } public OrganizationUserDetailsResponseModel(OrganizationUserUserDetails organizationUser, - bool managedByOrganization, + bool claimedByOrganization, IEnumerable collections) : base(organizationUser, "organizationUserDetails") { - ManagedByOrganization = managedByOrganization; + ClaimedByOrganization = claimedByOrganization; Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c)); } - public bool ManagedByOrganization { get; set; } + [Obsolete("Please use ClaimedByOrganization instead. This property will be removed in a future version.")] + public bool ManagedByOrganization + { + get => ClaimedByOrganization; + set => ClaimedByOrganization = value; + } + public bool ClaimedByOrganization { get; set; } public IEnumerable Collections { get; set; } @@ -117,7 +123,7 @@ public class OrganizationUserUserMiniDetailsResponseModel : ResponseModel public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel { public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser, - bool twoFactorEnabled, bool managedByOrganization, string obj = "organizationUserUserDetails") + bool twoFactorEnabled, bool claimedByOrganization, string obj = "organizationUserUserDetails") : base(organizationUser, obj) { if (organizationUser == null) @@ -134,7 +140,7 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse Groups = organizationUser.Groups; // Prevent reset password when using key connector. ResetPasswordEnrolled = ResetPasswordEnrolled && !organizationUser.UsesKeyConnector; - ManagedByOrganization = managedByOrganization; + ClaimedByOrganization = claimedByOrganization; } public string Name { get; set; } @@ -142,11 +148,17 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse public string AvatarColor { get; set; } public bool TwoFactorEnabled { get; set; } public bool SsoBound { get; set; } + [Obsolete("Please use ClaimedByOrganization instead. This property will be removed in a future version.")] + public bool ManagedByOrganization + { + get => ClaimedByOrganization; + set => ClaimedByOrganization = value; + } /// - /// Indicates if the organization manages the user. If a user is "managed" by an organization, + /// Indicates if the organization claimed the user. If a user is "claimed" by an organization, /// the organization has greater control over their account, and some user actions are restricted. /// - public bool ManagedByOrganization { get; set; } + public bool ClaimedByOrganization { get; set; } public IEnumerable Collections { get; set; } public IEnumerable Groups { get; set; } } diff --git a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs index 28ec09e984..437c30b8b9 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs @@ -18,7 +18,7 @@ public class ProfileOrganizationResponseModel : ResponseModel public ProfileOrganizationResponseModel( OrganizationUserOrganizationDetails organization, - IEnumerable organizationIdsManagingUser) + IEnumerable organizationIdsClaimingUser) : this("profileOrganization") { Id = organization.OrganizationId; @@ -70,7 +70,7 @@ public class ProfileOrganizationResponseModel : ResponseModel LimitCollectionDeletion = organization.LimitCollectionDeletion; LimitItemDeletion = organization.LimitItemDeletion; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; - UserIsManagedByOrganization = organizationIdsManagingUser.Contains(organization.OrganizationId); + UserIsClaimedByOrganization = organizationIdsClaimingUser.Contains(organization.OrganizationId); UseRiskInsights = organization.UseRiskInsights; if (organization.SsoConfig != null) @@ -133,15 +133,26 @@ public class ProfileOrganizationResponseModel : ResponseModel public bool LimitItemDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } /// - /// Indicates if the organization manages the user. + /// Obsolete. + /// + /// See + /// + [Obsolete("Please use UserIsClaimedByOrganization instead. This property will be removed in a future version.")] + public bool UserIsManagedByOrganization + { + get => UserIsClaimedByOrganization; + set => UserIsClaimedByOrganization = value; + } + /// + /// Indicates if the organization claims the user. /// /// - /// An organization manages a user if the user's email domain is verified by the organization and the user is a member of it. + /// An organization claims a user if the user's email domain is verified by the organization and the user is a member of it. /// The organization must be enabled and able to have verified domains. /// /// /// False if the Account Deprovisioning feature flag is disabled. /// - public bool UserIsManagedByOrganization { get; set; } + public bool UserIsClaimedByOrganization { get; set; } public bool UseRiskInsights { get; set; } } diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 2555a6fe2d..b22d54fa55 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -124,11 +124,11 @@ public class AccountsController : Controller throw new BadRequestException("MasterPasswordHash", "Invalid password."); } - var managedUserValidationResult = await _userService.ValidateManagedUserDomainAsync(user, model.NewEmail); + var claimedUserValidationResult = await _userService.ValidateClaimedUserDomainAsync(user, model.NewEmail); - if (!managedUserValidationResult.Succeeded) + if (!claimedUserValidationResult.Succeeded) { - throw new BadRequestException(managedUserValidationResult.Errors); + throw new BadRequestException(claimedUserValidationResult.Errors); } await _userService.InitiateEmailChangeAsync(user, model.NewEmail); @@ -437,11 +437,11 @@ public class AccountsController : Controller var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user); - var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id); + var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id); var response = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails, twoFactorEnabled, - hasPremiumFromOrg, organizationIdsManagingActiveUser); + hasPremiumFromOrg, organizationIdsClaimingActiveUser); return response; } @@ -451,9 +451,9 @@ public class AccountsController : Controller var userId = _userService.GetProperUserId(User); var organizationUserDetails = await _organizationUserRepository.GetManyDetailsByUserAsync(userId.Value, OrganizationUserStatusType.Confirmed); - var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(userId.Value); + var organizationIdsClaimingUser = await GetOrganizationIdsClaimingUserAsync(userId.Value); - var responseData = organizationUserDetails.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingActiveUser)); + var responseData = organizationUserDetails.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsClaimingUser)); return new ListResponseModel(responseData); } @@ -471,9 +471,9 @@ public class AccountsController : Controller var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user); - var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id); + var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id); - var response = new ProfileResponseModel(user, null, null, null, twoFactorEnabled, hasPremiumFromOrg, organizationIdsManagingActiveUser); + var response = new ProfileResponseModel(user, null, null, null, twoFactorEnabled, hasPremiumFromOrg, organizationIdsClaimingActiveUser); return response; } @@ -490,9 +490,9 @@ public class AccountsController : Controller var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user); - var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id); + var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id); - var response = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsManagingActiveUser); + var response = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsClaimingActiveUser); return response; } @@ -560,9 +560,9 @@ public class AccountsController : Controller } else { - // If Account Deprovisioning is enabled, we need to check if the user is managed by any organization. + // If Account Deprovisioning is enabled, we need to check if the user is claimed by any organization. if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - && await _userService.IsManagedByAnyOrganizationAsync(user.Id)) + && await _userService.IsClaimedByAnyOrganizationAsync(user.Id)) { throw new BadRequestException("Cannot delete accounts owned by an organization. Contact your organization administrator for additional details."); } @@ -763,9 +763,9 @@ public class AccountsController : Controller await _userService.SaveUserAsync(user); } - private async Task> GetOrganizationIdsManagingUserAsync(Guid userId) + private async Task> GetOrganizationIdsClaimingUserAsync(Guid userId) { - var organizationManagingUser = await _userService.GetOrganizationsManagingUserAsync(userId); - return organizationManagingUser.Select(o => o.Id); + var organizationsClaimingUser = await _userService.GetOrganizationsClaimingUserAsync(userId); + return organizationsClaimingUser.Select(o => o.Id); } } diff --git a/src/Api/Billing/Controllers/AccountsController.cs b/src/Api/Billing/Controllers/AccountsController.cs index 9c5811b195..bc263691a8 100644 --- a/src/Api/Billing/Controllers/AccountsController.cs +++ b/src/Api/Billing/Controllers/AccountsController.cs @@ -58,10 +58,10 @@ public class AccountsController( var userTwoFactorEnabled = await userService.TwoFactorIsEnabledAsync(user); var userHasPremiumFromOrganization = await userService.HasPremiumFromOrganization(user); - var organizationIdsManagingActiveUser = await GetOrganizationIdsManagingUserAsync(user.Id); + var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id); var profile = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, - userHasPremiumFromOrganization, organizationIdsManagingActiveUser); + userHasPremiumFromOrganization, organizationIdsClaimingActiveUser); return new PaymentResponseModel { UserProfile = profile, @@ -229,9 +229,9 @@ public class AccountsController( await paymentService.SaveTaxInfoAsync(user, taxInfo); } - private async Task> GetOrganizationIdsManagingUserAsync(Guid userId) + private async Task> GetOrganizationIdsClaimingUserAsync(Guid userId) { - var organizationManagingUser = await userService.GetOrganizationsManagingUserAsync(userId); - return organizationManagingUser.Select(o => o.Id); + var organizationsClaimingUser = await userService.GetOrganizationsClaimingUserAsync(userId); + return organizationsClaimingUser.Select(o => o.Id); } } diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs index de14a8d798..510f6c2835 100644 --- a/src/Api/Billing/Controllers/OrganizationsController.cs +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -409,9 +409,9 @@ public class OrganizationsController( organizationId, OrganizationUserStatusType.Confirmed); - var organizationIdsManagingActiveUser = (await userService.GetOrganizationsManagingUserAsync(userId)) + var organizationIdsClaimingActiveUser = (await userService.GetOrganizationsClaimingUserAsync(userId)) .Select(o => o.Id); - return new ProfileOrganizationResponseModel(organizationUserDetails, organizationIdsManagingActiveUser); + return new ProfileOrganizationResponseModel(organizationUserDetails, organizationIdsClaimingActiveUser); } } diff --git a/src/Api/Models/Response/ProfileResponseModel.cs b/src/Api/Models/Response/ProfileResponseModel.cs index 82ffb05b0b..246b3c3227 100644 --- a/src/Api/Models/Response/ProfileResponseModel.cs +++ b/src/Api/Models/Response/ProfileResponseModel.cs @@ -15,7 +15,7 @@ public class ProfileResponseModel : ResponseModel IEnumerable providerUserOrganizationDetails, bool twoFactorEnabled, bool premiumFromOrganization, - IEnumerable organizationIdsManagingUser) : base("profile") + IEnumerable organizationIdsClaimingUser) : base("profile") { if (user == null) { @@ -38,7 +38,7 @@ public class ProfileResponseModel : ResponseModel AvatarColor = user.AvatarColor; CreationDate = user.CreationDate; VerifyDevices = user.VerifyDevices; - Organizations = organizationsUserDetails?.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingUser)); + Organizations = organizationsUserDetails?.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsClaimingUser)); Providers = providerUserDetails?.Select(p => new ProfileProviderResponseModel(p)); ProviderOrganizations = providerUserOrganizationDetails?.Select(po => new ProfileProviderOrganizationResponseModel(po)); diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 0f03f54be1..a9646acd1c 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -1091,9 +1091,9 @@ public class CiphersController : Controller throw new BadRequestException(ModelState); } - // If Account Deprovisioning is enabled, we need to check if the user is managed by any organization. + // If Account Deprovisioning is enabled, we need to check if the user is claimed by any organization. if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - && await _userService.IsManagedByAnyOrganizationAsync(user.Id)) + && await _userService.IsClaimedByAnyOrganizationAsync(user.Id)) { throw new BadRequestException("Cannot purge accounts owned by an organization. Contact your organization administrator for additional details."); } diff --git a/src/Api/Vault/Controllers/SyncController.cs b/src/Api/Vault/Controllers/SyncController.cs index 1b8978fc65..4b66c7f2bd 100644 --- a/src/Api/Vault/Controllers/SyncController.cs +++ b/src/Api/Vault/Controllers/SyncController.cs @@ -104,13 +104,13 @@ public class SyncController : Controller var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user); - var organizationManagingActiveUser = await _userService.GetOrganizationsManagingUserAsync(user.Id); - var organizationIdsManagingActiveUser = organizationManagingActiveUser.Select(o => o.Id); + var organizationClaimingActiveUser = await _userService.GetOrganizationsClaimingUserAsync(user.Id); + var organizationIdsClaimingActiveUser = organizationClaimingActiveUser.Select(o => o.Id); var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); var response = new SyncResponseModel(_globalSettings, user, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities, - organizationIdsManagingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails, + organizationIdsClaimingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails, folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends); return response; } diff --git a/src/Api/Vault/Models/Response/SyncResponseModel.cs b/src/Api/Vault/Models/Response/SyncResponseModel.cs index f1465264f2..b9da786567 100644 --- a/src/Api/Vault/Models/Response/SyncResponseModel.cs +++ b/src/Api/Vault/Models/Response/SyncResponseModel.cs @@ -23,7 +23,7 @@ public class SyncResponseModel : ResponseModel bool userTwoFactorEnabled, bool userHasPremiumFromOrganization, IDictionary organizationAbilities, - IEnumerable organizationIdsManagingUser, + IEnumerable organizationIdsClaimingingUser, IEnumerable organizationUserDetails, IEnumerable providerUserDetails, IEnumerable providerUserOrganizationDetails, @@ -37,7 +37,7 @@ public class SyncResponseModel : ResponseModel : base("sync") { Profile = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails, - providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsManagingUser); + providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsClaimingingUser); Folders = folders.Select(f => new FolderResponseModel(f)); Ciphers = ciphers.Select(cipher => new CipherDetailsResponseModel( diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs index e011819f0f..ec635282f7 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs @@ -154,6 +154,6 @@ public class VerifyOrganizationDomainCommand( var organization = await organizationRepository.GetByIdAsync(domain.OrganizationId); - await mailService.SendClaimedDomainUserEmailAsync(new ManagedUserDomainClaimedEmails(domainUserEmails, organization)); + await mailService.SendClaimedDomainUserEmailAsync(new ClaimedUserDomainClaimedEmails(domainUserEmails, organization)); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs similarity index 89% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommand.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs index 7b7d8003a3..49ddf0a548 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs @@ -15,11 +15,11 @@ using Bit.Core.Tools.Services; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; -public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganizationUserAccountCommand +public class DeleteClaimedOrganizationUserAccountCommand : IDeleteClaimedOrganizationUserAccountCommand { private readonly IUserService _userService; private readonly IEventService _eventService; - private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery; + private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IUserRepository _userRepository; private readonly ICurrentContext _currentContext; @@ -28,10 +28,10 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz private readonly IPushNotificationService _pushService; private readonly IOrganizationRepository _organizationRepository; private readonly IProviderUserRepository _providerUserRepository; - public DeleteManagedOrganizationUserAccountCommand( + public DeleteClaimedOrganizationUserAccountCommand( IUserService userService, IEventService eventService, - IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery, + IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery, IOrganizationUserRepository organizationUserRepository, IUserRepository userRepository, ICurrentContext currentContext, @@ -43,7 +43,7 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz { _userService = userService; _eventService = eventService; - _getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery; + _getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery; _organizationUserRepository = organizationUserRepository; _userRepository = userRepository; _currentContext = currentContext; @@ -62,10 +62,10 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz throw new NotFoundException("Member not found."); } - var managementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(organizationId, new[] { organizationUserId }); + var claimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, new[] { organizationUserId }); var hasOtherConfirmedOwners = await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, new[] { organizationUserId }, includeProvider: true); - await ValidateDeleteUserAsync(organizationId, organizationUser, deletingUserId, managementStatus, hasOtherConfirmedOwners); + await ValidateDeleteUserAsync(organizationId, organizationUser, deletingUserId, claimedStatus, hasOtherConfirmedOwners); var user = await _userRepository.GetByIdAsync(organizationUser.UserId!.Value); if (user == null) @@ -83,7 +83,7 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz var userIds = orgUsers.Where(ou => ou.UserId.HasValue).Select(ou => ou.UserId!.Value).ToList(); var users = await _userRepository.GetManyAsync(userIds); - var managementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(organizationId, orgUserIds); + var claimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, orgUserIds); var hasOtherConfirmedOwners = await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, orgUserIds, includeProvider: true); var results = new List<(Guid OrganizationUserId, string? ErrorMessage)>(); @@ -97,7 +97,7 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz throw new NotFoundException("Member not found."); } - await ValidateDeleteUserAsync(organizationId, orgUser, deletingUserId, managementStatus, hasOtherConfirmedOwners); + await ValidateDeleteUserAsync(organizationId, orgUser, deletingUserId, claimedStatus, hasOtherConfirmedOwners); var user = users.FirstOrDefault(u => u.Id == orgUser.UserId); if (user == null) @@ -129,7 +129,7 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz return results; } - private async Task ValidateDeleteUserAsync(Guid organizationId, OrganizationUser orgUser, Guid? deletingUserId, IDictionary managementStatus, bool hasOtherConfirmedOwners) + private async Task ValidateDeleteUserAsync(Guid organizationId, OrganizationUser orgUser, Guid? deletingUserId, IDictionary claimedStatus, bool hasOtherConfirmedOwners) { if (!orgUser.UserId.HasValue || orgUser.Status == OrganizationUserStatusType.Invited) { @@ -159,10 +159,9 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz throw new BadRequestException("Custom users can not delete admins."); } - - if (!managementStatus.TryGetValue(orgUser.Id, out var isManaged) || !isManaged) + if (!claimedStatus.TryGetValue(orgUser.Id, out var isClaimed) || !isClaimed) { - throw new BadRequestException("Member is not managed by the organization."); + throw new BadRequestException("Member is not claimed by the organization."); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersManagementStatusQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersClaimedStatusQuery.cs similarity index 82% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersManagementStatusQuery.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersClaimedStatusQuery.cs index 4ff6b87443..1dda9483cd 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersManagementStatusQuery.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersClaimedStatusQuery.cs @@ -4,12 +4,12 @@ using Bit.Core.Services; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; -public class GetOrganizationUsersManagementStatusQuery : IGetOrganizationUsersManagementStatusQuery +public class GetOrganizationUsersClaimedStatusQuery : IGetOrganizationUsersClaimedStatusQuery { private readonly IApplicationCacheService _applicationCacheService; private readonly IOrganizationUserRepository _organizationUserRepository; - public GetOrganizationUsersManagementStatusQuery( + public GetOrganizationUsersClaimedStatusQuery( IApplicationCacheService applicationCacheService, IOrganizationUserRepository organizationUserRepository) { @@ -17,11 +17,11 @@ public class GetOrganizationUsersManagementStatusQuery : IGetOrganizationUsersMa _organizationUserRepository = organizationUserRepository; } - public async Task> GetUsersOrganizationManagementStatusAsync(Guid organizationId, IEnumerable organizationUserIds) + public async Task> GetUsersOrganizationClaimedStatusAsync(Guid organizationId, IEnumerable organizationUserIds) { if (organizationUserIds.Any()) { - // Users can only be managed by an Organization that is enabled and can have organization domains + // Users can only be claimed by an Organization that is enabled and can have organization domains var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId); // TODO: Replace "UseSso" with a new organization ability like "UseOrganizationDomains" (PM-11622). @@ -31,7 +31,7 @@ public class GetOrganizationUsersManagementStatusQuery : IGetOrganizationUsersMa // Get all organization users with claimed domains by the organization var organizationUsersWithClaimedDomain = await _organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organizationId); - // Create a dictionary with the OrganizationUserId and a boolean indicating if the user is managed by the organization + // Create a dictionary with the OrganizationUserId and a boolean indicating if the user is claimed by the organization return organizationUserIds.ToDictionary(ouId => ouId, ouId => organizationUsersWithClaimedDomain.Any(ou => ou.Id == ouId)); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteManagedOrganizationUserAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteClaimedOrganizationUserAccountCommand.cs similarity index 92% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteManagedOrganizationUserAccountCommand.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteClaimedOrganizationUserAccountCommand.cs index d548966aaf..1c79687be9 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteManagedOrganizationUserAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteClaimedOrganizationUserAccountCommand.cs @@ -2,7 +2,7 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; -public interface IDeleteManagedOrganizationUserAccountCommand +public interface IDeleteClaimedOrganizationUserAccountCommand { /// /// Removes a user from an organization and deletes all of their associated user data. diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IGetOrganizationUsersManagementStatusQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IGetOrganizationUsersClaimedStatusQuery.cs similarity index 67% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IGetOrganizationUsersManagementStatusQuery.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IGetOrganizationUsersClaimedStatusQuery.cs index 694b44dd78..74a7d5fc0e 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IGetOrganizationUsersManagementStatusQuery.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IGetOrganizationUsersClaimedStatusQuery.cs @@ -1,19 +1,19 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; -public interface IGetOrganizationUsersManagementStatusQuery +public interface IGetOrganizationUsersClaimedStatusQuery { /// - /// Checks whether each user in the provided list of organization user IDs is managed by the specified organization. + /// Checks whether each user in the provided list of organization user IDs is claimed by the specified organization. /// /// The unique identifier of the organization to check against. /// A list of OrganizationUserIds to be checked. /// - /// A managed user is a user whose email domain matches one of the Organization's verified domains. + /// A claimed user is a user whose email domain matches one of the Organization's verified domains. /// The organization must be enabled and be on an Enterprise plan. /// /// - /// A dictionary containing the OrganizationUserId and a boolean indicating if the user is managed by the organization. + /// A dictionary containing the OrganizationUserId and a boolean indicating if the user is claimed by the organization. /// - Task> GetUsersOrganizationManagementStatusAsync(Guid organizationId, + Task> GetUsersOrganizationClaimedStatusAsync(Guid organizationId, IEnumerable organizationUserIds); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs index 3568a2a2b9..4de2cd0ea5 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs @@ -18,7 +18,7 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand private readonly IPushRegistrationService _pushRegistrationService; private readonly ICurrentContext _currentContext; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; - private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery; + private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery; private readonly IFeatureService _featureService; private readonly TimeProvider _timeProvider; @@ -38,7 +38,7 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand IPushRegistrationService pushRegistrationService, ICurrentContext currentContext, IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, - IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery, + IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery, IFeatureService featureService, TimeProvider timeProvider) { @@ -49,7 +49,7 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand _pushRegistrationService = pushRegistrationService; _currentContext = currentContext; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; - _getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery; + _getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery; _featureService = featureService; _timeProvider = timeProvider; } @@ -161,8 +161,8 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && deletingUserId.HasValue && eventSystemUser == null) { - var managementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(orgUser.OrganizationId, new[] { orgUser.Id }); - if (managementStatus.TryGetValue(orgUser.Id, out var isManaged) && isManaged) + var claimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(orgUser.OrganizationId, new[] { orgUser.Id }); + if (claimedStatus.TryGetValue(orgUser.Id, out var isClaimed) && isClaimed) { throw new BadRequestException(RemoveClaimedAccountErrorMessage); } @@ -214,8 +214,8 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand deletingUserIsOwner = await _currentContext.OrganizationOwner(organizationId); } - var managementStatus = _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && deletingUserId.HasValue && eventSystemUser == null - ? await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(organizationId, filteredUsers.Select(u => u.Id)) + var claimedStatus = _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && deletingUserId.HasValue && eventSystemUser == null + ? await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, filteredUsers.Select(u => u.Id)) : filteredUsers.ToDictionary(u => u.Id, u => false); var result = new List<(OrganizationUser OrganizationUser, string ErrorMessage)>(); foreach (var orgUser in filteredUsers) @@ -232,7 +232,7 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand throw new BadRequestException(RemoveOwnerByNonOwnerErrorMessage); } - if (managementStatus.TryGetValue(orgUser.Id, out var isManaged) && isManaged) + if (claimedStatus.TryGetValue(orgUser.Id, out var isClaimed) && isClaimed) { throw new BadRequestException(RemoveClaimedAccountErrorMessage); } diff --git a/src/Core/Auth/Models/Mail/CannotDeleteManagedAccountViewModel.cs b/src/Core/Auth/Models/Mail/CannotDeleteClaimedAccountViewModel.cs similarity index 53% rename from src/Core/Auth/Models/Mail/CannotDeleteManagedAccountViewModel.cs rename to src/Core/Auth/Models/Mail/CannotDeleteClaimedAccountViewModel.cs index 02549a9595..15fc9fd7f0 100644 --- a/src/Core/Auth/Models/Mail/CannotDeleteManagedAccountViewModel.cs +++ b/src/Core/Auth/Models/Mail/CannotDeleteClaimedAccountViewModel.cs @@ -2,6 +2,6 @@ namespace Bit.Core.Auth.Models.Mail; -public class CannotDeleteManagedAccountViewModel : BaseMailModel +public class CannotDeleteClaimedAccountViewModel : BaseMailModel { } diff --git a/src/Core/MailTemplates/Handlebars/AdminConsole/CannotDeleteManagedAccount.html.hbs b/src/Core/MailTemplates/Handlebars/AdminConsole/CannotDeleteClaimedAccount.html.hbs similarity index 100% rename from src/Core/MailTemplates/Handlebars/AdminConsole/CannotDeleteManagedAccount.html.hbs rename to src/Core/MailTemplates/Handlebars/AdminConsole/CannotDeleteClaimedAccount.html.hbs diff --git a/src/Core/MailTemplates/Handlebars/AdminConsole/CannotDeleteManagedAccount.text.hbs b/src/Core/MailTemplates/Handlebars/AdminConsole/CannotDeleteClaimedAccount.text.hbs similarity index 100% rename from src/Core/MailTemplates/Handlebars/AdminConsole/CannotDeleteManagedAccount.text.hbs rename to src/Core/MailTemplates/Handlebars/AdminConsole/CannotDeleteClaimedAccount.text.hbs diff --git a/src/Core/Models/Data/Organizations/ManagedUserDomainClaimedEmails.cs b/src/Core/Models/Data/Organizations/ClaimedUserDomainClaimedEmails.cs similarity index 66% rename from src/Core/Models/Data/Organizations/ManagedUserDomainClaimedEmails.cs rename to src/Core/Models/Data/Organizations/ClaimedUserDomainClaimedEmails.cs index 429257e266..2b73fc1525 100644 --- a/src/Core/Models/Data/Organizations/ManagedUserDomainClaimedEmails.cs +++ b/src/Core/Models/Data/Organizations/ClaimedUserDomainClaimedEmails.cs @@ -2,4 +2,4 @@ namespace Bit.Core.Models.Data.Organizations; -public record ManagedUserDomainClaimedEmails(IEnumerable EmailList, Organization Organization); +public record ClaimedUserDomainClaimedEmails(IEnumerable EmailList, Organization Organization); diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index c76345972a..96d9095c1a 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -121,7 +121,7 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); } @@ -172,7 +172,7 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index e61127c57a..48e0464905 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -21,7 +21,7 @@ public interface IMailService ProductTierType productTier, IEnumerable products); Task SendVerifyDeleteEmailAsync(string email, Guid userId, string token); - Task SendCannotDeleteManagedAccountEmailAsync(string email); + Task SendCannotDeleteClaimedAccountEmailAsync(string email); Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail); Task SendChangeEmailEmailAsync(string newEmailAddress, string token); Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, bool authentication = true); @@ -97,7 +97,7 @@ public interface IMailService Task SendRequestSMAccessToAdminEmailAsync(IEnumerable adminEmails, string organizationName, string userRequestingAccess, string emailContent); Task SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(string email, string offerAcceptanceDate, string organizationId, string organizationName); - Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList); + Task SendClaimedDomainUserEmailAsync(ClaimedUserDomainClaimedEmails emailList); Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable adminEmails, Guid organizationId, string email, string userName); Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable securityTaskNotifications, IEnumerable adminOwnerEmails); } diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index b6a1d1f05b..9b12713218 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -134,7 +134,7 @@ public interface IUserService /// /// False if the Account Deprovisioning feature flag is disabled. /// - Task IsManagedByAnyOrganizationAsync(Guid userId); + Task IsClaimedByAnyOrganizationAsync(Guid userId); /// /// Verify whether the new email domain meets the requirements for managed users. @@ -142,9 +142,9 @@ public interface IUserService /// /// /// - /// IdentityResult + /// IdentityResult /// - Task ValidateManagedUserDomainAsync(User user, string newEmail); + Task ValidateClaimedUserDomainAsync(User user, string newEmail); /// /// Gets the organizations that manage the user. @@ -152,6 +152,6 @@ public interface IUserService /// /// An empty collection if the Account Deprovisioning feature flag is disabled. /// - /// - Task> GetOrganizationsManagingUserAsync(Guid userId); + /// + Task> GetOrganizationsClaimingUserAsync(Guid userId); } diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 99bb8fa9dc..7bcf2c0ef5 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -117,16 +117,16 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } - public async Task SendCannotDeleteManagedAccountEmailAsync(string email) + public async Task SendCannotDeleteClaimedAccountEmailAsync(string email) { var message = CreateDefaultMessage("Delete Your Account", email); - var model = new CannotDeleteManagedAccountViewModel + var model = new CannotDeleteClaimedAccountViewModel { WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, SiteName = _globalSettings.SiteName, }; - await AddMessageContentAsync(message, "AdminConsole.CannotDeleteManagedAccount", model); - message.Category = "CannotDeleteManagedAccount"; + await AddMessageContentAsync(message, "AdminConsole.CannotDeleteClaimedAccount", model); + message.Category = "CannotDeleteClaimedAccount"; await _mailDeliveryService.SendEmailAsync(message); } @@ -474,7 +474,7 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } - public async Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList) + public async Task SendClaimedDomainUserEmailAsync(ClaimedUserDomainClaimedEmails emailList) { await EnqueueMailAsync(emailList.EmailList.Select(email => CreateMessage(email, emailList.Organization))); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 5076c8282e..de0fa427ba 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -314,9 +314,9 @@ public class UserService : UserManager, IUserService, IDisposable return; } - if (await IsManagedByAnyOrganizationAsync(user.Id)) + if (await IsClaimedByAnyOrganizationAsync(user.Id)) { - await _mailService.SendCannotDeleteManagedAccountEmailAsync(user.Email); + await _mailService.SendCannotDeleteClaimedAccountEmailAsync(user.Email); return; } @@ -545,11 +545,11 @@ public class UserService : UserManager, IUserService, IDisposable return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); } - var managedUserValidationResult = await ValidateManagedUserDomainAsync(user, newEmail); + var claimedUserValidationResult = await ValidateClaimedUserDomainAsync(user, newEmail); - if (!managedUserValidationResult.Succeeded) + if (!claimedUserValidationResult.Succeeded) { - return managedUserValidationResult; + return claimedUserValidationResult; } if (!await base.VerifyUserTokenAsync(user, _identityOptions.Tokens.ChangeEmailTokenProvider, @@ -617,18 +617,18 @@ public class UserService : UserManager, IUserService, IDisposable return IdentityResult.Success; } - public async Task ValidateManagedUserDomainAsync(User user, string newEmail) + public async Task ValidateClaimedUserDomainAsync(User user, string newEmail) { - var managingOrganizations = await GetOrganizationsManagingUserAsync(user.Id); + var claimingOrganization = await GetOrganizationsClaimingUserAsync(user.Id); - if (!managingOrganizations.Any()) + if (!claimingOrganization.Any()) { return IdentityResult.Success; } var newDomain = CoreHelpers.GetEmailDomain(newEmail); - var verifiedDomains = await _organizationDomainRepository.GetVerifiedDomainsByOrganizationIdsAsync(managingOrganizations.Select(org => org.Id)); + var verifiedDomains = await _organizationDomainRepository.GetVerifiedDomainsByOrganizationIdsAsync(claimingOrganization.Select(org => org.Id)); if (verifiedDomains.Any(verifiedDomain => verifiedDomain.DomainName == newDomain)) { @@ -1366,13 +1366,13 @@ public class UserService : UserManager, IUserService, IDisposable return IsLegacyUser(user); } - public async Task IsManagedByAnyOrganizationAsync(Guid userId) + public async Task IsClaimedByAnyOrganizationAsync(Guid userId) { - var managingOrganizations = await GetOrganizationsManagingUserAsync(userId); - return managingOrganizations.Any(); + var organizationsClaimingUser = await GetOrganizationsClaimingUserAsync(userId); + return organizationsClaimingUser.Any(); } - public async Task> GetOrganizationsManagingUserAsync(Guid userId) + public async Task> GetOrganizationsClaimingUserAsync(Guid userId) { if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) { diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index d829fbbacb..f6b27b0670 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -103,7 +103,7 @@ public class NoopMailService : IMailService return Task.FromResult(0); } - public Task SendCannotDeleteManagedAccountEmailAsync(string email) + public Task SendCannotDeleteClaimedAccountEmailAsync(string email) { return Task.FromResult(0); } @@ -317,7 +317,7 @@ public class NoopMailService : IMailService { return Task.FromResult(0); } - public Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList) => Task.CompletedTask; + public Task SendClaimedDomainUserEmailAsync(ClaimedUserDomainClaimedEmails emailList) => Task.CompletedTask; public Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable adminEmails, Guid organizationId, string email, string userName) { diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs index a19560ecee..107b9cdfb1 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs @@ -260,14 +260,15 @@ public class OrganizationUsersControllerTests .GetDetailsByIdWithCollectionsAsync(organizationUser.Id) .Returns((organizationUser, collections)); - sutProvider.GetDependency() - .GetUsersOrganizationManagementStatusAsync(organizationUser.OrganizationId, Arg.Is>(ids => ids.Contains(organizationUser.Id))) + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync(organizationUser.OrganizationId, Arg.Is>(ids => ids.Contains(organizationUser.Id))) .Returns(new Dictionary { { organizationUser.Id, true } }); var response = await sutProvider.Sut.Get(organizationUser.Id, false); Assert.Equal(organizationUser.Id, response.Id); Assert.Equal(accountDeprovisioningEnabled, response.ManagedByOrganization); + Assert.Equal(accountDeprovisioningEnabled, response.ClaimedByOrganization); } [Theory] @@ -331,7 +332,7 @@ public class OrganizationUsersControllerTests await sutProvider.Sut.DeleteAccount(orgId, id); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) .DeleteUserAsync(orgId, id, currentUser.Id); } @@ -367,7 +368,7 @@ public class OrganizationUsersControllerTests { sutProvider.GetDependency().ManageUsers(orgId).Returns(true); sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(currentUser); - sutProvider.GetDependency() + sutProvider.GetDependency() .DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id) .Returns(deleteResults); @@ -375,7 +376,7 @@ public class OrganizationUsersControllerTests Assert.Equal(deleteResults.Count, response.Data.Count()); Assert.True(response.Data.All(r => deleteResults.Any(res => res.Item1 == r.Id && res.Item2 == r.Error))); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) .DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id); } diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 8e6d2ce27b..3c06c78392 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -138,7 +138,7 @@ public class OrganizationsControllerTests : IDisposable _ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig); _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); - _userService.GetOrganizationsManagingUserAsync(user.Id).Returns(new List { null }); + _userService.GetOrganizationsClaimingUserAsync(user.Id).Returns(new List { null }); var exception = await Assert.ThrowsAsync(() => _sut.Leave(orgId)); Assert.Contains("Your organization's Single Sign-On settings prevent you from leaving.", @@ -168,10 +168,10 @@ public class OrganizationsControllerTests : IDisposable _ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig); _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); - _userService.GetOrganizationsManagingUserAsync(user.Id).Returns(new List { { foundOrg } }); + _userService.GetOrganizationsClaimingUserAsync(user.Id).Returns(new List { { foundOrg } }); var exception = await Assert.ThrowsAsync(() => _sut.Leave(orgId)); - Assert.Contains("Managed user account cannot leave managing organization. Contact your organization administrator for additional details.", + Assert.Contains("Claimed user account cannot leave claiming organization. Contact your organization administrator for additional details.", exception.Message); await _removeOrganizationUserCommand.DidNotReceiveWithAnyArgs().RemoveUserAsync(default, default); @@ -203,7 +203,7 @@ public class OrganizationsControllerTests : IDisposable _ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig); _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); - _userService.GetOrganizationsManagingUserAsync(user.Id).Returns(new List()); + _userService.GetOrganizationsClaimingUserAsync(user.Id).Returns(new List()); await _sut.Leave(orgId); diff --git a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs index 15c7573aca..bd22fd9346 100644 --- a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs @@ -120,7 +120,7 @@ public class AccountsControllerTests : IDisposable ConfigureUserServiceToReturnValidPrincipalFor(user); ConfigureUserServiceToAcceptPasswordFor(user); const string newEmail = "example@user.com"; - _userService.ValidateManagedUserDomainAsync(user, newEmail).Returns(IdentityResult.Success); + _userService.ValidateClaimedUserDomainAsync(user, newEmail).Returns(IdentityResult.Success); // Act await _sut.PostEmailToken(new EmailTokenRequestModel { NewEmail = newEmail }); @@ -130,7 +130,7 @@ public class AccountsControllerTests : IDisposable } [Fact] - public async Task PostEmailToken_WhenValidateManagedUserDomainAsyncFails_ShouldReturnError() + public async Task PostEmailToken_WhenValidateClaimedUserDomainAsyncFails_ShouldReturnError() { // Arrange var user = GenerateExampleUser(); @@ -139,7 +139,7 @@ public class AccountsControllerTests : IDisposable const string newEmail = "example@user.com"; - _userService.ValidateManagedUserDomainAsync(user, newEmail) + _userService.ValidateClaimedUserDomainAsync(user, newEmail) .Returns(IdentityResult.Failed(new IdentityError { Code = "TestFailure", @@ -197,7 +197,7 @@ public class AccountsControllerTests : IDisposable _userService.ChangeEmailAsync(user, default, default, default, default, default) .Returns(Task.FromResult(IdentityResult.Success)); _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); - _userService.IsManagedByAnyOrganizationAsync(user.Id).Returns(false); + _userService.IsClaimedByAnyOrganizationAsync(user.Id).Returns(false); await _sut.PostEmail(new EmailRequestModel()); @@ -539,7 +539,7 @@ public class AccountsControllerTests : IDisposable ConfigureUserServiceToReturnValidPrincipalFor(user); ConfigureUserServiceToAcceptPasswordFor(user); _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); - _userService.IsManagedByAnyOrganizationAsync(user.Id).Returns(true); + _userService.IsClaimedByAnyOrganizationAsync(user.Id).Returns(true); var result = await Assert.ThrowsAsync(() => _sut.Delete(new SecretVerificationRequestModel())); @@ -553,7 +553,7 @@ public class AccountsControllerTests : IDisposable ConfigureUserServiceToReturnValidPrincipalFor(user); ConfigureUserServiceToAcceptPasswordFor(user); _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); - _userService.IsManagedByAnyOrganizationAsync(user.Id).Returns(false); + _userService.IsClaimedByAnyOrganizationAsync(user.Id).Returns(false); _userService.DeleteAsync(user).Returns(IdentityResult.Success); await _sut.Delete(new SecretVerificationRequestModel()); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs index 6c6d0e35f0..daa560f3bc 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs @@ -317,7 +317,7 @@ public class VerifyOrganizationDomainCommandTests _ = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(domain); await sutProvider.GetDependency().Received().SendClaimedDomainUserEmailAsync( - Arg.Is(x => + Arg.Is(x => x.EmailList.Count(e => e.EndsWith(domain.DomainName)) == mockedUsers.Count && x.Organization.Id == organization.Id)); } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommandTests.cs similarity index 91% rename from test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommandTests.cs rename to test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommandTests.cs index f8f6bdb60d..7f1b101d7a 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommandTests.cs @@ -15,12 +15,12 @@ using Xunit; namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers; [SutProviderCustomize] -public class DeleteManagedOrganizationUserAccountCommandTests +public class DeleteClaimedOrganizationUserAccountCommandTests { [Theory] [BitAutoData] public async Task DeleteUserAsync_WithValidUser_DeletesUserAndLogsEvent( - SutProvider sutProvider, User user, Guid deletingUserId, + SutProvider sutProvider, User user, Guid deletingUserId, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) { // Arrange @@ -34,8 +34,8 @@ public class DeleteManagedOrganizationUserAccountCommandTests .GetByIdAsync(organizationUser.Id) .Returns(organizationUser); - sutProvider.GetDependency() - .GetUsersOrganizationManagementStatusAsync( + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync( organizationUser.OrganizationId, Arg.Is>(ids => ids.Contains(organizationUser.Id))) .Returns(new Dictionary { { organizationUser.Id, true } }); @@ -59,7 +59,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteUserAsync_WithUserNotFound_ThrowsException( - SutProvider sutProvider, + SutProvider sutProvider, Guid organizationId, Guid organizationUserId) { // Arrange @@ -81,7 +81,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteUserAsync_DeletingYourself_ThrowsException( - SutProvider sutProvider, + SutProvider sutProvider, User user, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser, Guid deletingUserId) @@ -110,7 +110,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteUserAsync_WhenUserIsInvited_ThrowsException( - SutProvider sutProvider, + SutProvider sutProvider, [OrganizationUser(OrganizationUserStatusType.Invited, OrganizationUserType.User)] OrganizationUser organizationUser) { // Arrange @@ -134,7 +134,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteUserAsync_WhenCustomUserDeletesAdmin_ThrowsException( - SutProvider sutProvider, User user, + SutProvider sutProvider, User user, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Admin)] OrganizationUser organizationUser, Guid deletingUserId) { @@ -166,7 +166,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteUserAsync_DeletingOwnerWhenNotOwner_ThrowsException( - SutProvider sutProvider, User user, + SutProvider sutProvider, User user, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser organizationUser, Guid deletingUserId) { @@ -198,7 +198,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteUserAsync_DeletingLastConfirmedOwner_ThrowsException( - SutProvider sutProvider, User user, + SutProvider sutProvider, User user, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser organizationUser, Guid deletingUserId) { @@ -237,7 +237,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteUserAsync_WithUserNotManaged_ThrowsException( - SutProvider sutProvider, User user, + SutProvider sutProvider, User user, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) { // Arrange @@ -250,8 +250,8 @@ public class DeleteManagedOrganizationUserAccountCommandTests sutProvider.GetDependency().GetByIdAsync(user.Id) .Returns(user); - sutProvider.GetDependency() - .GetUsersOrganizationManagementStatusAsync(organizationUser.OrganizationId, Arg.Any>()) + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync(organizationUser.OrganizationId, Arg.Any>()) .Returns(new Dictionary { { organizationUser.Id, false } }); // Act @@ -259,7 +259,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, null)); // Assert - Assert.Equal("Member is not managed by the organization.", exception.Message); + Assert.Equal("Member is not claimed by the organization.", exception.Message); await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); await sutProvider.GetDependency().Received(0) .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); @@ -268,7 +268,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_WithValidUsers_DeletesUsersAndLogsEvents( - SutProvider sutProvider, User user1, User user2, Guid organizationId, + SutProvider sutProvider, User user1, User user2, Guid organizationId, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser1, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser2) { @@ -285,8 +285,8 @@ public class DeleteManagedOrganizationUserAccountCommandTests .GetManyAsync(Arg.Is>(ids => ids.Contains(user1.Id) && ids.Contains(user2.Id))) .Returns(new[] { user1, user2 }); - sutProvider.GetDependency() - .GetUsersOrganizationManagementStatusAsync(organizationId, Arg.Any>()) + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync(organizationId, Arg.Any>()) .Returns(new Dictionary { { orgUser1.Id, true }, { orgUser2.Id, true } }); // Act @@ -308,7 +308,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_WhenUserNotFound_ReturnsErrorMessage( - SutProvider sutProvider, + SutProvider sutProvider, Guid organizationId, Guid orgUserId) { @@ -329,7 +329,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_WhenDeletingYourself_ReturnsErrorMessage( - SutProvider sutProvider, + SutProvider sutProvider, User user, [OrganizationUser] OrganizationUser orgUser, Guid deletingUserId) { // Arrange @@ -358,7 +358,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_WhenUserIsInvited_ReturnsErrorMessage( - SutProvider sutProvider, + SutProvider sutProvider, [OrganizationUser(OrganizationUserStatusType.Invited, OrganizationUserType.User)] OrganizationUser orgUser) { // Arrange @@ -383,7 +383,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_WhenDeletingOwnerAsNonOwner_ReturnsErrorMessage( - SutProvider sutProvider, User user, + SutProvider sutProvider, User user, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser orgUser, Guid deletingUserId) { @@ -415,7 +415,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_WhenDeletingLastOwner_ReturnsErrorMessage( - SutProvider sutProvider, User user, + SutProvider sutProvider, User user, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser orgUser, Guid deletingUserId) { @@ -453,7 +453,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_WhenUserNotManaged_ReturnsErrorMessage( - SutProvider sutProvider, User user, + SutProvider sutProvider, User user, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser) { // Arrange @@ -467,8 +467,8 @@ public class DeleteManagedOrganizationUserAccountCommandTests .GetManyAsync(Arg.Is>(ids => ids.Contains(orgUser.UserId.Value))) .Returns(new[] { user }); - sutProvider.GetDependency() - .GetUsersOrganizationManagementStatusAsync(Arg.Any(), Arg.Any>()) + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync(Arg.Any(), Arg.Any>()) .Returns(new Dictionary { { orgUser.Id, false } }); // Act @@ -477,7 +477,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests // Assert Assert.Single(result); Assert.Equal(orgUser.Id, result.First().Item1); - Assert.Contains("Member is not managed by the organization.", result.First().Item2); + Assert.Contains("Member is not claimed by the organization.", result.First().Item2); await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); await sutProvider.GetDependency().Received(0) .LogOrganizationUserEventsAsync(Arg.Any>()); @@ -486,7 +486,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_MixedValidAndInvalidUsers_ReturnsAppropriateResults( - SutProvider sutProvider, User user1, User user3, + SutProvider sutProvider, User user1, User user3, Guid organizationId, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser1, [OrganizationUser(OrganizationUserStatusType.Invited, OrganizationUserType.User)] OrganizationUser orgUser2, @@ -506,8 +506,8 @@ public class DeleteManagedOrganizationUserAccountCommandTests .GetManyAsync(Arg.Is>(ids => ids.Contains(user1.Id) && ids.Contains(user3.Id))) .Returns(new[] { user1, user3 }); - sutProvider.GetDependency() - .GetUsersOrganizationManagementStatusAsync(organizationId, Arg.Any>()) + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync(organizationId, Arg.Any>()) .Returns(new Dictionary { { orgUser1.Id, true }, { orgUser3.Id, false } }); // Act @@ -517,7 +517,7 @@ public class DeleteManagedOrganizationUserAccountCommandTests Assert.Equal(3, results.Count()); Assert.Empty(results.First(r => r.Item1 == orgUser1.Id).Item2); Assert.Equal("You cannot delete a member with Invited status.", results.First(r => r.Item1 == orgUser2.Id).Item2); - Assert.Equal("Member is not managed by the organization.", results.First(r => r.Item1 == orgUser3.Id).Item2); + Assert.Equal("Member is not claimed by the organization.", results.First(r => r.Item1 == orgUser3.Id).Item2); await sutProvider.GetDependency().Received(1).LogOrganizationUserEventsAsync( Arg.Is>(events => diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersManagementStatusQueryTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersClaimedStatusQueryTests.cs similarity index 80% rename from test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersManagementStatusQueryTests.cs rename to test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersClaimedStatusQueryTests.cs index dda9867fd2..fd6d827791 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersManagementStatusQueryTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersClaimedStatusQueryTests.cs @@ -12,14 +12,14 @@ using Xunit; namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers; [SutProviderCustomize] -public class GetOrganizationUsersManagementStatusQueryTests +public class GetOrganizationUsersClaimedStatusQueryTests { [Theory, BitAutoData] public async Task GetUsersOrganizationManagementStatusAsync_WithNoUsers_ReturnsEmpty( Organization organization, - SutProvider sutProvider) + SutProvider sutProvider) { - var result = await sutProvider.Sut.GetUsersOrganizationManagementStatusAsync(organization.Id, new List()); + var result = await sutProvider.Sut.GetUsersOrganizationClaimedStatusAsync(organization.Id, new List()); Assert.Empty(result); } @@ -28,7 +28,7 @@ public class GetOrganizationUsersManagementStatusQueryTests public async Task GetUsersOrganizationManagementStatusAsync_WithUseSsoEnabled_Success( Organization organization, ICollection usersWithClaimedDomain, - SutProvider sutProvider) + SutProvider sutProvider) { organization.Enabled = true; organization.UseSso = true; @@ -44,7 +44,7 @@ public class GetOrganizationUsersManagementStatusQueryTests .GetManyByOrganizationWithClaimedDomainsAsync(organization.Id) .Returns(usersWithClaimedDomain); - var result = await sutProvider.Sut.GetUsersOrganizationManagementStatusAsync(organization.Id, userIdsToCheck); + var result = await sutProvider.Sut.GetUsersOrganizationClaimedStatusAsync(organization.Id, userIdsToCheck); Assert.All(usersWithClaimedDomain, ou => Assert.True(result[ou.Id])); Assert.False(result[userIdWithoutClaimedDomain]); @@ -54,7 +54,7 @@ public class GetOrganizationUsersManagementStatusQueryTests public async Task GetUsersOrganizationManagementStatusAsync_WithUseSsoDisabled_ReturnsAllFalse( Organization organization, ICollection usersWithClaimedDomain, - SutProvider sutProvider) + SutProvider sutProvider) { organization.Enabled = true; organization.UseSso = false; @@ -70,7 +70,7 @@ public class GetOrganizationUsersManagementStatusQueryTests .GetManyByOrganizationWithClaimedDomainsAsync(organization.Id) .Returns(usersWithClaimedDomain); - var result = await sutProvider.Sut.GetUsersOrganizationManagementStatusAsync(organization.Id, userIdsToCheck); + var result = await sutProvider.Sut.GetUsersOrganizationClaimedStatusAsync(organization.Id, userIdsToCheck); Assert.All(result, r => Assert.False(r.Value)); } @@ -79,7 +79,7 @@ public class GetOrganizationUsersManagementStatusQueryTests public async Task GetUsersOrganizationManagementStatusAsync_WithDisabledOrganization_ReturnsAllFalse( Organization organization, ICollection usersWithClaimedDomain, - SutProvider sutProvider) + SutProvider sutProvider) { organization.Enabled = false; @@ -94,7 +94,7 @@ public class GetOrganizationUsersManagementStatusQueryTests .GetManyByOrganizationWithClaimedDomainsAsync(organization.Id) .Returns(usersWithClaimedDomain); - var result = await sutProvider.Sut.GetUsersOrganizationManagementStatusAsync(organization.Id, userIdsToCheck); + var result = await sutProvider.Sut.GetUsersOrganizationClaimedStatusAsync(organization.Id, userIdsToCheck); Assert.All(result, r => Assert.False(r.Value)); } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommandTests.cs index a60850c5a9..3578706e47 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommandTests.cs @@ -41,9 +41,9 @@ public class RemoveOrganizationUserCommandTests await sutProvider.Sut.RemoveUserAsync(deletingUser.OrganizationId, organizationUser.Id, deletingUser.UserId); // Assert - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .GetUsersOrganizationManagementStatusAsync(default, default); + .GetUsersOrganizationClaimedStatusAsync(default, default); await sutProvider.GetDependency() .Received(1) .DeleteAsync(organizationUser); @@ -78,9 +78,9 @@ public class RemoveOrganizationUserCommandTests await sutProvider.Sut.RemoveUserAsync(deletingUser.OrganizationId, organizationUser.Id, deletingUser.UserId); // Assert - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .GetUsersOrganizationManagementStatusAsync( + .GetUsersOrganizationClaimedStatusAsync( organizationUser.OrganizationId, Arg.Is>(i => i.Contains(organizationUser.Id))); await sutProvider.GetDependency() @@ -247,17 +247,17 @@ public class RemoveOrganizationUserCommandTests sutProvider.GetDependency() .GetByIdAsync(orgUser.Id) .Returns(orgUser); - sutProvider.GetDependency() - .GetUsersOrganizationManagementStatusAsync(orgUser.OrganizationId, Arg.Is>(i => i.Contains(orgUser.Id))) + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync(orgUser.OrganizationId, Arg.Is>(i => i.Contains(orgUser.Id))) .Returns(new Dictionary { { orgUser.Id, true } }); // Act & Assert var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.RemoveUserAsync(orgUser.OrganizationId, orgUser.Id, deletingUserId)); Assert.Contains(RemoveOrganizationUserCommand.RemoveClaimedAccountErrorMessage, exception.Message); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .GetUsersOrganizationManagementStatusAsync(orgUser.OrganizationId, Arg.Is>(i => i.Contains(orgUser.Id))); + .GetUsersOrganizationClaimedStatusAsync(orgUser.OrganizationId, Arg.Is>(i => i.Contains(orgUser.Id))); } [Theory, BitAutoData] @@ -274,9 +274,9 @@ public class RemoveOrganizationUserCommandTests await sutProvider.Sut.RemoveUserAsync(organizationUser.OrganizationId, organizationUser.Id, eventSystemUser); // Assert - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .GetUsersOrganizationManagementStatusAsync(default, default); + .GetUsersOrganizationClaimedStatusAsync(default, default); await sutProvider.GetDependency() .Received(1) .DeleteAsync(organizationUser); @@ -302,9 +302,9 @@ public class RemoveOrganizationUserCommandTests await sutProvider.Sut.RemoveUserAsync(organizationUser.OrganizationId, organizationUser.Id, eventSystemUser); // Assert - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .GetUsersOrganizationManagementStatusAsync(default, default); + .GetUsersOrganizationClaimedStatusAsync(default, default); await sutProvider.GetDependency() .Received(1) .DeleteAsync(organizationUser); @@ -490,8 +490,8 @@ public class RemoveOrganizationUserCommandTests sutProvider.GetDependency() .OrganizationOwner(deletingUser.OrganizationId) .Returns(true); - sutProvider.GetDependency() - .GetUsersOrganizationManagementStatusAsync( + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync( deletingUser.OrganizationId, Arg.Is>(i => i.Contains(orgUser1.Id) && i.Contains(orgUser2.Id))) .Returns(new Dictionary { { orgUser1.Id, false }, { orgUser2.Id, false } }); @@ -502,9 +502,9 @@ public class RemoveOrganizationUserCommandTests // Assert Assert.Equal(2, result.Count()); Assert.All(result, r => Assert.Empty(r.ErrorMessage)); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .GetUsersOrganizationManagementStatusAsync(default, default); + .GetUsersOrganizationClaimedStatusAsync(default, default); await sutProvider.GetDependency() .Received(1) .DeleteManyAsync(Arg.Is>(i => i.Contains(orgUser1.Id) && i.Contains(orgUser2.Id))); @@ -544,8 +544,8 @@ public class RemoveOrganizationUserCommandTests sutProvider.GetDependency() .OrganizationOwner(deletingUser.OrganizationId) .Returns(true); - sutProvider.GetDependency() - .GetUsersOrganizationManagementStatusAsync( + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync( deletingUser.OrganizationId, Arg.Is>(i => i.Contains(orgUser1.Id) && i.Contains(orgUser2.Id))) .Returns(new Dictionary { { orgUser1.Id, false }, { orgUser2.Id, false } }); @@ -556,9 +556,9 @@ public class RemoveOrganizationUserCommandTests // Assert Assert.Equal(2, result.Count()); Assert.All(result, r => Assert.Empty(r.ErrorMessage)); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .GetUsersOrganizationManagementStatusAsync( + .GetUsersOrganizationClaimedStatusAsync( deletingUser.OrganizationId, Arg.Is>(i => i.Contains(orgUser1.Id) && i.Contains(orgUser2.Id))); await sutProvider.GetDependency() @@ -638,7 +638,7 @@ public class RemoveOrganizationUserCommandTests } [Theory, BitAutoData] - public async Task RemoveUsers_WithDeletingUserId_RemovingManagedUser_WithAccountDeprovisioningEnabled_ThrowsException( + public async Task RemoveUsers_WithDeletingUserId_RemovingClaimedUser_WithAccountDeprovisioningEnabled_ThrowsException( [OrganizationUser(status: OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser, OrganizationUser deletingUser, SutProvider sutProvider) @@ -658,8 +658,8 @@ public class RemoveOrganizationUserCommandTests .HasConfirmedOwnersExceptAsync(orgUser.OrganizationId, Arg.Any>()) .Returns(true); - sutProvider.GetDependency() - .GetUsersOrganizationManagementStatusAsync(orgUser.OrganizationId, Arg.Is>(i => i.Contains(orgUser.Id))) + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync(orgUser.OrganizationId, Arg.Is>(i => i.Contains(orgUser.Id))) .Returns(new Dictionary { { orgUser.Id, true } }); // Act @@ -723,9 +723,9 @@ public class RemoveOrganizationUserCommandTests // Assert Assert.Equal(2, result.Count()); Assert.All(result, r => Assert.Empty(r.ErrorMessage)); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .GetUsersOrganizationManagementStatusAsync(default, default); + .GetUsersOrganizationClaimedStatusAsync(default, default); await sutProvider.GetDependency() .Received(1) .DeleteManyAsync(Arg.Is>(i => i.Contains(orgUser1.Id) && i.Contains(orgUser2.Id))); @@ -768,9 +768,9 @@ public class RemoveOrganizationUserCommandTests // Assert Assert.Equal(2, result.Count()); Assert.All(result, r => Assert.Empty(r.ErrorMessage)); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .GetUsersOrganizationManagementStatusAsync(default, default); + .GetUsersOrganizationClaimedStatusAsync(default, default); await sutProvider.GetDependency() .Received(1) .DeleteManyAsync(Arg.Is>(i => i.Contains(orgUser1.Id) && i.Contains(orgUser2.Id))); diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs index 3158c1595c..02ff24d9bf 100644 --- a/test/Core.Test/Services/UserServiceTests.cs +++ b/test/Core.Test/Services/UserServiceTests.cs @@ -341,19 +341,19 @@ public class UserServiceTests } [Theory, BitAutoData] - public async Task IsManagedByAnyOrganizationAsync_WithAccountDeprovisioningDisabled_ReturnsFalse( + public async Task IsClaimedByAnyOrganizationAsync_WithAccountDeprovisioningDisabled_ReturnsFalse( SutProvider sutProvider, Guid userId) { sutProvider.GetDependency() .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) .Returns(false); - var result = await sutProvider.Sut.IsManagedByAnyOrganizationAsync(userId); + var result = await sutProvider.Sut.IsClaimedByAnyOrganizationAsync(userId); Assert.False(result); } [Theory, BitAutoData] - public async Task IsManagedByAnyOrganizationAsync_WithAccountDeprovisioningEnabled_WithManagingEnabledOrganization_ReturnsTrue( + public async Task IsClaimedByAnyOrganizationAsync_WithAccountDeprovisioningEnabled_WithManagingEnabledOrganization_ReturnsTrue( SutProvider sutProvider, Guid userId, Organization organization) { organization.Enabled = true; @@ -367,12 +367,12 @@ public class UserServiceTests .GetByVerifiedUserEmailDomainAsync(userId) .Returns(new[] { organization }); - var result = await sutProvider.Sut.IsManagedByAnyOrganizationAsync(userId); + var result = await sutProvider.Sut.IsClaimedByAnyOrganizationAsync(userId); Assert.True(result); } [Theory, BitAutoData] - public async Task IsManagedByAnyOrganizationAsync_WithAccountDeprovisioningEnabled_WithManagingDisabledOrganization_ReturnsFalse( + public async Task IsClaimedByAnyOrganizationAsync_WithAccountDeprovisioningEnabled_WithManagingDisabledOrganization_ReturnsFalse( SutProvider sutProvider, Guid userId, Organization organization) { organization.Enabled = false; @@ -386,12 +386,12 @@ public class UserServiceTests .GetByVerifiedUserEmailDomainAsync(userId) .Returns(new[] { organization }); - var result = await sutProvider.Sut.IsManagedByAnyOrganizationAsync(userId); + var result = await sutProvider.Sut.IsClaimedByAnyOrganizationAsync(userId); Assert.False(result); } [Theory, BitAutoData] - public async Task IsManagedByAnyOrganizationAsync_WithAccountDeprovisioningEnabled_WithOrganizationUseSsoFalse_ReturnsFalse( + public async Task IsClaimedByAnyOrganizationAsync_WithAccountDeprovisioningEnabled_WithOrganizationUseSsoFalse_ReturnsFalse( SutProvider sutProvider, Guid userId, Organization organization) { organization.Enabled = true; @@ -405,7 +405,7 @@ public class UserServiceTests .GetByVerifiedUserEmailDomainAsync(userId) .Returns(new[] { organization }); - var result = await sutProvider.Sut.IsManagedByAnyOrganizationAsync(userId); + var result = await sutProvider.Sut.IsClaimedByAnyOrganizationAsync(userId); Assert.False(result); } From 8d4c3d83b2d6e21cf8c2c3fb4b2a26b38fbe6e82 Mon Sep 17 00:00:00 2001 From: Jonas Hendrickx Date: Tue, 8 Apr 2025 21:54:52 +0200 Subject: [PATCH 11/28] Not updating automatic tax flag correctly when removing VAT number (#5608) --- .../Implementations/SubscriberService.cs | 70 +++++++++---------- 1 file changed, 34 insertions(+), 36 deletions(-) diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index e4b0594433..1b0e5b665b 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -622,47 +622,45 @@ public class SubscriberService( await stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id); } - if (string.IsNullOrWhiteSpace(taxInformation.TaxId)) + if (!string.IsNullOrWhiteSpace(taxInformation.TaxId)) { - return; - } - - var taxIdType = taxInformation.TaxIdType; - if (string.IsNullOrWhiteSpace(taxIdType)) - { - taxIdType = taxService.GetStripeTaxCode(taxInformation.Country, - taxInformation.TaxId); - - if (taxIdType == null) + var taxIdType = taxInformation.TaxIdType; + if (string.IsNullOrWhiteSpace(taxIdType)) { - logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.", - taxInformation.Country, + taxIdType = taxService.GetStripeTaxCode(taxInformation.Country, taxInformation.TaxId); - throw new Exceptions.BadRequestException("billingTaxIdTypeInferenceError"); - } - } - try - { - await stripeAdapter.TaxIdCreateAsync(customer.Id, - new TaxIdCreateOptions { Type = taxIdType, Value = taxInformation.TaxId }); - } - catch (StripeException e) - { - switch (e.StripeError.Code) - { - case StripeConstants.ErrorCodes.TaxIdInvalid: - logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", - taxInformation.TaxId, - taxInformation.Country); - throw new Exceptions.BadRequestException("billingInvalidTaxIdError"); - default: - logger.LogError(e, - "Error creating tax ID '{TaxId}' in country '{Country}' for customer '{CustomerID}'.", - taxInformation.TaxId, + if (taxIdType == null) + { + logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.", taxInformation.Country, - customer.Id); - throw new Exceptions.BadRequestException("billingTaxIdCreationError"); + taxInformation.TaxId); + throw new Exceptions.BadRequestException("billingTaxIdTypeInferenceError"); + } + } + + try + { + await stripeAdapter.TaxIdCreateAsync(customer.Id, + new TaxIdCreateOptions { Type = taxIdType, Value = taxInformation.TaxId }); + } + catch (StripeException e) + { + switch (e.StripeError.Code) + { + case StripeConstants.ErrorCodes.TaxIdInvalid: + logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", + taxInformation.TaxId, + taxInformation.Country); + throw new Exceptions.BadRequestException("billingInvalidTaxIdError"); + default: + logger.LogError(e, + "Error creating tax ID '{TaxId}' in country '{Country}' for customer '{CustomerID}'.", + taxInformation.TaxId, + taxInformation.Country, + customer.Id); + throw new Exceptions.BadRequestException("billingTaxIdCreationError"); + } } } From f8e89f174776a1212d48f994feb8f98f0dbe09fa Mon Sep 17 00:00:00 2001 From: Jonas Hendrickx Date: Wed, 9 Apr 2025 07:53:43 +0200 Subject: [PATCH 12/28] [PM-18170] Remove PM-15814-alert-owners-of-reseller-managed-orgs (#5412) --- .../Implementations/SubscriptionUpdatedHandler.cs | 10 ---------- src/Core/Constants.cs | 1 - .../Services/SubscriptionUpdatedHandlerTests.cs | 7 ------- 3 files changed, 18 deletions(-) diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index d2ca7fa9bf..fe5021c827 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -1,6 +1,5 @@ using Bit.Billing.Constants; using Bit.Billing.Jobs; -using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.Billing.Pricing; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; @@ -24,7 +23,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler private readonly IPushNotificationService _pushNotificationService; private readonly IOrganizationRepository _organizationRepository; private readonly ISchedulerFactory _schedulerFactory; - private readonly IFeatureService _featureService; private readonly IOrganizationEnableCommand _organizationEnableCommand; private readonly IOrganizationDisableCommand _organizationDisableCommand; private readonly IPricingClient _pricingClient; @@ -39,7 +37,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler IPushNotificationService pushNotificationService, IOrganizationRepository organizationRepository, ISchedulerFactory schedulerFactory, - IFeatureService featureService, IOrganizationEnableCommand organizationEnableCommand, IOrganizationDisableCommand organizationDisableCommand, IPricingClient pricingClient) @@ -53,7 +50,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler _pushNotificationService = pushNotificationService; _organizationRepository = organizationRepository; _schedulerFactory = schedulerFactory; - _featureService = featureService; _organizationEnableCommand = organizationEnableCommand; _organizationDisableCommand = organizationDisableCommand; _pricingClient = pricingClient; @@ -227,12 +223,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler private async Task ScheduleCancellationJobAsync(string subscriptionId, Guid organizationId) { - var isResellerManagedOrgAlertEnabled = _featureService.IsEnabled(FeatureFlagKeys.ResellerManagedOrgAlert); - if (!isResellerManagedOrgAlertEnabled) - { - return; - } - var scheduler = await _schedulerFactory.GetScheduler(); var job = JobBuilder.Create() diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 0eaa6cd85f..328be72251 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -141,7 +141,6 @@ public static class FeatureFlagKeys /* Billing Team */ public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email"; public const string TrialPayment = "PM-8163-trial-payment"; - public const string ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs"; public const string UsePricingService = "use-pricing-service"; public const string P15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal"; public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features"; diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs index a6ac7e9512..9c58bbdbf7 100644 --- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -1,7 +1,6 @@ using Bit.Billing.Constants; using Bit.Billing.Services; using Bit.Billing.Services.Implementations; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.Billing.Enums; @@ -31,7 +30,6 @@ public class SubscriptionUpdatedHandlerTests private readonly IPushNotificationService _pushNotificationService; private readonly IOrganizationRepository _organizationRepository; private readonly ISchedulerFactory _schedulerFactory; - private readonly IFeatureService _featureService; private readonly IOrganizationEnableCommand _organizationEnableCommand; private readonly IOrganizationDisableCommand _organizationDisableCommand; private readonly IPricingClient _pricingClient; @@ -49,7 +47,6 @@ public class SubscriptionUpdatedHandlerTests _pushNotificationService = Substitute.For(); _organizationRepository = Substitute.For(); _schedulerFactory = Substitute.For(); - _featureService = Substitute.For(); _organizationEnableCommand = Substitute.For(); _organizationDisableCommand = Substitute.For(); _pricingClient = Substitute.For(); @@ -67,7 +64,6 @@ public class SubscriptionUpdatedHandlerTests _pushNotificationService, _organizationRepository, _schedulerFactory, - _featureService, _organizationEnableCommand, _organizationDisableCommand, _pricingClient); @@ -97,9 +93,6 @@ public class SubscriptionUpdatedHandlerTests _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) .Returns(Tuple.Create(organizationId, null, null)); - _featureService.IsEnabled(FeatureFlagKeys.ResellerManagedOrgAlert) - .Returns(true); - // Act await _sut.HandleAsync(parsedEvent); From 19b54311779aea83b7c46158cc41e45915a0c198 Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Wed, 9 Apr 2025 09:14:57 +0200 Subject: [PATCH 13/28] [PM-18040] Add new feature flag (#5498) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 328be72251..42d5d06450 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -176,6 +176,7 @@ public static class FeatureFlagKeys public const string StorageReseedRefactor = "storage-reseed-refactor"; public const string WebPush = "web-push"; public const string RecordInstallationLastActivityDate = "installation-last-activity-date"; + public const string IpcChannelFramework = "ipc-channel-framework"; /* Tools Team */ public const string ItemShare = "item-share"; From 0a4f97b50ebf48d0901b6b59c596cfb4e832d72c Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 9 Apr 2025 14:26:06 +0200 Subject: [PATCH 14/28] [PM-19883] Add untrust devices endpoint (#5619) * Add untrust devices endpoint * Fix tests * Update src/Core/Auth/UserFeatures/DeviceTrust/UntrustDevicesCommand.cs Co-authored-by: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> * Fix whitespace --------- Co-authored-by: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> --- .../Models/Request/UntrustDevicesModel.cs | 11 ++++ src/Api/Controllers/DevicesController.cs | 17 ++++++ .../Interfaces/IUntrustDevicesCommand.cs | 8 +++ .../DeviceTrust/UntrustDevicesCommand.cs | 39 +++++++++++++ .../UserServiceCollectionExtensions.cs | 7 +++ .../Controllers/DevicesControllerTests.cs | 4 ++ .../DeviceTrust/UntrustDevicesCommandTests.cs | 55 +++++++++++++++++++ 7 files changed, 141 insertions(+) create mode 100644 src/Api/Auth/Models/Request/UntrustDevicesModel.cs create mode 100644 src/Core/Auth/UserFeatures/DeviceTrust/Interfaces/IUntrustDevicesCommand.cs create mode 100644 src/Core/Auth/UserFeatures/DeviceTrust/UntrustDevicesCommand.cs create mode 100644 test/Core.Test/Auth/UserFeatures/DeviceTrust/UntrustDevicesCommandTests.cs diff --git a/src/Api/Auth/Models/Request/UntrustDevicesModel.cs b/src/Api/Auth/Models/Request/UntrustDevicesModel.cs new file mode 100644 index 0000000000..ca4f0ad2e7 --- /dev/null +++ b/src/Api/Auth/Models/Request/UntrustDevicesModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +#nullable enable + +namespace Bit.Api.Auth.Models.Request; + +public class UntrustDevicesRequestModel +{ + [Required] + public IEnumerable Devices { get; set; } = null!; +} diff --git a/src/Api/Controllers/DevicesController.cs b/src/Api/Controllers/DevicesController.cs index 4e21b5e9dc..6851aed5de 100644 --- a/src/Api/Controllers/DevicesController.cs +++ b/src/Api/Controllers/DevicesController.cs @@ -4,6 +4,7 @@ using Bit.Api.Models.Request; using Bit.Api.Models.Response; using Bit.Core.Auth.Models.Api.Request; using Bit.Core.Auth.Models.Api.Response; +using Bit.Core.Auth.UserFeatures.DeviceTrust; using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Repositories; @@ -21,6 +22,7 @@ public class DevicesController : Controller private readonly IDeviceRepository _deviceRepository; private readonly IDeviceService _deviceService; private readonly IUserService _userService; + private readonly IUntrustDevicesCommand _untrustDevicesCommand; private readonly IUserRepository _userRepository; private readonly ICurrentContext _currentContext; private readonly ILogger _logger; @@ -29,6 +31,7 @@ public class DevicesController : Controller IDeviceRepository deviceRepository, IDeviceService deviceService, IUserService userService, + IUntrustDevicesCommand untrustDevicesCommand, IUserRepository userRepository, ICurrentContext currentContext, ILogger logger) @@ -36,6 +39,7 @@ public class DevicesController : Controller _deviceRepository = deviceRepository; _deviceService = deviceService; _userService = userService; + _untrustDevicesCommand = untrustDevicesCommand; _userRepository = userRepository; _currentContext = currentContext; _logger = logger; @@ -165,6 +169,19 @@ public class DevicesController : Controller model.OtherDevices ?? Enumerable.Empty()); } + [HttpPost("untrust")] + public async Task PostUntrust([FromBody] UntrustDevicesRequestModel model) + { + var user = await _userService.GetUserByPrincipalAsync(User); + + if (user == null) + { + throw new UnauthorizedAccessException(); + } + + await _untrustDevicesCommand.UntrustDevices(user, model.Devices); + } + [HttpPut("identifier/{identifier}/token")] [HttpPost("identifier/{identifier}/token")] public async Task PutToken(string identifier, [FromBody] DeviceTokenRequestModel model) diff --git a/src/Core/Auth/UserFeatures/DeviceTrust/Interfaces/IUntrustDevicesCommand.cs b/src/Core/Auth/UserFeatures/DeviceTrust/Interfaces/IUntrustDevicesCommand.cs new file mode 100644 index 0000000000..860490ce1a --- /dev/null +++ b/src/Core/Auth/UserFeatures/DeviceTrust/Interfaces/IUntrustDevicesCommand.cs @@ -0,0 +1,8 @@ +using Bit.Core.Entities; + +namespace Bit.Core.Auth.UserFeatures.DeviceTrust; + +public interface IUntrustDevicesCommand +{ + public Task UntrustDevices(User user, IEnumerable devicesToUntrust); +} diff --git a/src/Core/Auth/UserFeatures/DeviceTrust/UntrustDevicesCommand.cs b/src/Core/Auth/UserFeatures/DeviceTrust/UntrustDevicesCommand.cs new file mode 100644 index 0000000000..1f6f49753a --- /dev/null +++ b/src/Core/Auth/UserFeatures/DeviceTrust/UntrustDevicesCommand.cs @@ -0,0 +1,39 @@ +using Bit.Core.Entities; +using Bit.Core.Repositories; + +namespace Bit.Core.Auth.UserFeatures.DeviceTrust; + +public class UntrustDevicesCommand : IUntrustDevicesCommand +{ + private readonly IDeviceRepository _deviceRepository; + + public UntrustDevicesCommand( + IDeviceRepository deviceRepository) + { + _deviceRepository = deviceRepository; + } + + public async Task UntrustDevices(User user, IEnumerable devicesToUntrust) + { + var userDevices = await _deviceRepository.GetManyByUserIdAsync(user.Id); + var deviceIdDict = userDevices.ToDictionary(device => device.Id); + + // Validate that the user owns all devices that they passed in + foreach (var deviceId in devicesToUntrust) + { + if (!deviceIdDict.ContainsKey(deviceId)) + { + throw new UnauthorizedAccessException($"User {user.Id} does not have access to device {deviceId}"); + } + } + + foreach (var deviceId in devicesToUntrust) + { + var device = deviceIdDict[deviceId]; + device.EncryptedPrivateKey = null; + device.EncryptedPublicKey = null; + device.EncryptedUserKey = null; + await _deviceRepository.UpsertAsync(device); + } + } +} diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs index 16a0ef9805..7731e04af2 100644 --- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs +++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs @@ -1,5 +1,6 @@  +using Bit.Core.Auth.UserFeatures.DeviceTrust; using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Auth.UserFeatures.Registration.Implementations; using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; @@ -22,6 +23,7 @@ public static class UserServiceCollectionExtensions public static void AddUserServices(this IServiceCollection services, IGlobalSettings globalSettings) { services.AddScoped(); + services.AddDeviceTrustCommands(); services.AddUserPasswordCommands(); services.AddUserRegistrationCommands(); services.AddWebAuthnLoginCommands(); @@ -29,6 +31,11 @@ public static class UserServiceCollectionExtensions services.AddTwoFactorQueries(); } + public static void AddDeviceTrustCommands(this IServiceCollection services) + { + services.AddScoped(); + } + public static void AddUserKeyCommands(this IServiceCollection services, IGlobalSettings globalSettings) { services.AddScoped(); diff --git a/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs b/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs index 3dcf2016c4..74f00be866 100644 --- a/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs @@ -2,6 +2,7 @@ using Bit.Api.Models.Response; using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.UserFeatures.DeviceTrust; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -19,6 +20,7 @@ public class DevicesControllerTest private readonly IDeviceRepository _deviceRepositoryMock; private readonly IDeviceService _deviceServiceMock; private readonly IUserService _userServiceMock; + private readonly IUntrustDevicesCommand _untrustDevicesCommand; private readonly IUserRepository _userRepositoryMock; private readonly ICurrentContext _currentContextMock; private readonly IGlobalSettings _globalSettingsMock; @@ -30,6 +32,7 @@ public class DevicesControllerTest _deviceRepositoryMock = Substitute.For(); _deviceServiceMock = Substitute.For(); _userServiceMock = Substitute.For(); + _untrustDevicesCommand = Substitute.For(); _userRepositoryMock = Substitute.For(); _currentContextMock = Substitute.For(); _loggerMock = Substitute.For>(); @@ -38,6 +41,7 @@ public class DevicesControllerTest _deviceRepositoryMock, _deviceServiceMock, _userServiceMock, + _untrustDevicesCommand, _userRepositoryMock, _currentContextMock, _loggerMock); diff --git a/test/Core.Test/Auth/UserFeatures/DeviceTrust/UntrustDevicesCommandTests.cs b/test/Core.Test/Auth/UserFeatures/DeviceTrust/UntrustDevicesCommandTests.cs new file mode 100644 index 0000000000..c4714be63b --- /dev/null +++ b/test/Core.Test/Auth/UserFeatures/DeviceTrust/UntrustDevicesCommandTests.cs @@ -0,0 +1,55 @@ +using Bit.Core.Auth.UserFeatures.DeviceTrust; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Auth.UserFeatures.WebAuthnLogin; + +[SutProviderCustomize] +public class UntrustDevicesCommandTests +{ + [Theory, BitAutoData] + public async Task SetsKeysToNull(SutProvider sutProvider, User user) + { + var deviceId = Guid.NewGuid(); + // Arrange + sutProvider.GetDependency() + .GetManyByUserIdAsync(user.Id) + .Returns([new Device + { + Id = deviceId, + EncryptedPrivateKey = "encryptedPrivateKey", + EncryptedPublicKey = "encryptedPublicKey", + EncryptedUserKey = "encryptedUserKey" + }]); + + // Act + await sutProvider.Sut.UntrustDevices(user, new List { deviceId }); + + // Assert + await sutProvider.GetDependency() + .Received() + .UpsertAsync(Arg.Is(d => + d.Id == deviceId && + d.EncryptedPrivateKey == null && + d.EncryptedPublicKey == null && + d.EncryptedUserKey == null)); + } + + [Theory, BitAutoData] + public async Task RejectsWrongUser(SutProvider sutProvider, User user) + { + var deviceId = Guid.NewGuid(); + // Arrange + sutProvider.GetDependency() + .GetManyByUserIdAsync(user.Id) + .Returns([]); + + // Act + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UntrustDevices(user, new List { deviceId })); + } +} From f1a4829e5ecedbb4bbc8dfc31a741ce09ad31d5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Wed, 9 Apr 2025 15:23:29 +0100 Subject: [PATCH 15/28] [PM-12485] Create OrganizationUpdateKeys command (#5600) * Add OrganizationUpdateKeysCommand * Add unit tests for OrganizationUpdateKeysCommand to validate permission checks and key updates * Register OrganizationUpdateKeysCommand for dependency injection * Refactor OrganizationsController to use IOrganizationUpdateKeysCommand for updating organization keys * Remove outdated unit tests for UpdateOrganizationKeysAsync in OrganizationServiceTests * Remove UpdateOrganizationKeysAsync method from IOrganizationService and OrganizationService implementations * Add IOrganizationUpdateKeysCommand dependency mock to OrganizationsControllerTests --- .../Controllers/OrganizationsController.cs | 9 ++- .../IOrganizationUpdateKeysCommand.cs | 13 ++++ .../OrganizationUpdateKeysCommand.cs | 47 ++++++++++++ .../Services/IOrganizationService.cs | 1 - .../Implementations/OrganizationService.cs | 22 ------ ...OrganizationServiceCollectionExtensions.cs | 6 ++ .../OrganizationsControllerTests.cs | 5 +- .../OrganizationUpdateKeysCommandTests.cs | 75 +++++++++++++++++++ .../Services/OrganizationServiceTests.cs | 42 ----------- 9 files changed, 151 insertions(+), 69 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationUpdateKeysCommand.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateKeysCommand.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateKeysCommandTests.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 6b7d031a00..c856c8ab91 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -65,6 +65,7 @@ public class OrganizationsController : Controller private readonly IOrganizationDeleteCommand _organizationDeleteCommand; private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IPricingClient _pricingClient; + private readonly IOrganizationUpdateKeysCommand _organizationUpdateKeysCommand; public OrganizationsController( IOrganizationRepository organizationRepository, @@ -88,7 +89,8 @@ public class OrganizationsController : Controller ICloudOrganizationSignUpCommand cloudOrganizationSignUpCommand, IOrganizationDeleteCommand organizationDeleteCommand, IPolicyRequirementQuery policyRequirementQuery, - IPricingClient pricingClient) + IPricingClient pricingClient, + IOrganizationUpdateKeysCommand organizationUpdateKeysCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -112,6 +114,7 @@ public class OrganizationsController : Controller _organizationDeleteCommand = organizationDeleteCommand; _policyRequirementQuery = policyRequirementQuery; _pricingClient = pricingClient; + _organizationUpdateKeysCommand = organizationUpdateKeysCommand; } [HttpGet("{id}")] @@ -490,7 +493,7 @@ public class OrganizationsController : Controller } [HttpPost("{id}/keys")] - public async Task PostKeys(string id, [FromBody] OrganizationKeysRequestModel model) + public async Task PostKeys(Guid id, [FromBody] OrganizationKeysRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); if (user == null) @@ -498,7 +501,7 @@ public class OrganizationsController : Controller throw new UnauthorizedAccessException(); } - var org = await _organizationService.UpdateOrganizationKeysAsync(new Guid(id), model.PublicKey, + var org = await _organizationUpdateKeysCommand.UpdateOrganizationKeysAsync(id, model.PublicKey, model.EncryptedPrivateKey); return new OrganizationKeysResponseModel(org); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationUpdateKeysCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationUpdateKeysCommand.cs new file mode 100644 index 0000000000..2d01a5e4e3 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationUpdateKeysCommand.cs @@ -0,0 +1,13 @@ +using Bit.Core.AdminConsole.Entities; + +public interface IOrganizationUpdateKeysCommand +{ + /// + /// Update the keys for an organization. + /// + /// The ID of the organization to update. + /// The public key for the organization. + /// The private key for the organization. + /// The updated organization. + Task UpdateOrganizationKeysAsync(Guid orgId, string publicKey, string privateKey); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateKeysCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateKeysCommand.cs new file mode 100644 index 0000000000..aa85c7e2a4 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateKeysCommand.cs @@ -0,0 +1,47 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; + +public class OrganizationUpdateKeysCommand : IOrganizationUpdateKeysCommand +{ + private readonly ICurrentContext _currentContext; + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationService _organizationService; + + public const string OrganizationKeysAlreadyExistErrorMessage = "Organization Keys already exist."; + + public OrganizationUpdateKeysCommand( + ICurrentContext currentContext, + IOrganizationRepository organizationRepository, + IOrganizationService organizationService) + { + _currentContext = currentContext; + _organizationRepository = organizationRepository; + _organizationService = organizationService; + } + + public async Task UpdateOrganizationKeysAsync(Guid organizationId, string publicKey, string privateKey) + { + if (!await _currentContext.ManageResetPassword(organizationId)) + { + throw new UnauthorizedAccessException(); + } + + // If the keys already exist, error out + var organization = await _organizationRepository.GetByIdAsync(organizationId); + if (organization.PublicKey != null && organization.PrivateKey != null) + { + throw new BadRequestException(OrganizationKeysAlreadyExistErrorMessage); + } + + // Update org with generated public/private key + organization.PublicKey = publicKey; + organization.PrivateKey = privateKey; + + await _organizationService.UpdateAsync(organization); + + return organization; + } +} diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index e0088f1f74..8d2997bbc6 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -43,7 +43,6 @@ public interface IOrganizationService IEnumerable newUsers, IEnumerable removeUserExternalIds, bool overwriteExisting, EventSystemUser eventSystemUser); Task DeleteSsoUserAsync(Guid userId, Guid? organizationId); - Task UpdateOrganizationKeysAsync(Guid orgId, string publicKey, string privateKey); Task RevokeUserAsync(OrganizationUser organizationUser, Guid? revokingUserId); Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser); Task>> RevokeUsersAsync(Guid organizationId, diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index c9027b8030..c9b38b3e30 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -1418,28 +1418,6 @@ public class OrganizationService : IOrganizationService } } - public async Task UpdateOrganizationKeysAsync(Guid orgId, string publicKey, string privateKey) - { - if (!await _currentContext.ManageResetPassword(orgId)) - { - throw new UnauthorizedAccessException(); - } - - // If the keys already exist, error out - var org = await _organizationRepository.GetByIdAsync(orgId); - if (org.PublicKey != null && org.PrivateKey != null) - { - throw new BadRequestException("Organization Keys already exist"); - } - - // Update org with generated public/private key - org.PublicKey = publicKey; - org.PrivateKey = privateKey; - await UpdateAsync(org); - - return org; - } - private async Task UpdateUsersAsync(Group group, HashSet groupUsers, Dictionary existingUsersIdDict, HashSet existingUsers = null) { diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 96d9095c1a..164710d522 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -60,6 +60,7 @@ public static class OrganizationServiceCollectionExtensions services.AddOrganizationDomainCommandsQueries(); services.AddOrganizationSignUpCommands(); services.AddOrganizationDeleteCommands(); + services.AddOrganizationUpdateCommands(); services.AddOrganizationEnableCommands(); services.AddOrganizationDisableCommands(); services.AddOrganizationAuthCommands(); @@ -77,6 +78,11 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); } + private static void AddOrganizationUpdateCommands(this IServiceCollection services) + { + services.AddScoped(); + } + private static void AddOrganizationEnableCommands(this IServiceCollection services) => services.AddScoped(); diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 3c06c78392..867f8f8ec6 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -60,6 +60,7 @@ public class OrganizationsControllerTests : IDisposable private readonly IOrganizationDeleteCommand _organizationDeleteCommand; private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IPricingClient _pricingClient; + private readonly IOrganizationUpdateKeysCommand _organizationUpdateKeysCommand; private readonly OrganizationsController _sut; public OrganizationsControllerTests() @@ -86,6 +87,7 @@ public class OrganizationsControllerTests : IDisposable _organizationDeleteCommand = Substitute.For(); _policyRequirementQuery = Substitute.For(); _pricingClient = Substitute.For(); + _organizationUpdateKeysCommand = Substitute.For(); _sut = new OrganizationsController( _organizationRepository, @@ -109,7 +111,8 @@ public class OrganizationsControllerTests : IDisposable _cloudOrganizationSignUpCommand, _organizationDeleteCommand, _policyRequirementQuery, - _pricingClient); + _pricingClient, + _organizationUpdateKeysCommand); } public void Dispose() diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateKeysCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateKeysCommandTests.cs new file mode 100644 index 0000000000..91ab9214e1 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateKeysCommandTests.cs @@ -0,0 +1,75 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Organizations; + +[SutProviderCustomize] +public class OrganizationUpdateKeysCommandTests +{ + [Theory, BitAutoData] + public async Task UpdateOrganizationKeysAsync_WithoutManageResetPasswordPermission_ThrowsUnauthorizedException( + Guid orgId, string publicKey, string privateKey, SutProvider sutProvider) + { + sutProvider.GetDependency() + .ManageResetPassword(orgId) + .Returns(false); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateOrganizationKeysAsync(orgId, publicKey, privateKey)); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationKeysAsync_WhenKeysAlreadyExist_ThrowsBadRequestException( + Organization organization, string publicKey, string privateKey, + SutProvider sutProvider) + { + organization.PublicKey = "existingPublicKey"; + organization.PrivateKey = "existingPrivateKey"; + + sutProvider.GetDependency() + .ManageResetPassword(organization.Id) + .Returns(true); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateOrganizationKeysAsync(organization.Id, publicKey, privateKey)); + + Assert.Equal(OrganizationUpdateKeysCommand.OrganizationKeysAlreadyExistErrorMessage, exception.Message); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationKeysAsync_WhenKeysDoNotExist_UpdatesOrganization( + Organization organization, string publicKey, string privateKey, + SutProvider sutProvider) + { + organization.PublicKey = null; + organization.PrivateKey = null; + + sutProvider.GetDependency() + .ManageResetPassword(organization.Id) + .Returns(true); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + var result = await sutProvider.Sut.UpdateOrganizationKeysAsync(organization.Id, publicKey, privateKey); + + Assert.Equal(publicKey, result.PublicKey); + Assert.Equal(privateKey, result.PrivateKey); + + await sutProvider.GetDependency() + .Received(1) + .UpdateAsync(organization); + } +} diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index e45643435d..c138cfac2e 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -814,48 +814,6 @@ public class OrganizationServiceTests sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); } - [Theory, BitAutoData] - public async Task UpdateOrganizationKeysAsync_WithoutManageResetPassword_Throws(Guid orgId, string publicKey, - string privateKey, SutProvider sutProvider) - { - var currentContext = Substitute.For(); - currentContext.ManageResetPassword(orgId).Returns(false); - - await Assert.ThrowsAsync( - () => sutProvider.Sut.UpdateOrganizationKeysAsync(orgId, publicKey, privateKey)); - } - - [Theory, BitAutoData] - public async Task UpdateOrganizationKeysAsync_KeysAlreadySet_Throws(Organization org, string publicKey, - string privateKey, SutProvider sutProvider) - { - var currentContext = sutProvider.GetDependency(); - currentContext.ManageResetPassword(org.Id).Returns(true); - - var organizationRepository = sutProvider.GetDependency(); - organizationRepository.GetByIdAsync(org.Id).Returns(org); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.UpdateOrganizationKeysAsync(org.Id, publicKey, privateKey)); - Assert.Contains("Organization Keys already exist", exception.Message); - } - - [Theory, BitAutoData] - public async Task UpdateOrganizationKeysAsync_KeysAlreadySet_Success(Organization org, string publicKey, - string privateKey, SutProvider sutProvider) - { - org.PublicKey = null; - org.PrivateKey = null; - - var currentContext = sutProvider.GetDependency(); - currentContext.ManageResetPassword(org.Id).Returns(true); - - var organizationRepository = sutProvider.GetDependency(); - organizationRepository.GetByIdAsync(org.Id).Returns(org); - - await sutProvider.Sut.UpdateOrganizationKeysAsync(org.Id, publicKey, privateKey); - } - [Theory] [PaidOrganizationCustomize(CheckedPlanType = PlanType.EnterpriseAnnually)] [BitAutoData("Cannot set max seat autoscaling below seat count", 1, 0, 2, 2)] From 4b6eac3a4604d282863fc900e2976ebc584c3526 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Wed, 9 Apr 2025 15:33:21 +0100 Subject: [PATCH 16/28] [PM-16091] Add SsoExternalId to OrganizationUserDetailsResponseModel (#5606) --- .../Response/Organizations/OrganizationUserResponseModel.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs index f9e5193045..4e869f59b1 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs @@ -67,10 +67,12 @@ public class OrganizationUserDetailsResponseModel : OrganizationUserResponseMode public OrganizationUserDetailsResponseModel( OrganizationUser organizationUser, bool claimedByOrganization, + string ssoExternalId, IEnumerable collections) : base(organizationUser, "organizationUserDetails") { ClaimedByOrganization = claimedByOrganization; + SsoExternalId = ssoExternalId; Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c)); } @@ -80,6 +82,7 @@ public class OrganizationUserDetailsResponseModel : OrganizationUserResponseMode : base(organizationUser, "organizationUserDetails") { ClaimedByOrganization = claimedByOrganization; + SsoExternalId = organizationUser.SsoExternalId; Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c)); } @@ -90,6 +93,7 @@ public class OrganizationUserDetailsResponseModel : OrganizationUserResponseMode set => ClaimedByOrganization = value; } public bool ClaimedByOrganization { get; set; } + public string SsoExternalId { get; set; } public IEnumerable Collections { get; set; } From 8cd14d55dd7982ab07fde74f3193a82514b1c2f4 Mon Sep 17 00:00:00 2001 From: MtnBurrit0 <77340197+mimartin12@users.noreply.github.com> Date: Wed, 9 Apr 2025 09:44:42 -0600 Subject: [PATCH 17/28] EE sync improvements (#5620) * Leverage new workflow changes * Refactor ephemeral-environment workflow * Add .has_secrets check back into build --- .github/workflows/build.yml | 46 ++------------------- .github/workflows/ephemeral-environment.yml | 39 ++++------------- 2 files changed, 13 insertions(+), 72 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f0df238b34..35c2070df3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -627,56 +627,18 @@ jobs: } }) - trigger-ee-updates: - name: Trigger Ephemeral Environment updates - if: | - needs.build-artifacts.outputs.has_secrets == 'true' - && github.event_name == 'pull_request' - && contains(github.event.pull_request.labels.*.name, 'ephemeral-environment') - runs-on: ubuntu-24.04 - needs: - - build-docker - steps: - - name: Log in to Azure - CI subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 - with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} - - - name: Retrieve GitHub PAT secrets - id: retrieve-secret-pat - uses: bitwarden/gh-actions/get-keyvault-secrets@main - with: - keyvault: "bitwarden-ci" - secrets: "github-pat-bitwarden-devops-bot-repo-scope" - - - name: Trigger Ephemeral Environment update - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - with: - github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }} - script: | - await github.rest.actions.createWorkflowDispatch({ - owner: 'bitwarden', - repo: 'devops', - workflow_id: '_update_ephemeral_tags.yml', - ref: 'main', - inputs: { - ephemeral_env_branch: process.env.GITHUB_HEAD_REF - } - }) - - trigger-ephemeral-environment-sync: - name: Trigger Ephemeral Environment Sync - needs: trigger-ee-updates + setup-ephemeral-environment: + name: Setup Ephemeral Environment + needs: build-docker if: | needs.build-artifacts.outputs.has_secrets == 'true' && github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ephemeral-environment') uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main with: - ephemeral_env_branch: process.env.GITHUB_HEAD_REF project: server - sync_environment: true pull_request_number: ${{ github.event.number }} + create_branch: true secrets: inherit check-failures: diff --git a/.github/workflows/ephemeral-environment.yml b/.github/workflows/ephemeral-environment.yml index c784d48354..699a28c6fb 100644 --- a/.github/workflows/ephemeral-environment.yml +++ b/.github/workflows/ephemeral-environment.yml @@ -5,34 +5,13 @@ on: types: [labeled] jobs: - trigger-ee-updates: - name: Trigger Ephemeral Environment updates - runs-on: ubuntu-24.04 + setup-ephemeral-environment: + name: Setup Ephemeral Environment if: github.event.label.name == 'ephemeral-environment' - steps: - - name: Log in to Azure - CI subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 - with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} - - - name: Retrieve GitHub PAT secrets - id: retrieve-secret-pat - uses: bitwarden/gh-actions/get-keyvault-secrets@main - with: - keyvault: "bitwarden-ci" - secrets: "github-pat-bitwarden-devops-bot-repo-scope" - - - name: Trigger Ephemeral Environment update - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - with: - github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }} - script: | - await github.rest.actions.createWorkflowDispatch({ - owner: 'bitwarden', - repo: 'devops', - workflow_id: '_update_ephemeral_tags.yml', - ref: 'main', - inputs: { - ephemeral_env_branch: process.env.GITHUB_HEAD_REF - } - }) + uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main + with: + project: server + pull_request_number: ${{ github.event.number }} + sync_environment: true + create_branch: true + secrets: inherit From d85807e94f82c316e49772026f63b3df4d51e7c1 Mon Sep 17 00:00:00 2001 From: Robyn MacCallum Date: Wed, 9 Apr 2025 12:17:04 -0400 Subject: [PATCH 18/28] Add mobile feature flags (#5629) * Add mobile feature flags * Update Constants.cs --- src/Core/Constants.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 42d5d06450..33dec32e34 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -168,8 +168,9 @@ public static class FeatureFlagKeys public const string SingleTapPasskeyAuthentication = "single-tap-passkey-authentication"; public const string EnablePMAuthenticatorSync = "enable-pm-bwa-sync"; public const string PM3503_MobileAnonAddySelfHostAlias = "anon-addy-self-host-alias"; - public const string PM3553_MobileSimpleLoginSelfHostAlias = "simple-login-self-host-alias"; + public const string EnablePMFlightRecorder = "enable-pm-flight-recorder"; + public const string MobileErrorReporting = "mobile-error-reporting"; /* Platform Team */ public const string PersistPopupView = "persist-popup-view"; From 54e7fac4d99b7a8e3fc83221c561d60c68194494 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Thu, 10 Apr 2025 10:06:16 -0400 Subject: [PATCH 19/28] [PM-18770] Convert Organization to Business Unit (#5610) * [NO LOGIC] Rename MultiOrganizationEnterprise to BusinessUnit * [Core] Add IMailService.SendBusinessUnitConversionInviteAsync * [Core] Add BusinessUnitConverter * [Admin] Add new permission * [Admin] Add BusinessUnitConverterController * [Admin] Add Convert to Business Unit button to Organization edit page * [Api] Add OrganizationBillingController.SetupBusinessUnitAsync action * [Multi] Propagate provider type to sync response * [Multi] Put updates behind feature flag * [Tests] BusinessUnitConverterTests * Run dotnet format * Fixing post-main merge compilation failure --- .../Providers/CreateProviderCommand.cs | 2 +- .../AdminConsole/Services/ProviderService.cs | 4 +- .../Billing/BusinessUnitConverter.cs | 462 ++++++++++++++++ .../Billing/ProviderBillingService.cs | 2 +- .../Billing/ProviderPriceAdapter.cs | 8 +- .../Utilities/ServiceCollectionExtensions.cs | 1 + .../CreateProviderCommandTests.cs | 10 +- .../Billing/BusinessUnitConverterTests.cs | 501 ++++++++++++++++++ .../Billing/ProviderBillingServiceTests.cs | 2 +- .../Billing/ProviderPriceAdapterTests.cs | 6 +- src/Admin/Admin.csproj | 3 - .../Controllers/ProvidersController.cs | 16 +- ....cs => CreateBusinessUnitProviderModel.cs} | 10 +- .../AdminConsole/Models/ProviderEditModel.cs | 4 +- .../AdminConsole/Models/ProviderViewModel.cs | 2 +- .../Views/Organizations/Edit.cshtml | 23 +- ...prise.cshtml => CreateBusinessUnit.cshtml} | 12 +- .../AdminConsole/Views/Providers/Edit.cshtml | 6 +- .../BusinessUnitConversionController.cs | 185 +++++++ .../Models/BusinessUnitConversionModel.cs | 25 + .../Views/BusinessUnitConversion/Index.cshtml | 75 +++ src/Admin/Enums/Permissions.cs | 1 + src/Admin/Utilities/RolePermissionMapping.cs | 3 + .../Providers/ProfileProviderResponseModel.cs | 2 + .../OrganizationBillingController.cs | 39 ++ .../Requests/SetupBusinessUnitRequestBody.cs | 18 + .../Enums/Provider/ProviderType.cs | 4 +- .../Provider/ProviderUserProviderDetails.cs | 1 + .../Interfaces/ICreateProviderCommand.cs | 2 +- .../Billing/Extensions/BillingExtensions.cs | 6 +- .../Services/IBusinessUnitConverter.cs | 58 ++ src/Core/Constants.cs | 1 + .../BusinessUnitConversionInvite.html.hbs | 19 + .../BusinessUnitConversionInvite.text.hbs | 5 + .../BusinessUnitConversionInviteModel.cs | 11 + src/Core/Services/IMailService.cs | 1 + .../Implementations/HandlebarsMailService.cs | 17 + .../NoopImplementations/NoopMailService.cs | 5 + .../Repositories/OrganizationRepository.cs | 2 +- ...rProviderDetailsReadByUserIdStatusQuery.cs | 1 + .../Controllers/ProvidersControllerTests.cs | 22 +- 41 files changed, 1513 insertions(+), 64 deletions(-) create mode 100644 bitwarden_license/src/Commercial.Core/Billing/BusinessUnitConverter.cs create mode 100644 bitwarden_license/test/Commercial.Core.Test/Billing/BusinessUnitConverterTests.cs rename src/Admin/AdminConsole/Models/{CreateMultiOrganizationEnterpriseProviderModel.cs => CreateBusinessUnitProviderModel.cs} (76%) rename src/Admin/AdminConsole/Views/Providers/{CreateMultiOrganizationEnterprise.cshtml => CreateBusinessUnit.cshtml} (77%) create mode 100644 src/Admin/Billing/Controllers/BusinessUnitConversionController.cs create mode 100644 src/Admin/Billing/Models/BusinessUnitConversionModel.cs create mode 100644 src/Admin/Billing/Views/BusinessUnitConversion/Index.cshtml create mode 100644 src/Api/Billing/Models/Requests/SetupBusinessUnitRequestBody.cs create mode 100644 src/Core/Billing/Services/IBusinessUnitConverter.cs create mode 100644 src/Core/MailTemplates/Handlebars/Billing/BusinessUnitConversionInvite.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/Billing/BusinessUnitConversionInvite.text.hbs create mode 100644 src/Core/Models/Mail/Billing/BusinessUnitConversionInviteModel.cs diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs index 69b7e67ec1..36a5f2c0a9 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs @@ -48,7 +48,7 @@ public class CreateProviderCommand : ICreateProviderCommand await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Created); } - public async Task CreateMultiOrganizationEnterpriseAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats) + public async Task CreateBusinessUnitAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats) { var providerId = await CreateProviderAsync(provider, ownerEmail); diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index 799b57dc5a..fff6b5271d 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -692,10 +692,10 @@ public class ProviderService : IProviderService throw new BadRequestException($"Managed Service Providers cannot manage organizations with the plan type {requestedType}. Only Teams (Monthly) and Enterprise (Monthly) are allowed."); } break; - case ProviderType.MultiOrganizationEnterprise: + case ProviderType.BusinessUnit: if (requestedType is not (PlanType.EnterpriseMonthly or PlanType.EnterpriseAnnually)) { - throw new BadRequestException($"Multi-organization Enterprise Providers cannot manage organizations with the plan type {requestedType}. Only Enterprise (Monthly) and Enterprise (Annually) are allowed."); + throw new BadRequestException($"Business Unit Providers cannot manage organizations with the plan type {requestedType}. Only Enterprise (Monthly) and Enterprise (Annually) are allowed."); } break; case ProviderType.Reseller: diff --git a/bitwarden_license/src/Commercial.Core/Billing/BusinessUnitConverter.cs b/bitwarden_license/src/Commercial.Core/Billing/BusinessUnitConverter.cs new file mode 100644 index 0000000000..97d9377cd6 --- /dev/null +++ b/bitwarden_license/src/Commercial.Core/Billing/BusinessUnitConverter.cs @@ -0,0 +1,462 @@ +#nullable enable +using System.Diagnostics.CodeAnalysis; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging; +using OneOf; +using Stripe; + +namespace Bit.Commercial.Core.Billing; + +[RequireFeature(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion)] +public class BusinessUnitConverter( + IDataProtectionProvider dataProtectionProvider, + GlobalSettings globalSettings, + ILogger logger, + IMailService mailService, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IPricingClient pricingClient, + IProviderOrganizationRepository providerOrganizationRepository, + IProviderPlanRepository providerPlanRepository, + IProviderRepository providerRepository, + IProviderUserRepository providerUserRepository, + IStripeAdapter stripeAdapter, + ISubscriberService subscriberService, + IUserRepository userRepository) : IBusinessUnitConverter +{ + private readonly IDataProtector _dataProtector = + dataProtectionProvider.CreateProtector($"{nameof(BusinessUnitConverter)}DataProtector"); + + public async Task FinalizeConversion( + Organization organization, + Guid userId, + string token, + string providerKey, + string organizationKey) + { + var user = await userRepository.GetByIdAsync(userId); + + var (subscription, provider, providerOrganization, providerUser) = await ValidateFinalizationAsync(organization, user, token); + + var existingPlan = await pricingClient.GetPlanOrThrow(organization.PlanType); + var updatedPlan = await pricingClient.GetPlanOrThrow(existingPlan.IsAnnual ? PlanType.EnterpriseAnnually : PlanType.EnterpriseMonthly); + + // Bring organization under management. + organization.Plan = updatedPlan.Name; + organization.PlanType = updatedPlan.Type; + organization.MaxCollections = updatedPlan.PasswordManager.MaxCollections; + organization.MaxStorageGb = updatedPlan.PasswordManager.BaseStorageGb; + organization.UsePolicies = updatedPlan.HasPolicies; + organization.UseSso = updatedPlan.HasSso; + organization.UseGroups = updatedPlan.HasGroups; + organization.UseEvents = updatedPlan.HasEvents; + organization.UseDirectory = updatedPlan.HasDirectory; + organization.UseTotp = updatedPlan.HasTotp; + organization.Use2fa = updatedPlan.Has2fa; + organization.UseApi = updatedPlan.HasApi; + organization.UseResetPassword = updatedPlan.HasResetPassword; + organization.SelfHost = updatedPlan.HasSelfHost; + organization.UsersGetPremium = updatedPlan.UsersGetPremium; + organization.UseCustomPermissions = updatedPlan.HasCustomPermissions; + organization.UseScim = updatedPlan.HasScim; + organization.UseKeyConnector = updatedPlan.HasKeyConnector; + organization.MaxStorageGb = updatedPlan.PasswordManager.BaseStorageGb; + organization.BillingEmail = provider.BillingEmail!; + organization.GatewayCustomerId = null; + organization.GatewaySubscriptionId = null; + organization.ExpirationDate = null; + organization.MaxAutoscaleSeats = null; + organization.Status = OrganizationStatusType.Managed; + + // Enable organization access via key exchange. + providerOrganization.Key = organizationKey; + + // Complete provider setup. + provider.Gateway = GatewayType.Stripe; + provider.GatewayCustomerId = subscription.CustomerId; + provider.GatewaySubscriptionId = subscription.Id; + provider.Status = ProviderStatusType.Billable; + + // Enable provider access via key exchange. + providerUser.Key = providerKey; + providerUser.Status = ProviderUserStatusType.Confirmed; + + // Stripe requires that we clear all the custom fields from the invoice settings if we want to replace them. + await stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions + { + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + CustomFields = [] + } + }); + + var metadata = new Dictionary + { + [StripeConstants.MetadataKeys.OrganizationId] = string.Empty, + [StripeConstants.MetadataKeys.ProviderId] = provider.Id.ToString(), + ["convertedFrom"] = organization.Id.ToString() + }; + + var updateCustomer = stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions + { + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + CustomFields = [ + new CustomerInvoiceSettingsCustomFieldOptions + { + Name = provider.SubscriberType(), + Value = provider.DisplayName()?.Length <= 30 + ? provider.DisplayName() + : provider.DisplayName()?[..30] + } + ] + }, + Metadata = metadata + }); + + // Find the existing password manager price on the subscription. + var passwordManagerItem = subscription.Items.First(item => + { + var priceId = existingPlan.HasNonSeatBasedPasswordManagerPlan() + ? existingPlan.PasswordManager.StripePlanId + : existingPlan.PasswordManager.StripeSeatPlanId; + + return item.Price.Id == priceId; + }); + + // Get the new business unit price. + var updatedPriceId = ProviderPriceAdapter.GetActivePriceId(provider, updatedPlan.Type); + + // Replace the existing password manager price with the new business unit price. + var updateSubscription = + stripeAdapter.SubscriptionUpdateAsync(subscription.Id, + new SubscriptionUpdateOptions + { + Items = [ + new SubscriptionItemOptions + { + Id = passwordManagerItem.Id, + Deleted = true + }, + new SubscriptionItemOptions + { + Price = updatedPriceId, + Quantity = organization.Seats + } + ], + Metadata = metadata + }); + + await Task.WhenAll(updateCustomer, updateSubscription); + + // Complete database updates for provider setup. + await Task.WhenAll( + organizationRepository.ReplaceAsync(organization), + providerOrganizationRepository.ReplaceAsync(providerOrganization), + providerRepository.ReplaceAsync(provider), + providerUserRepository.ReplaceAsync(providerUser)); + + return provider.Id; + } + + public async Task>> InitiateConversion( + Organization organization, + string providerAdminEmail) + { + var user = await userRepository.GetByEmailAsync(providerAdminEmail); + + var problems = await ValidateInitiationAsync(organization, user); + + if (problems is { Count: > 0 }) + { + return problems; + } + + var provider = await providerRepository.CreateAsync(new Provider + { + Name = organization.Name, + BillingEmail = organization.BillingEmail, + Status = ProviderStatusType.Pending, + UseEvents = true, + Type = ProviderType.BusinessUnit + }); + + var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); + + var managedPlanType = plan.IsAnnual + ? PlanType.EnterpriseAnnually + : PlanType.EnterpriseMonthly; + + var createProviderOrganization = providerOrganizationRepository.CreateAsync(new ProviderOrganization + { + ProviderId = provider.Id, + OrganizationId = organization.Id + }); + + var createProviderPlan = providerPlanRepository.CreateAsync(new ProviderPlan + { + ProviderId = provider.Id, + PlanType = managedPlanType, + SeatMinimum = 0, + PurchasedSeats = organization.Seats, + AllocatedSeats = organization.Seats + }); + + var createProviderUser = providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = provider.Id, + UserId = user!.Id, + Email = user.Email, + Status = ProviderUserStatusType.Invited, + Type = ProviderUserType.ProviderAdmin + }); + + await Task.WhenAll(createProviderOrganization, createProviderPlan, createProviderUser); + + await SendInviteAsync(organization, user.Email); + + return provider.Id; + } + + public Task ResendConversionInvite( + Organization organization, + string providerAdminEmail) => + IfConversionInProgressAsync(organization, providerAdminEmail, + async (_, _, providerUser) => + { + if (!string.IsNullOrEmpty(providerUser.Email)) + { + await SendInviteAsync(organization, providerUser.Email); + } + }); + + public Task ResetConversion( + Organization organization, + string providerAdminEmail) => + IfConversionInProgressAsync(organization, providerAdminEmail, + async (provider, providerOrganization, providerUser) => + { + var tasks = new List + { + providerOrganizationRepository.DeleteAsync(providerOrganization), + providerUserRepository.DeleteAsync(providerUser) + }; + + var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); + + if (providerPlans is { Count: > 0 }) + { + tasks.AddRange(providerPlans.Select(providerPlanRepository.DeleteAsync)); + } + + await Task.WhenAll(tasks); + + await providerRepository.DeleteAsync(provider); + }); + + #region Utilities + + private async Task IfConversionInProgressAsync( + Organization organization, + string providerAdminEmail, + Func callback) + { + var user = await userRepository.GetByEmailAsync(providerAdminEmail); + + if (user == null) + { + return; + } + + var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id); + + if (provider is not + { + Type: ProviderType.BusinessUnit, + Status: ProviderStatusType.Pending + }) + { + return; + } + + var providerUser = await providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id); + + if (providerUser is + { + Type: ProviderUserType.ProviderAdmin, + Status: ProviderUserStatusType.Invited + }) + { + var providerOrganization = await providerOrganizationRepository.GetByOrganizationId(organization.Id); + await callback(provider, providerOrganization!, providerUser); + } + } + + private async Task SendInviteAsync( + Organization organization, + string providerAdminEmail) + { + var token = _dataProtector.Protect( + $"BusinessUnitConversionInvite {organization.Id} {providerAdminEmail} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); + + await mailService.SendBusinessUnitConversionInviteAsync(organization, token, providerAdminEmail); + } + + private async Task<(Subscription, Provider, ProviderOrganization, ProviderUser)> ValidateFinalizationAsync( + Organization organization, + User? user, + string token) + { + if (organization.PlanType.GetProductTier() != ProductTierType.Enterprise) + { + Fail("Organization must be on an enterprise plan."); + } + + var subscription = await subscriberService.GetSubscription(organization); + + if (subscription is not + { + Status: + StripeConstants.SubscriptionStatus.Active or + StripeConstants.SubscriptionStatus.Trialing or + StripeConstants.SubscriptionStatus.PastDue + }) + { + Fail("Organization must have a valid subscription."); + } + + if (user == null) + { + Fail("Provider admin must be a Bitwarden user."); + } + + if (!CoreHelpers.TokenIsValid( + "BusinessUnitConversionInvite", + _dataProtector, + token, + user.Email, + organization.Id, + globalSettings.OrganizationInviteExpirationHours)) + { + Fail("Email token is invalid."); + } + + var organizationUser = + await organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id); + + if (organizationUser is not + { + Status: OrganizationUserStatusType.Confirmed + }) + { + Fail("Provider admin must be a confirmed member of the organization being converted."); + } + + var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id); + + if (provider is not + { + Type: ProviderType.BusinessUnit, + Status: ProviderStatusType.Pending + }) + { + Fail("Linked provider is not a pending business unit."); + } + + var providerUser = await providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id); + + if (providerUser is not + { + Type: ProviderUserType.ProviderAdmin, + Status: ProviderUserStatusType.Invited + }) + { + Fail("Provider admin has not been invited."); + } + + var providerOrganization = await providerOrganizationRepository.GetByOrganizationId(organization.Id); + + return (subscription, provider, providerOrganization!, providerUser); + + [DoesNotReturn] + void Fail(string scopedError) + { + logger.LogError("Could not finalize business unit conversion for organization ({OrganizationID}): {Error}", + organization.Id, scopedError); + throw new BillingException(); + } + } + + private async Task?> ValidateInitiationAsync( + Organization organization, + User? user) + { + var problems = new List(); + + if (organization.PlanType.GetProductTier() != ProductTierType.Enterprise) + { + problems.Add("Organization must be on an enterprise plan."); + } + + var subscription = await subscriberService.GetSubscription(organization); + + if (subscription is not + { + Status: + StripeConstants.SubscriptionStatus.Active or + StripeConstants.SubscriptionStatus.Trialing or + StripeConstants.SubscriptionStatus.PastDue + }) + { + problems.Add("Organization must have a valid subscription."); + } + + var providerOrganization = await providerOrganizationRepository.GetByOrganizationId(organization.Id); + + if (providerOrganization != null) + { + problems.Add("Organization is already linked to a provider."); + } + + if (user == null) + { + problems.Add("Provider admin must be a Bitwarden user."); + } + else + { + var organizationUser = + await organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id); + + if (organizationUser is not + { + Status: OrganizationUserStatusType.Confirmed + }) + { + problems.Add("Provider admin must be a confirmed member of the organization being converted."); + } + } + + return problems.Count == 0 ? null : problems; + } + + #endregion +} diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs index 757d6510f1..98ebefd4f1 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs @@ -791,7 +791,7 @@ public class ProviderBillingService( Provider provider, Organization organization) { - if (provider.Type == ProviderType.MultiOrganizationEnterprise) + if (provider.Type == ProviderType.BusinessUnit) { return (await providerPlanRepository.GetByProviderId(provider.Id)).First().PlanType; } diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderPriceAdapter.cs b/bitwarden_license/src/Commercial.Core/Billing/ProviderPriceAdapter.cs index 4cc0711ec9..a9dbb6febf 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderPriceAdapter.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/ProviderPriceAdapter.cs @@ -51,7 +51,7 @@ public static class ProviderPriceAdapter /// The provider's subscription. /// The plan type correlating to the desired Stripe price ID. /// A Stripe ID. - /// Thrown when the provider's type is not or . + /// Thrown when the provider's type is not or . /// Thrown when the provided does not relate to a Stripe price ID. public static string GetPriceId( Provider provider, @@ -78,7 +78,7 @@ public static class ProviderPriceAdapter PlanType.EnterpriseMonthly => MSP.Active.Enterprise, _ => throw invalidPlanType }, - ProviderType.MultiOrganizationEnterprise => BusinessUnit.Legacy.List.Intersect(priceIds).Any() + ProviderType.BusinessUnit => BusinessUnit.Legacy.List.Intersect(priceIds).Any() ? planType switch { PlanType.EnterpriseAnnually => BusinessUnit.Legacy.Annually, @@ -103,7 +103,7 @@ public static class ProviderPriceAdapter /// The provider to get the Stripe price ID for. /// The plan type correlating to the desired Stripe price ID. /// A Stripe ID. - /// Thrown when the provider's type is not or . + /// Thrown when the provider's type is not or . /// Thrown when the provided does not relate to a Stripe price ID. public static string GetActivePriceId( Provider provider, @@ -120,7 +120,7 @@ public static class ProviderPriceAdapter PlanType.EnterpriseMonthly => MSP.Active.Enterprise, _ => throw invalidPlanType }, - ProviderType.MultiOrganizationEnterprise => planType switch + ProviderType.BusinessUnit => planType switch { PlanType.EnterpriseAnnually => BusinessUnit.Active.Annually, PlanType.EnterpriseMonthly => BusinessUnit.Active.Monthly, diff --git a/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs b/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs index 5ae5be8847..7f8c82e2c9 100644 --- a/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs @@ -16,5 +16,6 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddTransient(); + services.AddTransient(); } } diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/CreateProviderCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/CreateProviderCommandTests.cs index e354e44173..82fcb016b3 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/CreateProviderCommandTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/CreateProviderCommandTests.cs @@ -63,7 +63,7 @@ public class CreateProviderCommandTests } [Theory, BitAutoData] - public async Task CreateMultiOrganizationEnterpriseAsync_Success( + public async Task CreateBusinessUnitAsync_Success( Provider provider, User user, PlanType plan, @@ -71,13 +71,13 @@ public class CreateProviderCommandTests SutProvider sutProvider) { // Arrange - provider.Type = ProviderType.MultiOrganizationEnterprise; + provider.Type = ProviderType.BusinessUnit; var userRepository = sutProvider.GetDependency(); userRepository.GetByEmailAsync(user.Email).Returns(user); // Act - await sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, user.Email, plan, minimumSeats); + await sutProvider.Sut.CreateBusinessUnitAsync(provider, user.Email, plan, minimumSeats); // Assert await sutProvider.GetDependency().ReceivedWithAnyArgs().CreateAsync(provider); @@ -85,7 +85,7 @@ public class CreateProviderCommandTests } [Theory, BitAutoData] - public async Task CreateMultiOrganizationEnterpriseAsync_UserIdIsInvalid_Throws( + public async Task CreateBusinessUnitAsync_UserIdIsInvalid_Throws( Provider provider, SutProvider sutProvider) { @@ -94,7 +94,7 @@ public class CreateProviderCommandTests // Act var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, default, default, default)); + () => sutProvider.Sut.CreateBusinessUnitAsync(provider, default, default, default)); // Assert Assert.Contains("Invalid owner.", exception.Message); diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/BusinessUnitConverterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/BusinessUnitConverterTests.cs new file mode 100644 index 0000000000..5d2d0a2c7c --- /dev/null +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/BusinessUnitConverterTests.cs @@ -0,0 +1,501 @@ +#nullable enable +using System.Text; +using Bit.Commercial.Core.Billing; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Stripe; +using Xunit; + +namespace Bit.Commercial.Core.Test.Billing; + +public class BusinessUnitConverterTests +{ + private readonly IDataProtectionProvider _dataProtectionProvider = Substitute.For(); + private readonly GlobalSettings _globalSettings = new(); + private readonly ILogger _logger = Substitute.For>(); + private readonly IMailService _mailService = Substitute.For(); + private readonly IOrganizationRepository _organizationRepository = Substitute.For(); + private readonly IOrganizationUserRepository _organizationUserRepository = Substitute.For(); + private readonly IPricingClient _pricingClient = Substitute.For(); + private readonly IProviderOrganizationRepository _providerOrganizationRepository = Substitute.For(); + private readonly IProviderPlanRepository _providerPlanRepository = Substitute.For(); + private readonly IProviderRepository _providerRepository = Substitute.For(); + private readonly IProviderUserRepository _providerUserRepository = Substitute.For(); + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly ISubscriberService _subscriberService = Substitute.For(); + private readonly IUserRepository _userRepository = Substitute.For(); + + private BusinessUnitConverter BuildConverter() => new( + _dataProtectionProvider, + _globalSettings, + _logger, + _mailService, + _organizationRepository, + _organizationUserRepository, + _pricingClient, + _providerOrganizationRepository, + _providerPlanRepository, + _providerRepository, + _providerUserRepository, + _stripeAdapter, + _subscriberService, + _userRepository); + + #region FinalizeConversion + + [Theory, BitAutoData] + public async Task FinalizeConversion_Succeeds_ReturnsProviderId( + Organization organization, + Guid userId, + string providerKey, + string organizationKey) + { + organization.PlanType = PlanType.EnterpriseAnnually2020; + + var enterpriseAnnually2020 = StaticStore.GetPlan(PlanType.EnterpriseAnnually2020); + + var subscription = new Subscription + { + Id = "subscription_id", + CustomerId = "customer_id", + Status = StripeConstants.SubscriptionStatus.Active, + Items = new StripeList + { + Data = [ + new SubscriptionItem + { + Id = "subscription_item_id", + Price = new Price + { + Id = enterpriseAnnually2020.PasswordManager.StripeSeatPlanId + } + } + ] + } + }; + + _subscriberService.GetSubscription(organization).Returns(subscription); + + var user = new User + { + Id = Guid.NewGuid(), + Email = "provider-admin@example.com" + }; + + _userRepository.GetByIdAsync(userId).Returns(user); + + var token = SetupDataProtection(organization, user.Email); + + var organizationUser = new OrganizationUser { Status = OrganizationUserStatusType.Confirmed }; + + _organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id) + .Returns(organizationUser); + + var provider = new Provider + { + Type = ProviderType.BusinessUnit, + Status = ProviderStatusType.Pending + }; + + _providerRepository.GetByOrganizationIdAsync(organization.Id).Returns(provider); + + var providerUser = new ProviderUser + { + Type = ProviderUserType.ProviderAdmin, + Status = ProviderUserStatusType.Invited + }; + + _providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser); + + var providerOrganization = new ProviderOrganization(); + + _providerOrganizationRepository.GetByOrganizationId(organization.Id).Returns(providerOrganization); + + _pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020) + .Returns(enterpriseAnnually2020); + + var enterpriseAnnually = StaticStore.GetPlan(PlanType.EnterpriseAnnually); + + _pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually) + .Returns(enterpriseAnnually); + + var businessUnitConverter = BuildConverter(); + + await businessUnitConverter.FinalizeConversion(organization, userId, token, providerKey, organizationKey); + + await _stripeAdapter.Received(2).CustomerUpdateAsync(subscription.CustomerId, Arg.Any()); + + var updatedPriceId = ProviderPriceAdapter.GetActivePriceId(provider, enterpriseAnnually.Type); + + await _stripeAdapter.Received(1).SubscriptionUpdateAsync(subscription.Id, Arg.Is( + arguments => + arguments.Items.Count == 2 && + arguments.Items[0].Id == "subscription_item_id" && + arguments.Items[0].Deleted == true && + arguments.Items[1].Price == updatedPriceId && + arguments.Items[1].Quantity == organization.Seats)); + + await _organizationRepository.Received(1).ReplaceAsync(Arg.Is(arguments => + arguments.PlanType == PlanType.EnterpriseAnnually && + arguments.Status == OrganizationStatusType.Managed && + arguments.GatewayCustomerId == null && + arguments.GatewaySubscriptionId == null)); + + await _providerOrganizationRepository.Received(1).ReplaceAsync(Arg.Is(arguments => + arguments.Key == organizationKey)); + + await _providerRepository.Received(1).ReplaceAsync(Arg.Is(arguments => + arguments.Gateway == GatewayType.Stripe && + arguments.GatewayCustomerId == subscription.CustomerId && + arguments.GatewaySubscriptionId == subscription.Id && + arguments.Status == ProviderStatusType.Billable)); + + await _providerUserRepository.Received(1).ReplaceAsync(Arg.Is(arguments => + arguments.Key == providerKey && + arguments.Status == ProviderUserStatusType.Confirmed)); + } + + /* + * Because the validation for finalization is not an applicative like initialization is, + * I'm just testing one specific failure here. I don't see much value in testing every single opportunity for failure. + */ + [Theory, BitAutoData] + public async Task FinalizeConversion_ValidationFails_ThrowsBillingException( + Organization organization, + Guid userId, + string token, + string providerKey, + string organizationKey) + { + organization.PlanType = PlanType.EnterpriseAnnually2020; + + var subscription = new Subscription + { + Status = StripeConstants.SubscriptionStatus.Canceled + }; + + _subscriberService.GetSubscription(organization).Returns(subscription); + + var businessUnitConverter = BuildConverter(); + + await Assert.ThrowsAsync(() => + businessUnitConverter.FinalizeConversion(organization, userId, token, providerKey, organizationKey)); + + await _organizationUserRepository.DidNotReceiveWithAnyArgs() + .GetByOrganizationAsync(Arg.Any(), Arg.Any()); + } + + #endregion + + #region InitiateConversion + + [Theory, BitAutoData] + public async Task InitiateConversion_Succeeds_ReturnsProviderId( + Organization organization, + string providerAdminEmail) + { + organization.PlanType = PlanType.EnterpriseAnnually; + + _subscriberService.GetSubscription(organization).Returns(new Subscription + { + Status = StripeConstants.SubscriptionStatus.Active + }); + + var user = new User + { + Id = Guid.NewGuid(), + Email = providerAdminEmail + }; + + _userRepository.GetByEmailAsync(providerAdminEmail).Returns(user); + + var organizationUser = new OrganizationUser { Status = OrganizationUserStatusType.Confirmed }; + + _organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id) + .Returns(organizationUser); + + var provider = new Provider { Id = Guid.NewGuid() }; + + _providerRepository.CreateAsync(Arg.Is(argument => + argument.Name == organization.Name && + argument.BillingEmail == organization.BillingEmail && + argument.Status == ProviderStatusType.Pending && + argument.Type == ProviderType.BusinessUnit)).Returns(provider); + + var plan = StaticStore.GetPlan(organization.PlanType); + + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + + var token = SetupDataProtection(organization, providerAdminEmail); + + var businessUnitConverter = BuildConverter(); + + var result = await businessUnitConverter.InitiateConversion(organization, providerAdminEmail); + + Assert.True(result.IsT0); + + var providerId = result.AsT0; + + Assert.Equal(provider.Id, providerId); + + await _providerOrganizationRepository.Received(1).CreateAsync( + Arg.Is(argument => + argument.ProviderId == provider.Id && + argument.OrganizationId == organization.Id)); + + await _providerPlanRepository.Received(1).CreateAsync( + Arg.Is(argument => + argument.ProviderId == provider.Id && + argument.PlanType == PlanType.EnterpriseAnnually && + argument.SeatMinimum == 0 && + argument.PurchasedSeats == organization.Seats && + argument.AllocatedSeats == organization.Seats)); + + await _providerUserRepository.Received(1).CreateAsync( + Arg.Is(argument => + argument.ProviderId == provider.Id && + argument.UserId == user.Id && + argument.Email == user.Email && + argument.Status == ProviderUserStatusType.Invited && + argument.Type == ProviderUserType.ProviderAdmin)); + + await _mailService.Received(1).SendBusinessUnitConversionInviteAsync( + organization, + token, + user.Email); + } + + [Theory, BitAutoData] + public async Task InitiateConversion_ValidationFails_ReturnsErrors( + Organization organization, + string providerAdminEmail) + { + organization.PlanType = PlanType.TeamsMonthly; + + _subscriberService.GetSubscription(organization).Returns(new Subscription + { + Status = StripeConstants.SubscriptionStatus.Canceled + }); + + var user = new User + { + Id = Guid.NewGuid(), + Email = providerAdminEmail + }; + + _providerOrganizationRepository.GetByOrganizationId(organization.Id) + .Returns(new ProviderOrganization()); + + _userRepository.GetByEmailAsync(providerAdminEmail).Returns(user); + + var organizationUser = new OrganizationUser { Status = OrganizationUserStatusType.Invited }; + + _organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id) + .Returns(organizationUser); + + var businessUnitConverter = BuildConverter(); + + var result = await businessUnitConverter.InitiateConversion(organization, providerAdminEmail); + + Assert.True(result.IsT1); + + var problems = result.AsT1; + + Assert.Contains("Organization must be on an enterprise plan.", problems); + + Assert.Contains("Organization must have a valid subscription.", problems); + + Assert.Contains("Organization is already linked to a provider.", problems); + + Assert.Contains("Provider admin must be a confirmed member of the organization being converted.", problems); + } + + #endregion + + #region ResendConversionInvite + + [Theory, BitAutoData] + public async Task ResendConversionInvite_ConversionInProgress_Succeeds( + Organization organization, + string providerAdminEmail) + { + SetupConversionInProgress(organization, providerAdminEmail); + + var token = SetupDataProtection(organization, providerAdminEmail); + + var businessUnitConverter = BuildConverter(); + + await businessUnitConverter.ResendConversionInvite(organization, providerAdminEmail); + + await _mailService.Received(1).SendBusinessUnitConversionInviteAsync( + organization, + token, + providerAdminEmail); + } + + [Theory, BitAutoData] + public async Task ResendConversionInvite_NoConversionInProgress_DoesNothing( + Organization organization, + string providerAdminEmail) + { + SetupDataProtection(organization, providerAdminEmail); + + var businessUnitConverter = BuildConverter(); + + await businessUnitConverter.ResendConversionInvite(organization, providerAdminEmail); + + await _mailService.DidNotReceiveWithAnyArgs().SendBusinessUnitConversionInviteAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + #endregion + + #region ResetConversion + + [Theory, BitAutoData] + public async Task ResetConversion_ConversionInProgress_Succeeds( + Organization organization, + string providerAdminEmail) + { + var (provider, providerOrganization, providerUser, providerPlan) = SetupConversionInProgress(organization, providerAdminEmail); + + var businessUnitConverter = BuildConverter(); + + await businessUnitConverter.ResetConversion(organization, providerAdminEmail); + + await _providerOrganizationRepository.Received(1) + .DeleteAsync(providerOrganization); + + await _providerUserRepository.Received(1) + .DeleteAsync(providerUser); + + await _providerPlanRepository.Received(1) + .DeleteAsync(providerPlan); + + await _providerRepository.Received(1) + .DeleteAsync(provider); + } + + [Theory, BitAutoData] + public async Task ResetConversion_NoConversionInProgress_DoesNothing( + Organization organization, + string providerAdminEmail) + { + var businessUnitConverter = BuildConverter(); + + await businessUnitConverter.ResetConversion(organization, providerAdminEmail); + + await _providerOrganizationRepository.DidNotReceiveWithAnyArgs() + .DeleteAsync(Arg.Any()); + + await _providerUserRepository.DidNotReceiveWithAnyArgs() + .DeleteAsync(Arg.Any()); + + await _providerPlanRepository.DidNotReceiveWithAnyArgs() + .DeleteAsync(Arg.Any()); + + await _providerRepository.DidNotReceiveWithAnyArgs() + .DeleteAsync(Arg.Any()); + } + + #endregion + + #region Utilities + + private string SetupDataProtection( + Organization organization, + string providerAdminEmail) + { + var dataProtector = new MockDataProtector(organization, providerAdminEmail); + _dataProtectionProvider.CreateProtector($"{nameof(BusinessUnitConverter)}DataProtector").Returns(dataProtector); + return dataProtector.Protect(dataProtector.Token); + } + + private (Provider, ProviderOrganization, ProviderUser, ProviderPlan) SetupConversionInProgress( + Organization organization, + string providerAdminEmail) + { + var user = new User { Id = Guid.NewGuid() }; + + _userRepository.GetByEmailAsync(providerAdminEmail).Returns(user); + + var provider = new Provider + { + Id = Guid.NewGuid(), + Type = ProviderType.BusinessUnit, + Status = ProviderStatusType.Pending + }; + + _providerRepository.GetByOrganizationIdAsync(organization.Id).Returns(provider); + + var providerUser = new ProviderUser + { + Id = Guid.NewGuid(), + ProviderId = provider.Id, + UserId = user.Id, + Type = ProviderUserType.ProviderAdmin, + Status = ProviderUserStatusType.Invited, + Email = providerAdminEmail + }; + + _providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id) + .Returns(providerUser); + + var providerOrganization = new ProviderOrganization + { + Id = Guid.NewGuid(), + OrganizationId = organization.Id, + ProviderId = provider.Id + }; + + _providerOrganizationRepository.GetByOrganizationId(organization.Id) + .Returns(providerOrganization); + + var providerPlan = new ProviderPlan + { + Id = Guid.NewGuid(), + ProviderId = provider.Id, + PlanType = PlanType.EnterpriseAnnually + }; + + _providerPlanRepository.GetByProviderId(provider.Id).Returns([providerPlan]); + + return (provider, providerOrganization, providerUser, providerPlan); + } + + #endregion +} + +public class MockDataProtector( + Organization organization, + string providerAdminEmail) : IDataProtector +{ + public string Token = $"BusinessUnitConversionInvite {organization.Id} {providerAdminEmail} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"; + + public IDataProtector CreateProtector(string purpose) => this; + + public byte[] Protect(byte[] plaintext) => Encoding.UTF8.GetBytes(Token); + + public byte[] Unprotect(byte[] protectedData) => Encoding.UTF8.GetBytes(Token); +} diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs index ab1000d631..2661a0eff6 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs @@ -116,7 +116,7 @@ public class ProviderBillingServiceTests SutProvider sutProvider) { // Arrange - provider.Type = ProviderType.MultiOrganizationEnterprise; + provider.Type = ProviderType.BusinessUnit; var providerPlanRepository = sutProvider.GetDependency(); var existingPlan = new ProviderPlan diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderPriceAdapterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderPriceAdapterTests.cs index 4fce78c05a..9ecb4b0511 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderPriceAdapterTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderPriceAdapterTests.cs @@ -71,7 +71,7 @@ public class ProviderPriceAdapterTests var provider = new Provider { Id = Guid.NewGuid(), - Type = ProviderType.MultiOrganizationEnterprise + Type = ProviderType.BusinessUnit }; var subscription = new Subscription @@ -98,7 +98,7 @@ public class ProviderPriceAdapterTests var provider = new Provider { Id = Guid.NewGuid(), - Type = ProviderType.MultiOrganizationEnterprise + Type = ProviderType.BusinessUnit }; var subscription = new Subscription @@ -141,7 +141,7 @@ public class ProviderPriceAdapterTests var provider = new Provider { Id = Guid.NewGuid(), - Type = ProviderType.MultiOrganizationEnterprise + Type = ProviderType.BusinessUnit }; var result = ProviderPriceAdapter.GetActivePriceId(provider, planType); diff --git a/src/Admin/Admin.csproj b/src/Admin/Admin.csproj index 4a255eefb2..cd30e841b4 100644 --- a/src/Admin/Admin.csproj +++ b/src/Admin/Admin.csproj @@ -14,9 +14,6 @@ - - - diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index 0b1e4035df..6dc33e4909 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -133,10 +133,10 @@ public class ProvidersController : Controller return View(new CreateResellerProviderModel()); } - [HttpGet("providers/create/multi-organization-enterprise")] - public IActionResult CreateMultiOrganizationEnterprise(int enterpriseMinimumSeats, string ownerEmail = null) + [HttpGet("providers/create/business-unit")] + public IActionResult CreateBusinessUnit(int enterpriseMinimumSeats, string ownerEmail = null) { - return View(new CreateMultiOrganizationEnterpriseProviderModel + return View(new CreateBusinessUnitProviderModel { OwnerEmail = ownerEmail, EnterpriseSeatMinimum = enterpriseMinimumSeats @@ -157,7 +157,7 @@ public class ProvidersController : Controller { ProviderType.Msp => RedirectToAction("CreateMsp"), ProviderType.Reseller => RedirectToAction("CreateReseller"), - ProviderType.MultiOrganizationEnterprise => RedirectToAction("CreateMultiOrganizationEnterprise"), + ProviderType.BusinessUnit => RedirectToAction("CreateBusinessUnit"), _ => View(model) }; } @@ -198,10 +198,10 @@ public class ProvidersController : Controller return RedirectToAction("Edit", new { id = provider.Id }); } - [HttpPost("providers/create/multi-organization-enterprise")] + [HttpPost("providers/create/business-unit")] [ValidateAntiForgeryToken] [RequirePermission(Permission.Provider_Create)] - public async Task CreateMultiOrganizationEnterprise(CreateMultiOrganizationEnterpriseProviderModel model) + public async Task CreateBusinessUnit(CreateBusinessUnitProviderModel model) { if (!ModelState.IsValid) { @@ -209,7 +209,7 @@ public class ProvidersController : Controller } var provider = model.ToProvider(); - await _createProviderCommand.CreateMultiOrganizationEnterpriseAsync( + await _createProviderCommand.CreateBusinessUnitAsync( provider, model.OwnerEmail, model.Plan.Value, @@ -307,7 +307,7 @@ public class ProvidersController : Controller ]); await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand); break; - case ProviderType.MultiOrganizationEnterprise: + case ProviderType.BusinessUnit: { var existingMoePlan = providerPlans.Single(); diff --git a/src/Admin/AdminConsole/Models/CreateMultiOrganizationEnterpriseProviderModel.cs b/src/Admin/AdminConsole/Models/CreateBusinessUnitProviderModel.cs similarity index 76% rename from src/Admin/AdminConsole/Models/CreateMultiOrganizationEnterpriseProviderModel.cs rename to src/Admin/AdminConsole/Models/CreateBusinessUnitProviderModel.cs index ef7210a9ef..b57d90e33b 100644 --- a/src/Admin/AdminConsole/Models/CreateMultiOrganizationEnterpriseProviderModel.cs +++ b/src/Admin/AdminConsole/Models/CreateBusinessUnitProviderModel.cs @@ -6,7 +6,7 @@ using Bit.SharedWeb.Utilities; namespace Bit.Admin.AdminConsole.Models; -public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject +public class CreateBusinessUnitProviderModel : IValidatableObject { [Display(Name = "Owner Email")] public string OwnerEmail { get; set; } @@ -22,7 +22,7 @@ public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject { return new Provider { - Type = ProviderType.MultiOrganizationEnterprise + Type = ProviderType.BusinessUnit }; } @@ -30,17 +30,17 @@ public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject { if (string.IsNullOrWhiteSpace(OwnerEmail)) { - var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute()?.GetName() ?? nameof(OwnerEmail); + var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute()?.GetName() ?? nameof(OwnerEmail); yield return new ValidationResult($"The {ownerEmailDisplayName} field is required."); } if (EnterpriseSeatMinimum < 0) { - var enterpriseSeatMinimumDisplayName = nameof(EnterpriseSeatMinimum).GetDisplayAttribute()?.GetName() ?? nameof(EnterpriseSeatMinimum); + var enterpriseSeatMinimumDisplayName = nameof(EnterpriseSeatMinimum).GetDisplayAttribute()?.GetName() ?? nameof(EnterpriseSeatMinimum); yield return new ValidationResult($"The {enterpriseSeatMinimumDisplayName} field can not be negative."); } if (Plan != PlanType.EnterpriseAnnually && Plan != PlanType.EnterpriseMonthly) { - var planDisplayName = nameof(Plan).GetDisplayAttribute()?.GetName() ?? nameof(Plan); + var planDisplayName = nameof(Plan).GetDisplayAttribute()?.GetName() ?? nameof(Plan); yield return new ValidationResult($"The {planDisplayName} field must be set to Enterprise Annually or Enterprise Monthly."); } } diff --git a/src/Admin/AdminConsole/Models/ProviderEditModel.cs b/src/Admin/AdminConsole/Models/ProviderEditModel.cs index bcdf602c07..7f8ffb224e 100644 --- a/src/Admin/AdminConsole/Models/ProviderEditModel.cs +++ b/src/Admin/AdminConsole/Models/ProviderEditModel.cs @@ -34,7 +34,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject GatewaySubscriptionUrl = gatewaySubscriptionUrl; Type = provider.Type; - if (Type == ProviderType.MultiOrganizationEnterprise) + if (Type == ProviderType.BusinessUnit) { var plan = providerPlans.SingleOrDefault(); EnterpriseMinimumSeats = plan?.SeatMinimum ?? 0; @@ -100,7 +100,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject yield return new ValidationResult($"The {billingEmailDisplayName} field is required."); } break; - case ProviderType.MultiOrganizationEnterprise: + case ProviderType.BusinessUnit: if (Plan == null) { var displayName = nameof(Plan).GetDisplayAttribute()?.GetName() ?? nameof(Plan); diff --git a/src/Admin/AdminConsole/Models/ProviderViewModel.cs b/src/Admin/AdminConsole/Models/ProviderViewModel.cs index 724e6220b3..bcb96df006 100644 --- a/src/Admin/AdminConsole/Models/ProviderViewModel.cs +++ b/src/Admin/AdminConsole/Models/ProviderViewModel.cs @@ -40,7 +40,7 @@ public class ProviderViewModel ProviderPlanViewModels.Add(new ProviderPlanViewModel("Enterprise (Monthly) Subscription", enterpriseProviderPlan, usedEnterpriseSeats)); } } - else if (Provider.Type == ProviderType.MultiOrganizationEnterprise) + else if (Provider.Type == ProviderType.BusinessUnit) { var usedEnterpriseSeats = ProviderOrganizations.Where(po => po.PlanType == PlanType.EnterpriseMonthly) .Sum(po => po.OccupiedSeats).GetValueOrDefault(0); diff --git a/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml b/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml index 3ac716a6d4..f240cb192f 100644 --- a/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml +++ b/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml @@ -1,8 +1,13 @@ @using Bit.Admin.Enums; @using Bit.Admin.Models +@using Bit.Core +@using Bit.Core.AdminConsole.Enums.Provider @using Bit.Core.Billing.Enums -@using Bit.Core.Enums +@using Bit.Core.Billing.Extensions +@using Bit.Core.Services +@using Microsoft.AspNetCore.Mvc.TagHelpers @inject Bit.Admin.Services.IAccessControlService AccessControlService +@inject IFeatureService FeatureService @model OrganizationEditModel @{ ViewData["Title"] = (Model.Provider != null ? "Client " : string.Empty) + "Organization: " + Model.Name; @@ -13,6 +18,13 @@ var canRequestDelete = AccessControlService.UserHasPermission(Permission.Org_RequestDelete); var canDelete = AccessControlService.UserHasPermission(Permission.Org_Delete); var canUnlinkFromProvider = AccessControlService.UserHasPermission(Permission.Provider_Edit); + + var canConvertToBusinessUnit = + FeatureService.IsEnabled(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion) && + AccessControlService.UserHasPermission(Permission.Org_Billing_ConvertToBusinessUnit) && + Model.Organization.PlanType.GetProductTier() == ProductTierType.Enterprise && + !string.IsNullOrEmpty(Model.Organization.GatewaySubscriptionId) && + Model.Provider is null or { Type: ProviderType.BusinessUnit, Status: ProviderStatusType.Pending }; } @section Scripts { @@ -114,6 +126,15 @@ Enterprise Trial } + @if (canConvertToBusinessUnit) + { + + Convert to Business Unit + + } @if (canUnlinkFromProvider && Model.Provider is not null) { + + } + @if (Model.Errors?.Any() ?? false) + { + @foreach (var error in Model.Errors) + { + + } + } +

This organization has a business unit conversion in progress.

+ +
+ + +
+ +
+ + + + +
+ + +
+ @if (Model.ProviderId.HasValue) + { + + Go to Provider + + } +
+} +else +{ +

Convert @Model.Organization.Name to Business Unit

+ @if (Model.Errors?.Any() ?? false) + { + @foreach (var error in Model.Errors) + { + + } + } +
+
+
+ + +
+ +
+} diff --git a/src/Admin/Enums/Permissions.cs b/src/Admin/Enums/Permissions.cs index 4edcd742b4..704fd770bb 100644 --- a/src/Admin/Enums/Permissions.cs +++ b/src/Admin/Enums/Permissions.cs @@ -38,6 +38,7 @@ public enum Permission Org_Billing_View, Org_Billing_Edit, Org_Billing_LaunchGateway, + Org_Billing_ConvertToBusinessUnit, Provider_List_View, Provider_Create, diff --git a/src/Admin/Utilities/RolePermissionMapping.cs b/src/Admin/Utilities/RolePermissionMapping.cs index 3b510781be..f342dfce7c 100644 --- a/src/Admin/Utilities/RolePermissionMapping.cs +++ b/src/Admin/Utilities/RolePermissionMapping.cs @@ -42,6 +42,7 @@ public static class RolePermissionMapping Permission.Org_Billing_View, Permission.Org_Billing_Edit, Permission.Org_Billing_LaunchGateway, + Permission.Org_Billing_ConvertToBusinessUnit, Permission.Provider_List_View, Permission.Provider_Create, Permission.Provider_View, @@ -90,6 +91,7 @@ public static class RolePermissionMapping Permission.Org_Billing_View, Permission.Org_Billing_Edit, Permission.Org_Billing_LaunchGateway, + Permission.Org_Billing_ConvertToBusinessUnit, Permission.Org_InitiateTrial, Permission.Provider_List_View, Permission.Provider_Create, @@ -166,6 +168,7 @@ public static class RolePermissionMapping Permission.Org_Billing_View, Permission.Org_Billing_Edit, Permission.Org_Billing_LaunchGateway, + Permission.Org_Billing_ConvertToBusinessUnit, Permission.Org_RequestDelete, Permission.Provider_Edit, Permission.Provider_View, diff --git a/src/Api/AdminConsole/Models/Response/Providers/ProfileProviderResponseModel.cs b/src/Api/AdminConsole/Models/Response/Providers/ProfileProviderResponseModel.cs index bdfec26242..9cc5b89ee8 100644 --- a/src/Api/AdminConsole/Models/Response/Providers/ProfileProviderResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Providers/ProfileProviderResponseModel.cs @@ -22,6 +22,7 @@ public class ProfileProviderResponseModel : ResponseModel UserId = provider.UserId; UseEvents = provider.UseEvents; ProviderStatus = provider.ProviderStatus; + ProviderType = provider.ProviderType; } public Guid Id { get; set; } @@ -35,4 +36,5 @@ public class ProfileProviderResponseModel : ResponseModel public Guid? UserId { get; set; } public bool UseEvents { get; set; } public ProviderStatusType ProviderStatus { get; set; } + public ProviderType ProviderType { get; set; } } diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index 2ec503281e..1d4ebc1511 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -2,6 +2,7 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; +using Bit.Core; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Pricing; @@ -18,7 +19,9 @@ namespace Bit.Api.Billing.Controllers; [Route("organizations/{organizationId:guid}/billing")] [Authorize("Application")] public class OrganizationBillingController( + IBusinessUnitConverter businessUnitConverter, ICurrentContext currentContext, + IFeatureService featureService, IOrganizationBillingService organizationBillingService, IOrganizationRepository organizationRepository, IPaymentService paymentService, @@ -296,4 +299,40 @@ public class OrganizationBillingController( return TypedResults.Ok(); } + + [HttpPost("setup-business-unit")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task SetupBusinessUnitAsync( + [FromRoute] Guid organizationId, + [FromBody] SetupBusinessUnitRequestBody requestBody) + { + var enableOrganizationBusinessUnitConversion = + featureService.IsEnabled(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion); + + if (!enableOrganizationBusinessUnitConversion) + { + return Error.NotFound(); + } + + var organization = await organizationRepository.GetByIdAsync(organizationId); + + if (organization == null) + { + return Error.NotFound(); + } + + if (!await currentContext.OrganizationUser(organizationId)) + { + return Error.Unauthorized(); + } + + var providerId = await businessUnitConverter.FinalizeConversion( + organization, + requestBody.UserId, + requestBody.Token, + requestBody.ProviderKey, + requestBody.OrganizationKey); + + return TypedResults.Ok(providerId); + } } diff --git a/src/Api/Billing/Models/Requests/SetupBusinessUnitRequestBody.cs b/src/Api/Billing/Models/Requests/SetupBusinessUnitRequestBody.cs new file mode 100644 index 0000000000..c4b87a01f5 --- /dev/null +++ b/src/Api/Billing/Models/Requests/SetupBusinessUnitRequestBody.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Billing.Models.Requests; + +public class SetupBusinessUnitRequestBody +{ + [Required] + public Guid UserId { get; set; } + + [Required] + public string Token { get; set; } + + [Required] + public string ProviderKey { get; set; } + + [Required] + public string OrganizationKey { get; set; } +} diff --git a/src/Core/AdminConsole/Enums/Provider/ProviderType.cs b/src/Core/AdminConsole/Enums/Provider/ProviderType.cs index e244b9391e..8e229ed508 100644 --- a/src/Core/AdminConsole/Enums/Provider/ProviderType.cs +++ b/src/Core/AdminConsole/Enums/Provider/ProviderType.cs @@ -8,6 +8,6 @@ public enum ProviderType : byte Msp = 0, [Display(ShortName = "Reseller", Name = "Reseller", Description = "Creates Bitwarden Portal page for client organization billing management", Order = 1000)] Reseller = 1, - [Display(ShortName = "MOE", Name = "Multi-organization Enterprises", Description = "Creates provider portal for multi-organization management", Order = 1)] - MultiOrganizationEnterprise = 2, + [Display(ShortName = "Business Unit", Name = "Business Unit", Description = "Creates provider portal for business unit management", Order = 1)] + BusinessUnit = 2, } diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserProviderDetails.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserProviderDetails.cs index 23f4e1d57a..67565bad6d 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserProviderDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserProviderDetails.cs @@ -17,4 +17,5 @@ public class ProviderUserProviderDetails public string Permissions { get; set; } public bool UseEvents { get; set; } public ProviderStatusType ProviderStatus { get; set; } + public ProviderType ProviderType { get; set; } } diff --git a/src/Core/AdminConsole/Providers/Interfaces/ICreateProviderCommand.cs b/src/Core/AdminConsole/Providers/Interfaces/ICreateProviderCommand.cs index bea3c08a85..b2484bf632 100644 --- a/src/Core/AdminConsole/Providers/Interfaces/ICreateProviderCommand.cs +++ b/src/Core/AdminConsole/Providers/Interfaces/ICreateProviderCommand.cs @@ -7,5 +7,5 @@ public interface ICreateProviderCommand { Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats); Task CreateResellerAsync(Provider provider); - Task CreateMultiOrganizationEnterpriseAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats); + Task CreateBusinessUnitAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats); } diff --git a/src/Core/Billing/Extensions/BillingExtensions.cs b/src/Core/Billing/Extensions/BillingExtensions.cs index 4fb97e1db7..c8a1496726 100644 --- a/src/Core/Billing/Extensions/BillingExtensions.cs +++ b/src/Core/Billing/Extensions/BillingExtensions.cs @@ -25,19 +25,19 @@ public static class BillingExtensions public static bool IsBillable(this Provider provider) => provider is { - Type: ProviderType.Msp or ProviderType.MultiOrganizationEnterprise, + Type: ProviderType.Msp or ProviderType.BusinessUnit, Status: ProviderStatusType.Billable }; public static bool IsBillable(this InviteOrganizationProvider inviteOrganizationProvider) => inviteOrganizationProvider is { - Type: ProviderType.Msp or ProviderType.MultiOrganizationEnterprise, + Type: ProviderType.Msp or ProviderType.BusinessUnit, Status: ProviderStatusType.Billable }; public static bool SupportsConsolidatedBilling(this ProviderType providerType) - => providerType is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise; + => providerType is ProviderType.Msp or ProviderType.BusinessUnit; public static bool IsValidClient(this Organization organization) => organization is diff --git a/src/Core/Billing/Services/IBusinessUnitConverter.cs b/src/Core/Billing/Services/IBusinessUnitConverter.cs new file mode 100644 index 0000000000..06ff883eae --- /dev/null +++ b/src/Core/Billing/Services/IBusinessUnitConverter.cs @@ -0,0 +1,58 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using OneOf; + +namespace Bit.Core.Billing.Services; + +public interface IBusinessUnitConverter +{ + /// + /// Finalizes the process of converting the to a by + /// saving all the necessary key provided by the client and updating the 's subscription to a + /// provider subscription. + /// + /// The organization to convert to a business unit. + /// The ID of the organization member who will be the provider admin. + /// The token sent to the client as part of the process. + /// The encrypted provider key used to enable the . + /// The encrypted organization key used to enable the . + /// The provider ID + Task FinalizeConversion( + Organization organization, + Guid userId, + string token, + string providerKey, + string organizationKey); + + /// + /// Begins the process of converting the to a by + /// creating all the necessary database entities and sending a setup invitation to the . + /// + /// The organization to convert to a business unit. + /// The email address of the organization member who will be the provider admin. + /// Either the newly created provider ID or a list of validation failures. + Task>> InitiateConversion( + Organization organization, + string providerAdminEmail); + + /// + /// Checks if the has a business unit conversion in progress and, if it does, resends the + /// setup invitation to the provider admin. + /// + /// The organization to convert to a business unit. + /// The email address of the organization member who will be the provider admin. + Task ResendConversionInvite( + Organization organization, + string providerAdminEmail); + + /// + /// Checks if the has a business unit conversion in progress and, if it does, resets that conversion + /// by deleting all the database entities created as part of . + /// + /// The organization to convert to a business unit. + /// The email address of the organization member who will be the provider admin. + Task ResetConversion( + Organization organization, + string providerAdminEmail); +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 33dec32e34..842c6b6341 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -147,6 +147,7 @@ public static class FeatureFlagKeys public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method"; public const string PM19147_AutomaticTaxImprovements = "pm-19147-automatic-tax-improvements"; public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates"; + public const string PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion"; /* Key Management Team */ public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; diff --git a/src/Core/MailTemplates/Handlebars/Billing/BusinessUnitConversionInvite.html.hbs b/src/Core/MailTemplates/Handlebars/Billing/BusinessUnitConversionInvite.html.hbs new file mode 100644 index 0000000000..59da019839 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Billing/BusinessUnitConversionInvite.html.hbs @@ -0,0 +1,19 @@ +{{#>FullHtmlLayout}} + + + + + + + +
+ You have been invited to set up a new Business Unit Portal within Bitwarden. +
+
+
+ + Set Up Business Unit Portal Now + +
+
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/Billing/BusinessUnitConversionInvite.text.hbs b/src/Core/MailTemplates/Handlebars/Billing/BusinessUnitConversionInvite.text.hbs new file mode 100644 index 0000000000..b2973f32c2 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Billing/BusinessUnitConversionInvite.text.hbs @@ -0,0 +1,5 @@ +{{#>BasicTextLayout}} + You have been invited to set up a new Business Unit Portal within Bitwarden. To continue, click the following link: + + {{{Url}}} +{{/BasicTextLayout}} diff --git a/src/Core/Models/Mail/Billing/BusinessUnitConversionInviteModel.cs b/src/Core/Models/Mail/Billing/BusinessUnitConversionInviteModel.cs new file mode 100644 index 0000000000..328d37058b --- /dev/null +++ b/src/Core/Models/Mail/Billing/BusinessUnitConversionInviteModel.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.Models.Mail.Billing; + +public class BusinessUnitConversionInviteModel : BaseMailModel +{ + public string OrganizationId { get; set; } + public string Email { get; set; } + public string Token { get; set; } + + public string Url => + $"{WebVaultUrl}/providers/setup-business-unit?organizationId={OrganizationId}&email={Email}&token={Token}"; +} diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 48e0464905..805c143173 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -70,6 +70,7 @@ public interface IMailService Task SendEnqueuedMailMessageAsync(IMailQueueMessage queueMessage); Task SendAdminResetPasswordEmailAsync(string email, string userName, string orgName); Task SendProviderSetupInviteEmailAsync(Provider provider, string token, string email); + Task SendBusinessUnitConversionInviteAsync(Organization organization, string token, string email); Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email); Task SendProviderConfirmedEmailAsync(string providerName, string email); Task SendProviderUserRemoved(string providerName, string email); diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 7bcf2c0ef5..81e17e7c6f 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -11,6 +11,7 @@ using Bit.Core.Billing.Models.Mail; using Bit.Core.Entities; using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Mail; +using Bit.Core.Models.Mail.Billing; using Bit.Core.Models.Mail.FamiliesForEnterprise; using Bit.Core.Models.Mail.Provider; using Bit.Core.SecretsManager.Models.Mail; @@ -949,6 +950,22 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendBusinessUnitConversionInviteAsync(Organization organization, string token, string email) + { + var message = CreateDefaultMessage("Set Up Business Unit", email); + var model = new BusinessUnitConversionInviteModel + { + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName, + OrganizationId = organization.Id.ToString(), + Email = WebUtility.UrlEncode(email), + Token = WebUtility.UrlEncode(token) + }; + await AddMessageContentAsync(message, "Billing.BusinessUnitConversionInvite", model); + message.Category = "BusinessUnitConversionInvite"; + await _mailDeliveryService.SendEmailAsync(message); + } + public async Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email) { var message = CreateDefaultMessage($"Join {providerName}", email); diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index f6b27b0670..381af2fd1c 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -212,6 +212,11 @@ public class NoopMailService : IMailService return Task.FromResult(0); } + public Task SendBusinessUnitConversionInviteAsync(Organization organization, string token, string email) + { + return Task.FromResult(0); + } + public Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email) { return Task.FromResult(0); diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index a5f4f0bd9d..26c782000e 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -332,7 +332,7 @@ public class OrganizationRepository : Repository PlanConstants.EnterprisePlanTypes.Concat(PlanConstants.TeamsPlanTypes), - ProviderType.MultiOrganizationEnterprise => PlanConstants.EnterprisePlanTypes, + ProviderType.BusinessUnit => PlanConstants.EnterprisePlanTypes, _ => [] }; diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserProviderDetailsReadByUserIdStatusQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserProviderDetailsReadByUserIdStatusQuery.cs index 6469ffc9ce..231f587429 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserProviderDetailsReadByUserIdStatusQuery.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserProviderDetailsReadByUserIdStatusQuery.cs @@ -35,6 +35,7 @@ public class ProviderUserProviderDetailsReadByUserIdStatusQuery : IQuery sutProvider) { // Arrange // Act - var actual = await sutProvider.Sut.CreateMultiOrganizationEnterprise(model); + var actual = await sutProvider.Sut.CreateBusinessUnit(model); // Assert Assert.NotNull(actual); await sutProvider.GetDependency() .Received(Quantity.Exactly(1)) - .CreateMultiOrganizationEnterpriseAsync( - Arg.Is(x => x.Type == ProviderType.MultiOrganizationEnterprise), + .CreateBusinessUnitAsync( + Arg.Is(x => x.Type == ProviderType.BusinessUnit), model.OwnerEmail, Arg.Is(y => y == model.Plan), model.EnterpriseSeatMinimum); @@ -102,16 +102,16 @@ public class ProvidersControllerTests [BitAutoData] [SutProviderCustomize] [Theory] - public async Task CreateMultiOrganizationEnterpriseAsync_RedirectsToExpectedPage_AfterCreatingProvider( - CreateMultiOrganizationEnterpriseProviderModel model, + public async Task CreateBusinessUnitAsync_RedirectsToExpectedPage_AfterCreatingProvider( + CreateBusinessUnitProviderModel model, Guid expectedProviderId, SutProvider sutProvider) { // Arrange sutProvider.GetDependency() .When(x => - x.CreateMultiOrganizationEnterpriseAsync( - Arg.Is(y => y.Type == ProviderType.MultiOrganizationEnterprise), + x.CreateBusinessUnitAsync( + Arg.Is(y => y.Type == ProviderType.BusinessUnit), model.OwnerEmail, Arg.Is(y => y == model.Plan), model.EnterpriseSeatMinimum)) @@ -122,7 +122,7 @@ public class ProvidersControllerTests }); // Act - var actual = await sutProvider.Sut.CreateMultiOrganizationEnterprise(model); + var actual = await sutProvider.Sut.CreateBusinessUnit(model); // Assert Assert.NotNull(actual); From cb9d7e450f3b1cb2fb7f3e13e62adea9cc848b6b Mon Sep 17 00:00:00 2001 From: MtnBurrit0 <77340197+mimartin12@users.noreply.github.com> Date: Thu, 10 Apr 2025 10:44:31 -0600 Subject: [PATCH 20/28] Drop create_branch input, it's enabled by default. (#5634) --- .github/workflows/build.yml | 1 - .github/workflows/ephemeral-environment.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 35c2070df3..33edd075a0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -638,7 +638,6 @@ jobs: with: project: server pull_request_number: ${{ github.event.number }} - create_branch: true secrets: inherit check-failures: diff --git a/.github/workflows/ephemeral-environment.yml b/.github/workflows/ephemeral-environment.yml index 699a28c6fb..6dd89536b6 100644 --- a/.github/workflows/ephemeral-environment.yml +++ b/.github/workflows/ephemeral-environment.yml @@ -13,5 +13,4 @@ jobs: project: server pull_request_number: ${{ github.event.number }} sync_environment: true - create_branch: true secrets: inherit From a1016b4df9098dd0c15f4fbb5032e1b916b9b815 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Thu, 10 Apr 2025 11:28:53 -0700 Subject: [PATCH 21/28] Fix feature flag key value (#5636) --- src/Core/Constants.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 842c6b6341..e85eee9a7d 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -197,7 +197,7 @@ public static class FeatureFlagKeys public const string RestrictProviderAccess = "restrict-provider-access"; public const string SecurityTasks = "security-tasks"; public const string CipherKeyEncryption = "cipher-key-encryption"; - public const string DesktopCipherForms = "pm-18520-desktop-cipher-forms"; + public const string DesktopCipherForm = "pm-18520-desktop-cipher-form"; public static List GetAllKeys() { From 0b50a1819ef2895d7b3add307093872b8a6e11e7 Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Thu, 10 Apr 2025 14:55:40 -0400 Subject: [PATCH 22/28] Added feature flag (#5632) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index e85eee9a7d..23c51d5974 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -198,6 +198,7 @@ public static class FeatureFlagKeys public const string SecurityTasks = "security-tasks"; public const string CipherKeyEncryption = "cipher-key-encryption"; public const string DesktopCipherForm = "pm-18520-desktop-cipher-form"; + public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk"; public static List GetAllKeys() { From d553d52c93da67a503cb9f2598baa94481e921df Mon Sep 17 00:00:00 2001 From: bnagawiecki <107435978+bnagawiecki@users.noreply.github.com> Date: Thu, 10 Apr 2025 16:24:16 -0400 Subject: [PATCH 23/28] revert back to plural key value (#5638) --- src/Core/Constants.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 23c51d5974..2b53c52ab8 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -197,7 +197,7 @@ public static class FeatureFlagKeys public const string RestrictProviderAccess = "restrict-provider-access"; public const string SecurityTasks = "security-tasks"; public const string CipherKeyEncryption = "cipher-key-encryption"; - public const string DesktopCipherForm = "pm-18520-desktop-cipher-form"; + public const string DesktopCipherForms = "pm-18520-desktop-cipher-forms"; public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk"; public static List GetAllKeys() From dff00e613d16789a4df822bfcdf3a2ee42947289 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Fri, 11 Apr 2025 09:34:51 -0400 Subject: [PATCH 24/28] Add invoice null check (#5642) --- .../Services/Implementations/OrganizationBillingService.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs index ae9ed15e72..5f5b803662 100644 --- a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs +++ b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs @@ -93,7 +93,9 @@ public class OrganizationBillingService( var isOnSecretsManagerStandalone = await IsOnSecretsManagerStandalone(organization, customer, subscription); - var invoice = await stripeAdapter.InvoiceGetAsync(subscription.LatestInvoiceId, new InvoiceGetOptions()); + var invoice = !string.IsNullOrEmpty(subscription.LatestInvoiceId) + ? await stripeAdapter.InvoiceGetAsync(subscription.LatestInvoiceId, new InvoiceGetOptions()) + : null; return new OrganizationMetadata( isEligibleForSelfHost, From bfe5ecda92c3ce116fb383d528f15e3f76178c86 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Fri, 11 Apr 2025 15:59:54 -0400 Subject: [PATCH 25/28] Add UpdateCiphersAsync Test (#5543) * Add UpdateCiphersAsync Test * Fix UpdateCiphersAsync * Fix #2 * Fix SQL Server * Formatting --- .../Vault/Repositories/CipherRepository.cs | 2 +- .../Vault/Repositories/CipherRepository.cs | 26 +++++++++++++++-- .../Repositories/CipherRepositoryTests.cs | 29 +++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs index b85f1991f7..3df365330c 100644 --- a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs @@ -711,7 +711,7 @@ public class CipherRepository : Repository, ICipherRepository row[creationDateColumn] = cipher.CreationDate; row[revisionDateColumn] = cipher.RevisionDate; row[deletedDateColumn] = cipher.DeletedDate.HasValue ? (object)cipher.DeletedDate : DBNull.Value; - row[repromptColumn] = cipher.Reprompt; + row[repromptColumn] = cipher.Reprompt.HasValue ? cipher.Reprompt.Value : DBNull.Value; row[keyColummn] = cipher.Key; ciphersTable.Rows.Add(row); diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs index e4930cb795..090c36ff29 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs @@ -863,8 +863,30 @@ public class CipherRepository : Repository>(ciphers); - await dbContext.BulkCopyAsync(base.DefaultBulkCopyOptions, entities); + var ciphersToUpdate = ciphers.ToDictionary(c => c.Id); + + var existingCiphers = await dbContext.Ciphers + .Where(c => c.UserId == userId && ciphersToUpdate.Keys.Contains(c.Id)) + .ToDictionaryAsync(c => c.Id); + + foreach (var (cipherId, cipher) in ciphersToUpdate) + { + if (!existingCiphers.TryGetValue(cipherId, out var existingCipher)) + { + // The Dapper version does not validate that the same amount of items given where updated. + continue; + } + + existingCipher.UserId = cipher.UserId; + existingCipher.OrganizationId = cipher.OrganizationId; + existingCipher.Type = cipher.Type; + existingCipher.Data = cipher.Data; + existingCipher.Attachments = cipher.Attachments; + existingCipher.RevisionDate = cipher.RevisionDate; + existingCipher.DeletedDate = cipher.DeletedDate; + existingCipher.Key = cipher.Key; + } + await dbContext.UserBumpAccountRevisionDateAsync(userId); await dbContext.SaveChangesAsync(); } diff --git a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs index 6f02740cf5..fde625e272 100644 --- a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs @@ -883,4 +883,33 @@ public class CipherRepositoryTests Assert.Contains(user2TaskCiphers, t => t.CipherId == manageCipher1.Id && t.TaskId == securityTasks[0].Id); Assert.Contains(user2TaskCiphers, t => t.CipherId == manageCipher2.Id && t.TaskId == securityTasks[1].Id); } + + [DatabaseTheory, DatabaseData] + public async Task UpdateCiphersAsync_Works(ICipherRepository cipherRepository, IUserRepository userRepository) + { + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var cipher1 = await CreatePersonalCipher(user, cipherRepository); + var cipher2 = await CreatePersonalCipher(user, cipherRepository); + + cipher1.Type = CipherType.SecureNote; + cipher2.Attachments = "new_attachments"; + + await cipherRepository.UpdateCiphersAsync(user.Id, [cipher1, cipher2]); + + var updatedCipher1 = await cipherRepository.GetByIdAsync(cipher1.Id); + var updatedCipher2 = await cipherRepository.GetByIdAsync(cipher2.Id); + + Assert.NotNull(updatedCipher1); + Assert.NotNull(updatedCipher2); + + Assert.Equal(CipherType.SecureNote, updatedCipher1.Type); + Assert.Equal("new_attachments", updatedCipher2.Attachments); + } } From c986cbb2086f296c7a0df9afec9aa7e8b981dd06 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Mon, 14 Apr 2025 10:10:37 -0400 Subject: [PATCH 26/28] Added IdentityServer directories to Auth ownership. (#5647) --- .github/CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 03dbb7aac4..973405dea5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -37,6 +37,8 @@ util/Setup/** @bitwarden/dept-bre @bitwarden/team-platform-dev **/Auth @bitwarden/team-auth-dev bitwarden_license/src/Sso @bitwarden/team-auth-dev src/Identity @bitwarden/team-auth-dev +src/Core/Identity @bitwarden/team-auth-dev +src/Core/IdentityServer @bitwarden/team-auth-dev # Key Management team **/KeyManagement @bitwarden/team-key-management-dev From 4d6e4d35f28858425ed5cc838f9e3d981d9ae1fe Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Mon, 14 Apr 2025 13:04:56 -0400 Subject: [PATCH 27/28] [PM-18555] Notifications service tests (#5473) * Add RelayPush Notifications Tests * Nullable Test Fixup * Azure Queue Notifications Tests * NotificationsHub Push Tests * Make common base for API based notifications * Register TimeProvider just in case * Format * React to TaskId * Remove completed TODO --- .../NotificationHubPushNotificationService.cs | 8 +- .../AzureQueuePushNotificationService.cs | 8 +- ...NotificationsApiPushNotificationService.cs | 7 +- .../Services/RelayPushNotificationService.cs | 7 +- .../Utilities/ServiceCollectionExtensions.cs | 3 + ...ficationHubPushNotificationServiceTests.cs | 638 ++++++++++++++ .../AzureQueuePushNotificationServiceTests.cs | 775 ++++++++++++++++++ ...icationsApiPushNotificationServiceTests.cs | 400 ++++++++- .../Platform/Push/Services/PushTestBase.cs | 498 +++++++++++ .../RelayPushNotificationServiceTests.cs | 544 +++++++++++- 10 files changed, 2826 insertions(+), 62 deletions(-) create mode 100644 test/Core.Test/Platform/Push/Services/PushTestBase.cs diff --git a/src/Core/NotificationHub/NotificationHubPushNotificationService.cs b/src/Core/NotificationHub/NotificationHubPushNotificationService.cs index 55badf80e4..bb3de80977 100644 --- a/src/Core/NotificationHub/NotificationHubPushNotificationService.cs +++ b/src/Core/NotificationHub/NotificationHubPushNotificationService.cs @@ -32,20 +32,22 @@ public class NotificationHubPushNotificationService : IPushNotificationService private readonly INotificationHubPool _notificationHubPool; private readonly ILogger _logger; private readonly IGlobalSettings _globalSettings; + private readonly TimeProvider _timeProvider; public NotificationHubPushNotificationService( IInstallationDeviceRepository installationDeviceRepository, INotificationHubPool notificationHubPool, IHttpContextAccessor httpContextAccessor, ILogger logger, - IGlobalSettings globalSettings) + IGlobalSettings globalSettings, + TimeProvider timeProvider) { _installationDeviceRepository = installationDeviceRepository; _httpContextAccessor = httpContextAccessor; _notificationHubPool = notificationHubPool; _logger = logger; _globalSettings = globalSettings; - + _timeProvider = timeProvider; if (globalSettings.Installation.Id == Guid.Empty) { logger.LogWarning("Installation ID is not set. Push notifications for installations will not work."); @@ -152,7 +154,7 @@ public class NotificationHubPushNotificationService : IPushNotificationService private async Task PushUserAsync(Guid userId, PushType type, bool excludeCurrentContext = false) { - var message = new UserPushNotification { UserId = userId, Date = DateTime.UtcNow }; + var message = new UserPushNotification { UserId = userId, Date = _timeProvider.GetUtcNow().UtcDateTime }; await SendPayloadToUserAsync(userId, type, message, excludeCurrentContext); } diff --git a/src/Core/Platform/Push/Services/AzureQueuePushNotificationService.cs b/src/Core/Platform/Push/Services/AzureQueuePushNotificationService.cs index a9140796f2..05d1dd2d1d 100644 --- a/src/Core/Platform/Push/Services/AzureQueuePushNotificationService.cs +++ b/src/Core/Platform/Push/Services/AzureQueuePushNotificationService.cs @@ -22,17 +22,19 @@ public class AzureQueuePushNotificationService : IPushNotificationService private readonly QueueClient _queueClient; private readonly IHttpContextAccessor _httpContextAccessor; private readonly IGlobalSettings _globalSettings; + private readonly TimeProvider _timeProvider; public AzureQueuePushNotificationService( [FromKeyedServices("notifications")] QueueClient queueClient, IHttpContextAccessor httpContextAccessor, IGlobalSettings globalSettings, - ILogger logger) + ILogger logger, + TimeProvider timeProvider) { _queueClient = queueClient; _httpContextAccessor = httpContextAccessor; _globalSettings = globalSettings; - + _timeProvider = timeProvider; if (globalSettings.Installation.Id == Guid.Empty) { logger.LogWarning("Installation ID is not set. Push notifications for installations will not work."); @@ -140,7 +142,7 @@ public class AzureQueuePushNotificationService : IPushNotificationService private async Task PushUserAsync(Guid userId, PushType type, bool excludeCurrentContext = false) { - var message = new UserPushNotification { UserId = userId, Date = DateTime.UtcNow }; + var message = new UserPushNotification { UserId = userId, Date = _timeProvider.GetUtcNow().UtcDateTime }; await SendMessageAsync(type, message, excludeCurrentContext); } diff --git a/src/Core/Platform/Push/Services/NotificationsApiPushNotificationService.cs b/src/Core/Platform/Push/Services/NotificationsApiPushNotificationService.cs index d5cc0819cd..bdeefc0363 100644 --- a/src/Core/Platform/Push/Services/NotificationsApiPushNotificationService.cs +++ b/src/Core/Platform/Push/Services/NotificationsApiPushNotificationService.cs @@ -24,12 +24,14 @@ public class NotificationsApiPushNotificationService : BaseIdentityClientService { private readonly IGlobalSettings _globalSettings; private readonly IHttpContextAccessor _httpContextAccessor; + private readonly TimeProvider _timeProvider; public NotificationsApiPushNotificationService( IHttpClientFactory httpFactory, GlobalSettings globalSettings, IHttpContextAccessor httpContextAccessor, - ILogger logger) + ILogger logger, + TimeProvider timeProvider) : base( httpFactory, globalSettings.BaseServiceUri.InternalNotifications, @@ -41,6 +43,7 @@ public class NotificationsApiPushNotificationService : BaseIdentityClientService { _globalSettings = globalSettings; _httpContextAccessor = httpContextAccessor; + _timeProvider = timeProvider; } public async Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable collectionIds) @@ -148,7 +151,7 @@ public class NotificationsApiPushNotificationService : BaseIdentityClientService var message = new UserPushNotification { UserId = userId, - Date = DateTime.UtcNow + Date = _timeProvider.GetUtcNow().UtcDateTime, }; await SendMessageAsync(type, message, excludeCurrentContext); diff --git a/src/Core/Platform/Push/Services/RelayPushNotificationService.cs b/src/Core/Platform/Push/Services/RelayPushNotificationService.cs index c9e77bf17f..0ede81e719 100644 --- a/src/Core/Platform/Push/Services/RelayPushNotificationService.cs +++ b/src/Core/Platform/Push/Services/RelayPushNotificationService.cs @@ -27,13 +27,15 @@ public class RelayPushNotificationService : BaseIdentityClientService, IPushNoti private readonly IDeviceRepository _deviceRepository; private readonly IGlobalSettings _globalSettings; private readonly IHttpContextAccessor _httpContextAccessor; + private readonly TimeProvider _timeProvider; public RelayPushNotificationService( IHttpClientFactory httpFactory, IDeviceRepository deviceRepository, GlobalSettings globalSettings, IHttpContextAccessor httpContextAccessor, - ILogger logger) + ILogger logger, + TimeProvider timeProvider) : base( httpFactory, globalSettings.PushRelayBaseUri, @@ -46,6 +48,7 @@ public class RelayPushNotificationService : BaseIdentityClientService, IPushNoti _deviceRepository = deviceRepository; _globalSettings = globalSettings; _httpContextAccessor = httpContextAccessor; + _timeProvider = timeProvider; } public async Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable collectionIds) @@ -147,7 +150,7 @@ public class RelayPushNotificationService : BaseIdentityClientService, IPushNoti private async Task PushUserAsync(Guid userId, PushType type, bool excludeCurrentContext = false) { - var message = new UserPushNotification { UserId = userId, Date = DateTime.UtcNow }; + var message = new UserPushNotification { UserId = userId, Date = _timeProvider.GetUtcNow().UtcDateTime }; await SendPayloadToUserAsync(userId, type, message, excludeCurrentContext); } diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 144ea1f036..5340a88aef 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -67,6 +67,7 @@ using Microsoft.Extensions.Caching.Cosmos; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -279,6 +280,8 @@ public static class ServiceCollectionExtensions services.AddSingleton(); } + services.TryAddSingleton(TimeProvider.System); + services.AddSingleton(); if (globalSettings.SelfHosted) { diff --git a/test/Core.Test/NotificationHub/NotificationHubPushNotificationServiceTests.cs b/test/Core.Test/NotificationHub/NotificationHubPushNotificationServiceTests.cs index 2ae3d739a8..6f4ea9ca12 100644 --- a/test/Core.Test/NotificationHub/NotificationHubPushNotificationServiceTests.cs +++ b/test/Core.Test/NotificationHub/NotificationHubPushNotificationServiceTests.cs @@ -1,15 +1,25 @@ #nullable enable using System.Text.Json; +using System.Text.Json.Nodes; +using Bit.Core.Auth.Entities; +using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Models; using Bit.Core.Models.Data; using Bit.Core.NotificationCenter.Entities; +using Bit.Core.NotificationCenter.Enums; using Bit.Core.NotificationHub; using Bit.Core.Repositories; using Bit.Core.Settings; using Bit.Core.Test.NotificationCenter.AutoFixture; +using Bit.Core.Tools.Entities; +using Bit.Core.Vault.Entities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; using NSubstitute; using Xunit; @@ -19,6 +29,10 @@ namespace Bit.Core.Test.NotificationHub; [NotificationStatusCustomize] public class NotificationHubPushNotificationServiceTests { + private static readonly string _deviceIdentifier = "test_device_identifier"; + private static readonly DateTime _now = DateTime.UtcNow; + private static readonly Guid _installationId = Guid.Parse("da73177b-513f-4444-b582-595c890e1022"); + [Theory] [BitAutoData] [NotificationCustomize] @@ -496,6 +510,630 @@ public class NotificationHubPushNotificationServiceTests .UpsertAsync(Arg.Any()); } + [Fact] + public async Task PushSyncCipherCreateAsync_SendExpectedData() + { + var collectionId = Guid.NewGuid(); + + var userId = Guid.NewGuid(); + + var cipher = new Cipher + { + Id = Guid.NewGuid(), + UserId = userId, + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Id"] = cipher.Id, + ["UserId"] = cipher.UserId, + ["OrganizationId"] = cipher.OrganizationId, + ["CollectionIds"] = new JsonArray(collectionId), + ["RevisionDate"] = cipher.RevisionDate, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncCipherCreateAsync(cipher, [collectionId]), + PushType.SyncCipherCreate, + expectedPayload, + $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})" + ); + } + + [Fact] + public async Task PushSyncCipherUpdateAsync_SendExpectedData() + { + var collectionId = Guid.NewGuid(); + + var userId = Guid.NewGuid(); + + var cipher = new Cipher + { + Id = Guid.NewGuid(), + UserId = userId, + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Id"] = cipher.Id, + ["UserId"] = cipher.UserId, + ["OrganizationId"] = cipher.OrganizationId, + ["CollectionIds"] = new JsonArray(collectionId), + ["RevisionDate"] = cipher.RevisionDate, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncCipherUpdateAsync(cipher, [collectionId]), + PushType.SyncCipherUpdate, + expectedPayload, + $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})" + ); + } + + [Fact] + public async Task PushSyncCipherDeleteAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var cipher = new Cipher + { + Id = Guid.NewGuid(), + UserId = userId, + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Id"] = cipher.Id, + ["UserId"] = cipher.UserId, + ["OrganizationId"] = cipher.OrganizationId, + ["CollectionIds"] = null, + ["RevisionDate"] = cipher.RevisionDate, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncCipherDeleteAsync(cipher), + PushType.SyncLoginDelete, + expectedPayload, + $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})" + ); + } + + [Fact] + public async Task PushSyncFolderCreateAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var folder = new Folder + { + Id = Guid.NewGuid(), + UserId = userId, + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Id"] = folder.Id, + ["UserId"] = folder.UserId, + ["RevisionDate"] = folder.RevisionDate, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncFolderCreateAsync(folder), + PushType.SyncFolderCreate, + expectedPayload, + $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})" + ); + } + + [Fact] + public async Task PushSyncFolderUpdateAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var folder = new Folder + { + Id = Guid.NewGuid(), + UserId = userId, + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Id"] = folder.Id, + ["UserId"] = folder.UserId, + ["RevisionDate"] = folder.RevisionDate, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncFolderUpdateAsync(folder), + PushType.SyncFolderUpdate, + expectedPayload, + $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})" + ); + } + + [Fact] + public async Task PushSyncSendCreateAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var send = new Send + { + Id = Guid.NewGuid(), + UserId = userId, + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Id"] = send.Id, + ["UserId"] = send.UserId, + ["RevisionDate"] = send.RevisionDate, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncSendCreateAsync(send), + PushType.SyncSendCreate, + expectedPayload, + $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})" + ); + } + + [Fact] + public async Task PushAuthRequestAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var authRequest = new AuthRequest + { + Id = Guid.NewGuid(), + UserId = userId, + }; + + var expectedPayload = new JsonObject + { + ["Id"] = authRequest.Id, + ["UserId"] = authRequest.UserId, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushAuthRequestAsync(authRequest), + PushType.AuthRequest, + expectedPayload, + $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})" + ); + } + + [Fact] + public async Task PushAuthRequestResponseAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var authRequest = new AuthRequest + { + Id = Guid.NewGuid(), + UserId = userId, + }; + + var expectedPayload = new JsonObject + { + ["Id"] = authRequest.Id, + ["UserId"] = authRequest.UserId, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushAuthRequestResponseAsync(authRequest), + PushType.AuthRequestResponse, + expectedPayload, + $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})" + ); + } + + [Fact] + public async Task PushSyncSendUpdateAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var send = new Send + { + Id = Guid.NewGuid(), + UserId = userId, + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Id"] = send.Id, + ["UserId"] = send.UserId, + ["RevisionDate"] = send.RevisionDate, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncSendUpdateAsync(send), + PushType.SyncSendUpdate, + expectedPayload, + $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})" + ); + } + + [Fact] + public async Task PushSyncSendDeleteAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var send = new Send + { + Id = Guid.NewGuid(), + UserId = userId, + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Id"] = send.Id, + ["UserId"] = send.UserId, + ["RevisionDate"] = send.RevisionDate, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncSendDeleteAsync(send), + PushType.SyncSendDelete, + expectedPayload, + $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})" + ); + } + + [Fact] + public async Task PushSyncCiphersAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var expectedPayload = new JsonObject + { + ["UserId"] = userId, + ["Date"] = _now, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncCiphersAsync(userId), + PushType.SyncCiphers, + expectedPayload, + $"(template:payload_userId:{userId})" + ); + } + + [Fact] + public async Task PushSyncVaultAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var expectedPayload = new JsonObject + { + ["UserId"] = userId, + ["Date"] = _now, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncVaultAsync(userId), + PushType.SyncVault, + expectedPayload, + $"(template:payload_userId:{userId})" + ); + } + + [Fact] + public async Task PushSyncOrganizationsAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var expectedPayload = new JsonObject + { + ["UserId"] = userId, + ["Date"] = _now, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncOrganizationsAsync(userId), + PushType.SyncOrganizations, + expectedPayload, + $"(template:payload_userId:{userId})" + ); + } + + [Fact] + public async Task PushSyncOrgKeysAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var expectedPayload = new JsonObject + { + ["UserId"] = userId, + ["Date"] = _now, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncOrgKeysAsync(userId), + PushType.SyncOrgKeys, + expectedPayload, + $"(template:payload_userId:{userId})" + ); + } + + [Fact] + public async Task PushSyncSettingsAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var expectedPayload = new JsonObject + { + ["UserId"] = userId, + ["Date"] = _now, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncSettingsAsync(userId), + PushType.SyncSettings, + expectedPayload, + $"(template:payload_userId:{userId})" + ); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task PushLogOutAsync_SendExpectedData(bool excludeCurrentContext) + { + var userId = Guid.NewGuid(); + + var expectedPayload = new JsonObject + { + ["UserId"] = userId, + ["Date"] = _now, + }; + + var expectedTag = excludeCurrentContext + ? $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})" + : $"(template:payload_userId:{userId})"; + + await VerifyNotificationAsync( + async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext), + PushType.LogOut, + expectedPayload, + expectedTag + ); + } + + [Fact] + public async Task PushSyncFolderDeleteAsync_SendExpectedData() + { + var userId = Guid.NewGuid(); + + var folder = new Folder + { + Id = Guid.NewGuid(), + UserId = userId, + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Id"] = folder.Id, + ["UserId"] = folder.UserId, + ["RevisionDate"] = folder.RevisionDate, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncFolderDeleteAsync(folder), + PushType.SyncFolderDelete, + expectedPayload, + $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})" + ); + } + + [Theory] + [InlineData(true, null, null)] + [InlineData(false, "e8e08ce8-8a26-4a65-913a-ba1d8c478b2f", null)] + [InlineData(false, null, "2f53ee32-edf9-4169-b276-760fe92e03bf")] + public async Task PushNotificationAsync_SendExpectedData(bool global, string? userId, string? organizationId) + { + var notification = new Notification + { + Id = Guid.NewGuid(), + Priority = Priority.High, + Global = global, + ClientType = ClientType.All, + UserId = userId != null ? Guid.Parse(userId) : null, + OrganizationId = organizationId != null ? Guid.Parse(organizationId) : null, + TaskId = Guid.NewGuid(), + Title = "My Title", + Body = "My Body", + CreationDate = DateTime.UtcNow.AddDays(-1), + RevisionDate = DateTime.UtcNow, + }; + + JsonNode? installationId = global ? _installationId : null; + + var expectedPayload = new JsonObject + { + ["Id"] = notification.Id, + ["Priority"] = 3, + ["Global"] = global, + ["ClientType"] = 0, + ["UserId"] = notification.UserId, + ["OrganizationId"] = notification.OrganizationId, + ["TaskId"] = notification.TaskId, + ["InstallationId"] = installationId, + ["Title"] = notification.Title, + ["Body"] = notification.Body, + ["CreationDate"] = notification.CreationDate, + ["RevisionDate"] = notification.RevisionDate, + ["ReadDate"] = null, + ["DeletedDate"] = null, + }; + + string expectedTag; + + if (global) + { + expectedTag = $"(template:payload && installationId:{_installationId} && !deviceIdentifier:{_deviceIdentifier})"; + } + else if (notification.OrganizationId.HasValue) + { + expectedTag = "(template:payload && organizationId:2f53ee32-edf9-4169-b276-760fe92e03bf && !deviceIdentifier:test_device_identifier)"; + } + else + { + expectedTag = $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})"; + } + + await VerifyNotificationAsync( + async sut => await sut.PushNotificationAsync(notification), + PushType.Notification, + expectedPayload, + expectedTag + ); + } + + [Theory] + [InlineData(true, null, null)] + [InlineData(false, "e8e08ce8-8a26-4a65-913a-ba1d8c478b2f", null)] + [InlineData(false, null, "2f53ee32-edf9-4169-b276-760fe92e03bf")] + public async Task PushNotificationStatusAsync_SendExpectedData(bool global, string? userId, string? organizationId) + { + var notification = new Notification + { + Id = Guid.NewGuid(), + Priority = Priority.High, + Global = global, + ClientType = ClientType.All, + UserId = userId != null ? Guid.Parse(userId) : null, + OrganizationId = organizationId != null ? Guid.Parse(organizationId) : null, + Title = "My Title", + Body = "My Body", + CreationDate = DateTime.UtcNow.AddDays(-1), + RevisionDate = DateTime.UtcNow, + }; + + var notificationStatus = new NotificationStatus + { + ReadDate = DateTime.UtcNow.AddDays(-1), + DeletedDate = DateTime.UtcNow, + }; + + JsonNode? installationId = global ? _installationId : null; + + var expectedPayload = new JsonObject + { + ["Id"] = notification.Id, + ["Priority"] = 3, + ["Global"] = global, + ["ClientType"] = 0, + ["UserId"] = notification.UserId, + ["OrganizationId"] = notification.OrganizationId, + ["TaskId"] = notification.TaskId, + ["InstallationId"] = installationId, + ["Title"] = notification.Title, + ["Body"] = notification.Body, + ["CreationDate"] = notification.CreationDate, + ["RevisionDate"] = notification.RevisionDate, + ["ReadDate"] = notificationStatus.ReadDate, + ["DeletedDate"] = notificationStatus.DeletedDate, + }; + + string expectedTag; + + if (global) + { + expectedTag = $"(template:payload && installationId:{_installationId} && !deviceIdentifier:{_deviceIdentifier})"; + } + else if (notification.OrganizationId.HasValue) + { + expectedTag = "(template:payload && organizationId:2f53ee32-edf9-4169-b276-760fe92e03bf && !deviceIdentifier:test_device_identifier)"; + } + else + { + expectedTag = $"(template:payload_userId:{userId} && !deviceIdentifier:{_deviceIdentifier})"; + } + + await VerifyNotificationAsync( + async sut => await sut.PushNotificationStatusAsync(notification, notificationStatus), + PushType.NotificationStatus, + expectedPayload, + expectedTag + ); + } + + private async Task VerifyNotificationAsync(Func test, + PushType type, JsonNode expectedPayload, string tag) + { + var installationDeviceRepository = Substitute.For(); + + var notificationHubPool = Substitute.For(); + + var notificationHubProxy = Substitute.For(); + + notificationHubPool.AllClients + .Returns(notificationHubProxy); + + var httpContextAccessor = Substitute.For(); + + var httpContext = new DefaultHttpContext(); + + var serviceCollection = new ServiceCollection(); + var currentContext = Substitute.For(); + currentContext.DeviceIdentifier = _deviceIdentifier; + serviceCollection.AddSingleton(currentContext); + + httpContext.RequestServices = serviceCollection.BuildServiceProvider(); + + httpContextAccessor.HttpContext + .Returns(httpContext); + + var globalSettings = new Core.Settings.GlobalSettings(); + globalSettings.Installation.Id = _installationId; + + var fakeTimeProvider = new FakeTimeProvider(); + + fakeTimeProvider.SetUtcNow(_now); + + var sut = new NotificationHubPushNotificationService( + installationDeviceRepository, + notificationHubPool, + httpContextAccessor, + NullLogger.Instance, + globalSettings, + fakeTimeProvider + ); + + // Act + await test(sut); + + // Assert + var calls = notificationHubProxy.ReceivedCalls(); + var methodInfo = typeof(INotificationHubProxy).GetMethod(nameof(INotificationHubProxy.SendTemplateNotificationAsync)); + var call = Assert.Single(calls, c => c.GetMethodInfo() == methodInfo); + + var arguments = call.GetArguments(); + + var dictionaryArg = (Dictionary)arguments[0]!; + var tagArg = (string)arguments[1]!; + + Assert.Equal(2, dictionaryArg.Count); + Assert.True(dictionaryArg.TryGetValue("type", out var typeString)); + Assert.True(byte.TryParse(typeString, out var typeByte)); + Assert.Equal(type, (PushType)typeByte); + + Assert.True(dictionaryArg.TryGetValue("payload", out var payloadString)); + var actualPayloadNode = JsonNode.Parse(payloadString); + + Assert.True(JsonNode.DeepEquals(expectedPayload, actualPayloadNode)); + + Assert.Equal(tag, tagArg); + } + private static NotificationPushNotification ToNotificationPushNotification(Notification notification, NotificationStatus? notificationStatus, Guid? installationId) => new() diff --git a/test/Core.Test/Platform/Push/Services/AzureQueuePushNotificationServiceTests.cs b/test/Core.Test/Platform/Push/Services/AzureQueuePushNotificationServiceTests.cs index 3025197c66..e57544b48a 100644 --- a/test/Core.Test/Platform/Push/Services/AzureQueuePushNotificationServiceTests.cs +++ b/test/Core.Test/Platform/Push/Services/AzureQueuePushNotificationServiceTests.cs @@ -1,18 +1,27 @@ #nullable enable using System.Text.Json; +using System.Text.Json.Nodes; using Azure.Storage.Queues; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Entities; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Models; using Bit.Core.NotificationCenter.Entities; +using Bit.Core.NotificationCenter.Enums; using Bit.Core.Platform.Push.Internal; using Bit.Core.Settings; using Bit.Core.Test.AutoFixture; using Bit.Core.Test.AutoFixture.CurrentContextFixtures; using Bit.Core.Test.NotificationCenter.AutoFixture; +using Bit.Core.Tools.Entities; +using Bit.Core.Vault.Entities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; using NSubstitute; using Xunit; @@ -22,6 +31,17 @@ namespace Bit.Core.Test.Platform.Push.Services; [SutProviderCustomize] public class AzureQueuePushNotificationServiceTests { + private static readonly Guid _deviceId = Guid.Parse("c4730f80-caaa-4772-97bd-5c0d23a2baa3"); + private static readonly string _deviceIdentifier = "test_device_identifier"; + private readonly FakeTimeProvider _fakeTimeProvider; + private readonly Core.Settings.GlobalSettings _globalSettings = new(); + + public AzureQueuePushNotificationServiceTests() + { + _fakeTimeProvider = new(); + _fakeTimeProvider.SetUtcNow(DateTime.UtcNow); + } + [Theory] [BitAutoData] [NotificationCustomize] @@ -112,6 +132,761 @@ public class AzureQueuePushNotificationServiceTests deviceIdentifier.ToString()))); } + [Theory] + [InlineData("6a5bbe1b-cf16-49a6-965f-5c2eac56a531", null)] + [InlineData(null, "b9a3fcb4-2447-45c1-aad2-24de43c88c44")] + public async Task PushSyncCipherCreateAsync_SendsExpectedResponse(string? userId, string? organizationId) + { + var collectionId = Guid.NewGuid(); + + var cipher = new Cipher + { + Id = Guid.NewGuid(), + UserId = userId != null ? Guid.Parse(userId) : null, + OrganizationId = organizationId != null ? Guid.Parse(organizationId) : null, + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 1, + ["Payload"] = new JsonObject + { + ["Id"] = cipher.Id, + ["UserId"] = cipher.UserId, + ["OrganizationId"] = cipher.OrganizationId, + ["CollectionIds"] = new JsonArray(collectionId), + ["RevisionDate"] = cipher.RevisionDate, + }, + ["ContextId"] = _deviceIdentifier, + }; + + if (!cipher.UserId.HasValue) + { + expectedPayload["Payload"]!.AsObject().Remove("UserId"); + } + + if (!cipher.OrganizationId.HasValue) + { + expectedPayload["Payload"]!.AsObject().Remove("OrganizationId"); + expectedPayload["Payload"]!.AsObject().Remove("CollectionIds"); + } + + await VerifyNotificationAsync( + async sut => await sut.PushSyncCipherCreateAsync(cipher, [collectionId]), + expectedPayload + ); + } + + [Theory] + [InlineData("6a5bbe1b-cf16-49a6-965f-5c2eac56a531", null)] + [InlineData(null, "b9a3fcb4-2447-45c1-aad2-24de43c88c44")] + public async Task PushSyncCipherUpdateAsync_SendsExpectedResponse(string? userId, string? organizationId) + { + var collectionId = Guid.NewGuid(); + + var cipher = new Cipher + { + Id = Guid.NewGuid(), + UserId = userId != null ? Guid.Parse(userId) : null, + OrganizationId = organizationId != null ? Guid.Parse(organizationId) : null, + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 0, + ["Payload"] = new JsonObject + { + ["Id"] = cipher.Id, + ["UserId"] = cipher.UserId, + ["OrganizationId"] = cipher.OrganizationId, + ["CollectionIds"] = new JsonArray(collectionId), + ["RevisionDate"] = cipher.RevisionDate, + }, + ["ContextId"] = _deviceIdentifier, + }; + + if (!cipher.UserId.HasValue) + { + expectedPayload["Payload"]!.AsObject().Remove("UserId"); + } + + if (!cipher.OrganizationId.HasValue) + { + expectedPayload["Payload"]!.AsObject().Remove("OrganizationId"); + expectedPayload["Payload"]!.AsObject().Remove("CollectionIds"); + } + + await VerifyNotificationAsync( + async sut => await sut.PushSyncCipherUpdateAsync(cipher, [collectionId]), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncCipherDeleteAsync_SendsExpectedResponse() + { + var collectionId = Guid.NewGuid(); + + var cipher = new Cipher + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + OrganizationId = null, + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 2, + ["Payload"] = new JsonObject + { + ["Id"] = cipher.Id, + ["UserId"] = cipher.UserId, + ["RevisionDate"] = cipher.RevisionDate, + }, + ["ContextId"] = _deviceIdentifier, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncCipherDeleteAsync(cipher), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncFolderCreateAsync_SendsExpectedResponse() + { + var collectionId = Guid.NewGuid(); + + var folder = new Folder + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 7, + ["Payload"] = new JsonObject + { + ["Id"] = folder.Id, + ["UserId"] = folder.UserId, + ["RevisionDate"] = folder.RevisionDate, + }, + ["ContextId"] = _deviceIdentifier, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncFolderCreateAsync(folder), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncFolderUpdateAsync_SendsExpectedResponse() + { + var collectionId = Guid.NewGuid(); + + var folder = new Folder + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 8, + ["Payload"] = new JsonObject + { + ["Id"] = folder.Id, + ["UserId"] = folder.UserId, + ["RevisionDate"] = folder.RevisionDate, + }, + ["ContextId"] = _deviceIdentifier, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncFolderUpdateAsync(folder), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncFolderDeleteAsync_SendsExpectedResponse() + { + var collectionId = Guid.NewGuid(); + + var folder = new Folder + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 3, + ["Payload"] = new JsonObject + { + ["Id"] = folder.Id, + ["UserId"] = folder.UserId, + ["RevisionDate"] = folder.RevisionDate, + }, + ["ContextId"] = _deviceIdentifier, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncFolderDeleteAsync(folder), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncCiphersAsync_SendsExpectedResponse() + { + var userId = Guid.NewGuid(); + + var expectedPayload = new JsonObject + { + ["Type"] = 4, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = _fakeTimeProvider.GetUtcNow().UtcDateTime, + }, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncCiphersAsync(userId), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncVaultAsync_SendsExpectedResponse() + { + var userId = Guid.NewGuid(); + + var expectedPayload = new JsonObject + { + ["Type"] = 5, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = _fakeTimeProvider.GetUtcNow().UtcDateTime, + }, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncVaultAsync(userId), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncOrganizationsAsync_SendsExpectedResponse() + { + var userId = Guid.NewGuid(); + + var expectedPayload = new JsonObject + { + ["Type"] = 17, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = _fakeTimeProvider.GetUtcNow().UtcDateTime, + }, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncOrganizationsAsync(userId), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncOrgKeysAsync_SendsExpectedResponse() + { + var userId = Guid.NewGuid(); + + var expectedPayload = new JsonObject + { + ["Type"] = 6, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = _fakeTimeProvider.GetUtcNow().UtcDateTime, + }, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncOrgKeysAsync(userId), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncSettingsAsync_SendsExpectedResponse() + { + var userId = Guid.NewGuid(); + + var expectedPayload = new JsonObject + { + ["Type"] = 10, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = _fakeTimeProvider.GetUtcNow().UtcDateTime, + }, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncSettingsAsync(userId), + expectedPayload + ); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task PushLogOutAsync_SendsExpectedResponse(bool excludeCurrentContext) + { + var userId = Guid.NewGuid(); + + var expectedPayload = new JsonObject + { + ["Type"] = 11, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = _fakeTimeProvider.GetUtcNow().UtcDateTime, + }, + }; + + if (excludeCurrentContext) + { + expectedPayload["ContextId"] = _deviceIdentifier; + } + + await VerifyNotificationAsync( + async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncSendCreateAsync_SendsExpectedResponse() + { + var send = new Send + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 12, + ["Payload"] = new JsonObject + { + ["Id"] = send.Id, + ["UserId"] = send.UserId, + ["RevisionDate"] = send.RevisionDate, + }, + ["ContextId"] = _deviceIdentifier, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncSendCreateAsync(send), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncSendUpdateAsync_SendsExpectedResponse() + { + var send = new Send + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 13, + ["Payload"] = new JsonObject + { + ["Id"] = send.Id, + ["UserId"] = send.UserId, + ["RevisionDate"] = send.RevisionDate, + }, + ["ContextId"] = _deviceIdentifier, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncSendUpdateAsync(send), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncSendDeleteAsync_SendsExpectedResponse() + { + var send = new Send + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 14, + ["Payload"] = new JsonObject + { + ["Id"] = send.Id, + ["UserId"] = send.UserId, + ["RevisionDate"] = send.RevisionDate, + }, + ["ContextId"] = _deviceIdentifier, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncSendDeleteAsync(send), + expectedPayload + ); + } + + [Fact] + public async Task PushAuthRequestAsync_SendsExpectedResponse() + { + var authRequest = new AuthRequest + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 15, + ["Payload"] = new JsonObject + { + ["Id"] = authRequest.Id, + ["UserId"] = authRequest.UserId, + }, + ["ContextId"] = _deviceIdentifier, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushAuthRequestAsync(authRequest), + expectedPayload + ); + } + + [Fact] + public async Task PushAuthRequestResponseAsync_SendsExpectedResponse() + { + var authRequest = new AuthRequest + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 16, + ["Payload"] = new JsonObject + { + ["Id"] = authRequest.Id, + ["UserId"] = authRequest.UserId, + }, + ["ContextId"] = _deviceIdentifier, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushAuthRequestResponseAsync(authRequest), + expectedPayload + ); + } + + [Theory] + [InlineData(true, null, null)] + [InlineData(false, "e8e08ce8-8a26-4a65-913a-ba1d8c478b2f", null)] + [InlineData(false, null, "2f53ee32-edf9-4169-b276-760fe92e03bf")] + public async Task PushNotificationAsync_SendsExpectedResponse(bool global, string? userId, string? organizationId) + { + var notification = new Notification + { + Id = Guid.NewGuid(), + Priority = Priority.High, + Global = global, + ClientType = ClientType.All, + UserId = userId != null ? Guid.Parse(userId) : null, + OrganizationId = organizationId != null ? Guid.Parse(organizationId) : null, + Title = "My Title", + Body = "My Body", + CreationDate = DateTime.UtcNow.AddDays(-1), + RevisionDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 20, + ["Payload"] = new JsonObject + { + ["Id"] = notification.Id, + ["Priority"] = 3, + ["Global"] = global, + ["ClientType"] = 0, + ["UserId"] = notification.UserId, + ["OrganizationId"] = notification.OrganizationId, + ["InstallationId"] = _globalSettings.Installation.Id, + ["Title"] = notification.Title, + ["Body"] = notification.Body, + ["CreationDate"] = notification.CreationDate, + ["RevisionDate"] = notification.RevisionDate, + }, + ["ContextId"] = _deviceIdentifier, + }; + + if (!global) + { + expectedPayload["Payload"]!.AsObject().Remove("InstallationId"); + } + + if (!notification.UserId.HasValue) + { + expectedPayload["Payload"]!.AsObject().Remove("UserId"); + } + + if (!notification.OrganizationId.HasValue) + { + expectedPayload["Payload"]!.AsObject().Remove("OrganizationId"); + } + + await VerifyNotificationAsync( + async sut => await sut.PushNotificationAsync(notification), + expectedPayload + ); + } + + [Theory] + [InlineData(true, null, null)] + [InlineData(false, "e8e08ce8-8a26-4a65-913a-ba1d8c478b2f", null)] + [InlineData(false, null, "2f53ee32-edf9-4169-b276-760fe92e03bf")] + public async Task PushNotificationStatusAsync_SendsExpectedResponse(bool global, string? userId, string? organizationId) + { + var notification = new Notification + { + Id = Guid.NewGuid(), + Priority = Priority.High, + Global = global, + ClientType = ClientType.All, + UserId = userId != null ? Guid.Parse(userId) : null, + OrganizationId = organizationId != null ? Guid.Parse(organizationId) : null, + Title = "My Title", + Body = "My Body", + CreationDate = DateTime.UtcNow.AddDays(-1), + RevisionDate = DateTime.UtcNow, + }; + + var notificationStatus = new NotificationStatus + { + ReadDate = DateTime.UtcNow, + DeletedDate = DateTime.UtcNow, + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 21, + ["Payload"] = new JsonObject + { + ["Id"] = notification.Id, + ["Priority"] = 3, + ["Global"] = global, + ["ClientType"] = 0, + ["UserId"] = notification.UserId, + ["OrganizationId"] = notification.OrganizationId, + ["InstallationId"] = _globalSettings.Installation.Id, + ["Title"] = notification.Title, + ["Body"] = notification.Body, + ["CreationDate"] = notification.CreationDate, + ["RevisionDate"] = notification.RevisionDate, + ["ReadDate"] = notificationStatus.ReadDate, + ["DeletedDate"] = notificationStatus.DeletedDate, + }, + ["ContextId"] = _deviceIdentifier, + }; + + if (!global) + { + expectedPayload["Payload"]!.AsObject().Remove("InstallationId"); + } + + if (!notification.UserId.HasValue) + { + expectedPayload["Payload"]!.AsObject().Remove("UserId"); + } + + if (!notification.OrganizationId.HasValue) + { + expectedPayload["Payload"]!.AsObject().Remove("OrganizationId"); + } + + await VerifyNotificationAsync( + async sut => await sut.PushNotificationStatusAsync(notification, notificationStatus), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncOrganizationStatusAsync_SendsExpectedResponse() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + Enabled = true, + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 18, + ["Payload"] = new JsonObject + { + ["OrganizationId"] = organization.Id, + ["Enabled"] = organization.Enabled, + }, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncOrganizationStatusAsync(organization), + expectedPayload + ); + } + + [Fact] + public async Task PushSyncOrganizationCollectionManagementSettingsAsync_SendsExpectedResponse() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + Enabled = true, + LimitCollectionCreation = true, + LimitCollectionDeletion = true, + LimitItemDeletion = true, + }; + + var expectedPayload = new JsonObject + { + ["Type"] = 19, + ["Payload"] = new JsonObject + { + ["OrganizationId"] = organization.Id, + ["LimitCollectionCreation"] = organization.LimitCollectionCreation, + ["LimitCollectionDeletion"] = organization.LimitCollectionDeletion, + ["LimitItemDeletion"] = organization.LimitItemDeletion, + }, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncOrganizationCollectionManagementSettingsAsync(organization), + expectedPayload + ); + } + + [Fact] + public async Task PushPendingSecurityTasksAsync_SendsExpectedResponse() + { + var userId = Guid.NewGuid(); + + var expectedPayload = new JsonObject + { + ["Type"] = 22, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = _fakeTimeProvider.GetUtcNow().UtcDateTime, + }, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushPendingSecurityTasksAsync(userId), + expectedPayload + ); + } + + // [Fact] + // public async Task SendPayloadToInstallationAsync_ThrowsNotImplementedException() + // { + // await Assert.ThrowsAsync( + // async () => await sut.SendPayloadToInstallationAsync("installation_id", PushType.AuthRequest, new {}, null) + // ); + // } + + // [Fact] + // public async Task SendPayloadToUserAsync_ThrowsNotImplementedException() + // { + // await Assert.ThrowsAsync( + // async () => await _sut.SendPayloadToUserAsync("user_id", PushType.AuthRequest, new {}, null) + // ); + // } + + // [Fact] + // public async Task SendPayloadToOrganizationAsync_ThrowsNotImplementedException() + // { + // await Assert.ThrowsAsync( + // async () => await _sut.SendPayloadToOrganizationAsync("organization_id", PushType.AuthRequest, new {}, null) + // ); + // } + + private async Task VerifyNotificationAsync(Func test, JsonNode expectedMessage) + { + var queueClient = Substitute.For(); + + var httpContextAccessor = Substitute.For(); + + var httpContext = new DefaultHttpContext(); + + var serviceCollection = new ServiceCollection(); + var currentContext = Substitute.For(); + currentContext.DeviceIdentifier = _deviceIdentifier; + serviceCollection.AddSingleton(currentContext); + + httpContext.RequestServices = serviceCollection.BuildServiceProvider(); + + httpContextAccessor.HttpContext + .Returns(httpContext); + + var globalSettings = new Core.Settings.GlobalSettings(); + + var sut = new AzureQueuePushNotificationService( + queueClient, + httpContextAccessor, + globalSettings, + NullLogger.Instance, + _fakeTimeProvider + ); + + await test(sut); + + // Hoist equality checker outside the expression so that we + // can more easily place a breakpoint + var checkEquality = (string actual) => + { + var actualNode = JsonNode.Parse(actual); + return JsonNode.DeepEquals(actualNode, expectedMessage); + }; + + await queueClient + .Received(1) + .SendMessageAsync(Arg.Is((actual) => checkEquality(actual))); + } + private static bool MatchMessage(PushType pushType, string message, IEquatable expectedPayloadEquatable, string contextId) { diff --git a/test/Core.Test/Platform/Push/Services/NotificationsApiPushNotificationServiceTests.cs b/test/Core.Test/Platform/Push/Services/NotificationsApiPushNotificationServiceTests.cs index 07f348a5ba..d206d96d44 100644 --- a/test/Core.Test/Platform/Push/Services/NotificationsApiPushNotificationServiceTests.cs +++ b/test/Core.Test/Platform/Push/Services/NotificationsApiPushNotificationServiceTests.cs @@ -1,41 +1,385 @@ -using Bit.Core.Platform.Push; -using Bit.Core.Settings; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; -using NSubstitute; -using Xunit; +using System.Text.Json.Nodes; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Entities; +using Bit.Core.NotificationCenter.Entities; +using Bit.Core.Platform.Push; +using Bit.Core.Tools.Entities; +using Bit.Core.Vault.Entities; +using Microsoft.Extensions.Logging.Abstractions; namespace Bit.Core.Test.Platform.Push.Services; -public class NotificationsApiPushNotificationServiceTests +public class NotificationsApiPushNotificationServiceTests : PushTestBase { - private readonly NotificationsApiPushNotificationService _sut; - - private readonly IHttpClientFactory _httpFactory; - private readonly GlobalSettings _globalSettings; - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly ILogger _logger; - public NotificationsApiPushNotificationServiceTests() { - _httpFactory = Substitute.For(); - _globalSettings = new GlobalSettings(); - _httpContextAccessor = Substitute.For(); - _logger = Substitute.For>(); + GlobalSettings.BaseServiceUri.InternalNotifications = "https://localhost:7777"; + GlobalSettings.BaseServiceUri.InternalIdentity = "https://localhost:8888"; + } - _sut = new NotificationsApiPushNotificationService( - _httpFactory, - _globalSettings, - _httpContextAccessor, - _logger + protected override string ExpectedClientUrl() => "https://localhost:7777/send"; + + protected override IPushNotificationService CreateService() + { + return new NotificationsApiPushNotificationService( + HttpClientFactory, + GlobalSettings, + HttpContextAccessor, + NullLogger.Instance, + FakeTimeProvider ); } - // Remove this test when we add actual tests. It only proves that - // we've properly constructed the system under test. - [Fact(Skip = "Needs additional work")] - public void ServiceExists() + protected override JsonNode GetPushSyncCipherCreatePayload(Cipher cipher, Guid collectionId) { - Assert.NotNull(_sut); + return new JsonObject + { + ["Type"] = 1, + ["Payload"] = new JsonObject + { + ["Id"] = cipher.Id, + ["UserId"] = cipher.UserId, + ["OrganizationId"] = null, + ["CollectionIds"] = new JsonArray(collectionId), + ["RevisionDate"] = cipher.RevisionDate, + }, + ["ContextId"] = DeviceIdentifier, + }; + } + protected override JsonNode GetPushSyncCipherUpdatePayload(Cipher cipher, Guid collectionId) + { + return new JsonObject + { + ["Type"] = 0, + ["Payload"] = new JsonObject + { + ["Id"] = cipher.Id, + ["UserId"] = cipher.UserId, + ["OrganizationId"] = null, + ["CollectionIds"] = new JsonArray(collectionId), + ["RevisionDate"] = cipher.RevisionDate, + }, + ["ContextId"] = DeviceIdentifier, + }; + } + protected override JsonNode GetPushSyncCipherDeletePayload(Cipher cipher) + { + return new JsonObject + { + ["Type"] = 2, + ["Payload"] = new JsonObject + { + ["Id"] = cipher.Id, + ["UserId"] = cipher.UserId, + ["OrganizationId"] = null, + ["CollectionIds"] = null, + ["RevisionDate"] = cipher.RevisionDate, + }, + ["ContextId"] = DeviceIdentifier, + }; + } + + protected override JsonNode GetPushSyncFolderCreatePayload(Folder folder) + { + return new JsonObject + { + ["Type"] = 7, + ["Payload"] = new JsonObject + { + ["Id"] = folder.Id, + ["UserId"] = folder.UserId, + ["RevisionDate"] = folder.RevisionDate, + }, + ["ContextId"] = DeviceIdentifier, + }; + } + + protected override JsonNode GetPushSyncFolderUpdatePayload(Folder folder) + { + return new JsonObject + { + ["Type"] = 8, + ["Payload"] = new JsonObject + { + ["Id"] = folder.Id, + ["UserId"] = folder.UserId, + ["RevisionDate"] = folder.RevisionDate, + }, + ["ContextId"] = DeviceIdentifier, + }; + } + + protected override JsonNode GetPushSyncFolderDeletePayload(Folder folder) + { + return new JsonObject + { + ["Type"] = 3, + ["Payload"] = new JsonObject + { + ["Id"] = folder.Id, + ["UserId"] = folder.UserId, + ["RevisionDate"] = folder.RevisionDate, + }, + ["ContextId"] = DeviceIdentifier, + }; + } + + protected override JsonNode GetPushSyncCiphersPayload(Guid userId) + { + return new JsonObject + { + ["Type"] = 4, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ContextId"] = null, + }; + } + + protected override JsonNode GetPushSyncVaultPayload(Guid userId) + { + return new JsonObject + { + ["Type"] = 5, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ContextId"] = null, + }; + } + + protected override JsonNode GetPushSyncOrganizationsPayload(Guid userId) + { + return new JsonObject + { + ["Type"] = 17, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ContextId"] = null, + }; + } + + protected override JsonNode GetPushSyncOrgKeysPayload(Guid userId) + { + return new JsonObject + { + ["Type"] = 6, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ContextId"] = null, + }; + } + + protected override JsonNode GetPushSyncSettingsPayload(Guid userId) + { + return new JsonObject + { + ["Type"] = 10, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ContextId"] = null, + }; + } + + protected override JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext) + { + JsonNode? contextId = excludeCurrentContext ? DeviceIdentifier : null; + + return new JsonObject + { + ["Type"] = 11, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ContextId"] = contextId, + }; + } + + protected override JsonNode GetPushSendCreatePayload(Send send) + { + return new JsonObject + { + ["Type"] = 12, + ["Payload"] = new JsonObject + { + ["Id"] = send.Id, + ["UserId"] = send.UserId, + ["RevisionDate"] = send.RevisionDate, + }, + ["ContextId"] = null, + }; + } + + protected override JsonNode GetPushSendUpdatePayload(Send send) + { + return new JsonObject + { + ["Type"] = 13, + ["Payload"] = new JsonObject + { + ["Id"] = send.Id, + ["UserId"] = send.UserId, + ["RevisionDate"] = send.RevisionDate, + }, + ["ContextId"] = null, + }; + } + + protected override JsonNode GetPushSendDeletePayload(Send send) + { + return new JsonObject + { + ["Type"] = 14, + ["Payload"] = new JsonObject + { + ["Id"] = send.Id, + ["UserId"] = send.UserId, + ["RevisionDate"] = send.RevisionDate, + }, + ["ContextId"] = null, + }; + } + + protected override JsonNode GetPushAuthRequestPayload(AuthRequest authRequest) + { + return new JsonObject + { + ["Type"] = 15, + ["Payload"] = new JsonObject + { + ["Id"] = authRequest.Id, + ["UserId"] = authRequest.UserId, + }, + ["ContextId"] = DeviceIdentifier, + }; + } + + protected override JsonNode GetPushAuthRequestResponsePayload(AuthRequest authRequest) + { + return new JsonObject + { + ["Type"] = 16, + ["Payload"] = new JsonObject + { + ["Id"] = authRequest.Id, + ["UserId"] = authRequest.UserId, + }, + ["ContextId"] = DeviceIdentifier, + }; + } + + protected override JsonNode GetPushNotificationResponsePayload(Notification notification, Guid? userId, Guid? organizationId) + { + JsonNode? installationId = notification.Global ? GlobalSettings.Installation.Id : null; + + return new JsonObject + { + ["Type"] = 20, + ["Payload"] = new JsonObject + { + ["Id"] = notification.Id, + ["Priority"] = 3, + ["Global"] = notification.Global, + ["ClientType"] = 0, + ["UserId"] = notification.UserId, + ["OrganizationId"] = notification.OrganizationId, + ["TaskId"] = notification.TaskId, + ["InstallationId"] = installationId, + ["Title"] = notification.Title, + ["Body"] = notification.Body, + ["CreationDate"] = notification.CreationDate, + ["RevisionDate"] = notification.RevisionDate, + ["ReadDate"] = null, + ["DeletedDate"] = null, + }, + ["ContextId"] = DeviceIdentifier, + }; + } + + protected override JsonNode GetPushNotificationStatusResponsePayload(Notification notification, NotificationStatus notificationStatus, Guid? userId, Guid? organizationId) + { + JsonNode? installationId = notification.Global ? GlobalSettings.Installation.Id : null; + + return new JsonObject + { + ["Type"] = 21, + ["Payload"] = new JsonObject + { + ["Id"] = notification.Id, + ["Priority"] = 3, + ["Global"] = notification.Global, + ["ClientType"] = 0, + ["UserId"] = notification.UserId, + ["OrganizationId"] = notification.OrganizationId, + ["InstallationId"] = installationId, + ["TaskId"] = notification.TaskId, + ["Title"] = notification.Title, + ["Body"] = notification.Body, + ["CreationDate"] = notification.CreationDate, + ["RevisionDate"] = notification.RevisionDate, + ["ReadDate"] = notificationStatus.ReadDate, + ["DeletedDate"] = notificationStatus.DeletedDate, + }, + ["ContextId"] = DeviceIdentifier, + }; + } + + protected override JsonNode GetPushSyncOrganizationStatusResponsePayload(Organization organization) + { + return new JsonObject + { + ["Type"] = 18, + ["Payload"] = new JsonObject + { + ["OrganizationId"] = organization.Id, + ["Enabled"] = organization.Enabled, + }, + ["ContextId"] = null, + }; + } + + protected override JsonNode GetPushSyncOrganizationCollectionManagementSettingsResponsePayload(Organization organization) + { + return new JsonObject + { + ["Type"] = 19, + ["Payload"] = new JsonObject + { + ["OrganizationId"] = organization.Id, + ["LimitCollectionCreation"] = organization.LimitCollectionCreation, + ["LimitCollectionDeletion"] = organization.LimitCollectionDeletion, + ["LimitItemDeletion"] = organization.LimitItemDeletion, + }, + ["ContextId"] = null, + }; + } + + protected override JsonNode GetPushPendingSecurityTasksResponsePayload(Guid userId) + { + return new JsonObject + { + ["Type"] = 22, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ContextId"] = null, + }; } } diff --git a/test/Core.Test/Platform/Push/Services/PushTestBase.cs b/test/Core.Test/Platform/Push/Services/PushTestBase.cs new file mode 100644 index 0000000000..111df7ca26 --- /dev/null +++ b/test/Core.Test/Platform/Push/Services/PushTestBase.cs @@ -0,0 +1,498 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Entities; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.NotificationCenter.Entities; +using Bit.Core.NotificationCenter.Enums; +using Bit.Core.Platform.Push; +using Bit.Core.Settings; +using Bit.Core.Tools.Entities; +using Bit.Core.Vault.Entities; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using RichardSzalay.MockHttp; +using Xunit; + +public abstract class PushTestBase +{ + protected static readonly string DeviceIdentifier = "test_device_identifier"; + + protected readonly MockHttpMessageHandler MockClient = new(); + protected readonly MockHttpMessageHandler MockIdentityClient = new(); + + protected readonly IHttpClientFactory HttpClientFactory; + protected readonly GlobalSettings GlobalSettings; + protected readonly IHttpContextAccessor HttpContextAccessor; + protected readonly FakeTimeProvider FakeTimeProvider; + + public PushTestBase() + { + HttpClientFactory = Substitute.For(); + + // Mock HttpClient + HttpClientFactory.CreateClient("client") + .Returns(new HttpClient(MockClient)); + + HttpClientFactory.CreateClient("identity") + .Returns(new HttpClient(MockIdentityClient)); + + GlobalSettings = new GlobalSettings(); + HttpContextAccessor = Substitute.For(); + + FakeTimeProvider = new FakeTimeProvider(); + + FakeTimeProvider.SetUtcNow(DateTimeOffset.UtcNow); + } + + protected abstract IPushNotificationService CreateService(); + + protected abstract string ExpectedClientUrl(); + + protected abstract JsonNode GetPushSyncCipherCreatePayload(Cipher cipher, Guid collectionId); + protected abstract JsonNode GetPushSyncCipherUpdatePayload(Cipher cipher, Guid collectionId); + protected abstract JsonNode GetPushSyncCipherDeletePayload(Cipher cipher); + protected abstract JsonNode GetPushSyncFolderCreatePayload(Folder folder); + protected abstract JsonNode GetPushSyncFolderUpdatePayload(Folder folder); + protected abstract JsonNode GetPushSyncFolderDeletePayload(Folder folder); + protected abstract JsonNode GetPushSyncCiphersPayload(Guid userId); + protected abstract JsonNode GetPushSyncVaultPayload(Guid userId); + protected abstract JsonNode GetPushSyncOrganizationsPayload(Guid userId); + protected abstract JsonNode GetPushSyncOrgKeysPayload(Guid userId); + protected abstract JsonNode GetPushSyncSettingsPayload(Guid userId); + protected abstract JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext); + protected abstract JsonNode GetPushSendCreatePayload(Send send); + protected abstract JsonNode GetPushSendUpdatePayload(Send send); + protected abstract JsonNode GetPushSendDeletePayload(Send send); + protected abstract JsonNode GetPushAuthRequestPayload(AuthRequest authRequest); + protected abstract JsonNode GetPushAuthRequestResponsePayload(AuthRequest authRequest); + protected abstract JsonNode GetPushNotificationResponsePayload(Notification notification, Guid? userId, Guid? organizationId); + protected abstract JsonNode GetPushNotificationStatusResponsePayload(Notification notification, NotificationStatus notificationStatus, Guid? userId, Guid? organizationId); + protected abstract JsonNode GetPushSyncOrganizationStatusResponsePayload(Organization organization); + protected abstract JsonNode GetPushSyncOrganizationCollectionManagementSettingsResponsePayload(Organization organization); + protected abstract JsonNode GetPushPendingSecurityTasksResponsePayload(Guid userId); + + [Fact] + public async Task PushSyncCipherCreateAsync_SendsExpectedResponse() + { + var collectionId = Guid.NewGuid(); + + var cipher = new Cipher + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + OrganizationId = null, + RevisionDate = DateTime.UtcNow, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncCipherCreateAsync(cipher, [collectionId]), + GetPushSyncCipherCreatePayload(cipher, collectionId) + ); + } + + [Fact] + public async Task PushSyncCipherUpdateAsync_SendsExpectedResponse() + { + var collectionId = Guid.NewGuid(); + + var cipher = new Cipher + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + OrganizationId = null, + RevisionDate = DateTime.UtcNow, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncCipherUpdateAsync(cipher, [collectionId]), + GetPushSyncCipherUpdatePayload(cipher, collectionId) + ); + } + + [Fact] + public async Task PushSyncCipherDeleteAsync_SendsExpectedResponse() + { + var collectionId = Guid.NewGuid(); + + var cipher = new Cipher + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + OrganizationId = null, + RevisionDate = DateTime.UtcNow, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncCipherDeleteAsync(cipher), + GetPushSyncCipherDeletePayload(cipher) + ); + } + + [Fact] + public async Task PushSyncFolderCreateAsync_SendsExpectedResponse() + { + var folder = new Folder + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + RevisionDate = DateTime.UtcNow, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncFolderCreateAsync(folder), + GetPushSyncFolderCreatePayload(folder) + ); + } + + [Fact] + public async Task PushSyncFolderUpdateAsync_SendsExpectedResponse() + { + var collectionId = Guid.NewGuid(); + + var folder = new Folder + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + RevisionDate = DateTime.UtcNow, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncFolderUpdateAsync(folder), + GetPushSyncFolderUpdatePayload(folder) + ); + } + + [Fact] + public async Task PushSyncFolderDeleteAsync_SendsExpectedResponse() + { + var collectionId = Guid.NewGuid(); + + var folder = new Folder + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + RevisionDate = DateTime.UtcNow, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncFolderDeleteAsync(folder), + GetPushSyncFolderDeletePayload(folder) + ); + } + + [Fact] + public async Task PushSyncCiphersAsync_SendsExpectedResponse() + { + var userId = Guid.NewGuid(); + + await VerifyNotificationAsync( + async sut => await sut.PushSyncCiphersAsync(userId), + GetPushSyncCiphersPayload(userId) + ); + } + + [Fact] + public async Task PushSyncVaultAsync_SendsExpectedResponse() + { + var userId = Guid.NewGuid(); + + await VerifyNotificationAsync( + async sut => await sut.PushSyncVaultAsync(userId), + GetPushSyncVaultPayload(userId) + ); + } + + [Fact] + public async Task PushSyncOrganizationsAsync_SendsExpectedResponse() + { + var userId = Guid.NewGuid(); + + await VerifyNotificationAsync( + async sut => await sut.PushSyncOrganizationsAsync(userId), + GetPushSyncOrganizationsPayload(userId) + ); + } + + [Fact] + public async Task PushSyncOrgKeysAsync_SendsExpectedResponse() + { + var userId = Guid.NewGuid(); + + await VerifyNotificationAsync( + async sut => await sut.PushSyncOrgKeysAsync(userId), + GetPushSyncOrgKeysPayload(userId) + ); + } + + [Fact] + public async Task PushSyncSettingsAsync_SendsExpectedResponse() + { + var userId = Guid.NewGuid(); + + await VerifyNotificationAsync( + async sut => await sut.PushSyncSettingsAsync(userId), + GetPushSyncSettingsPayload(userId) + ); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task PushLogOutAsync_SendsExpectedResponse(bool excludeCurrentContext) + { + var userId = Guid.NewGuid(); + + await VerifyNotificationAsync( + async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext), + GetPushLogOutPayload(userId, excludeCurrentContext) + ); + } + + [Fact] + public async Task PushSyncSendCreateAsync_SendsExpectedResponse() + { + var send = new Send + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + RevisionDate = DateTime.UtcNow, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncSendCreateAsync(send), + GetPushSendCreatePayload(send) + ); + } + + [Fact] + public async Task PushSyncSendUpdateAsync_SendsExpectedResponse() + { + var send = new Send + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + RevisionDate = DateTime.UtcNow, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncSendUpdateAsync(send), + GetPushSendUpdatePayload(send) + ); + } + + [Fact] + public async Task PushSyncSendDeleteAsync_SendsExpectedResponse() + { + var send = new Send + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + RevisionDate = DateTime.UtcNow, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncSendDeleteAsync(send), + GetPushSendDeletePayload(send) + ); + } + + [Fact] + public async Task PushAuthRequestAsync_SendsExpectedResponse() + { + var authRequest = new AuthRequest + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + }; + + await VerifyNotificationAsync( + async sut => await sut.PushAuthRequestAsync(authRequest), + GetPushAuthRequestPayload(authRequest) + ); + } + + [Fact] + public async Task PushAuthRequestResponseAsync_SendsExpectedResponse() + { + var authRequest = new AuthRequest + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + }; + + await VerifyNotificationAsync( + async sut => await sut.PushAuthRequestResponseAsync(authRequest), + GetPushAuthRequestResponsePayload(authRequest) + ); + } + + [Theory] + [InlineData(true, null, null)] + [InlineData(false, "e8e08ce8-8a26-4a65-913a-ba1d8c478b2f", null)] + [InlineData(false, null, "2f53ee32-edf9-4169-b276-760fe92e03bf")] + public async Task PushNotificationAsync_SendsExpectedResponse(bool global, string? userId, string? organizationId) + { + var notification = new Notification + { + Id = Guid.NewGuid(), + Priority = Priority.High, + Global = global, + ClientType = ClientType.All, + UserId = userId != null ? Guid.Parse(userId) : null, + OrganizationId = organizationId != null ? Guid.Parse(organizationId) : null, + TaskId = Guid.NewGuid(), + Title = "My Title", + Body = "My Body", + CreationDate = DateTime.UtcNow.AddDays(-1), + RevisionDate = DateTime.UtcNow, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushNotificationAsync(notification), + GetPushNotificationResponsePayload(notification, notification.UserId, notification.OrganizationId) + ); + } + + [Theory] + [InlineData(true, null, null)] + [InlineData(false, "e8e08ce8-8a26-4a65-913a-ba1d8c478b2f", null)] + [InlineData(false, null, "2f53ee32-edf9-4169-b276-760fe92e03bf")] + public async Task PushNotificationStatusAsync_SendsExpectedResponse(bool global, string? userId, string? organizationId) + { + var notification = new Notification + { + Id = Guid.NewGuid(), + Priority = Priority.High, + Global = global, + ClientType = ClientType.All, + UserId = userId != null ? Guid.Parse(userId) : null, + OrganizationId = organizationId != null ? Guid.Parse(organizationId) : null, + TaskId = Guid.NewGuid(), + Title = "My Title", + Body = "My Body", + CreationDate = DateTime.UtcNow.AddDays(-1), + RevisionDate = DateTime.UtcNow, + }; + + var notificationStatus = new NotificationStatus + { + ReadDate = DateTime.UtcNow, + DeletedDate = DateTime.UtcNow, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushNotificationStatusAsync(notification, notificationStatus), + GetPushNotificationStatusResponsePayload(notification, notificationStatus, notification.UserId, notification.OrganizationId) + ); + } + + [Fact] + public async Task PushSyncOrganizationStatusAsync_SendsExpectedResponse() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + Enabled = true, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncOrganizationStatusAsync(organization), + GetPushSyncOrganizationStatusResponsePayload(organization) + ); + } + + [Fact] + public async Task PushSyncOrganizationCollectionManagementSettingsAsync_SendsExpectedResponse() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + Enabled = true, + LimitCollectionCreation = true, + LimitCollectionDeletion = true, + LimitItemDeletion = true, + }; + + await VerifyNotificationAsync( + async sut => await sut.PushSyncOrganizationCollectionManagementSettingsAsync(organization), + GetPushSyncOrganizationCollectionManagementSettingsResponsePayload(organization) + ); + } + + [Fact] + public async Task PushPendingSecurityTasksAsync_SendsExpectedResponse() + { + var userId = Guid.NewGuid(); + + await VerifyNotificationAsync( + async sut => await sut.PushPendingSecurityTasksAsync(userId), + GetPushPendingSecurityTasksResponsePayload(userId) + ); + } + + private async Task VerifyNotificationAsync( + Func test, + JsonNode expectedRequestBody + ) + { + var httpContext = new DefaultHttpContext(); + + var serviceCollection = new ServiceCollection(); + var currentContext = Substitute.For(); + currentContext.DeviceIdentifier = DeviceIdentifier; + serviceCollection.AddSingleton(currentContext); + + httpContext.RequestServices = serviceCollection.BuildServiceProvider(); + + HttpContextAccessor.HttpContext + .Returns(httpContext); + + var connectTokenRequest = MockIdentityClient + .Expect(HttpMethod.Post, "https://localhost:8888/connect/token") + .Respond(HttpStatusCode.OK, JsonContent.Create(new + { + access_token = CreateAccessToken(DateTime.UtcNow.AddDays(1)), + })); + + JsonNode actualNode = null; + + var clientRequest = MockClient + .Expect(HttpMethod.Post, ExpectedClientUrl()) + .With(request => + { + if (request.Content is not JsonContent jsonContent) + { + return false; + } + + // TODO: What options? + var actualString = JsonSerializer.Serialize(jsonContent.Value); + actualNode = JsonNode.Parse(actualString); + + return JsonNode.DeepEquals(actualNode, expectedRequestBody); + }) + .Respond(HttpStatusCode.OK); + + await test(CreateService()); + + Assert.NotNull(actualNode); + + Assert.Equal(expectedRequestBody, actualNode, EqualityComparer.Create(JsonNode.DeepEquals)); + + Assert.Equal(1, MockClient.GetMatchCount(clientRequest)); + } + + protected static string CreateAccessToken(DateTime expirationTime) + { + var tokenHandler = new JwtSecurityTokenHandler(); + var token = new JwtSecurityToken(expires: expirationTime); + return tokenHandler.WriteToken(token); + } +} diff --git a/test/Core.Test/Platform/Push/Services/RelayPushNotificationServiceTests.cs b/test/Core.Test/Platform/Push/Services/RelayPushNotificationServiceTests.cs index 9ae79f7142..faa6b5dfa7 100644 --- a/test/Core.Test/Platform/Push/Services/RelayPushNotificationServiceTests.cs +++ b/test/Core.Test/Platform/Push/Services/RelayPushNotificationServiceTests.cs @@ -1,45 +1,541 @@ -using Bit.Core.Platform.Push.Internal; +#nullable enable + +using System.Text.Json.Nodes; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Entities; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.NotificationCenter.Entities; +using Bit.Core.Platform.Push.Internal; using Bit.Core.Repositories; using Bit.Core.Settings; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; +using Bit.Core.Tools.Entities; +using Bit.Core.Vault.Entities; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; using NSubstitute; using Xunit; namespace Bit.Core.Test.Platform.Push.Services; -public class RelayPushNotificationServiceTests +public class RelayPushNotificationServiceTests : PushTestBase { - private readonly RelayPushNotificationService _sut; - - private readonly IHttpClientFactory _httpFactory; + private static readonly Guid _deviceId = Guid.Parse("c4730f80-caaa-4772-97bd-5c0d23a2baa3"); private readonly IDeviceRepository _deviceRepository; - private readonly GlobalSettings _globalSettings; - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly ILogger _logger; public RelayPushNotificationServiceTests() { - _httpFactory = Substitute.For(); _deviceRepository = Substitute.For(); - _globalSettings = new GlobalSettings(); - _httpContextAccessor = Substitute.For(); - _logger = Substitute.For>(); - _sut = new RelayPushNotificationService( - _httpFactory, + _deviceRepository.GetByIdentifierAsync(DeviceIdentifier) + .Returns(new Device + { + Id = _deviceId, + }); + + GlobalSettings.PushRelayBaseUri = "https://localhost:7777"; + GlobalSettings.Installation.Id = Guid.Parse("478c608a-99fd-452a-94f0-af271654e6ee"); + GlobalSettings.Installation.IdentityUri = "https://localhost:8888"; + } + + protected override RelayPushNotificationService CreateService() + { + return new RelayPushNotificationService( + HttpClientFactory, _deviceRepository, - _globalSettings, - _httpContextAccessor, - _logger + GlobalSettings, + HttpContextAccessor, + NullLogger.Instance, + FakeTimeProvider ); } - // Remove this test when we add actual tests. It only proves that - // we've properly constructed the system under test. - [Fact(Skip = "Needs additional work")] - public void ServiceExists() + protected override string ExpectedClientUrl() => "https://localhost:7777/push/send"; + + [Fact] + public async Task SendPayloadToInstallationAsync_ThrowsNotImplementedException() { - Assert.NotNull(_sut); + var sut = CreateService(); + await Assert.ThrowsAsync( + async () => await sut.SendPayloadToInstallationAsync("installation_id", PushType.AuthRequest, new { }, null) + ); + } + + [Fact] + public async Task SendPayloadToUserAsync_ThrowsNotImplementedException() + { + var sut = CreateService(); + await Assert.ThrowsAsync( + async () => await sut.SendPayloadToUserAsync("user_id", PushType.AuthRequest, new { }, null) + ); + } + + [Fact] + public async Task SendPayloadToOrganizationAsync_ThrowsNotImplementedException() + { + var sut = CreateService(); + await Assert.ThrowsAsync( + async () => await sut.SendPayloadToOrganizationAsync("organization_id", PushType.AuthRequest, new { }, null) + ); + } + + protected override JsonNode GetPushSyncCipherCreatePayload(Cipher cipher, Guid collectionIds) + { + return new JsonObject + { + ["UserId"] = cipher.UserId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = DeviceIdentifier, + ["Type"] = 1, + ["Payload"] = new JsonObject + { + ["Id"] = cipher.Id, + ["UserId"] = cipher.UserId, + ["OrganizationId"] = null, + // Currently CollectionIds are not passed along from the method signature + // to the request body. + ["CollectionIds"] = null, + ["RevisionDate"] = cipher.RevisionDate, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + + protected override JsonNode GetPushSyncCipherUpdatePayload(Cipher cipher, Guid collectionIds) + { + return new JsonObject + { + ["UserId"] = cipher.UserId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = DeviceIdentifier, + ["Type"] = 0, + ["Payload"] = new JsonObject + { + ["Id"] = cipher.Id, + ["UserId"] = cipher.UserId, + ["OrganizationId"] = null, + // Currently CollectionIds are not passed along from the method signature + // to the request body. + ["CollectionIds"] = null, + ["RevisionDate"] = cipher.RevisionDate, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + + protected override JsonNode GetPushSyncCipherDeletePayload(Cipher cipher) + { + return new JsonObject + { + ["UserId"] = cipher.UserId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = DeviceIdentifier, + ["Type"] = 2, + ["Payload"] = new JsonObject + { + ["Id"] = cipher.Id, + ["UserId"] = cipher.UserId, + ["OrganizationId"] = null, + ["CollectionIds"] = null, + ["RevisionDate"] = cipher.RevisionDate, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + + protected override JsonNode GetPushSyncFolderCreatePayload(Folder folder) + { + return new JsonObject + { + ["UserId"] = folder.UserId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = DeviceIdentifier, + ["Type"] = 7, + ["Payload"] = new JsonObject + { + ["Id"] = folder.Id, + ["UserId"] = folder.UserId, + ["RevisionDate"] = folder.RevisionDate, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + + protected override JsonNode GetPushSyncFolderUpdatePayload(Folder folder) + { + return new JsonObject + { + ["UserId"] = folder.UserId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = DeviceIdentifier, + ["Type"] = 8, + ["Payload"] = new JsonObject + { + ["Id"] = folder.Id, + ["UserId"] = folder.UserId, + ["RevisionDate"] = folder.RevisionDate, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + + protected override JsonNode GetPushSyncFolderDeletePayload(Folder folder) + { + return new JsonObject + { + ["UserId"] = folder.UserId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = DeviceIdentifier, + ["Type"] = 3, + ["Payload"] = new JsonObject + { + ["Id"] = folder.Id, + ["UserId"] = folder.UserId, + ["RevisionDate"] = folder.RevisionDate, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + + protected override JsonNode GetPushSyncCiphersPayload(Guid userId) + { + return new JsonObject + { + ["UserId"] = userId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = null, + ["Type"] = 4, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + + protected override JsonNode GetPushSyncVaultPayload(Guid userId) + { + return new JsonObject + { + ["UserId"] = userId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = null, + ["Type"] = 5, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + + protected override JsonNode GetPushSyncOrganizationsPayload(Guid userId) + { + return new JsonObject + { + ["UserId"] = userId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = null, + ["Type"] = 17, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + + protected override JsonNode GetPushSyncOrgKeysPayload(Guid userId) + { + return new JsonObject + { + ["UserId"] = userId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = null, + ["Type"] = 6, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + + protected override JsonNode GetPushSyncSettingsPayload(Guid userId) + { + return new JsonObject + { + ["UserId"] = userId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = null, + ["Type"] = 10, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + + protected override JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext) + { + JsonNode? identifier = excludeCurrentContext ? DeviceIdentifier : null; + + return new JsonObject + { + ["UserId"] = userId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = identifier, + ["Type"] = 11, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + protected override JsonNode GetPushSendCreatePayload(Send send) + { + return new JsonObject + { + ["UserId"] = send.UserId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = DeviceIdentifier, + ["Type"] = 12, + ["Payload"] = new JsonObject + { + ["Id"] = send.Id, + ["UserId"] = send.UserId, + ["RevisionDate"] = send.RevisionDate, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + protected override JsonNode GetPushSendUpdatePayload(Send send) + { + return new JsonObject + { + ["UserId"] = send.UserId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = DeviceIdentifier, + ["Type"] = 13, + ["Payload"] = new JsonObject + { + ["Id"] = send.Id, + ["UserId"] = send.UserId, + ["RevisionDate"] = send.RevisionDate, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + protected override JsonNode GetPushSendDeletePayload(Send send) + { + return new JsonObject + { + ["UserId"] = send.UserId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = DeviceIdentifier, + ["Type"] = 14, + ["Payload"] = new JsonObject + { + ["Id"] = send.Id, + ["UserId"] = send.UserId, + ["RevisionDate"] = send.RevisionDate, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + protected override JsonNode GetPushAuthRequestPayload(AuthRequest authRequest) + { + return new JsonObject + { + ["UserId"] = authRequest.UserId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = DeviceIdentifier, + ["Type"] = 15, + ["Payload"] = new JsonObject + { + ["Id"] = authRequest.Id, + ["UserId"] = authRequest.UserId, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + protected override JsonNode GetPushAuthRequestResponsePayload(AuthRequest authRequest) + { + return new JsonObject + { + ["UserId"] = authRequest.UserId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = DeviceIdentifier, + ["Type"] = 16, + ["Payload"] = new JsonObject + { + ["Id"] = authRequest.Id, + ["UserId"] = authRequest.UserId, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + protected override JsonNode GetPushNotificationResponsePayload(Notification notification, Guid? userId, Guid? organizationId) + { + JsonNode? installationId = notification.Global ? GlobalSettings.Installation.Id : null; + + return new JsonObject + { + ["UserId"] = notification.UserId, + ["OrganizationId"] = notification.OrganizationId, + ["DeviceId"] = _deviceId, + ["Identifier"] = DeviceIdentifier, + ["Type"] = 20, + ["Payload"] = new JsonObject + { + ["Id"] = notification.Id, + ["Priority"] = 3, + ["Global"] = notification.Global, + ["ClientType"] = 0, + ["UserId"] = userId, + ["OrganizationId"] = organizationId, + ["TaskId"] = notification.TaskId, + ["InstallationId"] = installationId, + ["Title"] = notification.Title, + ["Body"] = notification.Body, + ["CreationDate"] = notification.CreationDate, + ["RevisionDate"] = notification.RevisionDate, + ["ReadDate"] = null, + ["DeletedDate"] = null, + }, + ["ClientType"] = 0, + ["InstallationId"] = installationId?.DeepClone(), + }; + } + protected override JsonNode GetPushNotificationStatusResponsePayload(Notification notification, NotificationStatus notificationStatus, Guid? userId, Guid? organizationId) + { + JsonNode? installationId = notification.Global ? GlobalSettings.Installation.Id : null; + + return new JsonObject + { + ["UserId"] = notification.UserId, + ["OrganizationId"] = notification.OrganizationId, + ["DeviceId"] = _deviceId, + ["Identifier"] = DeviceIdentifier, + ["Type"] = 21, + ["Payload"] = new JsonObject + { + ["Id"] = notification.Id, + ["Priority"] = 3, + ["Global"] = notification.Global, + ["ClientType"] = 0, + ["UserId"] = notification.UserId, + ["OrganizationId"] = notification.OrganizationId, + ["InstallationId"] = installationId, + ["TaskId"] = notification.TaskId, + ["Title"] = notification.Title, + ["Body"] = notification.Body, + ["CreationDate"] = notification.CreationDate, + ["RevisionDate"] = notification.RevisionDate, + ["ReadDate"] = notificationStatus.ReadDate, + ["DeletedDate"] = notificationStatus.DeletedDate, + }, + ["ClientType"] = 0, + ["InstallationId"] = installationId?.DeepClone(), + }; + } + protected override JsonNode GetPushSyncOrganizationStatusResponsePayload(Organization organization) + { + return new JsonObject + { + ["UserId"] = null, + ["OrganizationId"] = organization.Id, + ["DeviceId"] = _deviceId, + ["Identifier"] = null, + ["Type"] = 18, + ["Payload"] = new JsonObject + { + ["OrganizationId"] = organization.Id, + ["Enabled"] = organization.Enabled, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + protected override JsonNode GetPushSyncOrganizationCollectionManagementSettingsResponsePayload(Organization organization) + { + return new JsonObject + { + ["UserId"] = null, + ["OrganizationId"] = organization.Id, + ["DeviceId"] = _deviceId, + ["Identifier"] = null, + ["Type"] = 19, + ["Payload"] = new JsonObject + { + ["OrganizationId"] = organization.Id, + ["LimitCollectionCreation"] = organization.LimitCollectionCreation, + ["LimitCollectionDeletion"] = organization.LimitCollectionDeletion, + ["LimitItemDeletion"] = organization.LimitItemDeletion, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; + } + + protected override JsonNode GetPushPendingSecurityTasksResponsePayload(Guid userId) + { + return new JsonObject + { + ["UserId"] = userId, + ["OrganizationId"] = null, + ["DeviceId"] = _deviceId, + ["Identifier"] = null, + ["Type"] = 22, + ["Payload"] = new JsonObject + { + ["UserId"] = userId, + ["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime, + }, + ["ClientType"] = null, + ["InstallationId"] = null, + }; } } From c9a42d861cb87e4f1c17a42194dc55b341826331 Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Mon, 14 Apr 2025 12:48:52 -0500 Subject: [PATCH 28/28] [PM-17987] Add feature flag (#5554) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 2b53c52ab8..3a31480109 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -156,6 +156,7 @@ public static class FeatureFlagKeys public const string Argon2Default = "argon2-default"; public const string UserkeyRotationV2 = "userkey-rotation-v2"; public const string SSHKeyItemVaultItem = "ssh-key-vault-item"; + public const string PM17987_BlockType0 = "pm-17987-block-type-0"; /* Mobile Team */ public const string NativeCarouselFlow = "native-carousel-flow";