From f732db4d2df1fa8fb6117fc3fce12d12fad19317 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Tue, 8 Apr 2025 12:33:44 +0200 Subject: [PATCH 01/45] 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 02/45] [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 03/45] 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 04/45] [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 05/45] [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 06/45] 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 07/45] [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 08/45] [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 09/45] [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 10/45] [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 11/45] [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 12/45] 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 13/45] 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 14/45] [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 15/45] 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 16/45] 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 17/45] 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 18/45] 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 19/45] 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 20/45] 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 21/45] 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 22/45] [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 23/45] [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"; From 84a984a9e6595e6499ce0477d1099bc2cc954476 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Tue, 15 Apr 2025 14:36:00 +1000 Subject: [PATCH 24/45] [PM-19585] Use Authorize attributes for simple role authorization (#5555) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Authorize attribute - Add IOrganizationRequirement and example implementation - Add OrganizationRequirementHandler - Add extension methods (replacing ICurrentContext) - Move custom permissions claim definitions --- Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> Co-authored-by: ✨ Audrey ✨ --- .../Authorization/AuthorizeAttribute.cs | 21 ++++ .../Authorization/HttpContextExtensions.cs | 79 ++++++++++++ .../Authorization/IOrganizationRequirement.cs | 31 +++++ .../OrganizationClaimsExtensions.cs | 119 ++++++++++++++++++ .../OrganizationRequirementHandler.cs | 49 ++++++++ .../Requirements/ManageUsersRequirement.cs | 20 +++ .../MemberOrProviderRequirement.cs | 16 +++ .../Utilities/ServiceCollectionExtensions.cs | 5 +- .../AdminConsole/Models/Data/Permissions.cs | 25 ++-- src/Core/Identity/Claims.cs | 17 +++ .../HttpContextExtensionsTests.cs | 28 +++++ .../OrganizationClaimsExtensionsTests.cs | 60 +++++++++ .../OrganizationRequirementHandlerTests.cs | 112 +++++++++++++++++ .../VaultExportAuthorizationHandlerTests.cs | 2 +- ...zationHelpers.cs => PermissionsHelpers.cs} | 20 ++- ...ersTests.cs => PermissionsHelpersTests.cs} | 2 +- 16 files changed, 590 insertions(+), 16 deletions(-) create mode 100644 src/Api/AdminConsole/Authorization/AuthorizeAttribute.cs create mode 100644 src/Api/AdminConsole/Authorization/HttpContextExtensions.cs create mode 100644 src/Api/AdminConsole/Authorization/IOrganizationRequirement.cs create mode 100644 src/Api/AdminConsole/Authorization/OrganizationClaimsExtensions.cs create mode 100644 src/Api/AdminConsole/Authorization/OrganizationRequirementHandler.cs create mode 100644 src/Api/AdminConsole/Authorization/Requirements/ManageUsersRequirement.cs create mode 100644 src/Api/AdminConsole/Authorization/Requirements/MemberOrProviderRequirement.cs create mode 100644 test/Api.Test/AdminConsole/Authorization/HttpContextExtensionsTests.cs create mode 100644 test/Api.Test/AdminConsole/Authorization/OrganizationClaimsExtensionsTests.cs create mode 100644 test/Api.Test/AdminConsole/Authorization/OrganizationRequirementHandlerTests.cs rename test/Core.Test/AdminConsole/Helpers/{AuthorizationHelpers.cs => PermissionsHelpers.cs} (74%) rename test/Core.Test/AdminConsole/Helpers/{AuthorizationHelpersTests.cs => PermissionsHelpersTests.cs} (95%) diff --git a/src/Api/AdminConsole/Authorization/AuthorizeAttribute.cs b/src/Api/AdminConsole/Authorization/AuthorizeAttribute.cs new file mode 100644 index 0000000000..1f864ece66 --- /dev/null +++ b/src/Api/AdminConsole/Authorization/AuthorizeAttribute.cs @@ -0,0 +1,21 @@ +#nullable enable + +using Microsoft.AspNetCore.Authorization; + +namespace Bit.Api.AdminConsole.Authorization; + +/// +/// An attribute which requires authorization using the specified requirement. +/// This uses the standard ASP.NET authorization middleware. +/// +/// The IAuthorizationRequirement that will be used to authorize the user. +public class AuthorizeAttribute + : AuthorizeAttribute, IAuthorizationRequirementData + where T : IAuthorizationRequirement, new() +{ + public IEnumerable GetRequirements() + { + var requirement = new T(); + return [requirement]; + } +} diff --git a/src/Api/AdminConsole/Authorization/HttpContextExtensions.cs b/src/Api/AdminConsole/Authorization/HttpContextExtensions.cs new file mode 100644 index 0000000000..ba00ea6c18 --- /dev/null +++ b/src/Api/AdminConsole/Authorization/HttpContextExtensions.cs @@ -0,0 +1,79 @@ +#nullable enable + +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.AdminConsole.Repositories; + +namespace Bit.Api.AdminConsole.Authorization; + +public static class HttpContextExtensions +{ + public const string NoOrgIdError = + "A route decorated with with '[Authorize]' must include a route value named 'orgId' either through the [Controller] attribute or through a '[Http*]' attribute."; + + /// + /// Returns the result of the callback, caching it in HttpContext.Features for the lifetime of the request. + /// Subsequent calls will retrieve the cached value. + /// Results are stored by type and therefore must be of a unique type. + /// + public static async Task WithFeaturesCacheAsync(this HttpContext httpContext, Func> callback) + { + var cachedResult = httpContext.Features.Get(); + if (cachedResult != null) + { + return cachedResult; + } + + var result = await callback(); + httpContext.Features.Set(result); + + return result; + } + + /// + /// Returns true if the user is a ProviderUser for a Provider which manages the specified organization, otherwise false. + /// + /// + /// This data is fetched from the database and cached as a HttpContext Feature for the lifetime of the request. + /// + public static async Task IsProviderUserForOrgAsync( + this HttpContext httpContext, + IProviderUserRepository providerUserRepository, + Guid userId, + Guid organizationId) + { + var organizations = await httpContext.GetProviderUserOrganizationsAsync(providerUserRepository, userId); + return organizations.Any(o => o.OrganizationId == organizationId); + } + + /// + /// Returns the ProviderUserOrganizations for a user. These are the organizations the ProviderUser manages via their Provider, if any. + /// + /// + /// This data is fetched from the database and cached as a HttpContext Feature for the lifetime of the request. + /// + private static async Task> GetProviderUserOrganizationsAsync( + this HttpContext httpContext, + IProviderUserRepository providerUserRepository, + Guid userId) + => await httpContext.WithFeaturesCacheAsync(() => + providerUserRepository.GetManyOrganizationDetailsByUserAsync(userId, ProviderUserStatusType.Confirmed)); + + + /// + /// Parses the {orgId} route parameter into a Guid, or throws if the {orgId} is not present or not a valid guid. + /// + /// + /// + /// + public static Guid GetOrganizationId(this HttpContext httpContext) + { + httpContext.GetRouteData().Values.TryGetValue("orgId", out var orgIdParam); + if (orgIdParam == null || !Guid.TryParse(orgIdParam.ToString(), out var orgId)) + { + throw new InvalidOperationException(NoOrgIdError); + } + + return orgId; + } +} diff --git a/src/Api/AdminConsole/Authorization/IOrganizationRequirement.cs b/src/Api/AdminConsole/Authorization/IOrganizationRequirement.cs new file mode 100644 index 0000000000..007647f4c0 --- /dev/null +++ b/src/Api/AdminConsole/Authorization/IOrganizationRequirement.cs @@ -0,0 +1,31 @@ +#nullable enable + +using Bit.Core.Context; +using Microsoft.AspNetCore.Authorization; + +namespace Bit.Api.AdminConsole.Authorization; + +/// +/// A requirement that implements this interface will be handled by , +/// which calls AuthorizeAsync with the organization details from the route. +/// This is used for simple role-based checks. +/// This may only be used on endpoints with {orgId} in their path. +/// +public interface IOrganizationRequirement : IAuthorizationRequirement +{ + /// + /// Whether to authorize a request that has this requirement. + /// + /// + /// The CurrentContextOrganization for the user if they are a member of the organization. + /// This is null if they are not a member. + /// + /// + /// A callback that returns true if the user is a ProviderUser that manages the organization, otherwise false. + /// This requires a database query, call it last. + /// + /// True if the requirement has been satisfied, otherwise false. + public Task AuthorizeAsync( + CurrentContextOrganization? organizationClaims, + Func> isProviderUserForOrg); +} diff --git a/src/Api/AdminConsole/Authorization/OrganizationClaimsExtensions.cs b/src/Api/AdminConsole/Authorization/OrganizationClaimsExtensions.cs new file mode 100644 index 0000000000..e21d153bab --- /dev/null +++ b/src/Api/AdminConsole/Authorization/OrganizationClaimsExtensions.cs @@ -0,0 +1,119 @@ +#nullable enable + +using System.Security.Claims; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Identity; +using Bit.Core.Models.Data; + +namespace Bit.Api.AdminConsole.Authorization; + +public static class OrganizationClaimsExtensions +{ + /// + /// Parses a user's claims and returns an object representing their claims for the specified organization. + /// + /// The user who has the claims. + /// The organizationId to look for in the claims. + /// + /// A representing the user's claims for that organization, or null + /// if the user does not have any claims for that organization. + /// + public static CurrentContextOrganization? GetCurrentContextOrganization(this ClaimsPrincipal user, Guid organizationId) + { + var hasClaim = GetClaimsParser(user, organizationId); + + var role = GetRoleFromClaims(hasClaim); + if (!role.HasValue) + { + // Not an organization member + return null; + } + + return new CurrentContextOrganization + { + Id = organizationId, + Type = role.Value, + AccessSecretsManager = hasClaim(Claims.SecretsManagerAccess), + Permissions = role == OrganizationUserType.Custom + ? GetPermissionsFromClaims(hasClaim) + : new Permissions() + }; + } + + /// + /// Returns a function for evaluating claims for the specified user and organizationId. + /// The function returns true if the claim type exists and false otherwise. + /// + private static Func GetClaimsParser(ClaimsPrincipal user, Guid organizationId) + { + // Group claims by ClaimType + var claimsDict = user.Claims + .GroupBy(c => c.Type) + .ToDictionary( + c => c.Key, + c => c.ToList()); + + return claimType + => claimsDict.TryGetValue(claimType, out var claims) && + claims + .ParseGuids() + .Any(v => v == organizationId); + } + + /// + /// Parses the provided claims into proper Guids, or ignore them if they are not valid guids. + /// + private static IEnumerable ParseGuids(this IEnumerable claims) + { + foreach (var claim in claims) + { + if (Guid.TryParse(claim.Value, out var guid)) + { + yield return guid; + } + } + } + + private static OrganizationUserType? GetRoleFromClaims(Func hasClaim) + { + if (hasClaim(Claims.OrganizationOwner)) + { + return OrganizationUserType.Owner; + } + + if (hasClaim(Claims.OrganizationAdmin)) + { + return OrganizationUserType.Admin; + } + + if (hasClaim(Claims.OrganizationCustom)) + { + return OrganizationUserType.Custom; + } + + if (hasClaim(Claims.OrganizationUser)) + { + return OrganizationUserType.User; + } + + return null; + } + + private static Permissions GetPermissionsFromClaims(Func hasClaim) + => new() + { + AccessEventLogs = hasClaim(Claims.CustomPermissions.AccessEventLogs), + AccessImportExport = hasClaim(Claims.CustomPermissions.AccessImportExport), + AccessReports = hasClaim(Claims.CustomPermissions.AccessReports), + CreateNewCollections = hasClaim(Claims.CustomPermissions.CreateNewCollections), + EditAnyCollection = hasClaim(Claims.CustomPermissions.EditAnyCollection), + DeleteAnyCollection = hasClaim(Claims.CustomPermissions.DeleteAnyCollection), + ManageGroups = hasClaim(Claims.CustomPermissions.ManageGroups), + ManagePolicies = hasClaim(Claims.CustomPermissions.ManagePolicies), + ManageSso = hasClaim(Claims.CustomPermissions.ManageSso), + ManageUsers = hasClaim(Claims.CustomPermissions.ManageUsers), + ManageResetPassword = hasClaim(Claims.CustomPermissions.ManageResetPassword), + ManageScim = hasClaim(Claims.CustomPermissions.ManageScim), + }; +} diff --git a/src/Api/AdminConsole/Authorization/OrganizationRequirementHandler.cs b/src/Api/AdminConsole/Authorization/OrganizationRequirementHandler.cs new file mode 100644 index 0000000000..178090fadf --- /dev/null +++ b/src/Api/AdminConsole/Authorization/OrganizationRequirementHandler.cs @@ -0,0 +1,49 @@ +#nullable enable + +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Authorization; + +namespace Bit.Api.AdminConsole.Authorization; + +/// +/// Handles any requirement that implements . +/// Retrieves the Organization ID from the route and then passes it to the requirement's AuthorizeAsync callback to +/// determine whether the action is authorized. +/// +public class OrganizationRequirementHandler( + IHttpContextAccessor httpContextAccessor, + IProviderUserRepository providerUserRepository, + IUserService userService) + : AuthorizationHandler +{ + public const string NoHttpContextError = "This method should only be called in the context of an HTTP Request."; + public const string NoUserIdError = "This method should only be called on the private api with a logged in user."; + + protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, IOrganizationRequirement requirement) + { + var httpContext = httpContextAccessor.HttpContext; + if (httpContext == null) + { + throw new InvalidOperationException(NoHttpContextError); + } + + var organizationId = httpContext.GetOrganizationId(); + var organizationClaims = httpContext.User.GetCurrentContextOrganization(organizationId); + + var userId = userService.GetProperUserId(httpContext.User); + if (userId == null) + { + throw new InvalidOperationException(NoUserIdError); + } + + Task IsProviderUserForOrg() => httpContext.IsProviderUserForOrgAsync(providerUserRepository, userId.Value, organizationId); + + var authorized = await requirement.AuthorizeAsync(organizationClaims, IsProviderUserForOrg); + + if (authorized) + { + context.Succeed(requirement); + } + } +} diff --git a/src/Api/AdminConsole/Authorization/Requirements/ManageUsersRequirement.cs b/src/Api/AdminConsole/Authorization/Requirements/ManageUsersRequirement.cs new file mode 100644 index 0000000000..84f38e36c2 --- /dev/null +++ b/src/Api/AdminConsole/Authorization/Requirements/ManageUsersRequirement.cs @@ -0,0 +1,20 @@ +#nullable enable + +using Bit.Core.Context; +using Bit.Core.Enums; + +namespace Bit.Api.AdminConsole.Authorization.Requirements; + +public class ManageUsersRequirement : IOrganizationRequirement +{ + public async Task AuthorizeAsync( + CurrentContextOrganization? organizationClaims, + Func> isProviderUserForOrg) + => organizationClaims switch + { + { Type: OrganizationUserType.Owner } => true, + { Type: OrganizationUserType.Admin } => true, + { Permissions.ManageUsers: true } => true, + _ => await isProviderUserForOrg() + }; +} diff --git a/src/Api/AdminConsole/Authorization/Requirements/MemberOrProviderRequirement.cs b/src/Api/AdminConsole/Authorization/Requirements/MemberOrProviderRequirement.cs new file mode 100644 index 0000000000..030509adef --- /dev/null +++ b/src/Api/AdminConsole/Authorization/Requirements/MemberOrProviderRequirement.cs @@ -0,0 +1,16 @@ +#nullable enable + +using Bit.Core.Context; + +namespace Bit.Api.AdminConsole.Authorization.Requirements; + +/// +/// Requires that the user is a member of the organization or a provider for the organization. +/// +public class MemberOrProviderRequirement : IOrganizationRequirement +{ + public async Task AuthorizeAsync( + CurrentContextOrganization? organizationClaims, + Func> isProviderUserForOrg) + => organizationClaims is not null || await isProviderUserForOrg(); +} diff --git a/src/Api/Utilities/ServiceCollectionExtensions.cs b/src/Api/Utilities/ServiceCollectionExtensions.cs index feeac03e54..4c8589657e 100644 --- a/src/Api/Utilities/ServiceCollectionExtensions.cs +++ b/src/Api/Utilities/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ -using Bit.Api.Tools.Authorization; +using Bit.Api.AdminConsole.Authorization; +using Bit.Api.Tools.Authorization; using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization; using Bit.Core.IdentityServer; @@ -105,5 +106,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + + services.AddScoped(); } } diff --git a/src/Core/AdminConsole/Models/Data/Permissions.cs b/src/Core/AdminConsole/Models/Data/Permissions.cs index 9edc3f1d50..def468f18d 100644 --- a/src/Core/AdminConsole/Models/Data/Permissions.cs +++ b/src/Core/AdminConsole/Models/Data/Permissions.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Bit.Core.Identity; namespace Bit.Core.Models.Data; @@ -20,17 +21,17 @@ public class Permissions [JsonIgnore] public List<(bool Permission, string ClaimName)> ClaimsMap => new() { - (AccessEventLogs, "accesseventlogs"), - (AccessImportExport, "accessimportexport"), - (AccessReports, "accessreports"), - (CreateNewCollections, "createnewcollections"), - (EditAnyCollection, "editanycollection"), - (DeleteAnyCollection, "deleteanycollection"), - (ManageGroups, "managegroups"), - (ManagePolicies, "managepolicies"), - (ManageSso, "managesso"), - (ManageUsers, "manageusers"), - (ManageResetPassword, "manageresetpassword"), - (ManageScim, "managescim"), + (AccessEventLogs, Claims.CustomPermissions.AccessEventLogs), + (AccessImportExport, Claims.CustomPermissions.AccessImportExport), + (AccessReports, Claims.CustomPermissions.AccessReports), + (CreateNewCollections, Claims.CustomPermissions.CreateNewCollections), + (EditAnyCollection, Claims.CustomPermissions.EditAnyCollection), + (DeleteAnyCollection, Claims.CustomPermissions.DeleteAnyCollection), + (ManageGroups, Claims.CustomPermissions.ManageGroups), + (ManagePolicies, Claims.CustomPermissions.ManagePolicies), + (ManageSso, Claims.CustomPermissions.ManageSso), + (ManageUsers, Claims.CustomPermissions.ManageUsers), + (ManageResetPassword, Claims.CustomPermissions.ManageResetPassword), + (ManageScim, Claims.CustomPermissions.ManageScim), }; } diff --git a/src/Core/Identity/Claims.cs b/src/Core/Identity/Claims.cs index 65d5eb210a..fad7b37b5f 100644 --- a/src/Core/Identity/Claims.cs +++ b/src/Core/Identity/Claims.cs @@ -22,4 +22,21 @@ public static class Claims // General public const string Type = "type"; + + // Organization custom permissions + public static class CustomPermissions + { + public const string AccessEventLogs = "accesseventlogs"; + public const string AccessImportExport = "accessimportexport"; + public const string AccessReports = "accessreports"; + public const string CreateNewCollections = "createnewcollections"; + public const string EditAnyCollection = "editanycollection"; + public const string DeleteAnyCollection = "deleteanycollection"; + public const string ManageGroups = "managegroups"; + public const string ManagePolicies = "managepolicies"; + public const string ManageSso = "managesso"; + public const string ManageUsers = "manageusers"; + public const string ManageResetPassword = "manageresetpassword"; + public const string ManageScim = "managescim"; + } } diff --git a/test/Api.Test/AdminConsole/Authorization/HttpContextExtensionsTests.cs b/test/Api.Test/AdminConsole/Authorization/HttpContextExtensionsTests.cs new file mode 100644 index 0000000000..1901742777 --- /dev/null +++ b/test/Api.Test/AdminConsole/Authorization/HttpContextExtensionsTests.cs @@ -0,0 +1,28 @@ +using Bit.Api.AdminConsole.Authorization; +using Microsoft.AspNetCore.Http; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Authorization; + +public class HttpContextExtensionsTests +{ + [Fact] + public async Task WithFeaturesCacheAsync_OnlyExecutesCallbackOnce() + { + var httpContext = new DefaultHttpContext(); + var callback = Substitute.For>>(); + callback().Returns(Task.FromResult("hello world")); + + // Call once + var result1 = await httpContext.WithFeaturesCacheAsync(callback); + Assert.Equal("hello world", result1); + await callback.ReceivedWithAnyArgs(1).Invoke(); + + // Call again - callback not executed again + var result2 = await httpContext.WithFeaturesCacheAsync(callback); + Assert.Equal("hello world", result2); + await callback.ReceivedWithAnyArgs(1).Invoke(); + } + +} diff --git a/test/Api.Test/AdminConsole/Authorization/OrganizationClaimsExtensionsTests.cs b/test/Api.Test/AdminConsole/Authorization/OrganizationClaimsExtensionsTests.cs new file mode 100644 index 0000000000..7e2e51a6e1 --- /dev/null +++ b/test/Api.Test/AdminConsole/Authorization/OrganizationClaimsExtensionsTests.cs @@ -0,0 +1,60 @@ +using System.Security.Claims; +using Bit.Api.AdminConsole.Authorization; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Test.AdminConsole.Helpers; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.Helpers; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Authorization; + +public class OrganizationClaimsExtensionsTests +{ + [Theory, BitMemberAutoData(nameof(GetTestOrganizations))] + public void GetCurrentContextOrganization_ParsesOrganizationFromClaims(CurrentContextOrganization expected, User user) + { + var claims = CoreHelpers.BuildIdentityClaims(user, [expected], [], false) + .Select(c => new Claim(c.Key, c.Value)); + + var claimsPrincipal = new ClaimsPrincipal(); + claimsPrincipal.AddIdentities([new ClaimsIdentity(claims)]); + + var actual = claimsPrincipal.GetCurrentContextOrganization(expected.Id); + + AssertHelper.AssertPropertyEqual(expected, actual); + } + + public static IEnumerable GetTestOrganizations() + { + var roles = new List { OrganizationUserType.Owner, OrganizationUserType.Admin, OrganizationUserType.User }; + foreach (var role in roles) + { + yield return + [ + new CurrentContextOrganization + { + Id = Guid.NewGuid(), + Type = role, + AccessSecretsManager = true + } + ]; + } + + var permissions = PermissionsHelpers.GetAllPermissions(); + foreach (var permission in permissions) + { + yield return + [ + new CurrentContextOrganization + { + Id = Guid.NewGuid(), + Type = OrganizationUserType.Custom, + Permissions = permission + } + ]; + } + } +} diff --git a/test/Api.Test/AdminConsole/Authorization/OrganizationRequirementHandlerTests.cs b/test/Api.Test/AdminConsole/Authorization/OrganizationRequirementHandlerTests.cs new file mode 100644 index 0000000000..117630cd74 --- /dev/null +++ b/test/Api.Test/AdminConsole/Authorization/OrganizationRequirementHandlerTests.cs @@ -0,0 +1,112 @@ +using System.Security.Claims; +using Bit.Api.AdminConsole.Authorization; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Authorization; + +[SutProviderCustomize] +public class OrganizationRequirementHandlerTests +{ + [Theory] + [BitAutoData((string)null)] + [BitAutoData("malformed guid")] + public async Task IfInvalidOrganizationId_Throws(string orgId, Guid userId, SutProvider sutProvider) + { + // Arrange + ArrangeRouteAndUser(sutProvider, orgId, userId); + var testRequirement = Substitute.For(); + var authContext = new AuthorizationHandlerContext([testRequirement], new ClaimsPrincipal(), null); + + // Act + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.HandleAsync(authContext)); + Assert.Contains(HttpContextExtensions.NoOrgIdError, exception.Message); + Assert.False(authContext.HasSucceeded); + } + + [Theory, BitAutoData] + public async Task IfHttpContextIsNull_Throws(SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency().HttpContext = null; + var testRequirement = Substitute.For(); + var authContext = new AuthorizationHandlerContext([testRequirement], new ClaimsPrincipal(), null); + + // Act + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.HandleAsync(authContext)); + Assert.Contains(OrganizationRequirementHandler.NoHttpContextError, exception.Message); + Assert.False(authContext.HasSucceeded); + } + + [Theory, BitAutoData] + public async Task IfUserIdIsNull_Throws(Guid orgId, SutProvider sutProvider) + { + // Arrange + ArrangeRouteAndUser(sutProvider, orgId.ToString(), null); + var testRequirement = Substitute.For(); + var authContext = new AuthorizationHandlerContext([testRequirement], new ClaimsPrincipal(), null); + + // Act + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.HandleAsync(authContext)); + Assert.Contains(OrganizationRequirementHandler.NoUserIdError, exception.Message); + Assert.False(authContext.HasSucceeded); + } + + [Theory, BitAutoData] + public async Task DoesNotAuthorize_IfAuthorizeAsync_ReturnsFalse( + SutProvider sutProvider, Guid organizationId, Guid userId) + { + // Arrange route values + ArrangeRouteAndUser(sutProvider, organizationId.ToString(), userId); + + // Arrange requirement + var testRequirement = Substitute.For(); + testRequirement + .AuthorizeAsync(null, Arg.Any>>()) + .ReturnsForAnyArgs(false); + var authContext = new AuthorizationHandlerContext([testRequirement], new ClaimsPrincipal(), null); + + // Act + await sutProvider.Sut.HandleAsync(authContext); + + // Assert + await testRequirement.Received(1).AuthorizeAsync(null, Arg.Any>>()); + Assert.False(authContext.HasSucceeded); + } + + [Theory, BitAutoData] + public async Task Authorizes_IfAuthorizeAsync_ReturnsTrue( + SutProvider sutProvider, Guid organizationId, Guid userId) + { + // Arrange route values + ArrangeRouteAndUser(sutProvider, organizationId.ToString(), userId); + + // Arrange requirement + var testRequirement = Substitute.For(); + testRequirement + .AuthorizeAsync(null, Arg.Any>>()) + .ReturnsForAnyArgs(true); + var authContext = new AuthorizationHandlerContext([testRequirement], new ClaimsPrincipal(), null); + + // Act + await sutProvider.Sut.HandleAsync(authContext); + + // Assert + await testRequirement.Received(1).AuthorizeAsync(null, Arg.Any>>()); + Assert.True(authContext.HasSucceeded); + } + + private static void ArrangeRouteAndUser(SutProvider sutProvider, string orgIdRouteValue, + Guid? userId) + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.RouteValues["orgId"] = orgIdRouteValue; + sutProvider.GetDependency().HttpContext = httpContext; + sutProvider.GetDependency().GetProperUserId(Arg.Any()).Returns(userId); + } +} diff --git a/test/Api.Test/Tools/Authorization/VaultExportAuthorizationHandlerTests.cs b/test/Api.Test/Tools/Authorization/VaultExportAuthorizationHandlerTests.cs index 6c42205b1a..c876e1925b 100644 --- a/test/Api.Test/Tools/Authorization/VaultExportAuthorizationHandlerTests.cs +++ b/test/Api.Test/Tools/Authorization/VaultExportAuthorizationHandlerTests.cs @@ -64,7 +64,7 @@ public class VaultExportAuthorizationHandlerTests } public static IEnumerable CanExportManagedCollections => - AuthorizationHelpers.AllRoles().Select(o => new[] { o }); + PermissionsHelpers.AllRoles().Select(o => new[] { o }); [Theory] [BitMemberAutoData(nameof(CanExportManagedCollections))] diff --git a/test/Core.Test/AdminConsole/Helpers/AuthorizationHelpers.cs b/test/Core.Test/AdminConsole/Helpers/PermissionsHelpers.cs similarity index 74% rename from test/Core.Test/AdminConsole/Helpers/AuthorizationHelpers.cs rename to test/Core.Test/AdminConsole/Helpers/PermissionsHelpers.cs index 854cdcb3c8..f346c47624 100644 --- a/test/Core.Test/AdminConsole/Helpers/AuthorizationHelpers.cs +++ b/test/Core.Test/AdminConsole/Helpers/PermissionsHelpers.cs @@ -4,7 +4,7 @@ using Bit.Core.Models.Data; namespace Bit.Core.Test.AdminConsole.Helpers; -public static class AuthorizationHelpers +public static class PermissionsHelpers { /// /// Return a new Permission object with inverted permissions. @@ -36,6 +36,24 @@ public static class AuthorizationHelpers return result; } + /// + /// Returns a sequence of Permission objects, where each Permission object has a different permission flag set. + /// + public static IEnumerable GetAllPermissions() + { + // Get all boolean properties of input object + var props = typeof(Permissions) + .GetProperties() + .Where(p => p.PropertyType == typeof(bool)); + + foreach (var prop in props) + { + var result = new Permissions(); + prop.SetValue(result, true); + yield return result; + } + } + /// /// Returns a sequence of all possible roles and permissions represented as CurrentContextOrganization objects. /// Used largely for authorization testing. diff --git a/test/Core.Test/AdminConsole/Helpers/AuthorizationHelpersTests.cs b/test/Core.Test/AdminConsole/Helpers/PermissionsHelpersTests.cs similarity index 95% rename from test/Core.Test/AdminConsole/Helpers/AuthorizationHelpersTests.cs rename to test/Core.Test/AdminConsole/Helpers/PermissionsHelpersTests.cs index db128ffc4b..0873f045bc 100644 --- a/test/Core.Test/AdminConsole/Helpers/AuthorizationHelpersTests.cs +++ b/test/Core.Test/AdminConsole/Helpers/PermissionsHelpersTests.cs @@ -3,7 +3,7 @@ using Xunit; namespace Bit.Core.Test.AdminConsole.Helpers; -public class AuthorizationHelpersTests +public class PermissionsHelpersTests { [Fact] public void Permissions_Invert_InvertsAllPermissions() From 2242a70e504b643bbd4efa3f4e713332b8e66fe0 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Tue, 15 Apr 2025 12:56:58 -0400 Subject: [PATCH 25/45] [PM-336] Nullable Platform & Unowned Services (#5646) * Nullable Platform & Unowned Services * Fix build errors * Format --- .../IUpdateInstallationCommand.cs | 4 +- .../UpdateInstallationCommand.cs | 4 +- .../GetInstallationQuery.cs | 6 ++- .../IGetInstallationQuery.cs | 6 ++- .../PlatformServiceCollectionExtensions.cs | 4 +- .../Push/Services/IPushRegistrationService.cs | 4 +- .../Services/NoopPushRegistrationService.cs | 4 +- .../Services/RelayPushRegistrationService.cs | 4 +- .../Repositories/ICollectionRepository.cs | 2 +- src/Core/Services/ILicensingService.cs | 14 +++--- src/Core/Services/IMailService.cs | 10 ++-- .../AmazonSesMailDeliveryService.cs | 6 ++- .../Implementations/AzureQueueService.cs | 4 +- .../Implementations/CollectionService.cs | 8 +-- .../Implementations/HandlebarsMailService.cs | 26 ++++++---- .../Services/Implementations/I18nService.cs | 7 +-- .../Implementations/I18nViewLocalizer.cs | 9 ++-- .../MailKitSmtpMailDeliveryService.cs | 9 +++- .../NoopLicensingService.cs | 22 ++++---- .../NoopImplementations/NoopMailService.cs | 8 +-- src/Core/Utilities/CoreHelpers.cs | 50 +++++++++++-------- .../Repositories/CollectionRepository.cs | 14 ++++-- 22 files changed, 141 insertions(+), 84 deletions(-) diff --git a/src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/IUpdateInstallationCommand.cs b/src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/IUpdateInstallationCommand.cs index d0c25b96a4..02263bba40 100644 --- a/src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/IUpdateInstallationCommand.cs +++ b/src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/IUpdateInstallationCommand.cs @@ -1,4 +1,6 @@ -namespace Bit.Core.Platform.Installations; +#nullable enable + +namespace Bit.Core.Platform.Installations; /// /// Command interface responsible for updating data on an `Installation` diff --git a/src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/UpdateInstallationCommand.cs b/src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/UpdateInstallationCommand.cs index 4b0bc3bbe8..69667cb4ac 100644 --- a/src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/UpdateInstallationCommand.cs +++ b/src/Core/Platform/Installations/Commands/UpdateInstallationActivityDateCommand/UpdateInstallationCommand.cs @@ -1,4 +1,6 @@ -namespace Bit.Core.Platform.Installations; +#nullable enable + +namespace Bit.Core.Platform.Installations; /// /// Commands responsible for updating an installation from diff --git a/src/Core/Platform/Installations/Queries/GetInstallationQuery/GetInstallationQuery.cs b/src/Core/Platform/Installations/Queries/GetInstallationQuery/GetInstallationQuery.cs index b0d8745800..2afb319a1f 100644 --- a/src/Core/Platform/Installations/Queries/GetInstallationQuery/GetInstallationQuery.cs +++ b/src/Core/Platform/Installations/Queries/GetInstallationQuery/GetInstallationQuery.cs @@ -1,4 +1,6 @@ -namespace Bit.Core.Platform.Installations; +#nullable enable + +namespace Bit.Core.Platform.Installations; /// /// Queries responsible for fetching an installation from @@ -19,7 +21,7 @@ public class GetInstallationQuery : IGetInstallationQuery } /// - public async Task GetByIdAsync(Guid installationId) + public async Task GetByIdAsync(Guid installationId) { if (installationId == default(Guid)) { diff --git a/src/Core/Platform/Installations/Queries/GetInstallationQuery/IGetInstallationQuery.cs b/src/Core/Platform/Installations/Queries/GetInstallationQuery/IGetInstallationQuery.cs index 9615cf986d..10bcfba9b8 100644 --- a/src/Core/Platform/Installations/Queries/GetInstallationQuery/IGetInstallationQuery.cs +++ b/src/Core/Platform/Installations/Queries/GetInstallationQuery/IGetInstallationQuery.cs @@ -1,4 +1,6 @@ -namespace Bit.Core.Platform.Installations; +#nullable enable + +namespace Bit.Core.Platform.Installations; /// /// Query interface responsible for fetching an installation from @@ -16,5 +18,5 @@ public interface IGetInstallationQuery /// The GUID id of the installation. /// A task containing an `Installation`. /// - Task GetByIdAsync(Guid installationId); + Task GetByIdAsync(Guid installationId); } diff --git a/src/Core/Platform/PlatformServiceCollectionExtensions.cs b/src/Core/Platform/PlatformServiceCollectionExtensions.cs index bba0b0aedd..d426b934c8 100644 --- a/src/Core/Platform/PlatformServiceCollectionExtensions.cs +++ b/src/Core/Platform/PlatformServiceCollectionExtensions.cs @@ -1,4 +1,6 @@ -using Bit.Core.Platform.Installations; +#nullable enable + +using Bit.Core.Platform.Installations; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.Platform; diff --git a/src/Core/Platform/Push/Services/IPushRegistrationService.cs b/src/Core/Platform/Push/Services/IPushRegistrationService.cs index 469cd2577b..8e34e5e316 100644 --- a/src/Core/Platform/Push/Services/IPushRegistrationService.cs +++ b/src/Core/Platform/Push/Services/IPushRegistrationService.cs @@ -1,4 +1,6 @@ -using Bit.Core.Enums; +#nullable enable + +using Bit.Core.Enums; using Bit.Core.NotificationHub; namespace Bit.Core.Platform.Push; diff --git a/src/Core/Platform/Push/Services/NoopPushRegistrationService.cs b/src/Core/Platform/Push/Services/NoopPushRegistrationService.cs index 9a7674232a..32efc95ce6 100644 --- a/src/Core/Platform/Push/Services/NoopPushRegistrationService.cs +++ b/src/Core/Platform/Push/Services/NoopPushRegistrationService.cs @@ -1,4 +1,6 @@ -using Bit.Core.Enums; +#nullable enable + +using Bit.Core.Enums; using Bit.Core.NotificationHub; namespace Bit.Core.Platform.Push.Internal; diff --git a/src/Core/Platform/Push/Services/RelayPushRegistrationService.cs b/src/Core/Platform/Push/Services/RelayPushRegistrationService.cs index 1a3843d05a..20e405935b 100644 --- a/src/Core/Platform/Push/Services/RelayPushRegistrationService.cs +++ b/src/Core/Platform/Push/Services/RelayPushRegistrationService.cs @@ -1,4 +1,6 @@ -using Bit.Core.Enums; +#nullable enable + +using Bit.Core.Enums; using Bit.Core.IdentityServer; using Bit.Core.Models.Api; using Bit.Core.NotificationHub; diff --git a/src/Core/Repositories/ICollectionRepository.cs b/src/Core/Repositories/ICollectionRepository.cs index fc3a6715ac..1d41a6ee1f 100644 --- a/src/Core/Repositories/ICollectionRepository.cs +++ b/src/Core/Repositories/ICollectionRepository.cs @@ -48,7 +48,7 @@ public interface ICollectionRepository : IRepository Task GetByIdWithPermissionsAsync(Guid collectionId, Guid? userId, bool includeAccessRelationships); Task CreateAsync(Collection obj, IEnumerable? groups, IEnumerable? users); - Task ReplaceAsync(Collection obj, IEnumerable groups, IEnumerable users); + Task ReplaceAsync(Collection obj, IEnumerable? groups, IEnumerable? users); Task DeleteUserAsync(Guid collectionId, Guid organizationUserId); Task UpdateUsersAsync(Guid id, IEnumerable users); Task> GetManyUsersByIdAsync(Guid id); diff --git a/src/Core/Services/ILicensingService.cs b/src/Core/Services/ILicensingService.cs index 7301f7c689..2115e43085 100644 --- a/src/Core/Services/ILicensingService.cs +++ b/src/Core/Services/ILicensingService.cs @@ -1,4 +1,6 @@ -using System.Security.Claims; +#nullable enable + +using System.Security.Claims; using Bit.Core.AdminConsole.Entities; using Bit.Core.Entities; using Bit.Core.Models.Business; @@ -12,14 +14,14 @@ public interface ILicensingService Task ValidateUserPremiumAsync(User user); bool VerifyLicense(ILicense license); byte[] SignLicense(ILicense license); - Task ReadOrganizationLicenseAsync(Organization organization); - Task ReadOrganizationLicenseAsync(Guid organizationId); - ClaimsPrincipal GetClaimsPrincipalFromLicense(ILicense license); + Task ReadOrganizationLicenseAsync(Organization organization); + Task ReadOrganizationLicenseAsync(Guid organizationId); + ClaimsPrincipal? GetClaimsPrincipalFromLicense(ILicense license); - Task CreateOrganizationTokenAsync( + Task CreateOrganizationTokenAsync( Organization organization, Guid installationId, SubscriptionInfo subscriptionInfo); - Task CreateUserTokenAsync(User user, SubscriptionInfo subscriptionInfo); + Task CreateUserTokenAsync(User user, SubscriptionInfo subscriptionInfo); } diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 805c143173..9b05810eaa 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -1,4 +1,6 @@ -using Bit.Core.AdminConsole.Entities; +#nullable enable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Auth.Entities; using Bit.Core.Billing.Enums; @@ -55,7 +57,7 @@ public interface IMailService bool mentionInvoices); Task SendPaymentFailedAsync(string email, decimal amount, bool mentionInvoices); Task SendAddedCreditAsync(string email, decimal amount); - Task SendLicenseExpiredAsync(IEnumerable emails, string organizationName = null); + Task SendLicenseExpiredAsync(IEnumerable emails, string? organizationName = null); Task SendNewDeviceLoggedInEmail(string email, string deviceType, DateTime timestamp, string ip); Task SendRecoverTwoFactorEmail(string email, DateTime timestamp, string ip); Task SendOrganizationUserRemovedForPolicySingleOrgEmailAsync(string organizationName, string email); @@ -96,9 +98,11 @@ public interface IMailService Task SendInitiateDeletProviderEmailAsync(string email, Provider provider, string token); Task SendInitiateDeleteOrganzationEmailAsync(string email, Organization organization, string token); Task SendRequestSMAccessToAdminEmailAsync(IEnumerable adminEmails, string organizationName, string userRequestingAccess, string emailContent); +#nullable disable Task SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(string email, string offerAcceptanceDate, string organizationId, string organizationName); +#nullable enable Task SendClaimedDomainUserEmailAsync(ClaimedUserDomainClaimedEmails emailList); - Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable adminEmails, Guid organizationId, string email, string userName); + Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable adminEmails, Guid organizationId, string email, string? userName); Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable securityTaskNotifications, IEnumerable adminOwnerEmails); } diff --git a/src/Core/Services/Implementations/AmazonSesMailDeliveryService.cs b/src/Core/Services/Implementations/AmazonSesMailDeliveryService.cs index adf406cf0b..344c2e712d 100644 --- a/src/Core/Services/Implementations/AmazonSesMailDeliveryService.cs +++ b/src/Core/Services/Implementations/AmazonSesMailDeliveryService.cs @@ -1,4 +1,6 @@ -using Amazon; +#nullable enable + +using Amazon; using Amazon.SimpleEmail; using Amazon.SimpleEmail.Model; using Bit.Core.Models.Mail; @@ -17,7 +19,7 @@ public class AmazonSesMailDeliveryService : IMailDeliveryService, IDisposable private readonly IAmazonSimpleEmailService _client; private readonly string _source; private readonly string _senderTag; - private readonly string _configSetName; + private readonly string? _configSetName; public AmazonSesMailDeliveryService( GlobalSettings globalSettings, diff --git a/src/Core/Services/Implementations/AzureQueueService.cs b/src/Core/Services/Implementations/AzureQueueService.cs index 11c1a58ae3..ca95e96b64 100644 --- a/src/Core/Services/Implementations/AzureQueueService.cs +++ b/src/Core/Services/Implementations/AzureQueueService.cs @@ -1,4 +1,6 @@ -using System.Text; +#nullable enable + +using System.Text; using System.Text.Json; using Azure.Storage.Queues; using Bit.Core.Utilities; diff --git a/src/Core/Services/Implementations/CollectionService.cs b/src/Core/Services/Implementations/CollectionService.cs index f6e9735f4e..46853447d6 100644 --- a/src/Core/Services/Implementations/CollectionService.cs +++ b/src/Core/Services/Implementations/CollectionService.cs @@ -1,4 +1,6 @@ -using Bit.Core.Context; +#nullable enable + +using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Models.Data; @@ -34,8 +36,8 @@ public class CollectionService : ICollectionService _currentContext = currentContext; } - public async Task SaveAsync(Collection collection, IEnumerable groups = null, - IEnumerable users = null) + public async Task SaveAsync(Collection collection, IEnumerable? groups = null, + IEnumerable? users = null) { var org = await _organizationRepository.GetByIdAsync(collection.OrganizationId); if (org == null) diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 81e17e7c6f..1fca85eff4 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -1,4 +1,7 @@ -using System.Net; +#nullable enable + +using System.Diagnostics; +using System.Net; using System.Reflection; using System.Text.Json; using Bit.Core.AdminConsole.Entities; @@ -213,6 +216,7 @@ public class HandlebarsMailService : IMailService public async Task SendOrganizationAutoscaledEmailAsync(Organization organization, int initialSeatCount, IEnumerable ownerEmails) { + Debug.Assert(organization.Seats.HasValue, "Organization is expected to have a non-null value for seats at the time of sending this email"); var message = CreateDefaultMessage($"{organization.DisplayName()} Seat Count Has Increased", ownerEmails); var model = new OrganizationSeatsAutoscaledViewModel { @@ -286,7 +290,7 @@ public class HandlebarsMailService : IMailService var messageModels = orgInvitesInfo.OrgUserTokenPairs.Select(orgUserTokenPair => { - + Debug.Assert(orgUserTokenPair.OrgUser.Email is not null); var orgUserInviteViewModel = OrganizationUserInvitedViewModel.CreateFromInviteInfo( orgInvitesInfo, orgUserTokenPair.OrgUser, orgUserTokenPair.Token, _globalSettings); return CreateMessage(orgUserTokenPair.OrgUser.Email, orgUserInviteViewModel); @@ -358,7 +362,7 @@ public class HandlebarsMailService : IMailService WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, SiteName = _globalSettings.SiteName, ProviderId = provider.Id, - ProviderName = CoreHelpers.SanitizeForEmail(provider.DisplayName(), false), + ProviderName = CoreHelpers.SanitizeForEmail(provider.DisplayName()!, false), ProviderNameUrlEncoded = WebUtility.UrlEncode(provider.Name), ProviderBillingEmail = provider.BillingEmail, ProviderCreationDate = provider.CreationDate.ToLongDateString(), @@ -448,7 +452,7 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } - public async Task SendLicenseExpiredAsync(IEnumerable emails, string organizationName = null) + public async Task SendLicenseExpiredAsync(IEnumerable emails, string? organizationName = null) { var message = CreateDefaultMessage("License Expired", emails); var model = new LicenseExpiredViewModel(); @@ -598,12 +602,14 @@ public class HandlebarsMailService : IMailService } private async Task AddMessageContentAsync(MailMessage message, string templateName, T model) + where T : notnull { message.HtmlContent = await RenderAsync($"{templateName}.html", model); message.TextContent = await RenderAsync($"{templateName}.text", model); } - private async Task RenderAsync(string templateName, T model) + private async Task RenderAsync(string templateName, T model) + where T : notnull { await RegisterHelpersAndPartialsAsync(); if (!_templateCache.TryGetValue(templateName, out var template)) @@ -618,7 +624,7 @@ public class HandlebarsMailService : IMailService return template != null ? template(model) : null; } - private async Task ReadSourceAsync(string templateName) + private async Task ReadSourceAsync(string templateName) { var assembly = typeof(HandlebarsMailService).GetTypeInfo().Assembly; var fullTemplateName = $"{Namespace}.{templateName}.hbs"; @@ -626,7 +632,7 @@ public class HandlebarsMailService : IMailService { return null; } - using (var s = assembly.GetManifestResourceStream(fullTemplateName)) + using (var s = assembly.GetManifestResourceStream(fullTemplateName)!) using (var sr = new StreamReader(s)) { return await sr.ReadToEndAsync(); @@ -757,7 +763,7 @@ public class HandlebarsMailService : IMailService var emailList = new List(); if (parameters[0] is JsonElement jsonElement && jsonElement.ValueKind == JsonValueKind.Array) { - emailList = jsonElement.EnumerateArray().Select(e => e.GetString()).ToList(); + emailList = jsonElement.EnumerateArray().Select(e => e.GetString()!).ToList(); } else if (parameters[0] is IEnumerable emails) { @@ -1276,7 +1282,7 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } - public async Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable adminEmails, Guid organizationId, string email, string userName) + public async Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable adminEmails, Guid organizationId, string email, string? userName) { var templateName = _globalSettings.SelfHosted ? "AdminConsole.SelfHostNotifyAdminDeviceApprovalRequested" : @@ -1313,7 +1319,7 @@ public class HandlebarsMailService : IMailService await EnqueueMailAsync(messageModels.ToList()); } - private static string GetUserIdentifier(string email, string userName) + private static string GetUserIdentifier(string email, string? userName) { return string.IsNullOrEmpty(userName) ? email : CoreHelpers.SanitizeForEmail(userName, false); } diff --git a/src/Core/Services/Implementations/I18nService.cs b/src/Core/Services/Implementations/I18nService.cs index 7d99dacbac..25e2f8e5dc 100644 --- a/src/Core/Services/Implementations/I18nService.cs +++ b/src/Core/Services/Implementations/I18nService.cs @@ -1,4 +1,5 @@ -using System.Reflection; +#nullable enable + using Bit.Core.Resources; using Microsoft.Extensions.Localization; @@ -10,8 +11,8 @@ public class I18nService : II18nService public I18nService(IStringLocalizerFactory factory) { - var assemblyName = new AssemblyName(typeof(SharedResources).GetTypeInfo().Assembly.FullName); - _localizer = factory.Create("SharedResources", assemblyName.Name); + var assemblyName = typeof(SharedResources).Assembly.GetName()!; + _localizer = factory.Create("SharedResources", assemblyName.Name!); } public LocalizedString GetLocalizedHtmlString(string key) diff --git a/src/Core/Services/Implementations/I18nViewLocalizer.cs b/src/Core/Services/Implementations/I18nViewLocalizer.cs index 69699d9c4b..7f41782c53 100644 --- a/src/Core/Services/Implementations/I18nViewLocalizer.cs +++ b/src/Core/Services/Implementations/I18nViewLocalizer.cs @@ -1,4 +1,5 @@ -using System.Reflection; +#nullable enable + using Bit.Core.Resources; using Microsoft.AspNetCore.Mvc.Localization; using Microsoft.Extensions.Localization; @@ -13,9 +14,9 @@ public class I18nViewLocalizer : IViewLocalizer public I18nViewLocalizer(IStringLocalizerFactory stringFactory, IHtmlLocalizerFactory htmlFactory) { - var assemblyName = new AssemblyName(typeof(SharedResources).GetTypeInfo().Assembly.FullName); - _stringLocalizer = stringFactory.Create("SharedResources", assemblyName.Name); - _htmlLocalizer = htmlFactory.Create("SharedResources", assemblyName.Name); + var assemblyName = typeof(SharedResources).Assembly.GetName()!; + _stringLocalizer = stringFactory.Create("SharedResources", assemblyName.Name!); + _htmlLocalizer = htmlFactory.Create("SharedResources", assemblyName.Name!); } public LocalizedHtmlString this[string name] => _htmlLocalizer[name]; diff --git a/src/Core/Services/Implementations/MailKitSmtpMailDeliveryService.cs b/src/Core/Services/Implementations/MailKitSmtpMailDeliveryService.cs index 3a7cabd39e..2ebc7492f7 100644 --- a/src/Core/Services/Implementations/MailKitSmtpMailDeliveryService.cs +++ b/src/Core/Services/Implementations/MailKitSmtpMailDeliveryService.cs @@ -22,12 +22,17 @@ public class MailKitSmtpMailDeliveryService : IMailDeliveryService ILogger logger, IOptions x509ChainOptions) { - if (globalSettings.Mail?.Smtp?.Host == null) + if (globalSettings.Mail.Smtp?.Host == null) { throw new ArgumentNullException(nameof(globalSettings.Mail.Smtp.Host)); } - _replyEmail = CoreHelpers.PunyEncode(globalSettings.Mail?.ReplyToEmail); + if (globalSettings.Mail.ReplyToEmail == null) + { + throw new InvalidOperationException("A GlobalSettings.Mail.ReplyToEmail is required to be set up."); + } + + _replyEmail = CoreHelpers.PunyEncode(globalSettings.Mail.ReplyToEmail); if (_replyEmail.Contains("@")) { diff --git a/src/Core/Services/NoopImplementations/NoopLicensingService.cs b/src/Core/Services/NoopImplementations/NoopLicensingService.cs index dc733e9a33..b181e61138 100644 --- a/src/Core/Services/NoopImplementations/NoopLicensingService.cs +++ b/src/Core/Services/NoopImplementations/NoopLicensingService.cs @@ -1,4 +1,6 @@ -using System.Security.Claims; +#nullable enable + +using System.Security.Claims; using Bit.Core.AdminConsole.Entities; using Bit.Core.Entities; using Bit.Core.Models.Business; @@ -45,28 +47,28 @@ public class NoopLicensingService : ILicensingService return new byte[0]; } - public Task ReadOrganizationLicenseAsync(Organization organization) + public Task ReadOrganizationLicenseAsync(Organization organization) { - return Task.FromResult(null); + return Task.FromResult(null); } - public Task ReadOrganizationLicenseAsync(Guid organizationId) + public Task ReadOrganizationLicenseAsync(Guid organizationId) { - return Task.FromResult(null); + return Task.FromResult(null); } - public ClaimsPrincipal GetClaimsPrincipalFromLicense(ILicense license) + public ClaimsPrincipal? GetClaimsPrincipalFromLicense(ILicense license) { return null; } - public Task CreateOrganizationTokenAsync(Organization organization, Guid installationId, SubscriptionInfo subscriptionInfo) + public Task CreateOrganizationTokenAsync(Organization organization, Guid installationId, SubscriptionInfo subscriptionInfo) { - return Task.FromResult(null); + return Task.FromResult(null); } - public Task CreateUserTokenAsync(User user, SubscriptionInfo subscriptionInfo) + public Task CreateUserTokenAsync(User user, SubscriptionInfo subscriptionInfo) { - return Task.FromResult(null); + return Task.FromResult(null); } } diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index 381af2fd1c..cd5c1af8a8 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -1,4 +1,6 @@ -using Bit.Core.AdminConsole.Entities; +#nullable enable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Auth.Entities; using Bit.Core.Billing.Enums; @@ -137,7 +139,7 @@ public class NoopMailService : IMailService return Task.FromResult(0); } - public Task SendLicenseExpiredAsync(IEnumerable emails, string organizationName = null) + public Task SendLicenseExpiredAsync(IEnumerable emails, string? organizationName = null) { return Task.FromResult(0); } @@ -324,7 +326,7 @@ public class NoopMailService : IMailService } public Task SendClaimedDomainUserEmailAsync(ClaimedUserDomainClaimedEmails emailList) => Task.CompletedTask; - public Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable adminEmails, Guid organizationId, string email, string userName) + public Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable adminEmails, Guid organizationId, string email, string? userName) { return Task.FromResult(0); } diff --git a/src/Core/Utilities/CoreHelpers.cs b/src/Core/Utilities/CoreHelpers.cs index d7fe51cfb6..3ad28519b7 100644 --- a/src/Core/Utilities/CoreHelpers.cs +++ b/src/Core/Utilities/CoreHelpers.cs @@ -1,4 +1,7 @@ -using System.Globalization; +#nullable enable + +using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Reflection; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; @@ -116,11 +119,11 @@ public static class CoreHelpers return Regex.Replace(thumbprint, @"[^\da-fA-F]", string.Empty).ToUpper(); } - public static X509Certificate2 GetCertificate(string thumbprint) + public static X509Certificate2? GetCertificate(string thumbprint) { thumbprint = CleanCertificateThumbprint(thumbprint); - X509Certificate2 cert = null; + X509Certificate2? cert = null; var certStore = new X509Store(StoreName.My, StoreLocation.CurrentUser); certStore.Open(OpenFlags.ReadOnly); var certCollection = certStore.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); @@ -141,7 +144,7 @@ public static class CoreHelpers public async static Task GetEmbeddedCertificateAsync(string file, string password) { var assembly = typeof(CoreHelpers).GetTypeInfo().Assembly; - using (var s = assembly.GetManifestResourceStream($"Bit.Core.{file}")) + using (var s = assembly.GetManifestResourceStream($"Bit.Core.{file}")!) using (var ms = new MemoryStream()) { await s.CopyToAsync(ms); @@ -153,14 +156,14 @@ public static class CoreHelpers { var assembly = Assembly.GetCallingAssembly(); var resourceName = assembly.GetManifestResourceNames().Single(n => n.EndsWith(file)); - using (var stream = assembly.GetManifestResourceStream(resourceName)) + using (var stream = assembly.GetManifestResourceStream(resourceName)!) using (var reader = new StreamReader(stream)) { return reader.ReadToEnd(); } } - public async static Task GetBlobCertificateAsync(string connectionString, string container, string file, string password) + public async static Task GetBlobCertificateAsync(string connectionString, string container, string file, string password) { try { @@ -233,7 +236,7 @@ public static class CoreHelpers throw new ArgumentOutOfRangeException(nameof(length), "length cannot be less than zero."); } - if ((characters?.Length ?? 0) == 0) + if (string.IsNullOrEmpty(characters)) { throw new ArgumentOutOfRangeException(nameof(characters), "characters invalid."); } @@ -346,10 +349,10 @@ public static class CoreHelpers /// public static T CloneObject(T obj) { - return JsonSerializer.Deserialize(JsonSerializer.Serialize(obj)); + return JsonSerializer.Deserialize(JsonSerializer.Serialize(obj))!; } - public static bool SettingHasValue(string setting) + public static bool SettingHasValue([NotNullWhen(true)] string? setting) { var normalizedSetting = setting?.ToLowerInvariant(); return !string.IsNullOrWhiteSpace(normalizedSetting) && !normalizedSetting.Equals("secret") && @@ -448,7 +451,8 @@ public static class CoreHelpers return output; } - public static string PunyEncode(string text) + [return: NotNullIfNotNull(nameof(text))] + public static string? PunyEncode(string? text) { if (text == "") { @@ -473,21 +477,21 @@ public static class CoreHelpers } } - public static string FormatLicenseSignatureValue(object val) + public static string? FormatLicenseSignatureValue(object val) { if (val == null) { return string.Empty; } - if (val.GetType() == typeof(DateTime)) + if (val is DateTime dateTimeVal) { - return ToEpocSeconds((DateTime)val).ToString(); + return ToEpocSeconds(dateTimeVal).ToString(); } - if (val.GetType() == typeof(bool)) + if (val is bool boolVal) { - return val.ToString().ToLowerInvariant(); + return boolVal.ToString().ToLowerInvariant(); } if (val is PlanType planType) @@ -625,7 +629,7 @@ public static class CoreHelpers return subName; } - public static string GetIpAddress(this Microsoft.AspNetCore.Http.HttpContext httpContext, + public static string? GetIpAddress(this Microsoft.AspNetCore.Http.HttpContext httpContext, GlobalSettings globalSettings) { if (httpContext == null) @@ -652,7 +656,7 @@ public static class CoreHelpers (!globalSettings.SelfHosted && origin == "https://bitwarden.com"); } - public static X509Certificate2 GetIdentityServerCertificate(GlobalSettings globalSettings) + public static X509Certificate2? GetIdentityServerCertificate(GlobalSettings globalSettings) { if (globalSettings.SelfHosted && SettingHasValue(globalSettings.IdentityServer.CertificatePassword) @@ -804,14 +808,16 @@ public static class CoreHelpers /// The JSON data /// The type to deserialize into /// - public static T LoadClassFromJsonData(string jsonData) where T : new() + public static T LoadClassFromJsonData(string? jsonData) where T : new() { if (string.IsNullOrWhiteSpace(jsonData)) { return new T(); } +#nullable disable // TODO: Remove this and fix any callee warnings. return System.Text.Json.JsonSerializer.Deserialize(jsonData, _jsonSerializerOptions); +#nullable enable } public static string ClassToJsonData(T data) @@ -829,7 +835,7 @@ public static class CoreHelpers return list; } - public static string DecodeMessageText(this QueueMessage message) + public static string? DecodeMessageText(this QueueMessage message) { var text = message?.MessageText; if (string.IsNullOrWhiteSpace(text)) @@ -852,7 +858,7 @@ public static class CoreHelpers Encoding.UTF8.GetBytes(input1), Encoding.UTF8.GetBytes(input2)); } - public static string ObfuscateEmail(string email) + public static string? ObfuscateEmail(string email) { if (email == null) { @@ -886,7 +892,7 @@ public static class CoreHelpers } - public static string GetEmailDomain(string email) + public static string? GetEmailDomain(string email) { if (!string.IsNullOrWhiteSpace(email)) { @@ -906,7 +912,7 @@ public static class CoreHelpers return _whiteSpaceRegex.Replace(input, newValue); } - public static string RedactEmailAddress(string email) + public static string? RedactEmailAddress(string email) { if (string.IsNullOrWhiteSpace(email)) { diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs index 0bb6fb9925..73268d75bf 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs @@ -513,15 +513,21 @@ public class CollectionRepository : Repository groups, - IEnumerable users) + public async Task ReplaceAsync(Core.Entities.Collection collection, IEnumerable? groups, + IEnumerable? users) { await UpsertAsync(collection); using (var scope = ServiceScopeFactory.CreateScope()) { var dbContext = GetDatabaseContext(scope); - await ReplaceCollectionGroupsAsync(dbContext, collection, groups); - await ReplaceCollectionUsersAsync(dbContext, collection, users); + if (groups != null) + { + await ReplaceCollectionGroupsAsync(dbContext, collection, groups); + } + if (users != null) + { + await ReplaceCollectionUsersAsync(dbContext, collection, users); + } await dbContext.UserBumpAccountRevisionDateByCollectionIdAsync(collection.Id, collection.OrganizationId); await dbContext.SaveChangesAsync(); } From d7971c939e3d89fd44b3f8e636fbbe8d77272259 Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Tue, 15 Apr 2025 14:01:34 -0500 Subject: [PATCH 26/45] [PM-18890] Import errors because permissions are reversed (#5469) --- .../Controllers/ImportCiphersController.cs | 67 ++- .../ImportCiphersControllerTests.cs | 402 +++++++++++++++++- 2 files changed, 443 insertions(+), 26 deletions(-) diff --git a/src/Api/Tools/Controllers/ImportCiphersController.cs b/src/Api/Tools/Controllers/ImportCiphersController.cs index 62c55aceb8..817105c74b 100644 --- a/src/Api/Tools/Controllers/ImportCiphersController.cs +++ b/src/Api/Tools/Controllers/ImportCiphersController.cs @@ -77,10 +77,9 @@ public class ImportCiphersController : Controller //An User is allowed to import if CanCreate Collections or has AccessToImportExport var authorized = await CheckOrgImportPermission(collections, orgId); - if (!authorized) { - throw new NotFoundException(); + throw new BadRequestException("Not enough privileges to import into this organization."); } var userId = _userService.GetProperUserId(User).Value; @@ -103,21 +102,59 @@ public class ImportCiphersController : Controller .Select(c => c.Id) .ToHashSet(); - //We need to verify if the user is trying to import into existing collections - var existingCollections = collections.Where(tc => orgCollectionIds.Contains(tc.Id)); - - //When importing into existing collection, we need to verify if the user has permissions - if (existingCollections.Any() && !(await _authorizationService.AuthorizeAsync(User, existingCollections, BulkCollectionOperations.ImportCiphers)).Succeeded) + // when there are no collections, then we can import + if (collections.Count == 0) { - return false; - }; - - //Users allowed to import if they CanCreate Collections - if (!(await _authorizationService.AuthorizeAsync(User, collections, BulkCollectionOperations.Create)).Succeeded) - { - return false; + return true; } - return true; + // are we trying to import into existing collections? + var existingCollections = collections.Where(tc => orgCollectionIds.Contains(tc.Id)); + + // are we trying to create new collections? + var hasNewCollections = collections.Any(tc => !orgCollectionIds.Contains(tc.Id)); + + // suppose we have both new and existing collections + if (hasNewCollections && existingCollections.Any()) + { + // since we are creating new collection, user must have import/manage and create collection permission + if ((await _authorizationService.AuthorizeAsync(User, collections, BulkCollectionOperations.Create)).Succeeded + && (await _authorizationService.AuthorizeAsync(User, existingCollections, BulkCollectionOperations.ImportCiphers)).Succeeded) + { + // can import collections and create new ones + return true; + } + else + { + // user does not have permission to import + return false; + } + } + + // suppose we have new collections and none of our collections exist + if (hasNewCollections && !existingCollections.Any()) + { + // user is trying to create new collections + // we need to check if the user has permission to create collections + if ((await _authorizationService.AuthorizeAsync(User, collections, BulkCollectionOperations.Create)).Succeeded) + { + return true; + } + else + { + // user does not have permission to create new collections + return false; + } + } + + // in many import formats, we don't create collections, we just import ciphers into an existing collection + + // When importing, we need to verify if the user has ImportCiphers permission + if (existingCollections.Any() && (await _authorizationService.AuthorizeAsync(User, existingCollections, BulkCollectionOperations.ImportCiphers)).Succeeded) + { + return true; + }; + + return false; } } diff --git a/test/Api.Test/Tools/Controllers/ImportCiphersControllerTests.cs b/test/Api.Test/Tools/Controllers/ImportCiphersControllerTests.cs index 76055a6b64..457b9fd47d 100644 --- a/test/Api.Test/Tools/Controllers/ImportCiphersControllerTests.cs +++ b/test/Api.Test/Tools/Controllers/ImportCiphersControllerTests.cs @@ -17,6 +17,7 @@ using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Authorization; using NSubstitute; +using NSubstitute.ClearExtensions; using Xunit; using GlobalSettings = Bit.Core.Settings.GlobalSettings; @@ -94,6 +95,11 @@ public class ImportCiphersControllerTests // Arrange var globalSettings = sutProvider.GetDependency(); globalSettings.SelfHosted = false; + + var userService = sutProvider.GetDependency(); + userService.GetProperUserId(Arg.Any()) + .Returns(null as Guid?); + globalSettings.ImportCiphersLimitation = new GlobalSettings.ImportCiphersLimitationSettings() { // limits are set in appsettings.json, making values small for test to run faster. CiphersLimit = 200, @@ -243,7 +249,7 @@ public class ImportCiphersControllerTests } [Theory, BitAutoData] - public async Task PostImportOrganization_WithExistingCollectionsAndWithoutImportCiphersPermissions_NotFoundException( + public async Task PostImportOrganization_WithExistingCollectionsAndWithoutImportCiphersPermissions_ThrowsException( SutProvider sutProvider, IFixture fixture, User user) @@ -255,9 +261,7 @@ public class ImportCiphersControllerTests sutProvider.GetDependency().SelfHosted = false; - sutProvider.GetDependency() - .GetProperUserId(Arg.Any()) - .Returns(user.Id); + SetupUserService(sutProvider, user); var request = fixture.Build() .With(x => x.Ciphers, fixture.Build() @@ -288,22 +292,22 @@ public class ImportCiphersControllerTests Arg.Any>(), Arg.Is>(reqs => reqs.Contains(BulkCollectionOperations.Create))) - .Returns(AuthorizationResult.Success()); + .Returns(AuthorizationResult.Failed()); sutProvider.GetDependency() .GetManyByOrganizationIdAsync(orgIdGuid) .Returns(existingCollections.Select(c => new Collection { Id = orgIdGuid }).ToList()); // Act - var exception = await Assert.ThrowsAsync(() => + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PostImport(orgId, request)); // Assert - Assert.IsType(exception); + Assert.IsType(exception); } [Theory, BitAutoData] - public async Task PostImportOrganization_WithoutCreatePermissions_NotFoundException( + public async Task PostImportOrganization_WithoutCreatePermissions_ThrowsException( SutProvider sutProvider, IFixture fixture, User user) @@ -340,7 +344,7 @@ public class ImportCiphersControllerTests Arg.Any>(), Arg.Is>(reqs => reqs.Contains(BulkCollectionOperations.ImportCiphers))) - .Returns(AuthorizationResult.Success()); + .Returns(AuthorizationResult.Failed()); // BulkCollectionOperations.Create permission setup sutProvider.GetDependency() @@ -355,10 +359,386 @@ public class ImportCiphersControllerTests .Returns(existingCollections.Select(c => new Collection { Id = orgIdGuid }).ToList()); // Act - var exception = await Assert.ThrowsAsync(() => + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PostImport(orgId, request)); // Assert - Assert.IsType(exception); + Assert.IsType(exception); + } + + [Theory, BitAutoData] + public async Task PostImportOrganization_CanCreateChildCollectionsWithCreateAndImportPermissionsAsync( + SutProvider sutProvider, + IFixture fixture, + User user) + { + // Arrange + var orgId = Guid.NewGuid(); + + sutProvider.GetDependency().SelfHosted = false; + + SetupUserService(sutProvider, user); + + // Create new collections + var newCollections = fixture.Build() + .CreateMany(2).ToArray(); + + // define existing collections + var existingCollections = fixture.CreateMany(2).ToArray(); + + // import model includes new and existing collection + var request = new ImportOrganizationCiphersRequestModel + { + Collections = newCollections.Concat(existingCollections).ToArray(), + Ciphers = fixture.Build() + .With(_ => _.OrganizationId, orgId.ToString()) + .With(_ => _.FolderId, Guid.NewGuid().ToString()) + .CreateMany(2).ToArray(), + CollectionRelationships = new List>().ToArray(), + }; + + // AccessImportExport permission - false + sutProvider.GetDependency() + .AccessImportExport(Arg.Any()) + .Returns(false); + + // BulkCollectionOperations.ImportCiphers permission - true + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), + Arg.Any>(), + Arg.Is>(reqs => + reqs.Contains(BulkCollectionOperations.ImportCiphers))) + .Returns(AuthorizationResult.Success()); + + // BulkCollectionOperations.Create permission - true + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), + Arg.Any>(), + Arg.Is>(reqs => + reqs.Contains(BulkCollectionOperations.Create))) + .Returns(AuthorizationResult.Success()); + + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(orgId) + .Returns(existingCollections.Select(c => + new Collection { OrganizationId = orgId, Id = c.Id.GetValueOrDefault() }) + .ToList()); + + // Act + // User imports into collections and creates new collections + // User has ImportCiphers and Create ciphers permission + await sutProvider.Sut.PostImport(orgId.ToString(), request); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .ImportIntoOrganizationalVaultAsync( + Arg.Any>(), + Arg.Any>(), + Arg.Any>>(), + Arg.Any()); + } + + [Theory, BitAutoData] + public async Task PostImportOrganization_CannotCreateChildCollectionsWithoutCreatePermissionsAsync( + SutProvider sutProvider, + IFixture fixture, + User user) + { + // Arrange + var orgId = Guid.NewGuid(); + + sutProvider.GetDependency().SelfHosted = false; + + SetupUserService(sutProvider, user); + + // Create new collections + var newCollections = fixture.Build() + .CreateMany(2).ToArray(); + + // define existing collections + var existingCollections = fixture.CreateMany(2).ToArray(); + + // import model includes new and existing collection + var request = new ImportOrganizationCiphersRequestModel + { + Collections = newCollections.Concat(existingCollections).ToArray(), + Ciphers = fixture.Build() + .With(_ => _.OrganizationId, orgId.ToString()) + .With(_ => _.FolderId, Guid.NewGuid().ToString()) + .CreateMany(2).ToArray(), + CollectionRelationships = new List>().ToArray(), + }; + + // AccessImportExport permission - false + sutProvider.GetDependency() + .AccessImportExport(Arg.Any()) + .Returns(false); + + // BulkCollectionOperations.ImportCiphers permission - true + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), + Arg.Any>(), + Arg.Is>(reqs => + reqs.Contains(BulkCollectionOperations.ImportCiphers))) + .Returns(AuthorizationResult.Success()); + + // BulkCollectionOperations.Create permission - FALSE + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), + Arg.Any>(), + Arg.Is>(reqs => + reqs.Contains(BulkCollectionOperations.Create))) + .Returns(AuthorizationResult.Failed()); + + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(orgId) + .Returns(existingCollections.Select(c => + new Collection { OrganizationId = orgId, Id = c.Id.GetValueOrDefault() }) + .ToList()); + + // Act + // User imports into an existing collection and creates new collections + // User has ImportCiphers permission only and doesn't have Create permission + var exception = await Assert.ThrowsAsync(async () => + { + await sutProvider.Sut.PostImport(orgId.ToString(), request); + }); + + // Assert + Assert.IsType(exception); + await sutProvider.GetDependency() + .DidNotReceive() + .ImportIntoOrganizationalVaultAsync( + Arg.Any>(), + Arg.Any>(), + Arg.Any>>(), + Arg.Any()); + } + + [Theory, BitAutoData] + public async Task PostImportOrganization_ImportIntoNewCollectionWithCreatePermissionsOnlyAsync( + SutProvider sutProvider, + IFixture fixture, + User user) + { + // Arrange + var orgId = Guid.NewGuid(); + + sutProvider.GetDependency().SelfHosted = false; + SetupUserService(sutProvider, user); + + // Create new collections + var newCollections = fixture.CreateMany(1).ToArray(); + + // Define existing collections + var existingCollections = new List(); + + // Import model includes new and existing collection + var request = new ImportOrganizationCiphersRequestModel + { + Collections = newCollections.Concat(existingCollections).ToArray(), + Ciphers = fixture.Build() + .With(_ => _.OrganizationId, orgId.ToString()) + .With(_ => _.FolderId, Guid.NewGuid().ToString()) + .CreateMany(2).ToArray(), + CollectionRelationships = new List>().ToArray(), + }; + + // AccessImportExport permission - false + sutProvider.GetDependency() + .AccessImportExport(Arg.Any()) + .Returns(false); + + // BulkCollectionOperations.ImportCiphers permission - FALSE + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), + Arg.Any>(), + Arg.Is>(reqs => + reqs.Contains(BulkCollectionOperations.ImportCiphers))) + .Returns(AuthorizationResult.Failed()); + + // BulkCollectionOperations.Create permission - TRUE + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), + Arg.Any>(), + Arg.Is>(reqs => + reqs.Contains(BulkCollectionOperations.Create))) + .Returns(AuthorizationResult.Success()); + + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(orgId) + .Returns(new List()); + + // Act + // User imports/creates a new collection - existing collections not affected + // User has create permissions and doesn't need import permissions + await sutProvider.Sut.PostImport(orgId.ToString(), request); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .ImportIntoOrganizationalVaultAsync( + Arg.Any>(), + Arg.Any>(), + Arg.Any>>(), + Arg.Any()); + } + + [Theory, BitAutoData] + public async Task PostImportOrganization_ImportIntoExistingCollectionWithImportPermissionsOnlySuccessAsync( + SutProvider sutProvider, + IFixture fixture, + User user) + { + // Arrange + var orgId = Guid.NewGuid(); + + sutProvider.GetDependency().SelfHosted = false; + + SetupUserService(sutProvider, user); + + // No new collections + var newCollections = new List(); + + // Define existing collections + var existingCollections = fixture.CreateMany(1).ToArray(); + + // Import model includes new and existing collection + var request = new ImportOrganizationCiphersRequestModel + { + Collections = newCollections.Concat(existingCollections).ToArray(), + Ciphers = fixture.Build() + .With(_ => _.OrganizationId, orgId.ToString()) + .With(_ => _.FolderId, Guid.NewGuid().ToString()) + .CreateMany(2).ToArray(), + CollectionRelationships = new List>().ToArray(), + }; + + // AccessImportExport permission - false + sutProvider.GetDependency() + .AccessImportExport(Arg.Any()) + .Returns(false); + + // BulkCollectionOperations.ImportCiphers permission - true + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), + Arg.Any>(), + Arg.Is>(reqs => + reqs.Contains(BulkCollectionOperations.ImportCiphers))) + .Returns(AuthorizationResult.Success()); + + // BulkCollectionOperations.Create permission - FALSE + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), + Arg.Any>(), + Arg.Is>(reqs => + reqs.Contains(BulkCollectionOperations.Create))) + .Returns(AuthorizationResult.Failed()); + + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(orgId) + .Returns(existingCollections.Select(c => + new Collection { OrganizationId = orgId, Id = c.Id.GetValueOrDefault() }) + .ToList()); + + // Act + // User import into existing collection + // User has ImportCiphers permission only and doesn't need create permission + await sutProvider.Sut.PostImport(orgId.ToString(), request); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .ImportIntoOrganizationalVaultAsync( + Arg.Any>(), + Arg.Any>(), + Arg.Any>>(), + Arg.Any()); + } + + [Theory, BitAutoData] + public async Task PostImportOrganization_ImportWithNoCollectionsWithCreatePermissionsOnlySuccessAsync( + SutProvider sutProvider, + IFixture fixture, + User user) + { + // Arrange + var orgId = Guid.NewGuid(); + + sutProvider.GetDependency().SelfHosted = false; + + SetupUserService(sutProvider, user); + + // Import model includes new and existing collection + var request = new ImportOrganizationCiphersRequestModel + { + Collections = new List().ToArray(), // No collections + Ciphers = fixture.Build() + .With(_ => _.OrganizationId, orgId.ToString()) + .With(_ => _.FolderId, Guid.NewGuid().ToString()) + .CreateMany(2).ToArray(), + CollectionRelationships = new List>().ToArray(), + }; + + // AccessImportExport permission - false + sutProvider.GetDependency() + .AccessImportExport(Arg.Any()) + .Returns(false); + + // BulkCollectionOperations.ImportCiphers permission - false + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), + Arg.Any>(), + Arg.Is>(reqs => + reqs.Contains(BulkCollectionOperations.ImportCiphers))) + .Returns(AuthorizationResult.Failed()); + + // BulkCollectionOperations.Create permission - TRUE + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), + Arg.Any>(), + Arg.Is>(reqs => + reqs.Contains(BulkCollectionOperations.Create))) + .Returns(AuthorizationResult.Success()); + + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(orgId) + .Returns(new List()); + + // Act + // import ciphers only and no collections + // User has Create permissions + // expected to be successful + await sutProvider.Sut.PostImport(orgId.ToString(), request); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .ImportIntoOrganizationalVaultAsync( + Arg.Any>(), + Arg.Any>(), + Arg.Any>>(), + Arg.Any()); + } + + private static void SetupUserService(SutProvider sutProvider, User user) + { + // This is a workaround for the NSubstitute issue with ambiguous arguments + // when using Arg.Any() in the GetProperUserId method + // It clears the previous calls to the userService and sets up a new call + // with the same argument + var userService = sutProvider.GetDependency(); + try + { + // in order to fix the Ambiguous Arguments error in NSubstitute + // we need to clear the previous calls + userService.ClearSubstitute(); + userService.ClearReceivedCalls(); + userService.GetProperUserId(Arg.Any()); + } + catch { } + + userService.GetProperUserId(Arg.Any()).Returns(user.Id); } } From 1ac4a086726bb22f899bf530b9477b3af7cbe05d Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Tue, 15 Apr 2025 12:03:06 -0700 Subject: [PATCH 27/45] Define use sd for decryption feature flag (#5653) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 3a31480109..e87ec916ba 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 UserSdkForDecryption = "use-sdk-for-decryption"; public const string PM17987_BlockType0 = "pm-17987-block-type-0"; /* Mobile Team */ From f678e3db797d78a8f69dd49cfbd4be224136d386 Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Tue, 15 Apr 2025 15:39:21 -0400 Subject: [PATCH 28/45] [PM-19887] authorization for init pending organization (#5643) * add token authorization for initPendingOrganizations * clean up --- .../OrganizationUsersController.cs | 2 +- .../Services/IOrganizationService.cs | 2 +- .../Implementations/OrganizationService.cs | 35 +++++++++++++++++-- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 8a4cd54026..5713341dc4 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -313,7 +313,7 @@ public class OrganizationUsersController : Controller throw new UnauthorizedAccessException(); } - await _organizationService.InitPendingOrganization(user.Id, orgId, organizationUserId, model.Keys.PublicKey, model.Keys.EncryptedPrivateKey, model.CollectionName); + await _organizationService.InitPendingOrganization(user, orgId, organizationUserId, model.Keys.PublicKey, model.Keys.EncryptedPrivateKey, model.CollectionName, model.Token); await _acceptOrgUserCommand.AcceptOrgUserByEmailTokenAsync(organizationUserId, user, model.Token, _userService); await _confirmOrganizationUserCommand.ConfirmUserAsync(orgId, organizationUserId, model.Key, user.Id); } diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index 8d2997bbc6..228c2b522c 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -54,7 +54,7 @@ public interface IOrganizationService /// /// This method must target a disabled Organization that has null keys and status as 'Pending'. /// - Task InitPendingOrganization(Guid userId, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName); + Task InitPendingOrganization(User user, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName, string emailToken); Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null); void ValidatePasswordManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade); diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index c9b38b3e30..b31b43406e 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -13,6 +13,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Constants; @@ -30,10 +31,12 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Settings; +using Bit.Core.Tokens; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Business; using Bit.Core.Tools.Services; using Bit.Core.Utilities; +using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Logging; using Stripe; using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite; @@ -74,6 +77,8 @@ public class OrganizationService : IOrganizationService private readonly IPricingClient _pricingClient; private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand; + private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; + private readonly IDataProtector _dataProtector; public OrganizationService( IOrganizationRepository organizationRepository, @@ -107,7 +112,10 @@ public class OrganizationService : IOrganizationService IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, IPricingClient pricingClient, IPolicyRequirementQuery policyRequirementQuery, - ISendOrganizationInvitesCommand sendOrganizationInvitesCommand) + ISendOrganizationInvitesCommand sendOrganizationInvitesCommand, + IDataProtectorTokenFactory orgUserInviteTokenDataFactory, + IDataProtectionProvider dataProtectionProvider + ) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -141,6 +149,8 @@ public class OrganizationService : IOrganizationService _pricingClient = pricingClient; _policyRequirementQuery = policyRequirementQuery; _sendOrganizationInvitesCommand = sendOrganizationInvitesCommand; + _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; + _dataProtector = dataProtectionProvider.CreateProtector(OrgUserInviteTokenable.DataProtectorPurpose); } public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken, @@ -1912,9 +1922,28 @@ public class OrganizationService : IOrganizationService }); } - public async Task InitPendingOrganization(Guid userId, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName) + public async Task InitPendingOrganization(User user, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName, string emailToken) { - await ValidateSignUpPoliciesAsync(userId); + await ValidateSignUpPoliciesAsync(user.Id); + + var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); + if (orgUser == null) + { + throw new BadRequestException("User invalid."); + } + + // TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete + var newTokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken( + _orgUserInviteTokenDataFactory, emailToken, orgUser); + + var tokenValid = newTokenValid || + CoreHelpers.UserInviteTokenIsValid(_dataProtector, emailToken, user.Email, orgUser.Id, + _globalSettings); + + if (!tokenValid) + { + throw new BadRequestException("Invalid token."); + } var org = await GetOrgById(organizationId); From c182b37347b584ad2c0cc04ae513ccaad70c295b Mon Sep 17 00:00:00 2001 From: Jonas Hendrickx Date: Wed, 16 Apr 2025 17:27:58 +0200 Subject: [PATCH 29/45] [PM-17830] Backend changes for admin initiated sponsorships (#5531) * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * Add `Notes` column to `OrganizationSponsorships` table * Add feature flag to `CreateAdminInitiatedSponsorshipHandler` * Unit tests for `CreateSponsorshipHandler` * More tests for `CreateSponsorshipHandler` * Forgot to add `Notes` column to `OrganizationSponsorships` table in the migration script * `CreateAdminInitiatedSponsorshipHandler` unit tests * Fix `CreateSponsorshipCommandTests` * Encrypt the notes field * Wrong business logic checking for invalid permissions. * Wrong business logic checking for invalid permissions. * Remove design patterns * duplicate definition in Constants.cs * Allow rollback * Fix stored procedures & type * Fix stored procedures & type * Properly encapsulating this PR behind its feature flag * Removed comments * Updated ValidateSponsorshipCommand to validate admin initiated requirements --------- Co-authored-by: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Co-authored-by: Conner Turnbull --- .../Models/OrganizationEditModel.cs | 4 + .../OrganizationResponseModel.cs | 2 + .../ProfileOrganizationResponseModel.cs | 2 + ...rofileProviderOrganizationResponseModel.cs | 1 + .../OrganizationSponsorshipsController.cs | 21 +- ...ostedOrganizationSponsorshipsController.cs | 23 +- ...ganizationSponsorshipCreateRequestModel.cs | 10 + .../AdminConsole/Entities/Organization.cs | 5 + .../Data/Organizations/OrganizationAbility.cs | 2 + .../OrganizationUserOrganizationDetails.cs | 1 + .../ProviderUserOrganizationDetails.cs | 1 + src/Core/Constants.cs | 1 + src/Core/Entities/OrganizationSponsorship.cs | 2 + .../OrganizationSponsorshipData.cs | 4 + .../Cloud/ValidateSponsorshipCommand.cs | 7 + .../CreateSponsorshipCommand.cs | 78 +- .../Interfaces/ICreateSponsorshipCommand.cs | 2 +- .../Repositories/OrganizationRepository.cs | 3 +- ...izationUserOrganizationDetailsViewQuery.cs | 1 + ...roviderUserOrganizationDetailsViewQuery.cs | 1 + .../OrganizationSponsorship_Create.sql | 12 +- .../OrganizationSponsorship_CreateMany.sql | 10 +- .../OrganizationSponsorship_Update.sql | 8 +- .../OrganizationSponsorship_UpdateMany.sql | 8 +- .../Stored Procedures/Organization_Create.sql | 9 +- .../Organization_ReadAbilities.sql | 3 +- .../Stored Procedures/Organization_Update.sql | 6 +- src/Sql/dbo/Tables/Organization.sql | 1 + .../dbo/Tables/OrganizationSponsorship.sql | 2 + .../OrganizationSponsorshipType.sql | 6 +- ...rganizationUserOrganizationDetailsView.sql | 1 + ...derUserProviderOrganizationDetailsView.sql | 1 + .../Cloud/ValidateSponsorshipCommandTests.cs | 48 + .../CreateSponsorshipCommandTests.cs | 197 +- .../OrganizationUserRepositoryTests.cs | 1 + ...-14_00_AddUseAdminInitiatedSponsorship.sql | 532 +++ ...eAdminInitiatedSponsorship_RefreshView.sql | 278 ++ ...830_AdminInitiatedSponsorships.Designer.cs | 3026 ++++++++++++++++ ...2653_PM17830_AdminInitiatedSponsorships.cs | 50 + .../DatabaseContextModelSnapshot.cs | 9 + ...830_AdminInitiatedSponsorships.Designer.cs | 3032 +++++++++++++++++ ...2700_PM17830_AdminInitiatedSponsorships.cs | 49 + .../DatabaseContextModelSnapshot.cs | 9 + ...830_AdminInitiatedSponsorships.Designer.cs | 3015 ++++++++++++++++ ...2645_PM17830_AdminInitiatedSponsorships.cs | 49 + .../DatabaseContextModelSnapshot.cs | 9 + 46 files changed, 10466 insertions(+), 76 deletions(-) create mode 100644 util/Migrator/DbScripts/2025-03-14_00_AddUseAdminInitiatedSponsorship.sql create mode 100644 util/Migrator/DbScripts/2025-03-14_01_AddUseAdminInitiatedSponsorship_RefreshView.sql create mode 100644 util/MySqlMigrations/Migrations/20250326092653_PM17830_AdminInitiatedSponsorships.Designer.cs create mode 100644 util/MySqlMigrations/Migrations/20250326092653_PM17830_AdminInitiatedSponsorships.cs create mode 100644 util/PostgresMigrations/Migrations/20250326092700_PM17830_AdminInitiatedSponsorships.Designer.cs create mode 100644 util/PostgresMigrations/Migrations/20250326092700_PM17830_AdminInitiatedSponsorships.cs create mode 100644 util/SqliteMigrations/Migrations/20250326092645_PM17830_AdminInitiatedSponsorships.Designer.cs create mode 100644 util/SqliteMigrations/Migrations/20250326092645_PM17830_AdminInitiatedSponsorships.cs diff --git a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs index 1d23afd491..6af6c1b50a 100644 --- a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs @@ -86,6 +86,7 @@ public class OrganizationEditModel : OrganizationViewModel UseApi = org.UseApi; UseSecretsManager = org.UseSecretsManager; UseRiskInsights = org.UseRiskInsights; + UseAdminSponsoredFamilies = org.UseAdminSponsoredFamilies; UseResetPassword = org.UseResetPassword; SelfHost = org.SelfHost; UsersGetPremium = org.UsersGetPremium; @@ -154,6 +155,8 @@ public class OrganizationEditModel : OrganizationViewModel public new bool UseSecretsManager { get; set; } [Display(Name = "Risk Insights")] public new bool UseRiskInsights { get; set; } + [Display(Name = "Admin Sponsored Families")] + public bool UseAdminSponsoredFamilies { get; set; } [Display(Name = "Self Host")] public bool SelfHost { get; set; } [Display(Name = "Users Get Premium")] @@ -295,6 +298,7 @@ public class OrganizationEditModel : OrganizationViewModel existingOrganization.UseApi = UseApi; existingOrganization.UseSecretsManager = UseSecretsManager; existingOrganization.UseRiskInsights = UseRiskInsights; + existingOrganization.UseAdminSponsoredFamilies = UseAdminSponsoredFamilies; existingOrganization.UseResetPassword = UseResetPassword; existingOrganization.SelfHost = SelfHost; existingOrganization.UsersGetPremium = UsersGetPremium; diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs index 4dc4a4ec55..a14e3efb51 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs @@ -64,6 +64,7 @@ public class OrganizationResponseModel : ResponseModel LimitItemDeletion = organization.LimitItemDeletion; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; UseRiskInsights = organization.UseRiskInsights; + UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; } public Guid Id { get; set; } @@ -110,6 +111,7 @@ public class OrganizationResponseModel : ResponseModel public bool LimitItemDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } public bool UseRiskInsights { get; set; } + public bool UseAdminSponsoredFamilies { get; set; } } public class OrganizationSubscriptionResponseModel : OrganizationResponseModel diff --git a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs index 437c30b8b9..c74599a70e 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs @@ -72,6 +72,7 @@ public class ProfileOrganizationResponseModel : ResponseModel AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; UserIsClaimedByOrganization = organizationIdsClaimingUser.Contains(organization.OrganizationId); UseRiskInsights = organization.UseRiskInsights; + UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; if (organization.SsoConfig != null) { @@ -155,4 +156,5 @@ public class ProfileOrganizationResponseModel : ResponseModel /// public bool UserIsClaimedByOrganization { get; set; } public bool UseRiskInsights { get; set; } + public bool UseAdminSponsoredFamilies { get; set; } } diff --git a/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs index d31cb5a77a..5d5e1f9b85 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs @@ -50,5 +50,6 @@ public class ProfileProviderOrganizationResponseModel : ProfileOrganizationRespo LimitItemDeletion = organization.LimitItemDeletion; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; UseRiskInsights = organization.UseRiskInsights; + UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; } } diff --git a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs index a8c9fa622d..04667e61ad 100644 --- a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs +++ b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs @@ -84,10 +84,27 @@ public class OrganizationSponsorshipsController : Controller throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator."); } + if (!_featureService.IsEnabled(Bit.Core.FeatureFlagKeys.PM17772_AdminInitiatedSponsorships)) + { + if (model.SponsoringUserId.HasValue) + { + throw new NotFoundException(); + } + + if (!string.IsNullOrWhiteSpace(model.Notes)) + { + model.Notes = null; + } + } + + var targetUser = model.SponsoringUserId ?? _currentContext.UserId!.Value; var sponsorship = await _createSponsorshipCommand.CreateSponsorshipAsync( sponsoringOrg, - await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default), - model.PlanSponsorshipType, model.SponsoredEmail, model.FriendlyName); + await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, targetUser), + model.PlanSponsorshipType, + model.SponsoredEmail, + model.FriendlyName, + model.Notes); await _sendSponsorshipOfferCommand.SendSponsorshipOfferAsync(sponsorship, sponsoringOrg.Name); } diff --git a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs index ffb5c7bb98..d2c87c6b6f 100644 --- a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs +++ b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs @@ -3,6 +3,7 @@ using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -20,6 +21,7 @@ public class SelfHostedOrganizationSponsorshipsController : Controller private readonly ICreateSponsorshipCommand _offerSponsorshipCommand; private readonly IRevokeSponsorshipCommand _revokeSponsorshipCommand; private readonly ICurrentContext _currentContext; + private readonly IFeatureService _featureService; public SelfHostedOrganizationSponsorshipsController( ICreateSponsorshipCommand offerSponsorshipCommand, @@ -27,7 +29,8 @@ public class SelfHostedOrganizationSponsorshipsController : Controller IOrganizationRepository organizationRepository, IOrganizationSponsorshipRepository organizationSponsorshipRepository, IOrganizationUserRepository organizationUserRepository, - ICurrentContext currentContext + ICurrentContext currentContext, + IFeatureService featureService ) { _offerSponsorshipCommand = offerSponsorshipCommand; @@ -36,15 +39,29 @@ public class SelfHostedOrganizationSponsorshipsController : Controller _organizationSponsorshipRepository = organizationSponsorshipRepository; _organizationUserRepository = organizationUserRepository; _currentContext = currentContext; + _featureService = featureService; } [HttpPost("{sponsoringOrgId}/families-for-enterprise")] public async Task CreateSponsorship(Guid sponsoringOrgId, [FromBody] OrganizationSponsorshipCreateRequestModel model) { + if (!_featureService.IsEnabled(Bit.Core.FeatureFlagKeys.PM17772_AdminInitiatedSponsorships)) + { + if (model.SponsoringUserId.HasValue) + { + throw new NotFoundException(); + } + + if (!string.IsNullOrWhiteSpace(model.Notes)) + { + model.Notes = null; + } + } + await _offerSponsorshipCommand.CreateSponsorshipAsync( await _organizationRepository.GetByIdAsync(sponsoringOrgId), - await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default), - model.PlanSponsorshipType, model.SponsoredEmail, model.FriendlyName); + await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, model.SponsoringUserId ?? _currentContext.UserId ?? default), + model.PlanSponsorshipType, model.SponsoredEmail, model.FriendlyName, model.Notes); } [HttpDelete("{sponsoringOrgId}")] diff --git a/src/Api/Models/Request/Organizations/OrganizationSponsorshipCreateRequestModel.cs b/src/Api/Models/Request/Organizations/OrganizationSponsorshipCreateRequestModel.cs index ba88f1b90e..d3f03a7ddc 100644 --- a/src/Api/Models/Request/Organizations/OrganizationSponsorshipCreateRequestModel.cs +++ b/src/Api/Models/Request/Organizations/OrganizationSponsorshipCreateRequestModel.cs @@ -16,4 +16,14 @@ public class OrganizationSponsorshipCreateRequestModel [StringLength(256)] public string FriendlyName { get; set; } + + /// + /// (optional) The user to target for the sponsorship. + /// + /// Left empty when creating a sponsorship for the authenticated user. + public Guid? SponsoringUserId { get; set; } + + [EncryptedString] + [EncryptedStringLength(512)] + public string Notes { get; set; } } diff --git a/src/Core/AdminConsole/Entities/Organization.cs b/src/Core/AdminConsole/Entities/Organization.cs index e91f1ede29..17d9847574 100644 --- a/src/Core/AdminConsole/Entities/Organization.cs +++ b/src/Core/AdminConsole/Entities/Organization.cs @@ -114,6 +114,11 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable, /// public bool UseRiskInsights { get; set; } + /// + /// If set to true, admins can initiate organization-issued sponsorships. + /// + public bool UseAdminSponsoredFamilies { get; set; } + public void SetNewId() { if (Id == default(Guid)) diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs index 62914f6fa8..d27bf40994 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs @@ -26,6 +26,7 @@ public class OrganizationAbility LimitItemDeletion = organization.LimitItemDeletion; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; UseRiskInsights = organization.UseRiskInsights; + UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; } public Guid Id { get; set; } @@ -45,4 +46,5 @@ public class OrganizationAbility public bool LimitItemDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } public bool UseRiskInsights { get; set; } + public bool UseAdminSponsoredFamilies { get; set; } } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs index 18d68af220..0771457d0a 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs @@ -59,4 +59,5 @@ public class OrganizationUserOrganizationDetails public bool LimitItemDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } public bool UseRiskInsights { get; set; } + public bool UseAdminSponsoredFamilies { get; set; } } diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs index 57f176666a..8717a8f008 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs @@ -45,5 +45,6 @@ public class ProviderUserOrganizationDetails public bool LimitItemDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } public bool UseRiskInsights { get; set; } + public bool UseAdminSponsoredFamilies { get; set; } public ProviderType ProviderType { get; set; } } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index e87ec916ba..c57544283b 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -141,6 +141,7 @@ 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 PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships"; 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/src/Core/Entities/OrganizationSponsorship.cs b/src/Core/Entities/OrganizationSponsorship.cs index 77c77eab21..0bb21d780b 100644 --- a/src/Core/Entities/OrganizationSponsorship.cs +++ b/src/Core/Entities/OrganizationSponsorship.cs @@ -20,6 +20,8 @@ public class OrganizationSponsorship : ITableObject public DateTime? LastSyncDate { get; set; } public DateTime? ValidUntil { get; set; } public bool ToDelete { get; set; } + public bool IsAdminInitiated { get; set; } + public string? Notes { get; set; } public void SetNewId() { diff --git a/src/Core/Models/Data/Organizations/OrganizationSponsorships/OrganizationSponsorshipData.cs b/src/Core/Models/Data/Organizations/OrganizationSponsorships/OrganizationSponsorshipData.cs index 927262957a..649459bc6b 100644 --- a/src/Core/Models/Data/Organizations/OrganizationSponsorships/OrganizationSponsorshipData.cs +++ b/src/Core/Models/Data/Organizations/OrganizationSponsorships/OrganizationSponsorshipData.cs @@ -16,6 +16,8 @@ public class OrganizationSponsorshipData LastSyncDate = sponsorship.LastSyncDate; ValidUntil = sponsorship.ValidUntil; ToDelete = sponsorship.ToDelete; + IsAdminInitiated = sponsorship.IsAdminInitiated; + Notes = sponsorship.Notes; } public Guid SponsoringOrganizationUserId { get; set; } public Guid? SponsoredOrganizationId { get; set; } @@ -25,6 +27,8 @@ public class OrganizationSponsorshipData public DateTime? LastSyncDate { get; set; } public DateTime? ValidUntil { get; set; } public bool ToDelete { get; set; } + public bool IsAdminInitiated { get; set; } + public string Notes { get; set; } public bool CloudSponsorshipRemoved { get; set; } } diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/ValidateSponsorshipCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/ValidateSponsorshipCommand.cs index a7423b067e..6b8d6d6771 100644 --- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/ValidateSponsorshipCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/ValidateSponsorshipCommand.cs @@ -112,6 +112,13 @@ public class ValidateSponsorshipCommand : CancelSponsorshipCommand, IValidateSpo return false; } + if (existingSponsorship.IsAdminInitiated && !sponsoringOrganization.UseAdminSponsoredFamilies) + { + _logger.LogWarning("Admin initiated sponsorship for sponsored Organization {SponsoredOrganizationId} is not allowed because sponsoring organization does not have UseAdminSponsoredFamilies enabled", sponsoredOrganizationId); + await CancelSponsorshipAsync(sponsoredOrganization, existingSponsorship); + return false; + } + var sponsoringOrgProductTier = sponsoringOrganization.PlanType.GetProductTier(); if (sponsoredPlan.SponsoringProductTierType != sponsoringOrgProductTier) diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs index ac65d3b897..27589bea3e 100644 --- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Extensions; +using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -10,29 +11,24 @@ using Bit.Core.Utilities; namespace Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise; -public class CreateSponsorshipCommand : ICreateSponsorshipCommand +public class CreateSponsorshipCommand( + ICurrentContext currentContext, + IOrganizationSponsorshipRepository organizationSponsorshipRepository, + IUserService userService) : ICreateSponsorshipCommand { - private readonly IOrganizationSponsorshipRepository _organizationSponsorshipRepository; - private readonly IUserService _userService; - - public CreateSponsorshipCommand(IOrganizationSponsorshipRepository organizationSponsorshipRepository, - IUserService userService) + public async Task CreateSponsorshipAsync(Organization sponsoringOrganization, + OrganizationUser sponsoringMember, PlanSponsorshipType sponsorshipType, string sponsoredEmail, + string friendlyName, string notes) { - _organizationSponsorshipRepository = organizationSponsorshipRepository; - _userService = userService; - } + var sponsoringUser = await userService.GetUserByIdAsync(sponsoringMember.UserId!.Value); - public async Task CreateSponsorshipAsync(Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, - PlanSponsorshipType sponsorshipType, string sponsoredEmail, string friendlyName) - { - var sponsoringUser = await _userService.GetUserByIdAsync(sponsoringOrgUser.UserId.Value); - if (sponsoringUser == null || string.Equals(sponsoringUser.Email, sponsoredEmail, System.StringComparison.InvariantCultureIgnoreCase)) + if (sponsoringUser == null || string.Equals(sponsoringUser.Email, sponsoredEmail, StringComparison.InvariantCultureIgnoreCase)) { throw new BadRequestException("Cannot offer a Families Organization Sponsorship to yourself. Choose a different email."); } var requiredSponsoringProductType = StaticStore.GetSponsoredPlan(sponsorshipType)?.SponsoringProductTierType; - var sponsoringOrgProductTier = sponsoringOrg.PlanType.GetProductTier(); + var sponsoringOrgProductTier = sponsoringOrganization.PlanType.GetProductTier(); if (requiredSponsoringProductType == null || sponsoringOrgProductTier != requiredSponsoringProductType.Value) @@ -40,26 +36,24 @@ public class CreateSponsorshipCommand : ICreateSponsorshipCommand throw new BadRequestException("Specified Organization cannot sponsor other organizations."); } - if (sponsoringOrgUser == null || sponsoringOrgUser.Status != OrganizationUserStatusType.Confirmed) + if (sponsoringMember.Status != OrganizationUserStatusType.Confirmed) { throw new BadRequestException("Only confirmed users can sponsor other organizations."); } - var existingOrgSponsorship = await _organizationSponsorshipRepository - .GetBySponsoringOrganizationUserIdAsync(sponsoringOrgUser.Id); + var existingOrgSponsorship = await organizationSponsorshipRepository + .GetBySponsoringOrganizationUserIdAsync(sponsoringMember.Id); if (existingOrgSponsorship?.SponsoredOrganizationId != null) { throw new BadRequestException("Can only sponsor one organization per Organization User."); } - var sponsorship = new OrganizationSponsorship - { - SponsoringOrganizationId = sponsoringOrg.Id, - SponsoringOrganizationUserId = sponsoringOrgUser.Id, - FriendlyName = friendlyName, - OfferedToEmail = sponsoredEmail, - PlanSponsorshipType = sponsorshipType, - }; + var sponsorship = new OrganizationSponsorship(); + sponsorship.SponsoringOrganizationId = sponsoringOrganization.Id; + sponsorship.SponsoringOrganizationUserId = sponsoringMember.Id; + sponsorship.FriendlyName = friendlyName; + sponsorship.OfferedToEmail = sponsoredEmail; + sponsorship.PlanSponsorshipType = sponsorshipType; if (existingOrgSponsorship != null) { @@ -67,16 +61,42 @@ public class CreateSponsorshipCommand : ICreateSponsorshipCommand sponsorship.Id = existingOrgSponsorship.Id; } + var isAdminInitiated = false; + if (currentContext.UserId != sponsoringMember.UserId) + { + var organization = currentContext.Organizations.First(x => x.Id == sponsoringOrganization.Id); + OrganizationUserType[] allowedUserTypes = + [ + OrganizationUserType.Admin, + OrganizationUserType.Owner + ]; + + if (!organization.Permissions.ManageUsers && allowedUserTypes.All(x => x != organization.Type)) + { + throw new UnauthorizedAccessException("You do not have permissions to send sponsorships on behalf of the organization."); + } + + if (!sponsoringOrganization.UseAdminSponsoredFamilies) + { + throw new BadRequestException("Sponsoring organization cannot sponsor other Family organizations."); + } + + isAdminInitiated = true; + } + + sponsorship.IsAdminInitiated = isAdminInitiated; + sponsorship.Notes = notes; + try { - await _organizationSponsorshipRepository.UpsertAsync(sponsorship); + await organizationSponsorshipRepository.UpsertAsync(sponsorship); return sponsorship; } catch { - if (sponsorship.Id != default) + if (sponsorship.Id != Guid.Empty) { - await _organizationSponsorshipRepository.DeleteAsync(sponsorship); + await organizationSponsorshipRepository.DeleteAsync(sponsorship); } throw; } diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Interfaces/ICreateSponsorshipCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Interfaces/ICreateSponsorshipCommand.cs index 8e3d055a79..4a3e5a63dc 100644 --- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Interfaces/ICreateSponsorshipCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Interfaces/ICreateSponsorshipCommand.cs @@ -7,5 +7,5 @@ namespace Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnte public interface ICreateSponsorshipCommand { Task CreateSponsorshipAsync(Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, - PlanSponsorshipType sponsorshipType, string sponsoredEmail, string friendlyName); + PlanSponsorshipType sponsorshipType, string sponsoredEmail, string friendlyName, string notes); } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index 26c782000e..91e29c1b52 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -106,7 +106,8 @@ public class OrganizationRepository : Repository sutProvider) + { + sponsoringOrg.PlanType = planType; + sponsoringOrg.UseAdminSponsoredFamilies = false; + existingSponsorship.SponsoringOrganizationId = sponsoringOrg.Id; + existingSponsorship.IsAdminInitiated = true; + + sutProvider.GetDependency() + .GetBySponsoredOrganizationIdAsync(sponsoredOrg.Id).Returns(existingSponsorship); + sutProvider.GetDependency().GetByIdAsync(sponsoredOrg.Id).Returns(sponsoredOrg); + sutProvider.GetDependency().GetByIdAsync(sponsoringOrg.Id).Returns(sponsoringOrg); + + var result = await sutProvider.Sut.ValidateSponsorshipAsync(sponsoredOrg.Id); + + Assert.False(result); + await AssertRemovedSponsoredPaymentAsync(sponsoredOrg, existingSponsorship, sutProvider); + await AssertDeletedSponsorshipAsync(existingSponsorship, sutProvider); + } + + [Theory(Skip = "Temporarily disabled")] + [BitMemberAutoData(nameof(EnterprisePlanTypes))] + public async Task ValidateSponsorshipAsync_AdminInitiatedAndUseAdminSponsoredFamiliesTrue_ContinuesValidation( + PlanType planType, Organization sponsoredOrg, OrganizationSponsorship existingSponsorship, + Organization sponsoringOrg, SutProvider sutProvider) + { + sponsoringOrg.PlanType = planType; + sponsoringOrg.UseAdminSponsoredFamilies = true; + existingSponsorship.SponsoringOrganizationId = sponsoringOrg.Id; + existingSponsorship.IsAdminInitiated = true; + existingSponsorship.ToDelete = false; + existingSponsorship.LastSyncDate = null; // Not a self-hosted sponsorship + + sutProvider.GetDependency() + .GetBySponsoredOrganizationIdAsync(sponsoredOrg.Id).Returns(existingSponsorship); + sutProvider.GetDependency().GetByIdAsync(sponsoredOrg.Id).Returns(sponsoredOrg); + sutProvider.GetDependency().GetByIdAsync(sponsoringOrg.Id).Returns(sponsoringOrg); + + var result = await sutProvider.Sut.ValidateSponsorshipAsync(sponsoredOrg.Id); + + Assert.True(result); + await AssertDidNotRemoveSponsoredPaymentAsync(sutProvider); + await AssertDidNotDeleteSponsorshipAsync(sutProvider); + } } diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs index df75663045..93a2b629f0 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs @@ -1,8 +1,10 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Data; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise; using Bit.Core.Repositories; using Bit.Core.Services; @@ -36,28 +38,28 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase [Theory, BitAutoData] public async Task CreateSponsorship_OfferedToNotFound_ThrowsBadRequest(OrganizationUser orgUser, SutProvider sutProvider) { - sutProvider.GetDependency().GetUserByIdAsync(orgUser.UserId.Value).ReturnsNull(); + sutProvider.GetDependency().GetUserByIdAsync(orgUser.UserId!.Value).ReturnsNull(); var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.CreateSponsorshipAsync(null, orgUser, PlanSponsorshipType.FamiliesForEnterprise, default, default)); + sutProvider.Sut.CreateSponsorshipAsync(null, orgUser, PlanSponsorshipType.FamiliesForEnterprise, default, default, null)); Assert.Contains("Cannot offer a Families Organization Sponsorship to yourself. Choose a different email.", exception.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .CreateAsync(default); + .CreateAsync(null!); } [Theory, BitAutoData] public async Task CreateSponsorship_OfferedToSelf_ThrowsBadRequest(OrganizationUser orgUser, string sponsoredEmail, User user, SutProvider sutProvider) { user.Email = sponsoredEmail; - sutProvider.GetDependency().GetUserByIdAsync(orgUser.UserId.Value).Returns(user); + sutProvider.GetDependency().GetUserByIdAsync(orgUser.UserId!.Value).Returns(user); var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.CreateSponsorshipAsync(null, orgUser, PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, default)); + sutProvider.Sut.CreateSponsorshipAsync(null, orgUser, PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, default, null)); Assert.Contains("Cannot offer a Families Organization Sponsorship to yourself. Choose a different email.", exception.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .CreateAsync(default); + .CreateAsync(null!); } [Theory, BitMemberAutoData(nameof(NonEnterprisePlanTypes))] @@ -67,14 +69,14 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase org.PlanType = sponsoringOrgPlan; orgUser.Status = OrganizationUserStatusType.Confirmed; - sutProvider.GetDependency().GetUserByIdAsync(orgUser.UserId.Value).Returns(user); + sutProvider.GetDependency().GetUserByIdAsync(orgUser.UserId!.Value).Returns(user); var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.CreateSponsorshipAsync(org, orgUser, PlanSponsorshipType.FamiliesForEnterprise, default, default)); + sutProvider.Sut.CreateSponsorshipAsync(org, orgUser, PlanSponsorshipType.FamiliesForEnterprise, default, default, null)); Assert.Contains("Specified Organization cannot sponsor other organizations.", exception.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .CreateAsync(default); + .CreateAsync(null!); } [Theory] @@ -86,14 +88,14 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase org.PlanType = PlanType.EnterpriseAnnually; orgUser.Status = statusType; - sutProvider.GetDependency().GetUserByIdAsync(orgUser.UserId.Value).Returns(user); + sutProvider.GetDependency().GetUserByIdAsync(orgUser.UserId!.Value).Returns(user); var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.CreateSponsorshipAsync(org, orgUser, PlanSponsorshipType.FamiliesForEnterprise, default, default)); + sutProvider.Sut.CreateSponsorshipAsync(org, orgUser, PlanSponsorshipType.FamiliesForEnterprise, default, default, null)); Assert.Contains("Only confirmed users can sponsor other organizations.", exception.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .CreateAsync(default); + .CreateAsync(null!); } [Theory] @@ -106,16 +108,48 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase org.PlanType = PlanType.EnterpriseAnnually; orgUser.Status = OrganizationUserStatusType.Confirmed; - sutProvider.GetDependency().GetUserByIdAsync(orgUser.UserId.Value).Returns(user); + sutProvider.GetDependency().GetUserByIdAsync(orgUser.UserId!.Value).Returns(user); sutProvider.GetDependency() .GetBySponsoringOrganizationUserIdAsync(orgUser.Id).Returns(sponsorship); + sutProvider.GetDependency().UserId.Returns(orgUser.UserId.Value); + var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.CreateSponsorshipAsync(org, orgUser, sponsorship.PlanSponsorshipType.Value, default, default)); + sutProvider.Sut.CreateSponsorshipAsync(org, orgUser, sponsorship.PlanSponsorshipType!.Value, null, null, null)); Assert.Contains("Can only sponsor one organization per Organization User.", exception.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .CreateAsync(default); + .CreateAsync(null!); + } + + public static readonly OrganizationUserStatusType[] UnconfirmedOrganizationUsersStatuses = Enum + .GetValues() + .Where(x => x != OrganizationUserStatusType.Confirmed) + .ToArray(); + + [Theory] + [BitMemberAutoData(nameof(UnconfirmedOrganizationUsersStatuses))] + public async Task CreateSponsorship_ThrowsBadRequestException_WhenMemberDoesNotHaveConfirmedStatusInOrganization( + OrganizationUserStatusType status, Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, User user, + string sponsoredEmail, string friendlyName, Guid sponsorshipId, + SutProvider sutProvider) + { + sponsoringOrg.PlanType = PlanType.EnterpriseAnnually; + sponsoringOrgUser.Status = status; + + sutProvider.GetDependency().GetUserByIdAsync(sponsoringOrgUser.UserId!.Value).Returns(user); + sutProvider.GetDependency().WhenForAnyArgs(x => x.UpsertAsync(null!)).Do(callInfo => + { + var sponsorship = callInfo.Arg(); + sponsorship.Id = sponsorshipId; + }); + sutProvider.GetDependency().UserId.Returns(sponsoringOrgUser.UserId.Value); + + + var actual = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, null)); + + Assert.Equal("Only confirmed users can sponsor other organizations.", actual.Message); } [Theory] @@ -126,16 +160,17 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase sponsoringOrg.PlanType = PlanType.EnterpriseAnnually; sponsoringOrgUser.Status = OrganizationUserStatusType.Confirmed; - sutProvider.GetDependency().GetUserByIdAsync(sponsoringOrgUser.UserId.Value).Returns(user); - sutProvider.GetDependency().WhenForAnyArgs(x => x.UpsertAsync(default)).Do(callInfo => + sutProvider.GetDependency().GetUserByIdAsync(sponsoringOrgUser.UserId!.Value).Returns(user); + sutProvider.GetDependency().WhenForAnyArgs(x => x.UpsertAsync(null!)).Do(callInfo => { var sponsorship = callInfo.Arg(); sponsorship.Id = sponsorshipId; }); + sutProvider.GetDependency().UserId.Returns(sponsoringOrgUser.UserId.Value); await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, - PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName); + PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, null); var expectedSponsorship = new OrganizationSponsorship { @@ -145,6 +180,8 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase FriendlyName = friendlyName, OfferedToEmail = sponsoredEmail, PlanSponsorshipType = PlanSponsorshipType.FamiliesForEnterprise, + IsAdminInitiated = false, + Notes = null }; await sutProvider.GetDependency().Received(1) @@ -161,20 +198,138 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase var expectedException = new Exception(); OrganizationSponsorship createdSponsorship = null; - sutProvider.GetDependency().GetUserByIdAsync(sponsoringOrgUser.UserId.Value).Returns(user); - sutProvider.GetDependency().UpsertAsync(default).ThrowsForAnyArgs(callInfo => + sutProvider.GetDependency().GetUserByIdAsync(sponsoringOrgUser.UserId!.Value).Returns(user); + sutProvider.GetDependency().UpsertAsync(null!).ThrowsForAnyArgs(callInfo => { createdSponsorship = callInfo.ArgAt(0); createdSponsorship.Id = Guid.NewGuid(); return expectedException; }); + sutProvider.GetDependency().UserId.Returns(sponsoringOrgUser.UserId.Value); var actualException = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, - PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName)); + PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, null)); Assert.Same(expectedException, actualException); await sutProvider.GetDependency().Received(1) .DeleteAsync(createdSponsorship); } + + [Theory] + [BitAutoData] + public async Task CreateSponsorship_MissingManageUsersPermission_ThrowsUnauthorizedException( + Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, User user, string sponsoredEmail, + string friendlyName, Guid sponsorshipId, Guid currentUserId, SutProvider sutProvider) + { + sponsoringOrg.PlanType = PlanType.EnterpriseAnnually; + sponsoringOrgUser.Status = OrganizationUserStatusType.Confirmed; + + sutProvider.GetDependency().GetUserByIdAsync(sponsoringOrgUser.UserId!.Value).Returns(user); + sutProvider.GetDependency().WhenForAnyArgs(x => x.UpsertAsync(null!)).Do(callInfo => + { + var sponsorship = callInfo.Arg(); + sponsorship.Id = sponsorshipId; + }); + sutProvider.GetDependency().UserId.Returns(currentUserId); + sutProvider.GetDependency().Organizations.Returns([ + new() + { + Id = sponsoringOrg.Id, + Permissions = new Permissions(), + Type = OrganizationUserType.Custom + } + ]); + + + var actual = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, + PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, notes: null)); + + Assert.Equal("You do not have permissions to send sponsorships on behalf of the organization.", actual.Message); + } + + [Theory] + [BitAutoData(OrganizationUserType.User)] + [BitAutoData(OrganizationUserType.Custom)] + public async Task CreateSponsorship_InvalidUserType_ThrowsUnauthorizedException( + OrganizationUserType organizationUserType, + Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, User user, string sponsoredEmail, + string friendlyName, Guid sponsorshipId, Guid currentUserId, SutProvider sutProvider) + { + sponsoringOrg.PlanType = PlanType.EnterpriseAnnually; + sponsoringOrgUser.Status = OrganizationUserStatusType.Confirmed; + + sutProvider.GetDependency().GetUserByIdAsync(sponsoringOrgUser.UserId!.Value).Returns(user); + sutProvider.GetDependency().WhenForAnyArgs(x => x.UpsertAsync(null!)).Do(callInfo => + { + var sponsorship = callInfo.Arg(); + sponsorship.Id = sponsorshipId; + }); + sutProvider.GetDependency().UserId.Returns(currentUserId); + sutProvider.GetDependency().Organizations.Returns([ + new() + { + Id = sponsoringOrg.Id, + Permissions = new Permissions(), + Type = organizationUserType + } + ]); + + var actual = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, + PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, notes: null)); + + Assert.Equal("You do not have permissions to send sponsorships on behalf of the organization.", actual.Message); + } + + [Theory] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.Owner)] + public async Task CreateSponsorship_CreatesAdminInitiatedSponsorship( + OrganizationUserType organizationUserType, + Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, User user, string sponsoredEmail, + string friendlyName, Guid sponsorshipId, Guid currentUserId, string notes, SutProvider sutProvider) + { + sponsoringOrg.PlanType = PlanType.EnterpriseAnnually; + sponsoringOrg.UseAdminSponsoredFamilies = true; + sponsoringOrgUser.Status = OrganizationUserStatusType.Confirmed; + + sutProvider.GetDependency().GetUserByIdAsync(sponsoringOrgUser.UserId!.Value).Returns(user); + sutProvider.GetDependency().WhenForAnyArgs(x => x.UpsertAsync(null!)).Do(callInfo => + { + var sponsorship = callInfo.Arg(); + sponsorship.Id = sponsorshipId; + }); + sutProvider.GetDependency().UserId.Returns(currentUserId); + sutProvider.GetDependency().Organizations.Returns([ + new() + { + Id = sponsoringOrg.Id, + Permissions = new Permissions { ManageUsers = true }, + Type = organizationUserType + } + ]); + + var actual = await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, + PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, notes); + + + var expectedSponsorship = new OrganizationSponsorship + { + Id = sponsorshipId, + SponsoringOrganizationId = sponsoringOrg.Id, + SponsoringOrganizationUserId = sponsoringOrgUser.Id, + FriendlyName = friendlyName, + OfferedToEmail = sponsoredEmail, + PlanSponsorshipType = PlanSponsorshipType.FamiliesForEnterprise, + IsAdminInitiated = true, + Notes = notes + }; + + Assert.True(SponsorshipValidator(expectedSponsorship, actual)); + + await sutProvider.GetDependency().Received(1) + .UpsertAsync(Arg.Is(s => SponsorshipValidator(s, expectedSponsorship))); + } } diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs index 14b6f50415..637e970f8f 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs @@ -261,6 +261,7 @@ public class OrganizationUserRepositoryTests Assert.Equal(organization.LimitCollectionDeletion, result.LimitCollectionDeletion); Assert.Equal(organization.AllowAdminAccessToAllCollectionItems, result.AllowAdminAccessToAllCollectionItems); Assert.Equal(organization.UseRiskInsights, result.UseRiskInsights); + Assert.Equal(organization.UseAdminSponsoredFamilies, result.UseAdminSponsoredFamilies); } [DatabaseTheory, DatabaseData] diff --git a/util/Migrator/DbScripts/2025-03-14_00_AddUseAdminInitiatedSponsorship.sql b/util/Migrator/DbScripts/2025-03-14_00_AddUseAdminInitiatedSponsorship.sql new file mode 100644 index 0000000000..9a47706fc8 --- /dev/null +++ b/util/Migrator/DbScripts/2025-03-14_00_AddUseAdminInitiatedSponsorship.sql @@ -0,0 +1,532 @@ +ALTER TABLE [dbo].[Organization] ADD [UseAdminSponsoredFamilies] bit NOT NULL CONSTRAINT [DF_Organization_UseAdminSponsoredFamilies] default (0) + GO; + +ALTER TABLE [dbo].[OrganizationSponsorship] ADD [IsAdminInitiated] BIT CONSTRAINT [DF_OrganizationSponsorship_IsAdminInitiated] DEFAULT (0) NOT NULL + GO; + +ALTER TABLE [dbo].[OrganizationSponsorship] ADD [Notes] NVARCHAR(512) NULL + GO; + +CREATE OR ALTER PROCEDURE [dbo].[Organization_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @Identifier NVARCHAR(50), + @Name NVARCHAR(50), + @BusinessName NVARCHAR(50), + @BusinessAddress1 NVARCHAR(50), + @BusinessAddress2 NVARCHAR(50), + @BusinessAddress3 NVARCHAR(50), + @BusinessCountry VARCHAR(2), + @BusinessTaxNumber NVARCHAR(30), + @BillingEmail NVARCHAR(256), + @Plan NVARCHAR(50), + @PlanType TINYINT, + @Seats INT, + @MaxCollections SMALLINT, + @UsePolicies BIT, + @UseSso BIT, + @UseGroups BIT, + @UseDirectory BIT, + @UseEvents BIT, + @UseTotp BIT, + @Use2fa BIT, + @UseApi BIT, + @UseResetPassword BIT, + @SelfHost BIT, + @UsersGetPremium BIT, + @Storage BIGINT, + @MaxStorageGb SMALLINT, + @Gateway TINYINT, + @GatewayCustomerId VARCHAR(50), + @GatewaySubscriptionId VARCHAR(50), + @ReferenceData VARCHAR(MAX), + @Enabled BIT, + @LicenseKey VARCHAR(100), + @PublicKey VARCHAR(MAX), + @PrivateKey VARCHAR(MAX), + @TwoFactorProviders NVARCHAR(MAX), + @ExpirationDate DATETIME2(7), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @OwnersNotifiedOfAutoscaling DATETIME2(7), + @MaxAutoscaleSeats INT, + @UseKeyConnector BIT = 0, + @UseScim BIT = 0, + @UseCustomPermissions BIT = 0, + @UseSecretsManager BIT = 0, + @Status TINYINT = 0, + @UsePasswordManager BIT = 1, + @SmSeats INT = null, + @SmServiceAccounts INT = null, + @MaxAutoscaleSmSeats INT= null, + @MaxAutoscaleSmServiceAccounts INT = null, + @SecretsManagerBeta BIT = 0, + @LimitCollectionCreation BIT = NULL, + @LimitCollectionDeletion BIT = NULL, + @AllowAdminAccessToAllCollectionItems BIT = 0, + @UseRiskInsights BIT = 0, + @LimitItemDeletion BIT = 0, + @UseAdminSponsoredFamilies BIT = 0 +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[Organization] + ( + [Id], + [Identifier], + [Name], + [BusinessName], + [BusinessAddress1], + [BusinessAddress2], + [BusinessAddress3], + [BusinessCountry], + [BusinessTaxNumber], + [BillingEmail], + [Plan], + [PlanType], + [Seats], + [MaxCollections], + [UsePolicies], + [UseSso], + [UseGroups], + [UseDirectory], + [UseEvents], + [UseTotp], + [Use2fa], + [UseApi], + [UseResetPassword], + [SelfHost], + [UsersGetPremium], + [Storage], + [MaxStorageGb], + [Gateway], + [GatewayCustomerId], + [GatewaySubscriptionId], + [ReferenceData], + [Enabled], + [LicenseKey], + [PublicKey], + [PrivateKey], + [TwoFactorProviders], + [ExpirationDate], + [CreationDate], + [RevisionDate], + [OwnersNotifiedOfAutoscaling], + [MaxAutoscaleSeats], + [UseKeyConnector], + [UseScim], + [UseCustomPermissions], + [UseSecretsManager], + [Status], + [UsePasswordManager], + [SmSeats], + [SmServiceAccounts], + [MaxAutoscaleSmSeats], + [MaxAutoscaleSmServiceAccounts], + [SecretsManagerBeta], + [LimitCollectionCreation], + [LimitCollectionDeletion], + [AllowAdminAccessToAllCollectionItems], + [UseRiskInsights], + [LimitItemDeletion], + [UseAdminSponsoredFamilies] + ) + VALUES + ( + @Id, + @Identifier, + @Name, + @BusinessName, + @BusinessAddress1, + @BusinessAddress2, + @BusinessAddress3, + @BusinessCountry, + @BusinessTaxNumber, + @BillingEmail, + @Plan, + @PlanType, + @Seats, + @MaxCollections, + @UsePolicies, + @UseSso, + @UseGroups, + @UseDirectory, + @UseEvents, + @UseTotp, + @Use2fa, + @UseApi, + @UseResetPassword, + @SelfHost, + @UsersGetPremium, + @Storage, + @MaxStorageGb, + @Gateway, + @GatewayCustomerId, + @GatewaySubscriptionId, + @ReferenceData, + @Enabled, + @LicenseKey, + @PublicKey, + @PrivateKey, + @TwoFactorProviders, + @ExpirationDate, + @CreationDate, + @RevisionDate, + @OwnersNotifiedOfAutoscaling, + @MaxAutoscaleSeats, + @UseKeyConnector, + @UseScim, + @UseCustomPermissions, + @UseSecretsManager, + @Status, + @UsePasswordManager, + @SmSeats, + @SmServiceAccounts, + @MaxAutoscaleSmSeats, + @MaxAutoscaleSmServiceAccounts, + @SecretsManagerBeta, + @LimitCollectionCreation, + @LimitCollectionDeletion, + @AllowAdminAccessToAllCollectionItems, + @UseRiskInsights, + @LimitItemDeletion, + @UseAdminSponsoredFamilies + ) +END +GO; + +CREATE OR ALTER PROCEDURE [dbo].[Organization_ReadAbilities] +AS +BEGIN + SET NOCOUNT ON + +SELECT + [Id], + [UseEvents], + [Use2fa], + CASE + WHEN [Use2fa] = 1 AND [TwoFactorProviders] IS NOT NULL AND [TwoFactorProviders] != '{}' THEN + 1 + ELSE + 0 +END AS [Using2fa], + [UsersGetPremium], + [UseCustomPermissions], + [UseSso], + [UseKeyConnector], + [UseScim], + [UseResetPassword], + [UsePolicies], + [Enabled], + [LimitCollectionCreation], + [LimitCollectionDeletion], + [AllowAdminAccessToAllCollectionItems], + [UseRiskInsights], + [LimitItemDeletion], + [UseAdminSponsoredFamilies] + FROM + [dbo].[Organization] +END +GO; + +CREATE OR ALTER PROCEDURE [dbo].[Organization_Update] + @Id UNIQUEIDENTIFIER, + @Identifier NVARCHAR(50), + @Name NVARCHAR(50), + @BusinessName NVARCHAR(50), + @BusinessAddress1 NVARCHAR(50), + @BusinessAddress2 NVARCHAR(50), + @BusinessAddress3 NVARCHAR(50), + @BusinessCountry VARCHAR(2), + @BusinessTaxNumber NVARCHAR(30), + @BillingEmail NVARCHAR(256), + @Plan NVARCHAR(50), + @PlanType TINYINT, + @Seats INT, + @MaxCollections SMALLINT, + @UsePolicies BIT, + @UseSso BIT, + @UseGroups BIT, + @UseDirectory BIT, + @UseEvents BIT, + @UseTotp BIT, + @Use2fa BIT, + @UseApi BIT, + @UseResetPassword BIT, + @SelfHost BIT, + @UsersGetPremium BIT, + @Storage BIGINT, + @MaxStorageGb SMALLINT, + @Gateway TINYINT, + @GatewayCustomerId VARCHAR(50), + @GatewaySubscriptionId VARCHAR(50), + @ReferenceData VARCHAR(MAX), + @Enabled BIT, + @LicenseKey VARCHAR(100), + @PublicKey VARCHAR(MAX), + @PrivateKey VARCHAR(MAX), + @TwoFactorProviders NVARCHAR(MAX), + @ExpirationDate DATETIME2(7), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @OwnersNotifiedOfAutoscaling DATETIME2(7), + @MaxAutoscaleSeats INT, + @UseKeyConnector BIT = 0, + @UseScim BIT = 0, + @UseCustomPermissions BIT = 0, + @UseSecretsManager BIT = 0, + @Status TINYINT = 0, + @UsePasswordManager BIT = 1, + @SmSeats INT = null, + @SmServiceAccounts INT = null, + @MaxAutoscaleSmSeats INT = null, + @MaxAutoscaleSmServiceAccounts INT = null, + @SecretsManagerBeta BIT = 0, + @LimitCollectionCreation BIT = null, + @LimitCollectionDeletion BIT = null, + @AllowAdminAccessToAllCollectionItems BIT = 0, + @UseRiskInsights BIT = 0, + @LimitItemDeletion BIT = 0, + @UseAdminSponsoredFamilies BIT = 0 +AS +BEGIN + SET NOCOUNT ON + +UPDATE + [dbo].[Organization] +SET + [Identifier] = @Identifier, + [Name] = @Name, + [BusinessName] = @BusinessName, + [BusinessAddress1] = @BusinessAddress1, + [BusinessAddress2] = @BusinessAddress2, + [BusinessAddress3] = @BusinessAddress3, + [BusinessCountry] = @BusinessCountry, + [BusinessTaxNumber] = @BusinessTaxNumber, + [BillingEmail] = @BillingEmail, + [Plan] = @Plan, + [PlanType] = @PlanType, + [Seats] = @Seats, + [MaxCollections] = @MaxCollections, + [UsePolicies] = @UsePolicies, + [UseSso] = @UseSso, + [UseGroups] = @UseGroups, + [UseDirectory] = @UseDirectory, + [UseEvents] = @UseEvents, + [UseTotp] = @UseTotp, + [Use2fa] = @Use2fa, + [UseApi] = @UseApi, + [UseResetPassword] = @UseResetPassword, + [SelfHost] = @SelfHost, + [UsersGetPremium] = @UsersGetPremium, + [Storage] = @Storage, + [MaxStorageGb] = @MaxStorageGb, + [Gateway] = @Gateway, + [GatewayCustomerId] = @GatewayCustomerId, + [GatewaySubscriptionId] = @GatewaySubscriptionId, + [ReferenceData] = @ReferenceData, + [Enabled] = @Enabled, + [LicenseKey] = @LicenseKey, + [PublicKey] = @PublicKey, + [PrivateKey] = @PrivateKey, + [TwoFactorProviders] = @TwoFactorProviders, + [ExpirationDate] = @ExpirationDate, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate, + [OwnersNotifiedOfAutoscaling] = @OwnersNotifiedOfAutoscaling, + [MaxAutoscaleSeats] = @MaxAutoscaleSeats, + [UseKeyConnector] = @UseKeyConnector, + [UseScim] = @UseScim, + [UseCustomPermissions] = @UseCustomPermissions, + [UseSecretsManager] = @UseSecretsManager, + [Status] = @Status, + [UsePasswordManager] = @UsePasswordManager, + [SmSeats] = @SmSeats, + [SmServiceAccounts] = @SmServiceAccounts, + [MaxAutoscaleSmSeats] = @MaxAutoscaleSmSeats, + [MaxAutoscaleSmServiceAccounts] = @MaxAutoscaleSmServiceAccounts, + [SecretsManagerBeta] = @SecretsManagerBeta, + [LimitCollectionCreation] = @LimitCollectionCreation, + [LimitCollectionDeletion] = @LimitCollectionDeletion, + [AllowAdminAccessToAllCollectionItems] = @AllowAdminAccessToAllCollectionItems, + [UseRiskInsights] = @UseRiskInsights, + [LimitItemDeletion] = @LimitItemDeletion, + [UseAdminSponsoredFamilies] = @UseAdminSponsoredFamilies +WHERE + [Id] = @Id +END +GO; + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationSponsorship_Update] + @Id UNIQUEIDENTIFIER, + @SponsoringOrganizationId UNIQUEIDENTIFIER, + @SponsoringOrganizationUserID UNIQUEIDENTIFIER, + @SponsoredOrganizationId UNIQUEIDENTIFIER, + @FriendlyName NVARCHAR(256), + @OfferedToEmail NVARCHAR(256), + @PlanSponsorshipType TINYINT, + @ToDelete BIT, + @LastSyncDate DATETIME2 (7), + @ValidUntil DATETIME2 (7), + @IsAdminInitiated BIT = 0, + @Notes NVARCHAR(512) = NULL +AS +BEGIN + SET NOCOUNT ON + +UPDATE + [dbo].[OrganizationSponsorship] +SET + [SponsoringOrganizationId] = @SponsoringOrganizationId, + [SponsoringOrganizationUserID] = @SponsoringOrganizationUserID, + [SponsoredOrganizationId] = @SponsoredOrganizationId, + [FriendlyName] = @FriendlyName, + [OfferedToEmail] = @OfferedToEmail, + [PlanSponsorshipType] = @PlanSponsorshipType, + [ToDelete] = @ToDelete, + [LastSyncDate] = @LastSyncDate, + [ValidUntil] = @ValidUntil, + [IsAdminInitiated] = @IsAdminInitiated, + [Notes] = @Notes +WHERE + [Id] = @Id +END +GO; + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationSponsorship_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @SponsoringOrganizationId UNIQUEIDENTIFIER, + @SponsoringOrganizationUserID UNIQUEIDENTIFIER, + @SponsoredOrganizationId UNIQUEIDENTIFIER, + @FriendlyName NVARCHAR(256), + @OfferedToEmail NVARCHAR(256), + @PlanSponsorshipType TINYINT, + @ToDelete BIT, + @LastSyncDate DATETIME2 (7), + @ValidUntil DATETIME2 (7), + @IsAdminInitiated BIT = 0, + @Notes NVARCHAR(512) = NULL +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[OrganizationSponsorship] + ( + [Id], + [SponsoringOrganizationId], + [SponsoringOrganizationUserID], + [SponsoredOrganizationId], + [FriendlyName], + [OfferedToEmail], + [PlanSponsorshipType], + [ToDelete], + [LastSyncDate], + [ValidUntil], + [IsAdminInitiated], + [Notes] + ) + VALUES + ( + @Id, + @SponsoringOrganizationId, + @SponsoringOrganizationUserID, + @SponsoredOrganizationId, + @FriendlyName, + @OfferedToEmail, + @PlanSponsorshipType, + @ToDelete, + @LastSyncDate, + @ValidUntil, + @IsAdminInitiated, + @Notes + ) +END +GO; + +DROP PROCEDURE IF EXISTS [dbo].[OrganizationSponsorship_CreateMany]; +DROP PROCEDURE IF EXISTS [dbo].[OrganizationSponsorship_UpdateMany]; +DROP TYPE IF EXISTS [dbo].[OrganizationSponsorshipType] GO; + +CREATE TYPE [dbo].[OrganizationSponsorshipType] AS TABLE( + [Id] UNIQUEIDENTIFIER, + [SponsoringOrganizationId] UNIQUEIDENTIFIER, + [SponsoringOrganizationUserID] UNIQUEIDENTIFIER, + [SponsoredOrganizationId] UNIQUEIDENTIFIER, + [FriendlyName] NVARCHAR(256), + [OfferedToEmail] VARCHAR(256), + [PlanSponsorshipType] TINYINT, + [LastSyncDate] DATETIME2(7), + [ValidUntil] DATETIME2(7), + [ToDelete] BIT, + [IsAdminInitiated] BIT DEFAULT 0, + [Notes] NVARCHAR(512) NULL +); +GO; + +CREATE PROCEDURE [dbo].[OrganizationSponsorship_CreateMany] + @OrganizationSponsorshipsInput [dbo].[OrganizationSponsorshipType] READONLY +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[OrganizationSponsorship] + ( + [Id], + [SponsoringOrganizationId], + [SponsoringOrganizationUserID], + [SponsoredOrganizationId], + [FriendlyName], + [OfferedToEmail], + [PlanSponsorshipType], + [ToDelete], + [LastSyncDate], + [ValidUntil], + [IsAdminInitiated], + [Notes] + ) +SELECT + OS.[Id], + OS.[SponsoringOrganizationId], + OS.[SponsoringOrganizationUserID], + OS.[SponsoredOrganizationId], + OS.[FriendlyName], + OS.[OfferedToEmail], + OS.[PlanSponsorshipType], + OS.[ToDelete], + OS.[LastSyncDate], + OS.[ValidUntil], + OS.[IsAdminInitiated], + OS.[Notes] +FROM + @OrganizationSponsorshipsInput OS +END +GO; + +CREATE PROCEDURE [dbo].[OrganizationSponsorship_UpdateMany] + @OrganizationSponsorshipsInput [dbo].[OrganizationSponsorshipType] READONLY +AS +BEGIN + SET NOCOUNT ON + +UPDATE + OS +SET + [Id] = OSI.[Id], + [SponsoringOrganizationId] = OSI.[SponsoringOrganizationId], + [SponsoringOrganizationUserID] = OSI.[SponsoringOrganizationUserID], + [SponsoredOrganizationId] = OSI.[SponsoredOrganizationId], + [FriendlyName] = OSI.[FriendlyName], + [OfferedToEmail] = OSI.[OfferedToEmail], + [PlanSponsorshipType] = OSI.[PlanSponsorshipType], + [ToDelete] = OSI.[ToDelete], + [LastSyncDate] = OSI.[LastSyncDate], + [ValidUntil] = OSI.[ValidUntil], + [IsAdminInitiated] = OSI.[IsAdminInitiated], + [Notes] = OSI.[Notes] +FROM + [dbo].[OrganizationSponsorship] OS + INNER JOIN + @OrganizationSponsorshipsInput OSI ON OS.Id = OSI.Id + +END +GO; diff --git a/util/Migrator/DbScripts/2025-03-14_01_AddUseAdminInitiatedSponsorship_RefreshView.sql b/util/Migrator/DbScripts/2025-03-14_01_AddUseAdminInitiatedSponsorship_RefreshView.sql new file mode 100644 index 0000000000..5057dbfb13 --- /dev/null +++ b/util/Migrator/DbScripts/2025-03-14_01_AddUseAdminInitiatedSponsorship_RefreshView.sql @@ -0,0 +1,278 @@ +CREATE OR ALTER VIEW [dbo].[OrganizationUserOrganizationDetailsView] +AS +SELECT + OU.[UserId], + OU.[OrganizationId], + OU.[Id] OrganizationUserId, + O.[Name], + O.[Enabled], + O.[PlanType], + O.[UsePolicies], + O.[UseSso], + O.[UseKeyConnector], + O.[UseScim], + O.[UseGroups], + O.[UseDirectory], + O.[UseEvents], + O.[UseTotp], + O.[Use2fa], + O.[UseApi], + O.[UseResetPassword], + O.[SelfHost], + O.[UsersGetPremium], + O.[UseCustomPermissions], + O.[UseSecretsManager], + O.[Seats], + O.[MaxCollections], + O.[MaxStorageGb], + O.[Identifier], + OU.[Key], + OU.[ResetPasswordKey], + O.[PublicKey], + O.[PrivateKey], + OU.[Status], + OU.[Type], + SU.[ExternalId] SsoExternalId, + OU.[Permissions], + PO.[ProviderId], + P.[Name] ProviderName, + P.[Type] ProviderType, + SS.[Data] SsoConfig, + OS.[FriendlyName] FamilySponsorshipFriendlyName, + OS.[LastSyncDate] FamilySponsorshipLastSyncDate, + OS.[ToDelete] FamilySponsorshipToDelete, + OS.[ValidUntil] FamilySponsorshipValidUntil, + OU.[AccessSecretsManager], + O.[UsePasswordManager], + O.[SmSeats], + O.[SmServiceAccounts], + O.[LimitCollectionCreation], + O.[LimitCollectionDeletion], + O.[AllowAdminAccessToAllCollectionItems], + O.[UseRiskInsights], + O.[UseAdminSponsoredFamilies], + O.[LimitItemDeletion] +FROM + [dbo].[OrganizationUser] OU + LEFT JOIN + [dbo].[Organization] O ON O.[Id] = OU.[OrganizationId] + LEFT JOIN + [dbo].[SsoUser] SU ON SU.[UserId] = OU.[UserId] AND SU.[OrganizationId] = OU.[OrganizationId] + LEFT JOIN + [dbo].[ProviderOrganization] PO ON PO.[OrganizationId] = O.[Id] + LEFT JOIN + [dbo].[Provider] P ON P.[Id] = PO.[ProviderId] + LEFT JOIN + [dbo].[SsoConfig] SS ON SS.[OrganizationId] = OU.[OrganizationId] + LEFT JOIN + [dbo].[OrganizationSponsorship] OS ON OS.[SponsoringOrganizationUserID] = OU.[Id] +GO + +CREATE OR ALTER VIEW [dbo].[ProviderUserProviderOrganizationDetailsView] +AS +SELECT + PU.[UserId], + PO.[OrganizationId], + O.[Name], + O.[Enabled], + O.[UsePolicies], + O.[UseSso], + O.[UseKeyConnector], + O.[UseScim], + O.[UseGroups], + O.[UseDirectory], + O.[UseEvents], + O.[UseTotp], + O.[Use2fa], + O.[UseApi], + O.[UseResetPassword], + O.[SelfHost], + O.[UsersGetPremium], + O.[UseCustomPermissions], + O.[Seats], + O.[MaxCollections], + O.[MaxStorageGb], + O.[Identifier], + PO.[Key], + O.[PublicKey], + O.[PrivateKey], + PU.[Status], + PU.[Type], + PO.[ProviderId], + PU.[Id] ProviderUserId, + P.[Name] ProviderName, + O.[PlanType], + O.[LimitCollectionCreation], + O.[LimitCollectionDeletion], + O.[AllowAdminAccessToAllCollectionItems], + O.[UseRiskInsights], + O.[UseAdminSponsoredFamilies], + P.[Type] ProviderType, + O.[LimitItemDeletion] +FROM + [dbo].[ProviderUser] PU + INNER JOIN + [dbo].[ProviderOrganization] PO ON PO.[ProviderId] = PU.[ProviderId] + INNER JOIN + [dbo].[Organization] O ON O.[Id] = PO.[OrganizationId] + INNER JOIN + [dbo].[Provider] P ON P.[Id] = PU.[ProviderId] +GO + + +--Manually refresh [dbo].[OrganizationUserOrganizationDetailsView] +IF OBJECT_ID('[dbo].[OrganizationUserOrganizationDetailsView]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationUserOrganizationDetailsView]'; +END +GO + +IF OBJECT_ID('[dbo].[ProviderUserProviderOrganizationDetailsView]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[ProviderUserProviderOrganizationDetailsView]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationView]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationView]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorshipView]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorshipView]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationUserOrganizationDetailsView]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationUserOrganizationDetailsView]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_OrganizationUserDeleted]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_OrganizationUserDeleted]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_CreateMany]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_CreateMany]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_OrganizationUsersDeleted]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_OrganizationUsersDeleted]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_DeleteExpired]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_DeleteExpired]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_Update]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_Update]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_DeleteById]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_DeleteById]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_Create]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_Create]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_OrganizationDeleted]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_OrganizationDeleted]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_UpdateMany]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_UpdateMany]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_DeleteByIds]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_DeleteByIds]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_ReadLatestBySponsoringOrganizationId]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_ReadLatestBySponsoringOrganizationId]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_ReadByOfferedToEmail]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_ReadByOfferedToEmail]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_ReadBySponsoredOrganizationId]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_ReadBySponsoredOrganizationId]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_ReadBySponsoringOrganizationId]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_ReadBySponsoringOrganizationId]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_ReadById]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_ReadById]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationSponsorship_ReadBySponsoringOrganizationUserId]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationSponsorship_ReadBySponsoringOrganizationUserId]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationUserOrganizationDetails_ReadByUserIdStatus]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationUserOrganizationDetails_ReadByUserIdStatus]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationUserOrganizationDetails_ReadByUserIdStatusOrganizationId]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationUserOrganizationDetails_ReadByUserIdStatusOrganizationId]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationUser_DeleteById]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationUser_DeleteById]'; +END +GO + +IF OBJECT_ID('[dbo].[OrganizationUser_DeleteByIds]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationUser_DeleteByIds]'; +END +GO + +IF OBJECT_ID('[dbo].[Organization_DeleteById]') IS NOT NULL +BEGIN +EXECUTE sp_refreshsqlmodule N'[dbo].[Organization_DeleteById]'; +END +GO diff --git a/util/MySqlMigrations/Migrations/20250326092653_PM17830_AdminInitiatedSponsorships.Designer.cs b/util/MySqlMigrations/Migrations/20250326092653_PM17830_AdminInitiatedSponsorships.Designer.cs new file mode 100644 index 0000000000..ff9f22a15a --- /dev/null +++ b/util/MySqlMigrations/Migrations/20250326092653_PM17830_AdminInitiatedSponsorships.Designer.cs @@ -0,0 +1,3026 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250326092653_PM17830_AdminInitiatedSponsorships")] + partial class PM17830_AdminInitiatedSponsorships + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("LimitCollectionCreation") + .HasColumnType("tinyint(1)"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("tinyint(1)"); + + b.Property("LimitItemDeletion") + .HasColumnType("tinyint(1)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("int"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("int"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("int"); + + b.Property("MaxCollections") + .HasColumnType("smallint"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("datetime(6)"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("PrivateKey") + .HasColumnType("longtext"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("ReferenceData") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("SelfHost") + .HasColumnType("tinyint(1)"); + + b.Property("SmSeats") + .HasColumnType("int"); + + b.Property("SmServiceAccounts") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("Use2fa") + .HasColumnType("tinyint(1)"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("tinyint(1)"); + + b.Property("UseApi") + .HasColumnType("tinyint(1)"); + + b.Property("UseCustomPermissions") + .HasColumnType("tinyint(1)"); + + b.Property("UseDirectory") + .HasColumnType("tinyint(1)"); + + b.Property("UseEvents") + .HasColumnType("tinyint(1)"); + + b.Property("UseGroups") + .HasColumnType("tinyint(1)"); + + b.Property("UseKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("UsePasswordManager") + .HasColumnType("tinyint(1)"); + + b.Property("UsePolicies") + .HasColumnType("tinyint(1)"); + + b.Property("UseResetPassword") + .HasColumnType("tinyint(1)"); + + b.Property("UseRiskInsights") + .HasColumnType("tinyint(1)"); + + b.Property("UseScim") + .HasColumnType("tinyint(1)"); + + b.Property("UseSecretsManager") + .HasColumnType("tinyint(1)"); + + b.Property("UseSso") + .HasColumnType("tinyint(1)"); + + b.Property("UseTotp") + .HasColumnType("tinyint(1)"); + + b.Property("UsersGetPremium") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled") + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("BillingEmail") + .HasColumnType("longtext"); + + b.Property("BillingPhone") + .HasColumnType("longtext"); + + b.Property("BusinessAddress1") + .HasColumnType("longtext"); + + b.Property("BusinessAddress2") + .HasColumnType("longtext"); + + b.Property("BusinessAddress3") + .HasColumnType("longtext"); + + b.Property("BusinessCountry") + .HasColumnType("longtext"); + + b.Property("BusinessName") + .HasColumnType("longtext"); + + b.Property("BusinessTaxNumber") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DiscountId") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasColumnType("longtext"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("longtext"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UseEvents") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Settings") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasColumnType("longtext"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("varchar(25)"); + + b.Property("Approved") + .HasColumnType("tinyint(1)"); + + b.Property("AuthenticationDate") + .HasColumnType("datetime(6)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("MasterPasswordHash") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("RequestDeviceType") + .HasColumnType("tinyint unsigned"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ResponseDate") + .HasColumnType("datetime(6)"); + + b.Property("ResponseDeviceId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("GranteeId") + .HasColumnType("char(36)"); + + b.Property("GrantorId") + .HasColumnType("char(36)"); + + b.Property("KeyEncrypted") + .HasColumnType("longtext"); + + b.Property("LastNotificationDate") + .HasColumnType("datetime(6)"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("WaitTimeDays") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ConsumedDate") + .HasColumnType("datetime(6)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AaGuid") + .HasColumnType("char(36)"); + + b.Property("Counter") + .HasColumnType("int"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SupportsPrf") + .HasColumnType("tinyint(1)"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("int"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AssignedSeats") + .HasColumnType("int"); + + b.Property("ClientId") + .HasColumnType("char(36)"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Created") + .HasColumnType("datetime(6)"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Total") + .HasColumnType("decimal(65,30)"); + + b.Property("UsedSeats") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllocatedSeats") + .HasColumnType("int"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("PurchasedSeats") + .HasColumnType("int"); + + b.Property("SeatMinimum") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("varchar(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("datetime(6)"); + + b.Property("ExpiresAtTime") + .HasColumnType("datetime(6)"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longblob"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Active") + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("longtext"); + + b.Property("EncryptedPublicKey") + .HasColumnType("longtext"); + + b.Property("EncryptedUserKey") + .HasColumnType("longtext"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ActingUserId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("DeviceType") + .HasColumnType("tinyint unsigned"); + + b.Property("DomainName") + .HasColumnType("longtext"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("PolicyId") + .HasColumnType("char(36)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("ProviderOrganizationId") + .HasColumnType("char(36)"); + + b.Property("ProviderUserId") + .HasColumnType("char(36)"); + + b.Property("SecretId") + .HasColumnType("char(36)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("SystemUser") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Config") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("JobRunCount") + .HasColumnType("int"); + + b.Property("LastCheckedDate") + .HasColumnType("datetime(6)"); + + b.Property("NextRunDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("VerifiedDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("IsAdminInitiated") + .HasColumnType("tinyint(1)"); + + b.Property("LastSyncDate") + .HasColumnType("datetime(6)"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("tinyint unsigned"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("ToDelete") + .HasColumnType("tinyint(1)"); + + b.Property("ValidUntil") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessSecretsManager") + .HasColumnType("tinyint(1)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.Property("ResetPasswordKey") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessCount") + .HasColumnType("int"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletionDate") + .HasColumnType("datetime(6)"); + + b.Property("Disabled") + .HasColumnType("tinyint(1)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("HideEmail") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("MaxAccessCount") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Active") + .HasColumnType("tinyint(1)"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Rate") + .HasColumnType("decimal(65,30)"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Amount") + .HasColumnType("decimal(65,30)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PaymentMethodType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Refunded") + .HasColumnType("tinyint(1)"); + + b.Property("RefundedAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccountRevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("varchar(7)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailVerified") + .HasColumnType("tinyint(1)"); + + b.Property("EquivalentDomains") + .HasColumnType("longtext"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("longtext"); + + b.Property("FailedLoginCount") + .HasColumnType("int"); + + b.Property("ForcePasswordReset") + .HasColumnType("tinyint(1)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Kdf") + .HasColumnType("tinyint unsigned"); + + b.Property("KdfIterations") + .HasColumnType("int"); + + b.Property("KdfMemory") + .HasColumnType("int"); + + b.Property("KdfParallelism") + .HasColumnType("int"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("LastEmailChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastFailedLoginDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKdfChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKeyRotationDate") + .HasColumnType("datetime(6)"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Premium") + .HasColumnType("tinyint(1)"); + + b.Property("PremiumExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("PrivateKey") + .HasColumnType("longtext"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("ReferenceData") + .HasColumnType("longtext"); + + b.Property("RenewalReminderDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("VerifyDevices") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("varchar(3000)"); + + b.Property("ClientType") + .HasColumnType("tinyint unsigned"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Global") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Priority") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("TaskId") + .HasColumnType("char(36)"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("NotificationId") + .HasColumnType("char(36)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("ReadDate") + .HasColumnType("datetime(6)"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("LastActivityDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("varchar(34)"); + + b.Property("Read") + .HasColumnType("tinyint(1)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Write") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ExpireAt") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("Note") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Uri") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Attachments") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Favorites") + .HasColumnType("longtext"); + + b.Property("Folders") + .HasColumnType("longtext"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Reprompt") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("char(36)"); + + b.Property("SecretsId") + .HasColumnType("char(36)"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/MySqlMigrations/Migrations/20250326092653_PM17830_AdminInitiatedSponsorships.cs b/util/MySqlMigrations/Migrations/20250326092653_PM17830_AdminInitiatedSponsorships.cs new file mode 100644 index 0000000000..11b2ca5dfa --- /dev/null +++ b/util/MySqlMigrations/Migrations/20250326092653_PM17830_AdminInitiatedSponsorships.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations; + +/// +public partial class PM17830_AdminInitiatedSponsorships : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsAdminInitiated", + table: "OrganizationSponsorship", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "Notes", + table: "OrganizationSponsorship", + type: "longtext", + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "UseAdminSponsoredFamilies", + table: "Organization", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsAdminInitiated", + table: "OrganizationSponsorship"); + + migrationBuilder.DropColumn( + name: "Notes", + table: "OrganizationSponsorship"); + + migrationBuilder.DropColumn( + name: "UseAdminSponsoredFamilies", + table: "Organization"); + } +} diff --git a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs index 4b3f8934f3..ad2f88c1ae 100644 --- a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -164,6 +164,9 @@ namespace Bit.MySqlMigrations.Migrations b.Property("Use2fa") .HasColumnType("tinyint(1)"); + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("tinyint(1)"); + b.Property("UseApi") .HasColumnType("tinyint(1)"); @@ -1311,9 +1314,15 @@ namespace Bit.MySqlMigrations.Migrations .HasMaxLength(256) .HasColumnType("varchar(256)"); + b.Property("IsAdminInitiated") + .HasColumnType("tinyint(1)"); + b.Property("LastSyncDate") .HasColumnType("datetime(6)"); + b.Property("Notes") + .HasColumnType("longtext"); + b.Property("OfferedToEmail") .HasMaxLength(256) .HasColumnType("varchar(256)"); diff --git a/util/PostgresMigrations/Migrations/20250326092700_PM17830_AdminInitiatedSponsorships.Designer.cs b/util/PostgresMigrations/Migrations/20250326092700_PM17830_AdminInitiatedSponsorships.Designer.cs new file mode 100644 index 0000000000..e55146fa30 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20250326092700_PM17830_AdminInitiatedSponsorships.Designer.cs @@ -0,0 +1,3032 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250326092700_PM17830_AdminInitiatedSponsorships")] + partial class PM17830_AdminInitiatedSponsorships + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:postgresIndetermanisticCollation", "en-u-ks-primary,en-u-ks-primary,icu,False") + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LimitCollectionCreation") + .HasColumnType("boolean"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("boolean"); + + b.Property("LimitItemDeletion") + .HasColumnType("boolean"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("integer"); + + b.Property("MaxCollections") + .HasColumnType("smallint"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("timestamp with time zone"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("SelfHost") + .HasColumnType("boolean"); + + b.Property("SmSeats") + .HasColumnType("integer"); + + b.Property("SmServiceAccounts") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("Use2fa") + .HasColumnType("boolean"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("boolean"); + + b.Property("UseApi") + .HasColumnType("boolean"); + + b.Property("UseCustomPermissions") + .HasColumnType("boolean"); + + b.Property("UseDirectory") + .HasColumnType("boolean"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.Property("UseGroups") + .HasColumnType("boolean"); + + b.Property("UseKeyConnector") + .HasColumnType("boolean"); + + b.Property("UsePasswordManager") + .HasColumnType("boolean"); + + b.Property("UsePolicies") + .HasColumnType("boolean"); + + b.Property("UseResetPassword") + .HasColumnType("boolean"); + + b.Property("UseRiskInsights") + .HasColumnType("boolean"); + + b.Property("UseScim") + .HasColumnType("boolean"); + + b.Property("UseSecretsManager") + .HasColumnType("boolean"); + + b.Property("UseSso") + .HasColumnType("boolean"); + + b.Property("UseTotp") + .HasColumnType("boolean"); + + b.Property("UsersGetPremium") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Id", "Enabled"), new[] { "UseTotp" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BillingEmail") + .HasColumnType("text"); + + b.Property("BillingPhone") + .HasColumnType("text"); + + b.Property("BusinessAddress1") + .HasColumnType("text"); + + b.Property("BusinessAddress2") + .HasColumnType("text"); + + b.Property("BusinessAddress3") + .HasColumnType("text"); + + b.Property("BusinessCountry") + .HasColumnType("text"); + + b.Property("BusinessName") + .HasColumnType("text"); + + b.Property("BusinessTaxNumber") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscountId") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasColumnType("text"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("character varying(25)"); + + b.Property("Approved") + .HasColumnType("boolean"); + + b.Property("AuthenticationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MasterPasswordHash") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RequestDeviceType") + .HasColumnType("smallint"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ResponseDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ResponseDeviceId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GranteeId") + .HasColumnType("uuid"); + + b.Property("GrantorId") + .HasColumnType("uuid"); + + b.Property("KeyEncrypted") + .HasColumnType("text"); + + b.Property("LastNotificationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("WaitTimeDays") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ConsumedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("OrganizationId", "ExternalId"), new[] { "UserId" }); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AaGuid") + .HasColumnType("uuid"); + + b.Property("Counter") + .HasColumnType("integer"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SupportsPrf") + .HasColumnType("boolean"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssignedSeats") + .HasColumnType("integer"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Total") + .HasColumnType("numeric"); + + b.Property("UsedSeats") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllocatedSeats") + .HasColumnType("integer"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("PurchasedSeats") + .HasColumnType("integer"); + + b.Property("SeatMinimum") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("character varying(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("text"); + + b.Property("EncryptedPublicKey") + .HasColumnType("text"); + + b.Property("EncryptedUserKey") + .HasColumnType("text"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActingUserId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceType") + .HasColumnType("smallint"); + + b.Property("DomainName") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("ProviderOrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderUserId") + .HasColumnType("uuid"); + + b.Property("SecretId") + .HasColumnType("uuid"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.Property("SystemUser") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Config") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("JobRunCount") + .HasColumnType("integer"); + + b.Property("LastCheckedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NextRunDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("text"); + + b.Property("VerifiedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsAdminInitiated") + .HasColumnType("boolean"); + + b.Property("LastSyncDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("smallint"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("uuid"); + + b.Property("ToDelete") + .HasColumnType("boolean"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessSecretsManager") + .HasColumnType("boolean"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessCount") + .HasColumnType("integer"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Disabled") + .HasColumnType("boolean"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("HideEmail") + .HasColumnType("boolean"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MaxAccessCount") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Rate") + .HasColumnType("numeric"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PaymentMethodType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Refunded") + .HasColumnType("boolean"); + + b.Property("RefundedAmount") + .HasColumnType("numeric"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountRevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("character varying(7)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("EmailVerified") + .HasColumnType("boolean"); + + b.Property("EquivalentDomains") + .HasColumnType("text"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("text"); + + b.Property("FailedLoginCount") + .HasColumnType("integer"); + + b.Property("ForcePasswordReset") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Kdf") + .HasColumnType("smallint"); + + b.Property("KdfIterations") + .HasColumnType("integer"); + + b.Property("KdfMemory") + .HasColumnType("integer"); + + b.Property("KdfParallelism") + .HasColumnType("integer"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("LastEmailChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastFailedLoginDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKdfChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKeyRotationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Premium") + .HasColumnType("boolean"); + + b.Property("PremiumExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RenewalReminderDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.Property("VerifyDevices") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("character varying(3000)"); + + b.Property("ClientType") + .HasColumnType("smallint"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Global") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Priority") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("NotificationId") + .HasColumnType("uuid"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReadDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("LastActivityDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("character varying(34)"); + + b.Property("Read") + .HasColumnType("boolean"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Write") + .HasColumnType("boolean"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Uri") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Attachments") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Favorites") + .HasColumnType("text"); + + b.Property("Folders") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Reprompt") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("uuid"); + + b.Property("SecretsId") + .HasColumnType("uuid"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/PostgresMigrations/Migrations/20250326092700_PM17830_AdminInitiatedSponsorships.cs b/util/PostgresMigrations/Migrations/20250326092700_PM17830_AdminInitiatedSponsorships.cs new file mode 100644 index 0000000000..4507686e8f --- /dev/null +++ b/util/PostgresMigrations/Migrations/20250326092700_PM17830_AdminInitiatedSponsorships.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations; + +/// +public partial class PM17830_AdminInitiatedSponsorships : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsAdminInitiated", + table: "OrganizationSponsorship", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "Notes", + table: "OrganizationSponsorship", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "UseAdminSponsoredFamilies", + table: "Organization", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsAdminInitiated", + table: "OrganizationSponsorship"); + + migrationBuilder.DropColumn( + name: "Notes", + table: "OrganizationSponsorship"); + + migrationBuilder.DropColumn( + name: "UseAdminSponsoredFamilies", + table: "Organization"); + } +} diff --git a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs index ebb8fa470f..b9258bfb42 100644 --- a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -166,6 +166,9 @@ namespace Bit.PostgresMigrations.Migrations b.Property("Use2fa") .HasColumnType("boolean"); + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("boolean"); + b.Property("UseApi") .HasColumnType("boolean"); @@ -1316,9 +1319,15 @@ namespace Bit.PostgresMigrations.Migrations .HasMaxLength(256) .HasColumnType("character varying(256)"); + b.Property("IsAdminInitiated") + .HasColumnType("boolean"); + b.Property("LastSyncDate") .HasColumnType("timestamp with time zone"); + b.Property("Notes") + .HasColumnType("text"); + b.Property("OfferedToEmail") .HasMaxLength(256) .HasColumnType("character varying(256)"); diff --git a/util/SqliteMigrations/Migrations/20250326092645_PM17830_AdminInitiatedSponsorships.Designer.cs b/util/SqliteMigrations/Migrations/20250326092645_PM17830_AdminInitiatedSponsorships.Designer.cs new file mode 100644 index 0000000000..4cdb9e514f --- /dev/null +++ b/util/SqliteMigrations/Migrations/20250326092645_PM17830_AdminInitiatedSponsorships.Designer.cs @@ -0,0 +1,3015 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250326092645_PM17830_AdminInitiatedSponsorships")] + partial class PM17830_AdminInitiatedSponsorships + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LimitCollectionCreation") + .HasColumnType("INTEGER"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("INTEGER"); + + b.Property("LimitItemDeletion") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("MaxCollections") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("TEXT"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("SelfHost") + .HasColumnType("INTEGER"); + + b.Property("SmSeats") + .HasColumnType("INTEGER"); + + b.Property("SmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("Use2fa") + .HasColumnType("INTEGER"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("INTEGER"); + + b.Property("UseApi") + .HasColumnType("INTEGER"); + + b.Property("UseCustomPermissions") + .HasColumnType("INTEGER"); + + b.Property("UseDirectory") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.Property("UseGroups") + .HasColumnType("INTEGER"); + + b.Property("UseKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("UsePasswordManager") + .HasColumnType("INTEGER"); + + b.Property("UsePolicies") + .HasColumnType("INTEGER"); + + b.Property("UseResetPassword") + .HasColumnType("INTEGER"); + + b.Property("UseRiskInsights") + .HasColumnType("INTEGER"); + + b.Property("UseScim") + .HasColumnType("INTEGER"); + + b.Property("UseSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("UseSso") + .HasColumnType("INTEGER"); + + b.Property("UseTotp") + .HasColumnType("INTEGER"); + + b.Property("UsersGetPremium") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled") + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BillingEmail") + .HasColumnType("TEXT"); + + b.Property("BillingPhone") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DiscountId") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Settings") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("TEXT"); + + b.Property("Approved") + .HasColumnType("INTEGER"); + + b.Property("AuthenticationDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHash") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceType") + .HasColumnType("INTEGER"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ResponseDate") + .HasColumnType("TEXT"); + + b.Property("ResponseDeviceId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("GranteeId") + .HasColumnType("TEXT"); + + b.Property("GrantorId") + .HasColumnType("TEXT"); + + b.Property("KeyEncrypted") + .HasColumnType("TEXT"); + + b.Property("LastNotificationDate") + .HasColumnType("TEXT"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("WaitTimeDays") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ConsumedDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AaGuid") + .HasColumnType("TEXT"); + + b.Property("Counter") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SupportsPrf") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AssignedSeats") + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("TEXT"); + + b.Property("UsedSeats") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllocatedSeats") + .HasColumnType("INTEGER"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("PurchasedSeats") + .HasColumnType("INTEGER"); + + b.Property("SeatMinimum") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("TEXT"); + + b.Property("AbsoluteExpiration") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtTime") + .HasColumnType("TEXT"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasColumnType("TEXT"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActingUserId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasColumnType("INTEGER"); + + b.Property("DomainName") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("PolicyId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderOrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderUserId") + .HasColumnType("TEXT"); + + b.Property("SecretId") + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("SystemUser") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Config") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("JobRunCount") + .HasColumnType("INTEGER"); + + b.Property("LastCheckedDate") + .HasColumnType("TEXT"); + + b.Property("NextRunDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VerifiedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdminInitiated") + .HasColumnType("INTEGER"); + + b.Property("LastSyncDate") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlanSponsorshipType") + .HasColumnType("INTEGER"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("ToDelete") + .HasColumnType("INTEGER"); + + b.Property("ValidUntil") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessCount") + .HasColumnType("INTEGER"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletionDate") + .HasColumnType("TEXT"); + + b.Property("Disabled") + .HasColumnType("INTEGER"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("HideEmail") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MaxAccessCount") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Rate") + .HasColumnType("TEXT"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PaymentMethodType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Refunded") + .HasColumnType("INTEGER"); + + b.Property("RefundedAmount") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccountRevisionDate") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailVerified") + .HasColumnType("INTEGER"); + + b.Property("EquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("FailedLoginCount") + .HasColumnType("INTEGER"); + + b.Property("ForcePasswordReset") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Kdf") + .HasColumnType("INTEGER"); + + b.Property("KdfIterations") + .HasColumnType("INTEGER"); + + b.Property("KdfMemory") + .HasColumnType("INTEGER"); + + b.Property("KdfParallelism") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("LastEmailChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastFailedLoginDate") + .HasColumnType("TEXT"); + + b.Property("LastKdfChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastKeyRotationDate") + .HasColumnType("TEXT"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Premium") + .HasColumnType("INTEGER"); + + b.Property("PremiumExpirationDate") + .HasColumnType("TEXT"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RenewalReminderDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("VerifyDevices") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("TEXT"); + + b.Property("ClientType") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Global") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("NotificationId") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("ReadDate") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("TEXT"); + + b.Property("Read") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Write") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ExpireAt") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Uri") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Attachments") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Favorites") + .HasColumnType("TEXT"); + + b.Property("Folders") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Reprompt") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("TEXT"); + + b.Property("SecretsId") + .HasColumnType("TEXT"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/SqliteMigrations/Migrations/20250326092645_PM17830_AdminInitiatedSponsorships.cs b/util/SqliteMigrations/Migrations/20250326092645_PM17830_AdminInitiatedSponsorships.cs new file mode 100644 index 0000000000..5d28771b4c --- /dev/null +++ b/util/SqliteMigrations/Migrations/20250326092645_PM17830_AdminInitiatedSponsorships.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations; + +/// +public partial class PM17830_AdminInitiatedSponsorships : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsAdminInitiated", + table: "OrganizationSponsorship", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "Notes", + table: "OrganizationSponsorship", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "UseAdminSponsoredFamilies", + table: "Organization", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsAdminInitiated", + table: "OrganizationSponsorship"); + + migrationBuilder.DropColumn( + name: "Notes", + table: "OrganizationSponsorship"); + + migrationBuilder.DropColumn( + name: "UseAdminSponsoredFamilies", + table: "Organization"); + } +} diff --git a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs index 753c049651..bd1d47f089 100644 --- a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -159,6 +159,9 @@ namespace Bit.SqliteMigrations.Migrations b.Property("Use2fa") .HasColumnType("INTEGER"); + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("INTEGER"); + b.Property("UseApi") .HasColumnType("INTEGER"); @@ -1300,9 +1303,15 @@ namespace Bit.SqliteMigrations.Migrations .HasMaxLength(256) .HasColumnType("TEXT"); + b.Property("IsAdminInitiated") + .HasColumnType("INTEGER"); + b.Property("LastSyncDate") .HasColumnType("TEXT"); + b.Property("Notes") + .HasColumnType("TEXT"); + b.Property("OfferedToEmail") .HasMaxLength(256) .HasColumnType("TEXT"); From 4f698e9dea1dbea4046757e96e4f24e6eee408a8 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Wed, 16 Apr 2025 17:28:38 +0100 Subject: [PATCH 30/45] Resolve the member page not loading issue (#5649) --- .../OrganizationBillingService.cs | 9 +++++ .../OrganizationBillingServiceTests.cs | 40 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs index 5f5b803662..2e902ca028 100644 --- a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs +++ b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs @@ -91,6 +91,15 @@ public class OrganizationBillingService( var subscription = await subscriberService.GetSubscription(organization); + if (customer == null || subscription == null) + { + return OrganizationMetadata.Default with + { + IsEligibleForSelfHost = isEligibleForSelfHost, + IsManaged = isManaged + }; + } + var isOnSecretsManagerStandalone = await IsOnSecretsManagerStandalone(organization, customer, subscription); var invoice = !string.IsNullOrEmpty(subscription.LatestInvoiceId) diff --git a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs index 1f15c5f7fd..ab2f3a64c0 100644 --- a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs +++ b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs @@ -73,4 +73,44 @@ public class OrganizationBillingServiceTests } #endregion + + #region GetMetadata - Null Customer or Subscription + + [Theory, BitAutoData] + public async Task GetMetadata_WhenCustomerOrSubscriptionIsNull_ReturnsDefaultMetadata( + Guid organizationId, + Organization organization, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + + sutProvider.GetDependency().ListPlans().Returns(StaticStore.Plans.ToList()); + + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType) + .Returns(StaticStore.GetPlan(organization.PlanType)); + + var subscriberService = sutProvider.GetDependency(); + + // Set up subscriber service to return null for customer + subscriberService + .GetCustomer(organization, Arg.Is(options => options.Expand.FirstOrDefault() == "discount.coupon.applies_to")) + .Returns((Customer)null); + + // Set up subscriber service to return null for subscription + subscriberService.GetSubscription(organization).Returns((Subscription)null); + + var metadata = await sutProvider.Sut.GetMetadata(organizationId); + + Assert.NotNull(metadata); + Assert.False(metadata!.IsOnSecretsManagerStandalone); + Assert.False(metadata.HasSubscription); + Assert.False(metadata.IsSubscriptionUnpaid); + Assert.False(metadata.HasOpenInvoice); + Assert.False(metadata.IsSubscriptionCanceled); + Assert.Null(metadata.InvoiceDueDate); + Assert.Null(metadata.InvoiceCreatedDate); + Assert.Null(metadata.SubPeriodEndDate); + } + + #endregion } From e943a2f051a254c4a031f39f2638d418bdd2e4a2 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 16 Apr 2025 12:35:44 -0400 Subject: [PATCH 31/45] [PM-20264] Replace `StaticStore` with `PricingClient` in `MaxProjectsQuery` (#5651) * Replace StaticStore with PricingClient in MaxProjectsQuery * Run dotnet format --- .../Queries/Projects/MaxProjectsQuery.cs | 10 ++++++---- .../Queries/Projects/MaxProjectsQueryTests.cs | 8 ++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs index d9a7d4a2ce..106483ec4a 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs @@ -1,9 +1,9 @@ using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Queries.Projects.Interfaces; using Bit.Core.SecretsManager.Repositories; -using Bit.Core.Utilities; namespace Bit.Commercial.Core.SecretsManager.Queries.Projects; @@ -11,13 +11,16 @@ public class MaxProjectsQuery : IMaxProjectsQuery { private readonly IOrganizationRepository _organizationRepository; private readonly IProjectRepository _projectRepository; + private readonly IPricingClient _pricingClient; public MaxProjectsQuery( IOrganizationRepository organizationRepository, - IProjectRepository projectRepository) + IProjectRepository projectRepository, + IPricingClient pricingClient) { _organizationRepository = organizationRepository; _projectRepository = projectRepository; + _pricingClient = pricingClient; } public async Task<(short? max, bool? overMax)> GetByOrgIdAsync(Guid organizationId, int projectsToAdd) @@ -28,8 +31,7 @@ public class MaxProjectsQuery : IMaxProjectsQuery throw new NotFoundException(); } - // TODO: PRICING -> https://bitwarden.atlassian.net/browse/PM-17122 - var plan = StaticStore.GetPlan(org.PlanType); + var plan = await _pricingClient.GetPlan(org.PlanType); if (plan?.SecretsManager == null) { throw new BadRequestException("Existing plan not found."); diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs index 347f5b2128..afe9533292 100644 --- a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs @@ -1,9 +1,11 @@ using Bit.Commercial.Core.SecretsManager.Queries.Projects; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; +using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -66,6 +68,9 @@ public class MaxProjectsQueryTests SutProvider sutProvider, Organization organization) { organization.PlanType = planType; + + sutProvider.GetDependency().GetPlan(planType).Returns(StaticStore.GetPlan(planType)); + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); var (limit, overLimit) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1); @@ -106,6 +111,9 @@ public class MaxProjectsQueryTests SutProvider sutProvider, Organization organization) { organization.PlanType = planType; + + sutProvider.GetDependency().GetPlan(planType).Returns(StaticStore.GetPlan(planType)); + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency().GetProjectCountByOrganizationIdAsync(organization.Id) .Returns(projects); From 3d59f5522dc0fd60879451a3352fd3d287f341fd Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Wed, 16 Apr 2025 10:33:00 -0700 Subject: [PATCH 32/45] [PM-19357] - [Defect] Unauthorised access allows limited access user to change custom hidden field of Items (#5572) * prevent hidden password users from modifying hidden fields * add tests * fix serialization issues * DRY up code * return newly created cipher * add sshKey data type * fix tests --- .../Vault/Controllers/CiphersController.cs | 7 +-- .../Services/Implementations/CipherService.cs | 53 +++++++++++++++--- .../Vault/Services/CipherServiceTests.cs | 56 ++++++++++++++++++- 3 files changed, 100 insertions(+), 16 deletions(-) diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index a9646acd1c..3bdb6c4bf0 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -177,12 +177,7 @@ public class CiphersController : Controller } await _cipherService.SaveDetailsAsync(cipher, user.Id, model.Cipher.LastKnownRevisionDate, model.CollectionIds, cipher.OrganizationId.HasValue); - var response = new CipherResponseModel( - cipher, - user, - await _applicationCacheService.GetOrganizationAbilitiesAsync(), - _globalSettings); - return response; + return await Get(cipher.Id); } [HttpPost("admin")] diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index 989fbf43b8..745d90b741 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -1003,7 +1003,7 @@ public class CipherService : ICipherService private async Task ValidateViewPasswordUserAsync(Cipher cipher) { - if (cipher.Type != CipherType.Login || cipher.Data == null || !cipher.OrganizationId.HasValue) + if (cipher.Data == null || !cipher.OrganizationId.HasValue) { return; } @@ -1014,21 +1014,58 @@ public class CipherService : ICipherService // Check if user is a "hidden password" user if (!cipherPermissions.TryGetValue(cipher.Id, out var permission) || !(permission.ViewPassword && permission.Edit)) { + var existingCipherData = DeserializeCipherData(existingCipher); + var newCipherData = DeserializeCipherData(cipher); + // "hidden password" users may not add cipher key encryption if (existingCipher.Key == null && cipher.Key != null) { throw new BadRequestException("You do not have permission to add cipher key encryption."); } - // "hidden password" users may not change passwords, TOTP codes, or passkeys, so we need to set them back to the original values - var existingCipherData = JsonSerializer.Deserialize(existingCipher.Data); - var newCipherData = JsonSerializer.Deserialize(cipher.Data); - newCipherData.Fido2Credentials = existingCipherData.Fido2Credentials; - newCipherData.Totp = existingCipherData.Totp; - newCipherData.Password = existingCipherData.Password; - cipher.Data = JsonSerializer.Serialize(newCipherData); + // Keep only non-hidden fileds from the new cipher + var nonHiddenFields = newCipherData.Fields?.Where(f => f.Type != FieldType.Hidden) ?? []; + // Get hidden fields from the existing cipher + var hiddenFields = existingCipherData.Fields?.Where(f => f.Type == FieldType.Hidden) ?? []; + // Replace the hidden fields in new cipher data with the existing ones + newCipherData.Fields = nonHiddenFields.Concat(hiddenFields); + cipher.Data = SerializeCipherData(newCipherData); + if (existingCipherData is CipherLoginData existingLoginData && newCipherData is CipherLoginData newLoginCipherData) + { + // "hidden password" users may not change passwords, TOTP codes, or passkeys, so we need to set them back to the original values + newLoginCipherData.Fido2Credentials = existingLoginData.Fido2Credentials; + newLoginCipherData.Totp = existingLoginData.Totp; + newLoginCipherData.Password = existingLoginData.Password; + cipher.Data = SerializeCipherData(newLoginCipherData); + } } } + private string SerializeCipherData(CipherData data) + { + return data switch + { + CipherLoginData loginData => JsonSerializer.Serialize(loginData), + CipherIdentityData identityData => JsonSerializer.Serialize(identityData), + CipherCardData cardData => JsonSerializer.Serialize(cardData), + CipherSecureNoteData noteData => JsonSerializer.Serialize(noteData), + CipherSSHKeyData sshKeyData => JsonSerializer.Serialize(sshKeyData), + _ => throw new ArgumentException("Unsupported cipher data type.", nameof(data)) + }; + } + + private CipherData DeserializeCipherData(Cipher cipher) + { + return cipher.Type switch + { + CipherType.Login => JsonSerializer.Deserialize(cipher.Data), + CipherType.Identity => JsonSerializer.Deserialize(cipher.Data), + CipherType.Card => JsonSerializer.Deserialize(cipher.Data), + CipherType.SecureNote => JsonSerializer.Deserialize(cipher.Data), + CipherType.SSHKey => JsonSerializer.Deserialize(cipher.Data), + _ => throw new ArgumentException("Unsupported cipher type.", nameof(cipher)) + }; + } + // This method is used to filter ciphers based on the user's permissions to delete them. // It supports both the old and new logic depending on the feature flag. private async Task> FilterCiphersByDeletePermission( diff --git a/test/Core.Test/Vault/Services/CipherServiceTests.cs b/test/Core.Test/Vault/Services/CipherServiceTests.cs index ed07799c93..061d90bcc3 100644 --- a/test/Core.Test/Vault/Services/CipherServiceTests.cs +++ b/test/Core.Test/Vault/Services/CipherServiceTests.cs @@ -1228,7 +1228,8 @@ public class CipherServiceTests bool editPermission, string? key = null, string? totp = null, - CipherLoginFido2CredentialData[]? passkeys = null + CipherLoginFido2CredentialData[]? passkeys = null, + CipherFieldData[]? fields = null ) { var cipherDetails = new CipherDetails @@ -1241,12 +1242,13 @@ public class CipherServiceTests Key = key, }; - var newLoginData = new CipherLoginData { Username = "user", Password = newPassword, Totp = totp, Fido2Credentials = passkeys }; + var newLoginData = new CipherLoginData { Username = "user", Password = newPassword, Totp = totp, Fido2Credentials = passkeys, Fields = fields }; cipherDetails.Data = JsonSerializer.Serialize(newLoginData); var existingCipher = new Cipher { Id = cipherDetails.Id, + Type = CipherType.Login, Data = JsonSerializer.Serialize( new CipherLoginData { @@ -1442,6 +1444,56 @@ public class CipherServiceTests Assert.Equal(passkeys.Length, updatedLoginData.Fido2Credentials.Length); } + [Theory] + [BitAutoData] + public async Task SaveDetailsAsync_HiddenFieldsChangedWithoutPermission(string _, SutProvider sutProvider) + { + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: false, editPermission: false, fields: + [ + new CipherFieldData + { + Name = "FieldName", + Value = "FieldValue", + Type = FieldType.Hidden, + } + ]); + + await deps.SutProvider.Sut.SaveDetailsAsync( + deps.CipherDetails, + deps.CipherDetails.UserId.Value, + deps.CipherDetails.RevisionDate, + null, + true); + + var updatedLoginData = JsonSerializer.Deserialize(deps.CipherDetails.Data); + Assert.Empty(updatedLoginData.Fields); + } + + [Theory] + [BitAutoData] + public async Task SaveDetailsAsync_HiddenFieldsChangedWithPermission(string _, SutProvider sutProvider) + { + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: true, fields: + [ + new CipherFieldData + { + Name = "FieldName", + Value = "FieldValue", + Type = FieldType.Hidden, + } + ]); + + await deps.SutProvider.Sut.SaveDetailsAsync( + deps.CipherDetails, + deps.CipherDetails.UserId.Value, + deps.CipherDetails.RevisionDate, + null, + true); + + var updatedLoginData = JsonSerializer.Deserialize(deps.CipherDetails.Data); + Assert.Single(updatedLoginData.Fields.ToArray()); + } + [Theory] [BitAutoData] public async Task DeleteAsync_WithPersonalCipherOwner_DeletesCipher( From 01a08c581476c56289bbc7fc1013f35be2a67d3d Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 16 Apr 2025 13:36:04 -0400 Subject: [PATCH 33/45] [PM-19566] Update MSPs to "charge_automatically" with Admin-based opt-out (#5650) * Update provider to charge automatically with Admin Portal-based opt-out * Design feedback * Run dotnet format --- .../Controllers/ProvidersController.cs | 33 +++++- .../AdminConsole/Models/ProviderEditModel.cs | 4 + .../AdminConsole/Views/Providers/Edit.cshtml | 11 ++ src/Billing/Services/IStripeFacade.cs | 6 ++ .../PaymentMethodAttachedHandler.cs | 100 +++++++++++++++++- .../Services/Implementations/StripeFacade.cs | 7 ++ src/Core/Billing/Constants/StripeConstants.cs | 1 + .../Billing/Extensions/CustomerExtensions.cs | 4 + src/Core/Constants.cs | 1 + 9 files changed, 163 insertions(+), 4 deletions(-) diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index 6dc33e4909..264e9df069 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -3,11 +3,13 @@ using System.Net; using Bit.Admin.AdminConsole.Models; using Bit.Admin.Enums; using Bit.Admin.Utilities; +using Bit.Core; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; @@ -23,6 +25,7 @@ using Bit.Core.Settings; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Stripe; namespace Bit.Admin.AdminConsole.Controllers; @@ -44,6 +47,7 @@ public class ProvidersController : Controller private readonly IProviderPlanRepository _providerPlanRepository; private readonly IProviderBillingService _providerBillingService; private readonly IPricingClient _pricingClient; + private readonly IStripeAdapter _stripeAdapter; private readonly string _stripeUrl; private readonly string _braintreeMerchantUrl; private readonly string _braintreeMerchantId; @@ -63,7 +67,8 @@ public class ProvidersController : Controller IProviderPlanRepository providerPlanRepository, IProviderBillingService providerBillingService, IWebHostEnvironment webHostEnvironment, - IPricingClient pricingClient) + IPricingClient pricingClient, + IStripeAdapter stripeAdapter) { _organizationRepository = organizationRepository; _organizationService = organizationService; @@ -79,6 +84,7 @@ public class ProvidersController : Controller _providerPlanRepository = providerPlanRepository; _providerBillingService = providerBillingService; _pricingClient = pricingClient; + _stripeAdapter = stripeAdapter; _stripeUrl = webHostEnvironment.GetStripeUrl(); _braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl(); _braintreeMerchantId = globalSettings.Braintree.MerchantId; @@ -306,6 +312,23 @@ public class ProvidersController : Controller (Plan: PlanType.EnterpriseMonthly, SeatsMinimum: model.EnterpriseMonthlySeatMinimum) ]); await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand); + + if (_featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically)) + { + var customer = await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId); + + if (model.PayByInvoice != customer.ApprovedToPayByInvoice()) + { + var approvedToPayByInvoice = model.PayByInvoice ? "1" : "0"; + await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions + { + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.InvoiceApproved] = approvedToPayByInvoice + } + }); + } + } break; case ProviderType.BusinessUnit: { @@ -345,14 +368,18 @@ public class ProvidersController : Controller if (!provider.IsBillable()) { - return new ProviderEditModel(provider, users, providerOrganizations, new List()); + return new ProviderEditModel(provider, users, providerOrganizations, new List(), false); } var providerPlans = await _providerPlanRepository.GetByProviderId(id); + var payByInvoice = + _featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically) && + (await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId)).ApprovedToPayByInvoice(); + return new ProviderEditModel( provider, users, providerOrganizations, - providerPlans.ToList(), GetGatewayCustomerUrl(provider), GetGatewaySubscriptionUrl(provider)); + providerPlans.ToList(), payByInvoice, GetGatewayCustomerUrl(provider), GetGatewaySubscriptionUrl(provider)); } [RequirePermission(Permission.Provider_ResendEmailInvite)] diff --git a/src/Admin/AdminConsole/Models/ProviderEditModel.cs b/src/Admin/AdminConsole/Models/ProviderEditModel.cs index 7f8ffb224e..44eebb8d7d 100644 --- a/src/Admin/AdminConsole/Models/ProviderEditModel.cs +++ b/src/Admin/AdminConsole/Models/ProviderEditModel.cs @@ -18,6 +18,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject IEnumerable providerUsers, IEnumerable organizations, IReadOnlyCollection providerPlans, + bool payByInvoice, string gatewayCustomerUrl = null, string gatewaySubscriptionUrl = null) : base(provider, providerUsers, organizations, providerPlans) { @@ -33,6 +34,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject GatewayCustomerUrl = gatewayCustomerUrl; GatewaySubscriptionUrl = gatewaySubscriptionUrl; Type = provider.Type; + PayByInvoice = payByInvoice; if (Type == ProviderType.BusinessUnit) { @@ -62,6 +64,8 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject public string GatewaySubscriptionId { get; set; } public string GatewayCustomerUrl { get; } public string GatewaySubscriptionUrl { get; } + [Display(Name = "Pay By Invoice")] + public bool PayByInvoice { get; set; } [Display(Name = "Provider Type")] public ProviderType Type { get; set; } diff --git a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml index 2f48054600..ce215e1575 100644 --- a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml +++ b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml @@ -136,6 +136,17 @@ + @if (FeatureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically) && Model.Provider.Type == ProviderType.Msp && Model.Provider.IsBillable()) + { +
+
+
+ + +
+
+
+ } } @await Html.PartialAsync("Organizations", Model) diff --git a/src/Billing/Services/IStripeFacade.cs b/src/Billing/Services/IStripeFacade.cs index 77ba9a1ad4..e53d901083 100644 --- a/src/Billing/Services/IStripeFacade.cs +++ b/src/Billing/Services/IStripeFacade.cs @@ -16,6 +16,12 @@ public interface IStripeFacade RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + Task UpdateCustomer( + string customerId, + CustomerUpdateOptions customerUpdateOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); + Task GetEvent( string eventId, EventGetOptions eventGetOptions = null, diff --git a/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs b/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs index 6092e001ce..c46429412f 100644 --- a/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs +++ b/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs @@ -1,4 +1,8 @@ using Bit.Billing.Constants; +using Bit.Core; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Extensions; +using Bit.Core.Services; using Stripe; using Event = Stripe.Event; @@ -10,20 +14,114 @@ public class PaymentMethodAttachedHandler : IPaymentMethodAttachedHandler private readonly IStripeEventService _stripeEventService; private readonly IStripeFacade _stripeFacade; private readonly IStripeEventUtilityService _stripeEventUtilityService; + private readonly IFeatureService _featureService; public PaymentMethodAttachedHandler( ILogger logger, IStripeEventService stripeEventService, IStripeFacade stripeFacade, - IStripeEventUtilityService stripeEventUtilityService) + IStripeEventUtilityService stripeEventUtilityService, + IFeatureService featureService) { _logger = logger; _stripeEventService = stripeEventService; _stripeFacade = stripeFacade; _stripeEventUtilityService = stripeEventUtilityService; + _featureService = featureService; } public async Task HandleAsync(Event parsedEvent) + { + var updateMSPToChargeAutomatically = + _featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically); + + if (updateMSPToChargeAutomatically) + { + await HandleVNextAsync(parsedEvent); + } + else + { + await HandleVCurrentAsync(parsedEvent); + } + } + + private async Task HandleVNextAsync(Event parsedEvent) + { + var paymentMethod = await _stripeEventService.GetPaymentMethod(parsedEvent, true, ["customer.subscriptions.data.latest_invoice"]); + + if (paymentMethod == null) + { + _logger.LogWarning("Attempted to handle the event payment_method.attached but paymentMethod was null"); + return; + } + + var customer = paymentMethod.Customer; + var subscriptions = customer?.Subscriptions; + + // This represents a provider subscription set to "send_invoice" that was paid using a Stripe hosted invoice payment page. + var invoicedProviderSubscription = subscriptions?.Data.FirstOrDefault(subscription => + subscription.Metadata.ContainsKey(StripeConstants.MetadataKeys.ProviderId) && + subscription.Status != StripeConstants.SubscriptionStatus.Canceled && + subscription.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice); + + /* + * If we have an invoiced provider subscription where the customer hasn't been marked as invoice-approved, + * we need to try and set the default payment method and update the collection method to be "charge_automatically". + */ + if (invoicedProviderSubscription != null && !customer.ApprovedToPayByInvoice()) + { + if (customer.InvoiceSettings.DefaultPaymentMethodId != paymentMethod.Id) + { + try + { + await _stripeFacade.UpdateCustomer(customer.Id, + new CustomerUpdateOptions + { + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + DefaultPaymentMethod = paymentMethod.Id + } + }); + } + catch (Exception exception) + { + _logger.LogWarning(exception, + "Failed to set customer's ({CustomerID}) default payment method during 'payment_method.attached' webhook", + customer.Id); + } + } + + try + { + await _stripeFacade.UpdateSubscription(invoicedProviderSubscription.Id, + new SubscriptionUpdateOptions + { + CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically + }); + } + catch (Exception exception) + { + _logger.LogWarning(exception, + "Failed to set subscription's ({SubscriptionID}) collection method to 'charge_automatically' during 'payment_method.attached' webhook", + customer.Id); + } + } + + var unpaidSubscriptions = subscriptions?.Data.Where(subscription => + subscription.Status == StripeConstants.SubscriptionStatus.Unpaid).ToList(); + + if (unpaidSubscriptions == null || unpaidSubscriptions.Count == 0) + { + return; + } + + foreach (var unpaidSubscription in unpaidSubscriptions) + { + await AttemptToPayOpenSubscriptionAsync(unpaidSubscription); + } + } + + private async Task HandleVCurrentAsync(Event parsedEvent) { var paymentMethod = await _stripeEventService.GetPaymentMethod(parsedEvent); if (paymentMethod is null) diff --git a/src/Billing/Services/Implementations/StripeFacade.cs b/src/Billing/Services/Implementations/StripeFacade.cs index 91e0c1c33a..191f84a343 100644 --- a/src/Billing/Services/Implementations/StripeFacade.cs +++ b/src/Billing/Services/Implementations/StripeFacade.cs @@ -33,6 +33,13 @@ public class StripeFacade : IStripeFacade CancellationToken cancellationToken = default) => await _customerService.GetAsync(customerId, customerGetOptions, requestOptions, cancellationToken); + public async Task UpdateCustomer( + string customerId, + CustomerUpdateOptions customerUpdateOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + await _customerService.UpdateAsync(customerId, customerUpdateOptions, requestOptions, cancellationToken); + public async Task GetInvoice( string invoiceId, InvoiceGetOptions invoiceGetOptions = null, diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 326023e34c..8a4303e378 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -46,6 +46,7 @@ public static class StripeConstants public static class MetadataKeys { + public const string InvoiceApproved = "invoice_approved"; public const string OrganizationId = "organizationId"; public const string ProviderId = "providerId"; public const string UserId = "userId"; diff --git a/src/Core/Billing/Extensions/CustomerExtensions.cs b/src/Core/Billing/Extensions/CustomerExtensions.cs index 8f15f61a7f..3e0c1ea0fb 100644 --- a/src/Core/Billing/Extensions/CustomerExtensions.cs +++ b/src/Core/Billing/Extensions/CustomerExtensions.cs @@ -27,4 +27,8 @@ public static class CustomerExtensions { return customer != null ? customer.Balance / 100M : default; } + + public static bool ApprovedToPayByInvoice(this Customer customer) + => customer.Metadata.TryGetValue(StripeConstants.MetadataKeys.InvoiceApproved, out var value) && + int.TryParse(value, out var invoiceApproved) && invoiceApproved == 1; } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index c57544283b..44962cd0ab 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -149,6 +149,7 @@ public static class FeatureFlagKeys public const string PM19147_AutomaticTaxImprovements = "pm-19147-automatic-tax-improvements"; public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates"; public const string PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion"; + public const string PM199566_UpdateMSPToChargeAutomatically = "pm-199566-update-msp-to-charge-automatically"; /* Key Management Team */ public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; From 1399b1417ea3bd99717473d8dd7f5724b5a8530e Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Wed, 16 Apr 2025 15:46:49 -0400 Subject: [PATCH 34/45] PM-6675 - Remove old registration endpoint (#5585) * feat : remove old registration endpoint * fix: update integration test user registration to match current registration; We need to keep the IRegistrationCommand.RegisterUser method to JIT user. * fix: updating accounts/profile tests to match current implementations --- .../Registration/IRegisterUserCommand.cs | 1 + .../Controllers/AccountsController.cs | 20 - .../Controllers/AccountsControllerTest.cs | 40 +- .../Factories/ApiApplicationFactory.cs | 26 +- .../RegisterFinishRequestModelFixtures.cs | 58 +++ .../EventsApplicationFactory.cs | 23 +- .../Controllers/AccountsControllerTests.cs | 35 +- .../Endpoints/IdentityServerSsoTests.cs | 32 +- .../Endpoints/IdentityServerTests.cs | 346 +++++++++--------- .../Endpoints/IdentityServerTwoFactorTests.cs | 104 +++--- .../Identity.IntegrationTest.csproj | 1 + .../ResourceOwnerPasswordValidatorTests.cs | 85 +++-- .../Controllers/AccountsControllerTests.cs | 44 --- .../Factories/IdentityApplicationFactory.cs | 74 +++- 14 files changed, 457 insertions(+), 432 deletions(-) create mode 100644 test/Core.Test/Auth/AutoFixture/RegisterFinishRequestModelFixtures.cs diff --git a/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs index f61cce895a..62dd9dd293 100644 --- a/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs @@ -8,6 +8,7 @@ public interface IRegisterUserCommand /// /// Creates a new user, sends a welcome email, and raises the signup reference event. + /// This method is used for JIT of organization Users. /// /// The to create /// diff --git a/src/Identity/Controllers/AccountsController.cs b/src/Identity/Controllers/AccountsController.cs index 9360da586c..fd42074359 100644 --- a/src/Identity/Controllers/AccountsController.cs +++ b/src/Identity/Controllers/AccountsController.cs @@ -8,7 +8,6 @@ using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Services; using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Auth.UserFeatures.WebAuthnLogin; -using Bit.Core.Auth.Utilities; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -114,16 +113,6 @@ public class AccountsController : Controller } } - [HttpPost("register")] - [CaptchaProtected] - public async Task PostRegister([FromBody] RegisterRequestModel model) - { - var user = model.ToUser(); - var identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken(user, model.MasterPasswordHash, - model.Token, model.OrganizationUserId); - return ProcessRegistrationResult(identityResult, user); - } - [HttpPost("register/send-verification-email")] public async Task PostRegisterSendVerificationEmail([FromBody] RegisterSendVerificationEmailRequestModel model) { @@ -175,8 +164,6 @@ public class AccountsController : Controller } return Ok(); - - } [HttpPost("register/finish")] @@ -185,7 +172,6 @@ public class AccountsController : Controller var user = model.ToUser(); // Users will either have an emailed token or an email verification token - not both. - IdentityResult identityResult = null; switch (model.GetTokenType()) @@ -196,33 +182,27 @@ public class AccountsController : Controller model.EmailVerificationToken); return ProcessRegistrationResult(identityResult, user); - break; case RegisterFinishTokenType.OrganizationInvite: identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken(user, model.MasterPasswordHash, model.OrgInviteToken, model.OrganizationUserId); return ProcessRegistrationResult(identityResult, user); - break; case RegisterFinishTokenType.OrgSponsoredFreeFamilyPlan: identityResult = await _registerUserCommand.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, model.MasterPasswordHash, model.OrgSponsoredFreeFamilyPlanToken); return ProcessRegistrationResult(identityResult, user); - break; case RegisterFinishTokenType.EmergencyAccessInvite: Debug.Assert(model.AcceptEmergencyAccessId.HasValue); identityResult = await _registerUserCommand.RegisterUserViaAcceptEmergencyAccessInviteToken(user, model.MasterPasswordHash, model.AcceptEmergencyAccessInviteToken, model.AcceptEmergencyAccessId.Value); return ProcessRegistrationResult(identityResult, user); - break; case RegisterFinishTokenType.ProviderInvite: Debug.Assert(model.ProviderUserId.HasValue); identityResult = await _registerUserCommand.RegisterUserViaProviderInviteToken(user, model.MasterPasswordHash, model.ProviderInviteToken, model.ProviderUserId.Value); return ProcessRegistrationResult(identityResult, user); - break; - default: throw new BadRequestException("Invalid registration finish request"); } diff --git a/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs b/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs index 277f558566..4e5a6850e7 100644 --- a/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs +++ b/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs @@ -1,13 +1,6 @@ using System.Net.Http.Headers; using Bit.Api.IntegrationTest.Factories; -using Bit.Api.IntegrationTest.Helpers; using Bit.Api.Models.Response; -using Bit.Core; -using Bit.Core.Billing.Enums; -using Bit.Core.Enums; -using Bit.Core.Models.Data; -using Bit.Core.Services; -using NSubstitute; using Xunit; namespace Bit.Api.IntegrationTest.Controllers; @@ -19,7 +12,7 @@ public class AccountsControllerTest : IClassFixture public AccountsControllerTest(ApiApplicationFactory factory) => _factory = factory; [Fact] - public async Task GetPublicKey() + public async Task GetAccountsProfile_success() { var tokens = await _factory.LoginWithNewAccount(); var client = _factory.CreateClient(); @@ -33,36 +26,13 @@ public class AccountsControllerTest : IClassFixture var content = await response.Content.ReadFromJsonAsync(); Assert.NotNull(content); Assert.Equal("integration-test@bitwarden.com", content.Email); - Assert.Null(content.Name); - Assert.False(content.EmailVerified); + Assert.NotNull(content.Name); + Assert.True(content.EmailVerified); Assert.False(content.Premium); Assert.False(content.PremiumFromOrganization); Assert.Equal("en-US", content.Culture); - Assert.Null(content.Key); - Assert.Null(content.PrivateKey); + Assert.NotNull(content.Key); + Assert.NotNull(content.PrivateKey); Assert.NotNull(content.SecurityStamp); } - - private async Task SetupOrganizationManagedAccount() - { - _factory.SubstituteService(featureService => - featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true)); - - // Create the owner account - var ownerEmail = $"{Guid.NewGuid()}@bitwarden.com"; - await _factory.LoginWithNewAccount(ownerEmail); - - // Create the organization - var (_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually2023, - ownerEmail: ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card); - - // Create a new organization member - var (email, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, - OrganizationUserType.Custom, new Permissions { AccessReports = true, ManageScim = true }); - - // Add a verified domain - await OrganizationTestHelpers.CreateVerifiedDomainAsync(_factory, _organization.Id, "bitwarden.com"); - - return email; - } } diff --git a/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs b/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs index 230f0bcf08..a0963745de 100644 --- a/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs +++ b/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs @@ -1,4 +1,6 @@ -using Bit.Identity.Models.Request.Accounts; +using Bit.Core; +using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Enums; using Bit.IntegrationTestCommon.Factories; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.TestHost; @@ -42,13 +44,23 @@ public class ApiApplicationFactory : WebApplicationFactoryBase /// /// Helper for registering and logging in to a new account /// - public async Task<(string Token, string RefreshToken)> LoginWithNewAccount(string email = "integration-test@bitwarden.com", string masterPasswordHash = "master_password_hash") + public async Task<(string Token, string RefreshToken)> LoginWithNewAccount( + string email = "integration-test@bitwarden.com", string masterPasswordHash = "master_password_hash") { - await _identityApplicationFactory.RegisterAsync(new RegisterRequestModel - { - Email = email, - MasterPasswordHash = masterPasswordHash, - }); + await _identityApplicationFactory.RegisterNewIdentityFactoryUserAsync( + new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordHash, + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + UserAsymmetricKeys = new KeysRequestModel() + { + PublicKey = "public_key", + EncryptedPrivateKey = "private_key" + }, + UserSymmetricKey = "sym_key", + }); return await _identityApplicationFactory.TokenFromPasswordAsync(email, masterPasswordHash); } diff --git a/test/Core.Test/Auth/AutoFixture/RegisterFinishRequestModelFixtures.cs b/test/Core.Test/Auth/AutoFixture/RegisterFinishRequestModelFixtures.cs new file mode 100644 index 0000000000..a751a16f31 --- /dev/null +++ b/test/Core.Test/Auth/AutoFixture/RegisterFinishRequestModelFixtures.cs @@ -0,0 +1,58 @@ +using System.ComponentModel.DataAnnotations; +using AutoFixture; +using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Enums; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture.Attributes; + +namespace Bit.Core.Test.Auth.AutoFixture; + +internal class RegisterFinishRequestModelCustomization : ICustomization +{ + [StrictEmailAddress, StringLength(256)] + public required string Email { get; set; } + public required KdfType Kdf { get; set; } + public required int KdfIterations { get; set; } + public string? EmailVerificationToken { get; set; } + public string? OrgInviteToken { get; set; } + public string? OrgSponsoredFreeFamilyPlanToken { get; set; } + public string? AcceptEmergencyAccessInviteToken { get; set; } + public string? ProviderInviteToken { get; set; } + + public void Customize(IFixture fixture) + { + fixture.Customize(composer => composer + .With(o => o.Email, Email) + .With(o => o.Kdf, Kdf) + .With(o => o.KdfIterations, KdfIterations) + .With(o => o.EmailVerificationToken, EmailVerificationToken) + .With(o => o.OrgInviteToken, OrgInviteToken) + .With(o => o.OrgSponsoredFreeFamilyPlanToken, OrgSponsoredFreeFamilyPlanToken) + .With(o => o.AcceptEmergencyAccessInviteToken, AcceptEmergencyAccessInviteToken) + .With(o => o.ProviderInviteToken, ProviderInviteToken)); + } +} + +public class RegisterFinishRequestModelCustomizeAttribute : BitCustomizeAttribute +{ + public string _email { get; set; } = "{0}@email.com"; + public KdfType _kdf { get; set; } = KdfType.PBKDF2_SHA256; + public int _kdfIterations { get; set; } = AuthConstants.PBKDF2_ITERATIONS.Default; + public string? _emailVerificationToken { get; set; } + public string? _orgInviteToken { get; set; } + public string? _orgSponsoredFreeFamilyPlanToken { get; set; } + public string? _acceptEmergencyAccessInviteToken { get; set; } + public string? _providerInviteToken { get; set; } + + public override ICustomization GetCustomization() => new RegisterFinishRequestModelCustomization() + { + Email = _email, + Kdf = _kdf, + KdfIterations = _kdfIterations, + EmailVerificationToken = _emailVerificationToken, + OrgInviteToken = _orgInviteToken, + OrgSponsoredFreeFamilyPlanToken = _orgSponsoredFreeFamilyPlanToken, + AcceptEmergencyAccessInviteToken = _acceptEmergencyAccessInviteToken, + ProviderInviteToken = _providerInviteToken + }; +} diff --git a/test/Events.IntegrationTest/EventsApplicationFactory.cs b/test/Events.IntegrationTest/EventsApplicationFactory.cs index 3faf5e81bf..b1c3ef8bf5 100644 --- a/test/Events.IntegrationTest/EventsApplicationFactory.cs +++ b/test/Events.IntegrationTest/EventsApplicationFactory.cs @@ -1,4 +1,6 @@ -using Bit.Identity.Models.Request.Accounts; +using Bit.Core; +using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Enums; using Bit.IntegrationTestCommon.Factories; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Hosting; @@ -40,11 +42,20 @@ public class EventsApplicationFactory : WebApplicationFactoryBase ///
public async Task<(string Token, string RefreshToken)> LoginWithNewAccount(string email = "integration-test@bitwarden.com", string masterPasswordHash = "master_password_hash") { - await _identityApplicationFactory.RegisterAsync(new RegisterRequestModel - { - Email = email, - MasterPasswordHash = masterPasswordHash, - }); + await _identityApplicationFactory.RegisterNewIdentityFactoryUserAsync( + new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordHash, + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + UserAsymmetricKeys = new KeysRequestModel() + { + PublicKey = "public_key", + EncryptedPrivateKey = "private_key" + }, + UserSymmetricKey = "sym_key", + }); return await _identityApplicationFactory.TokenFromPasswordAsync(email, masterPasswordHash); } diff --git a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs index 3b8534ef32..88e8af3dc6 100644 --- a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs +++ b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs @@ -8,10 +8,8 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business.Tokenables; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Tokens; using Bit.Core.Utilities; -using Bit.Identity.Models.Request.Accounts; using Bit.IntegrationTestCommon.Factories; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.DataProtection; @@ -31,24 +29,6 @@ public class AccountsControllerTests : IClassFixture _factory = factory; } - [Fact] - public async Task PostRegister_Success() - { - var context = await _factory.RegisterAsync(new RegisterRequestModel - { - Email = "test+register@email.com", - MasterPasswordHash = "master_password_hash" - }); - - Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); - - var database = _factory.GetDatabaseContext(); - var user = await database.Users - .SingleAsync(u => u.Email == "test+register@email.com"); - - Assert.NotNull(user); - } - [Theory] [BitAutoData("invalidEmail")] [BitAutoData("")] @@ -154,6 +134,7 @@ public class AccountsControllerTests : IClassFixture } [Theory, BitAutoData] + // marketing emails can stay at top level public async Task RegistrationWithEmailVerification_WithEmailVerificationToken_Succeeds([Required] string name, bool receiveMarketingEmails, [StringLength(1000), Required] string masterPasswordHash, [StringLength(50)] string masterPasswordHint, [Required] string userSymmetricKey, [Required] KeysRequestModel userAsymmetricKeys, int kdfMemory, int kdfParallelism) @@ -161,16 +142,6 @@ public class AccountsControllerTests : IClassFixture // Localize substitutions to this test. var localFactory = new IdentityApplicationFactory(); - // First we must substitute the mail service in order to be able to get a valid email verification token - // for the complete registration step - string capturedEmailVerificationToken = null; - localFactory.SubstituteService(mailService => - { - mailService.SendRegistrationVerificationEmailAsync(Arg.Any(), Arg.Do(t => capturedEmailVerificationToken = t)) - .Returns(Task.CompletedTask); - - }); - // we must first call the send verification email endpoint to trigger the first part of the process var email = $"test+register+{name}@email.com"; var sendVerificationEmailReqModel = new RegisterSendVerificationEmailRequestModel @@ -183,7 +154,7 @@ public class AccountsControllerTests : IClassFixture var sendEmailVerificationResponseHttpContext = await localFactory.PostRegisterSendEmailVerificationAsync(sendVerificationEmailReqModel); Assert.Equal(StatusCodes.Status204NoContent, sendEmailVerificationResponseHttpContext.Response.StatusCode); - Assert.NotNull(capturedEmailVerificationToken); + Assert.NotNull(localFactory.RegistrationTokens[email]); // Now we call the finish registration endpoint with the email verification token var registerFinishReqModel = new RegisterFinishRequestModel @@ -191,7 +162,7 @@ public class AccountsControllerTests : IClassFixture Email = email, MasterPasswordHash = masterPasswordHash, MasterPasswordHint = masterPasswordHint, - EmailVerificationToken = capturedEmailVerificationToken, + EmailVerificationToken = localFactory.RegistrationTokens[email], Kdf = KdfType.PBKDF2_SHA256, KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, UserSymmetricKey = userSymmetricKey, diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs index 602d5cfe48..c2812cc58f 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs @@ -1,11 +1,13 @@ using System.Security.Claims; using System.Text.Json; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; using Bit.Core.Entities; @@ -13,7 +15,6 @@ using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Utilities; -using Bit.Identity.Models.Request.Accounts; using Bit.IntegrationTestCommon.Factories; using Bit.Test.Common.Helpers; using Duende.IdentityModel; @@ -545,16 +546,15 @@ public class IdentityServerSsoTests { var factory = new IdentityApplicationFactory(); - var authorizationCode = new AuthorizationCode { ClientId = "web", CreationTime = DateTime.UtcNow, Lifetime = (int)TimeSpan.FromMinutes(5).TotalSeconds, RedirectUri = "https://localhost:8080/sso-connector.html", - RequestedScopes = new[] { "api", "offline_access" }, + RequestedScopes = ["api", "offline_access"], CodeChallenge = challenge.Sha256(), - CodeChallengeMethod = "plain", // + CodeChallengeMethod = "plain", Subject = null!, // Temporarily set it to null }; @@ -564,16 +564,20 @@ public class IdentityServerSsoTests .Returns(authorizationCode); }); - // This starts the server and finalizes services - var registerResponse = await factory.RegisterAsync(new RegisterRequestModel - { - Email = TestEmail, - MasterPasswordHash = "master_password_hash", - }); - - var userRepository = factory.Services.GetRequiredService(); - var user = await userRepository.GetByEmailAsync(TestEmail); - Assert.NotNull(user); + var user = await factory.RegisterNewIdentityFactoryUserAsync( + new RegisterFinishRequestModel + { + Email = TestEmail, + MasterPasswordHash = "masterPasswordHash", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + UserAsymmetricKeys = new KeysRequestModel() + { + PublicKey = "public_key", + EncryptedPrivateKey = "private_key" + }, + UserSymmetricKey = "sym_key", + }); var organizationRepository = factory.Services.GetRequiredService(); var organization = await organizationRepository.CreateAsync(new Organization diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs index 38a1518d14..f4e36fa7d5 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs @@ -3,11 +3,13 @@ using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Platform.Installations; using Bit.Core.Repositories; +using Bit.Core.Test.Auth.AutoFixture; using Bit.Identity.IdentityServer; -using Bit.Identity.Models.Request.Accounts; using Bit.IntegrationTestCommon.Factories; using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.Helpers; @@ -17,6 +19,7 @@ using Xunit; namespace Bit.Identity.IntegrationTest.Endpoints; +[SutProviderCustomize] public class IdentityServerTests : IClassFixture { private const int SecondsInMinute = 60; @@ -27,7 +30,7 @@ public class IdentityServerTests : IClassFixture public IdentityServerTests(IdentityApplicationFactory factory) { _factory = factory; - ReinitializeDbForTests(); + ReinitializeDbForTests(_factory); } [Fact] @@ -48,18 +51,14 @@ public class IdentityServerTests : IClassFixture AssertHelper.AssertEqualJson(endpointRoot, knownConfigurationRoot); } - [Theory, BitAutoData] - public async Task TokenEndpoint_GrantTypePassword_Success(string deviceId) + [Theory, BitAutoData, RegisterFinishRequestModelCustomize] + public async Task TokenEndpoint_GrantTypePassword_Success(RegisterFinishRequestModel requestModel) { - var username = "test+tokenpassword@email.com"; + var localFactory = new IdentityApplicationFactory(); + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); - await _factory.RegisterAsync(new RegisterRequestModel - { - Email = username, - MasterPasswordHash = "master_password_hash" - }); - - var context = await PostLoginAsync(_factory.Server, username, deviceId, context => context.SetAuthEmail(username)); + var context = await PostLoginAsync(localFactory.Server, user, requestModel.MasterPasswordHash, + context => context.SetAuthEmail(user.Email)); using var body = await AssertDefaultTokenBodyAsync(context); var root = body.RootElement; @@ -73,18 +72,16 @@ public class IdentityServerTests : IClassFixture AssertUserDecryptionOptions(root); } - [Theory, BitAutoData] - public async Task TokenEndpoint_GrantTypePassword_NoAuthEmailHeader_Fails(string deviceId) + [Theory, BitAutoData, RegisterFinishRequestModelCustomize] + public async Task TokenEndpoint_GrantTypePassword_NoAuthEmailHeader_Fails( + RegisterFinishRequestModel requestModel) { - var username = "test+noauthemailheader@email.com"; + requestModel.Email = "test+noauthemailheader@email.com"; - await _factory.RegisterAsync(new RegisterRequestModel - { - Email = username, - MasterPasswordHash = "master_password_hash", - }); + var localFactory = new IdentityApplicationFactory(); + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); - var context = await PostLoginAsync(_factory.Server, username, deviceId, null); + var context = await PostLoginAsync(localFactory.Server, user, requestModel.MasterPasswordHash, null); Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); @@ -96,18 +93,17 @@ public class IdentityServerTests : IClassFixture AssertHelper.AssertJsonProperty(root, "error_description", JsonValueKind.String); } - [Theory, BitAutoData] - public async Task TokenEndpoint_GrantTypePassword_InvalidBase64AuthEmailHeader_Fails(string deviceId) + [Theory, BitAutoData, RegisterFinishRequestModelCustomize] + public async Task TokenEndpoint_GrantTypePassword_InvalidBase64AuthEmailHeader_Fails( + RegisterFinishRequestModel requestModel) { - var username = "test+badauthheader@email.com"; + requestModel.Email = "test+badauthheader@email.com"; - await _factory.RegisterAsync(new RegisterRequestModel - { - Email = username, - MasterPasswordHash = "master_password_hash", - }); + var localFactory = new IdentityApplicationFactory(); + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); - var context = await PostLoginAsync(_factory.Server, username, deviceId, context => context.Request.Headers.Append("Auth-Email", "bad_value")); + var context = await PostLoginAsync(localFactory.Server, user, requestModel.MasterPasswordHash, + context => context.Request.Headers.Append("Auth-Email", "bad_value")); Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); @@ -119,18 +115,17 @@ public class IdentityServerTests : IClassFixture AssertHelper.AssertJsonProperty(root, "error_description", JsonValueKind.String); } - [Theory, BitAutoData] - public async Task TokenEndpoint_GrantTypePassword_WrongAuthEmailHeader_Fails(string deviceId) + [Theory, BitAutoData, RegisterFinishRequestModelCustomize] + public async Task TokenEndpoint_GrantTypePassword_WrongAuthEmailHeader_Fails( + RegisterFinishRequestModel requestModel) { - var username = "test+badauthheader@email.com"; + requestModel.Email = "test+badauthheader@email.com"; - await _factory.RegisterAsync(new RegisterRequestModel - { - Email = username, - MasterPasswordHash = "master_password_hash", - }); + var localFactory = new IdentityApplicationFactory(); + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); - var context = await PostLoginAsync(_factory.Server, username, deviceId, context => context.SetAuthEmail("bad_value")); + var context = await PostLoginAsync(localFactory.Server, user, requestModel.MasterPasswordHash, + context => context.SetAuthEmail("bad_value")); Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); @@ -142,215 +137,198 @@ public class IdentityServerTests : IClassFixture AssertHelper.AssertJsonProperty(root, "error_description", JsonValueKind.String); } - [Theory] + [Theory, RegisterFinishRequestModelCustomize] [BitAutoData(OrganizationUserType.Owner)] [BitAutoData(OrganizationUserType.Admin)] [BitAutoData(OrganizationUserType.User)] [BitAutoData(OrganizationUserType.Custom)] - public async Task TokenEndpoint_GrantTypePassword_WithAllUserTypes_WithSsoPolicyDisabled_WithEnforceSsoPolicyForAllUsersTrue_Success(OrganizationUserType organizationUserType, Guid organizationId, string deviceId, int generatedUsername) + public async Task TokenEndpoint_GrantTypePassword_WithAllUserTypes_WithSsoPolicyDisabled_WithEnforceSsoPolicyForAllUsersTrue_Success( + OrganizationUserType organizationUserType, RegisterFinishRequestModel requestModel, Guid organizationId, int generatedUsername) { - var username = $"{generatedUsername}@example.com"; + requestModel.Email = $"{generatedUsername}@example.com"; - var server = _factory.WithWebHostBuilder(builder => + var localFactory = new IdentityApplicationFactory(); + var server = localFactory.WithWebHostBuilder(builder => { builder.UseSetting("globalSettings:sso:enforceSsoPolicyForAllUsers", "true"); }).Server; + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); - await server.PostAsync("/accounts/register", JsonContent.Create(new RegisterRequestModel - { - Email = username, - MasterPasswordHash = "master_password_hash" - })); + await CreateOrganizationWithSsoPolicyAsync(localFactory, + organizationId, user.Email, organizationUserType, ssoPolicyEnabled: false); - await CreateOrganizationWithSsoPolicyAsync(organizationId, username, organizationUserType, ssoPolicyEnabled: false); - - var context = await PostLoginAsync(server, username, deviceId, context => context.SetAuthEmail(username)); + var context = await PostLoginAsync(server, user, requestModel.MasterPasswordHash, + context => context.SetAuthEmail(user.Email)); Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); } - [Theory] + [Theory, RegisterFinishRequestModelCustomize] [BitAutoData(OrganizationUserType.Owner)] [BitAutoData(OrganizationUserType.Admin)] [BitAutoData(OrganizationUserType.User)] [BitAutoData(OrganizationUserType.Custom)] - public async Task TokenEndpoint_GrantTypePassword_WithAllUserTypes_WithSsoPolicyDisabled_WithEnforceSsoPolicyForAllUsersFalse_Success(OrganizationUserType organizationUserType, Guid organizationId, string deviceId, int generatedUsername) + public async Task TokenEndpoint_GrantTypePassword_WithAllUserTypes_WithSsoPolicyDisabled_WithEnforceSsoPolicyForAllUsersFalse_Success( + OrganizationUserType organizationUserType, RegisterFinishRequestModel requestModel, Guid organizationId, int generatedUsername) { - var username = $"{generatedUsername}@example.com"; + requestModel.Email = $"{generatedUsername}@example.com"; - var server = _factory.WithWebHostBuilder(builder => + var localFactory = new IdentityApplicationFactory(); + var server = localFactory.WithWebHostBuilder(builder => { builder.UseSetting("globalSettings:sso:enforceSsoPolicyForAllUsers", "false"); + }).Server; + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); - await server.PostAsync("/accounts/register", JsonContent.Create(new RegisterRequestModel - { - Email = username, - MasterPasswordHash = "master_password_hash" - })); + await CreateOrganizationWithSsoPolicyAsync( + localFactory, organizationId, user.Email, organizationUserType, ssoPolicyEnabled: false); - await CreateOrganizationWithSsoPolicyAsync(organizationId, username, organizationUserType, ssoPolicyEnabled: false); - - var context = await PostLoginAsync(server, username, deviceId, context => context.SetAuthEmail(username)); + var context = await PostLoginAsync(server, user, requestModel.MasterPasswordHash, + context => context.SetAuthEmail(user.Email)); Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); } - [Theory] + [Theory, RegisterFinishRequestModelCustomize] [BitAutoData(OrganizationUserType.Owner)] [BitAutoData(OrganizationUserType.Admin)] [BitAutoData(OrganizationUserType.User)] [BitAutoData(OrganizationUserType.Custom)] - public async Task TokenEndpoint_GrantTypePassword_WithAllUserTypes_WithSsoPolicyEnabled_WithEnforceSsoPolicyForAllUsersTrue_Throw(OrganizationUserType organizationUserType, Guid organizationId, string deviceId, int generatedUsername) + public async Task TokenEndpoint_GrantTypePassword_WithAllUserTypes_WithSsoPolicyEnabled_WithEnforceSsoPolicyForAllUsersTrue_Throw( + OrganizationUserType organizationUserType, RegisterFinishRequestModel requestModel, Guid organizationId, int generatedUsername) { - var username = $"{generatedUsername}@example.com"; + requestModel.Email = $"{generatedUsername}@example.com"; - var server = _factory.WithWebHostBuilder(builder => + var localFactory = new IdentityApplicationFactory(); + var server = localFactory.WithWebHostBuilder(builder => { builder.UseSetting("globalSettings:sso:enforceSsoPolicyForAllUsers", "true"); }).Server; + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); - await server.PostAsync("/accounts/register", JsonContent.Create(new RegisterRequestModel - { - Email = username, - MasterPasswordHash = "master_password_hash" - })); + await CreateOrganizationWithSsoPolicyAsync(localFactory, organizationId, user.Email, organizationUserType, ssoPolicyEnabled: true); - await CreateOrganizationWithSsoPolicyAsync(organizationId, username, organizationUserType, ssoPolicyEnabled: true); - - var context = await PostLoginAsync(server, username, deviceId, context => context.SetAuthEmail(username)); + var context = await PostLoginAsync(server, user, requestModel.MasterPasswordHash, + context => context.SetAuthEmail(user.Email)); Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); await AssertRequiredSsoAuthenticationResponseAsync(context); } - [Theory] + [Theory, RegisterFinishRequestModelCustomize] [BitAutoData(OrganizationUserType.Owner)] [BitAutoData(OrganizationUserType.Admin)] - public async Task TokenEndpoint_GrantTypePassword_WithOwnerOrAdmin_WithSsoPolicyEnabled_WithEnforceSsoPolicyForAllUsersFalse_Success(OrganizationUserType organizationUserType, Guid organizationId, string deviceId, int generatedUsername) + public async Task TokenEndpoint_GrantTypePassword_WithOwnerOrAdmin_WithSsoPolicyEnabled_WithEnforceSsoPolicyForAllUsersFalse_Success( + OrganizationUserType organizationUserType, RegisterFinishRequestModel requestModel, Guid organizationId, int generatedUsername) { - var username = $"{generatedUsername}@example.com"; + requestModel.Email = $"{generatedUsername}@example.com"; - var server = _factory.WithWebHostBuilder(builder => + var localFactory = new IdentityApplicationFactory(); + var server = localFactory.WithWebHostBuilder(builder => { builder.UseSetting("globalSettings:sso:enforceSsoPolicyForAllUsers", "false"); + }).Server; + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); - await server.PostAsync("/accounts/register", JsonContent.Create(new RegisterRequestModel - { - Email = username, - MasterPasswordHash = "master_password_hash" - })); + await CreateOrganizationWithSsoPolicyAsync(localFactory, organizationId, user.Email, organizationUserType, ssoPolicyEnabled: true); - await CreateOrganizationWithSsoPolicyAsync(organizationId, username, organizationUserType, ssoPolicyEnabled: true); - - var context = await PostLoginAsync(server, username, deviceId, context => context.SetAuthEmail(username)); + var context = await PostLoginAsync(server, user, requestModel.MasterPasswordHash, + context => context.SetAuthEmail(user.Email)); Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); } - [Theory] + [Theory, RegisterFinishRequestModelCustomize] [BitAutoData(OrganizationUserType.User)] [BitAutoData(OrganizationUserType.Custom)] - public async Task TokenEndpoint_GrantTypePassword_WithNonOwnerOrAdmin_WithSsoPolicyEnabled_WithEnforceSsoPolicyForAllUsersFalse_Throws(OrganizationUserType organizationUserType, Guid organizationId, string deviceId, int generatedUsername) + public async Task TokenEndpoint_GrantTypePassword_WithNonOwnerOrAdmin_WithSsoPolicyEnabled_WithEnforceSsoPolicyForAllUsersFalse_Throws( + OrganizationUserType organizationUserType, RegisterFinishRequestModel requestModel, Guid organizationId, int generatedUsername) { - var username = $"{generatedUsername}@example.com"; + requestModel.Email = $"{generatedUsername}@example.com"; - var server = _factory.WithWebHostBuilder(builder => + var localFactory = new IdentityApplicationFactory(); + var server = localFactory.WithWebHostBuilder(builder => { builder.UseSetting("globalSettings:sso:enforceSsoPolicyForAllUsers", "false"); + }).Server; + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); - await server.PostAsync("/accounts/register", JsonContent.Create(new RegisterRequestModel - { - Email = username, - MasterPasswordHash = "master_password_hash" - })); + await CreateOrganizationWithSsoPolicyAsync(localFactory, organizationId, user.Email, organizationUserType, ssoPolicyEnabled: true); - await CreateOrganizationWithSsoPolicyAsync(organizationId, username, organizationUserType, ssoPolicyEnabled: true); - - var context = await PostLoginAsync(server, username, deviceId, context => context.SetAuthEmail(username)); + var context = await PostLoginAsync(server, user, requestModel.MasterPasswordHash, + context => context.SetAuthEmail(user.Email)); Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); await AssertRequiredSsoAuthenticationResponseAsync(context); } - [Theory, BitAutoData] - public async Task TokenEndpoint_GrantTypeRefreshToken_Success(string deviceId) + [Theory, BitAutoData, RegisterFinishRequestModelCustomize] + public async Task TokenEndpoint_GrantTypeRefreshToken_Success(RegisterFinishRequestModel requestModel) { - var username = "test+tokenrefresh@email.com"; + var localFactory = new IdentityApplicationFactory(); - await _factory.RegisterAsync(new RegisterRequestModel - { - Email = username, - MasterPasswordHash = "master_password_hash", - }); + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); - var (_, refreshToken) = await _factory.TokenFromPasswordAsync(username, "master_password_hash", deviceId); + var (_, refreshToken) = await localFactory.TokenFromPasswordAsync( + requestModel.Email, requestModel.MasterPasswordHash); - var context = await _factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary - { - { "grant_type", "refresh_token" }, - { "client_id", "web" }, - { "refresh_token", refreshToken }, - })); + var context = await localFactory.Server.PostAsync("/connect/token", + new FormUrlEncodedContent(new Dictionary + { + { "grant_type", "refresh_token" }, + { "client_id", "web" }, + { "refresh_token", refreshToken }, + })); using var body = await AssertDefaultTokenBodyAsync(context); AssertRefreshTokenExists(body.RootElement); } - [Theory, BitAutoData] - public async Task TokenEndpoint_GrantTypeClientCredentials_Success(string deviceId) + [Theory, BitAutoData, RegisterFinishRequestModelCustomize] + public async Task TokenEndpoint_GrantTypeClientCredentials_Success(RegisterFinishRequestModel model) { - var username = "test+tokenclientcredentials@email.com"; + var localFactory = new IdentityApplicationFactory(); + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(model); - await _factory.RegisterAsync(new RegisterRequestModel - { - Email = username, - MasterPasswordHash = "master_password_hash", - }); - - var database = _factory.GetDatabaseContext(); - var user = await database.Users - .FirstAsync(u => u.Email == username); - - var context = await _factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary - { - { "grant_type", "client_credentials" }, - { "client_id", $"user.{user.Id}" }, - { "client_secret", user.ApiKey }, - { "scope", "api" }, - { "DeviceIdentifier", deviceId }, - { "DeviceType", DeviceTypeAsString(DeviceType.FirefoxBrowser) }, - { "DeviceName", "firefox" }, - })); + var context = await localFactory.Server.PostAsync("/connect/token", + new FormUrlEncodedContent(new Dictionary + { + { "grant_type", "client_credentials" }, + { "client_id", $"user.{user.Id}" }, + { "client_secret", user.ApiKey }, + { "scope", "api" }, + { "DeviceIdentifier", IdentityApplicationFactory.DefaultDeviceIdentifier }, + { "DeviceType", DeviceTypeAsString(DeviceType.FirefoxBrowser) }, + { "DeviceName", "firefox" }, + }) + ); await AssertDefaultTokenBodyAsync(context, "api"); } - [Theory, BitAutoData] - public async Task TokenEndpoint_GrantTypeClientCredentials_AsLegacyUser_NotOnWebClient_Fails(string deviceId) + [Theory, BitAutoData, RegisterFinishRequestModelCustomize] + public async Task TokenEndpoint_GrantTypeClientCredentials_AsLegacyUser_NotOnWebClient_Fails( + RegisterFinishRequestModel model, + string deviceId) { - var server = _factory.WithWebHostBuilder(builder => + var localFactory = new IdentityApplicationFactory(); + var server = localFactory.WithWebHostBuilder(builder => { builder.UseSetting("globalSettings:launchDarkly:flagValues:block-legacy-users", "true"); }).Server; - var username = "test+tokenclientcredentials@email.com"; + model.Email = "test+tokenclientcredentials@email.com"; + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(model); - - await server.PostAsync("/accounts/register", JsonContent.Create(new RegisterRequestModel - { - Email = username, - MasterPasswordHash = "master_password_hash" - })); - - - var database = _factory.GetDatabaseContext(); - var user = await database.Users - .FirstAsync(u => u.Email == username); - - user.PrivateKey = "EncryptedPrivateKey"; + // Modify user to be legacy user. We have to fetch the user again to put it in the ef-context + // so when we modify change tracking will save the changes. + var database = localFactory.GetDatabaseContext(); + user = await database.Users + .FirstAsync(u => u.Email == model.Email); + user.Key = null; await database.SaveChangesAsync(); var context = await server.PostAsync("/connect/token", new FormUrlEncodedContent( @@ -362,9 +340,9 @@ public class IdentityServerTests : IClassFixture { "deviceIdentifier", deviceId }, { "deviceName", "chrome" }, { "grant_type", "password" }, - { "username", username }, - { "password", "master_password_hash" }, - }), context => context.SetAuthEmail(username)); + { "username", model.Email }, + { "password", model.MasterPasswordHash }, + }), context => context.SetAuthEmail(model.Email)); Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); @@ -535,23 +513,21 @@ public class IdentityServerTests : IClassFixture Assert.Equal("invalid_client", error); } - [Theory, BitAutoData] - public async Task TokenEndpoint_TooQuickInOneSecond_BlockRequest(string deviceId) + [Theory, BitAutoData, RegisterFinishRequestModelCustomize] + public async Task TokenEndpoint_TooQuickInOneSecond_BlockRequest( + RegisterFinishRequestModel requestModel) { const int AmountInOneSecondAllowed = 10; // The rule we are testing is 10 requests in 1 second - var username = "test+ratelimiting@email.com"; + requestModel.Email = "test+ratelimiting@email.com"; - await _factory.RegisterAsync(new RegisterRequestModel - { - Email = username, - MasterPasswordHash = "master_password_hash", - }); + var localFactory = new IdentityApplicationFactory(); + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); - var database = _factory.GetDatabaseContext(); - var user = await database.Users - .FirstAsync(u => u.Email == username); + var database = localFactory.GetDatabaseContext(); + user = await database.Users + .FirstAsync(u => u.Email == user.Email); var tasks = new Task[AmountInOneSecondAllowed + 1]; @@ -573,36 +549,40 @@ public class IdentityServerTests : IClassFixture { "scope", "api offline_access" }, { "client_id", "web" }, { "deviceType", DeviceTypeAsString(DeviceType.FirefoxBrowser) }, - { "deviceIdentifier", deviceId }, + { "deviceIdentifier", IdentityApplicationFactory.DefaultDeviceIdentifier }, { "deviceName", "firefox" }, { "grant_type", "password" }, - { "username", username }, + { "username", user.Email}, { "password", "master_password_hash" }, - }), context => context.SetAuthEmail(username).SetIp("1.1.1.2")); + }), context => context.SetAuthEmail(user.Email).SetIp("1.1.1.2")); } } - private async Task PostLoginAsync(TestServer server, string username, string deviceId, Action extraConfiguration) + private async Task PostLoginAsync( + TestServer server, User user, string MasterPasswordHash, Action extraConfiguration) { return await server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary { { "scope", "api offline_access" }, { "client_id", "web" }, { "deviceType", DeviceTypeAsString(DeviceType.FirefoxBrowser) }, - { "deviceIdentifier", deviceId }, + { "deviceIdentifier", IdentityApplicationFactory.DefaultDeviceIdentifier }, { "deviceName", "firefox" }, { "grant_type", "password" }, - { "username", username }, - { "password", "master_password_hash" }, + { "username", user.Email }, + { "password", MasterPasswordHash }, }), extraConfiguration); } - private async Task CreateOrganizationWithSsoPolicyAsync(Guid organizationId, string username, OrganizationUserType organizationUserType, bool ssoPolicyEnabled) + private async Task CreateOrganizationWithSsoPolicyAsync( + IdentityApplicationFactory localFactory, + Guid organizationId, + string username, OrganizationUserType organizationUserType, bool ssoPolicyEnabled) { - var userRepository = _factory.Services.GetService(); - var organizationRepository = _factory.Services.GetService(); - var organizationUserRepository = _factory.Services.GetService(); - var policyRepository = _factory.Services.GetService(); + var userRepository = localFactory.Services.GetService(); + var organizationRepository = localFactory.Services.GetService(); + var organizationUserRepository = localFactory.Services.GetService(); + var policyRepository = localFactory.Services.GetService(); var organization = new Organization { @@ -617,7 +597,7 @@ public class IdentityServerTests : IClassFixture await organizationRepository.CreateAsync(organization); var user = await userRepository.GetByEmailAsync(username); - var organizationUser = new Bit.Core.Entities.OrganizationUser + var organizationUser = new OrganizationUser { OrganizationId = organization.Id, UserId = user.Id, @@ -703,9 +683,9 @@ public class IdentityServerTests : IClassFixture (prop) => { Assert.Equal("Object", prop.Name); Assert.Equal("userDecryptionOptions", prop.Value.GetString()); }); } - private void ReinitializeDbForTests() + private void ReinitializeDbForTests(IdentityApplicationFactory factory) { - var databaseContext = _factory.GetDatabaseContext(); + var databaseContext = factory.GetDatabaseContext(); databaseContext.Policies.RemoveRange(databaseContext.Policies); databaseContext.OrganizationUsers.RemoveRange(databaseContext.OrganizationUsers); databaseContext.Organizations.RemoveRange(databaseContext.Organizations); diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs index 6f0ef20295..82c6b13aad 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs @@ -1,8 +1,11 @@ using System.Security.Claims; +using System.Text; using System.Text.Json; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; using Bit.Core.Entities; @@ -11,7 +14,6 @@ using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; -using Bit.Identity.Models.Request.Accounts; using Bit.IntegrationTestCommon.Factories; using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.Helpers; @@ -19,6 +21,7 @@ using Duende.IdentityModel; using Duende.IdentityServer.Models; using Duende.IdentityServer.Stores; using LinqToDB; +using Microsoft.Extensions.Caching.Distributed; using NSubstitute; using Xunit; @@ -61,19 +64,14 @@ public class IdentityServerTwoFactorTests : IClassFixture(mailService => + // return specified email token from cache + var emailToken = "12345678"; + factory.SubstituteService(distCache => { - mailService.SendTwoFactorEmailAsync( - Arg.Any(), - Arg.Any(), - Arg.Do(t => emailToken = t), - Arg.Any(), - Arg.Any()) - .Returns(Task.CompletedTask); + distCache.GetAsync(Arg.Is(s => s.StartsWith("EmailToken_"))) + .Returns(Task.FromResult(Encoding.UTF8.GetBytes(emailToken))); }); // Create Test User @@ -102,10 +100,11 @@ public class IdentityServerTwoFactorTests : IClassFixture + var context = await localFactory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary { { "scope", "api offline_access" }, { "client_id", "web" }, @@ -156,10 +156,11 @@ public class IdentityServerTwoFactorTests : IClassFixture u.Email == _testEmail); // Act - var context = await _factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary + var context = await localFactory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary { { "grant_type", "client_credentials" }, { "client_id", $"user.{user.Id}" }, @@ -275,16 +277,13 @@ public class IdentityServerTwoFactorTests : IClassFixture(mailService => + + // return specified email token from cache + var emailToken = "12345678"; + localFactory.SubstituteService(distCache => { - mailService.SendTwoFactorEmailAsync( - Arg.Any(), - Arg.Any(), - Arg.Do(t => emailToken = t), - Arg.Any(), - Arg.Any()) - .Returns(Task.CompletedTask); + distCache.GetAsync(Arg.Is(s => s.StartsWith("EmailToken_"))) + .Returns(Task.FromResult(Encoding.UTF8.GetBytes(emailToken))); }); // Create Test User @@ -379,17 +378,24 @@ public class IdentityServerTwoFactorTests : IClassFixture(); - var user = await userRepository.GetByEmailAsync(testEmail); + var user = await factory.RegisterNewIdentityFactoryUserAsync( + new RegisterFinishRequestModel + { + Email = testEmail, + MasterPasswordHash = _testPassword, + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + UserAsymmetricKeys = new KeysRequestModel() + { + PublicKey = "public_key", + EncryptedPrivateKey = "private_key" + }, + UserSymmetricKey = "sym_key", + }); Assert.NotNull(user); var userService = factory.GetService(); + var userRepository = factory.Services.GetRequiredService(); if (userTwoFactor != null) { user.TwoFactorProviders = userTwoFactor; @@ -426,16 +432,20 @@ public class IdentityServerTwoFactorTests : IClassFixture(); - var user = await userRepository.GetByEmailAsync(testEmail); - Assert.NotNull(user); + var user = await factory.RegisterNewIdentityFactoryUserAsync( + new RegisterFinishRequestModel + { + Email = testEmail, + MasterPasswordHash = _testPassword, + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + UserAsymmetricKeys = new KeysRequestModel() + { + PublicKey = "public_key", + EncryptedPrivateKey = "private_key" + }, + UserSymmetricKey = "sym_key", + }); var userService = factory.GetService(); if (userTwoFactor != null) diff --git a/test/Identity.IntegrationTest/Identity.IntegrationTest.csproj b/test/Identity.IntegrationTest/Identity.IntegrationTest.csproj index d7a7bb9a01..5c94fad1d1 100644 --- a/test/Identity.IntegrationTest/Identity.IntegrationTest.csproj +++ b/test/Identity.IntegrationTest/Identity.IntegrationTest.csproj @@ -24,6 +24,7 @@ + diff --git a/test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs b/test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs index 4bec8d8167..9a1b8141ae 100644 --- a/test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs @@ -1,11 +1,11 @@ using System.Text.Json; +using Bit.Core; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; -using Bit.Core.Services; -using Bit.Identity.Models.Request.Accounts; using Bit.IntegrationTestCommon.Factories; using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.Helpers; @@ -19,28 +19,16 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture _userManager; - private readonly IAuthRequestRepository _authRequestRepository; - private readonly IDeviceService _deviceService; - - public ResourceOwnerPasswordValidatorTests(IdentityApplicationFactory factory) - { - _factory = factory; - - _userManager = _factory.GetService>(); - _authRequestRepository = _factory.GetService(); - _deviceService = _factory.GetService(); - } [Fact] public async Task ValidateAsync_Success() { // Arrange - await EnsureUserCreatedAsync(); + var localFactory = new IdentityApplicationFactory(); + await EnsureUserCreatedAsync(localFactory); // Act - var context = await _factory.Server.PostAsync("/connect/token", + var context = await localFactory.Server.PostAsync("/connect/token", GetFormUrlEncodedContent(), context => context.SetAuthEmail(DefaultUsername)); @@ -56,10 +44,11 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture context.SetAuthEmail(username)); @@ -105,13 +96,16 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture>(); // Verify the User is not null to ensure the failure is due to bad password - Assert.NotNull(await _userManager.FindByEmailAsync(DefaultUsername)); + Assert.NotNull(await userManager.FindByEmailAsync(DefaultUsername)); // Act - var context = await _factory.Server.PostAsync("/connect/token", + var context = await localFactory.Server.PostAsync("/connect/token", GetFormUrlEncodedContent(password: badPassword), context => context.SetAuthEmail(DefaultUsername)); @@ -128,9 +122,12 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture>(); + var user = await userManager.FindByEmailAsync(DefaultUsername); Assert.NotNull(user); // Connect Request to User and set CreationDate @@ -139,13 +136,14 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture(); + await authRequestRepository.CreateAsync(authRequest); - var expectedAuthRequest = await _authRequestRepository.GetManyByUserIdAsync(user.Id); + var expectedAuthRequest = await authRequestRepository.GetManyByUserIdAsync(user.Id); Assert.NotEmpty(expectedAuthRequest); // Act - var context = await _factory.Server.PostAsync("/connect/token", + var context = await localFactory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary { { "scope", "api offline_access" }, @@ -171,9 +169,12 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture>(); + + var user = await userManager.FindByEmailAsync(DefaultUsername); Assert.NotNull(user); // Create AuthRequest @@ -184,7 +185,7 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture { { "scope", "api offline_access" }, @@ -214,19 +215,23 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture(), passwordHash, token, userGuid) - .Returns(Task.FromResult(IdentityResult.Success)); - var request = new RegisterRequestModel - { - Name = "Example User", - Email = "user@example.com", - MasterPasswordHash = passwordHash, - MasterPasswordHint = "example", - Token = token, - OrganizationUserId = userGuid - }; - - await _sut.PostRegister(request); - - await _registerUserCommand.Received(1).RegisterUserViaOrganizationInviteToken(Arg.Any(), passwordHash, token, userGuid); - } - - [Fact] - public async Task PostRegister_WhenUserServiceFails_ShouldThrowBadRequestException() - { - var passwordHash = "abcdef"; - var token = "123456"; - var userGuid = new Guid(); - _registerUserCommand.RegisterUserViaOrganizationInviteToken(Arg.Any(), passwordHash, token, userGuid) - .Returns(Task.FromResult(IdentityResult.Failed())); - var request = new RegisterRequestModel - { - Name = "Example User", - Email = "user@example.com", - MasterPasswordHash = passwordHash, - MasterPasswordHint = "example", - Token = token, - OrganizationUserId = userGuid - }; - - await Assert.ThrowsAsync(() => _sut.PostRegister(request)); - } - [Theory] [BitAutoData] public async Task PostRegisterSendEmailVerification_WhenTokenReturnedFromCommand_Returns200WithToken(string email, string name, bool receiveMarketingEmails) diff --git a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs index b69a93013b..a686605836 100644 --- a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs +++ b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs @@ -1,23 +1,51 @@ -using System.Net.Http.Json; +using System.Collections.Concurrent; +using System.Net.Http.Json; using System.Text.Json; using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Services; using Bit.Core.Utilities; using Bit.Identity; -using Bit.Identity.Models.Request.Accounts; using Bit.Test.Common.Helpers; using HandlebarsDotNet; +using LinqToDB; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using NSubstitute; +using Xunit; namespace Bit.IntegrationTestCommon.Factories; public class IdentityApplicationFactory : WebApplicationFactoryBase { public const string DefaultDeviceIdentifier = "92b9d953-b9b6-4eaf-9d3e-11d57144dfeb"; + public const string DefaultUserEmail = "DefaultEmail@bitwarden.com"; + public const string DefaultUserPasswordHash = "default_password_hash"; - public async Task RegisterAsync(RegisterRequestModel model) + /// + /// A dictionary to store registration tokens for email verification. We cannot substitute the IMailService more than once, so + /// we capture the email tokens for new user registration in the constructor. The email must be unique otherwise an error will be thrown. + /// + public ConcurrentDictionary RegistrationTokens { get; private set; } = new ConcurrentDictionary(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) { - return await Server.PostAsync("/accounts/register", JsonContent.Create(model)); + // This allows us to use the official registration flow + SubstituteService(service => + { + service.SendRegistrationVerificationEmailAsync(Arg.Any(), Arg.Any()) + .ReturnsForAnyArgs(Task.CompletedTask) + .AndDoes(call => + { + if (!RegistrationTokens.TryAdd(call.ArgAt(0), call.ArgAt(1))) + { + throw new InvalidOperationException("This email was already registered for new user registration."); + } + }); + }); + + base.ConfigureWebHost(builder); } public async Task PostRegisterSendEmailVerificationAsync(RegisterSendVerificationEmailRequestModel model) @@ -155,4 +183,42 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase })); return context; } + + /// + /// Registers a new user to the Identity Application Factory based on the RegisterFinishRequestModel + /// + /// RegisterFinishRequestModel needed to seed data to the test user + /// optional parameter that is tracked during the inital steps of registration. + /// returns the newly created user + public async Task RegisterNewIdentityFactoryUserAsync( + RegisterFinishRequestModel requestModel, + bool marketingEmails = true) + { + var sendVerificationEmailReqModel = new RegisterSendVerificationEmailRequestModel + { + Email = requestModel.Email, + Name = "name", + ReceiveMarketingEmails = marketingEmails + }; + + var sendEmailVerificationResponseHttpContext = await PostRegisterSendEmailVerificationAsync(sendVerificationEmailReqModel); + + Assert.Equal(StatusCodes.Status204NoContent, sendEmailVerificationResponseHttpContext.Response.StatusCode); + Assert.NotNull(RegistrationTokens[requestModel.Email]); + + // Now we call the finish registration endpoint with the email verification token + requestModel.EmailVerificationToken = RegistrationTokens[requestModel.Email]; + + var postRegisterFinishHttpContext = await PostRegisterFinishAsync(requestModel); + + Assert.Equal(StatusCodes.Status200OK, postRegisterFinishHttpContext.Response.StatusCode); + + var database = GetDatabaseContext(); + var user = await database.Users + .SingleAsync(u => u.Email == requestModel.Email); + + Assert.NotNull(user); + + return user; + } } From 49bae6c241d96953d5658e5ffb34aa5eae41c83b Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Wed, 16 Apr 2025 15:38:09 -0700 Subject: [PATCH 35/45] [PM-10611] Add EndUserNotifications feature flag (#5663) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 44962cd0ab..5f3e954a46 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -203,6 +203,7 @@ public static class FeatureFlagKeys public const string CipherKeyEncryption = "cipher-key-encryption"; public const string DesktopCipherForms = "pm-18520-desktop-cipher-forms"; public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk"; + public const string EndUserNotifications = "pm-10609-end-user-notifications"; public static List GetAllKeys() { From ca29cda9ed332f091f999cd53c394acc8abaf141 Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Thu, 17 Apr 2025 08:45:05 -0400 Subject: [PATCH 36/45] [PM-17830] Force Admin Initiated Sponsorships migration script to run in QA (#5662) * Copy and pasted scripts for admin initiated sponsorship to force migration in QA * Include idempotency to ensure columns are correct if prior version of this script added them already without default value * Ensure this script works if the default constraints already exist --- ...-16_00_AddUseAdminInitiatedSponsorship.sql | 571 ++++++++++++++++++ 1 file changed, 571 insertions(+) create mode 100644 util/Migrator/DbScripts/2025-04-16_00_AddUseAdminInitiatedSponsorship.sql diff --git a/util/Migrator/DbScripts/2025-04-16_00_AddUseAdminInitiatedSponsorship.sql b/util/Migrator/DbScripts/2025-04-16_00_AddUseAdminInitiatedSponsorship.sql new file mode 100644 index 0000000000..73b37ce969 --- /dev/null +++ b/util/Migrator/DbScripts/2025-04-16_00_AddUseAdminInitiatedSponsorship.sql @@ -0,0 +1,571 @@ +IF EXISTS (SELECT * FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[Organization]') AND name = 'UseAdminSponsoredFamilies') +BEGIN + -- First drop the default constraint + DECLARE @ConstraintName nvarchar(200) + SELECT @ConstraintName = name FROM sys.default_constraints + WHERE parent_object_id = OBJECT_ID(N'[dbo].[Organization]') + AND parent_column_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[Organization]') AND name = 'UseAdminSponsoredFamilies') + + IF @ConstraintName IS NOT NULL + EXEC('ALTER TABLE [dbo].[Organization] DROP CONSTRAINT ' + @ConstraintName) + + -- Then drop the column + ALTER TABLE [dbo].[Organization] DROP COLUMN [UseAdminSponsoredFamilies] +END +GO; + +ALTER TABLE [dbo].[Organization] ADD [UseAdminSponsoredFamilies] bit NOT NULL CONSTRAINT [DF_Organization_UseAdminSponsoredFamilies] default (0) +GO; + +IF EXISTS (SELECT * FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[OrganizationSponsorship]') AND name = 'IsAdminInitiated') +BEGIN + -- First drop the default constraint + DECLARE @ConstraintName nvarchar(200) + SELECT @ConstraintName = name FROM sys.default_constraints + WHERE parent_object_id = OBJECT_ID(N'[dbo].[OrganizationSponsorship]') + AND parent_column_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[OrganizationSponsorship]') AND name = 'IsAdminInitiated') + + IF @ConstraintName IS NOT NULL + EXEC('ALTER TABLE [dbo].[OrganizationSponsorship] DROP CONSTRAINT ' + @ConstraintName) + + -- Then drop the column + ALTER TABLE [dbo].[OrganizationSponsorship] DROP COLUMN [IsAdminInitiated] +END +GO; + +ALTER TABLE [dbo].[OrganizationSponsorship] ADD [IsAdminInitiated] BIT CONSTRAINT [DF_OrganizationSponsorship_IsAdminInitiated] DEFAULT (0) NOT NULL +GO; + +IF EXISTS (SELECT * FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[OrganizationSponsorship]') AND name = 'Notes') +BEGIN + -- Notes column doesn't have a default constraint, so we can just drop it + ALTER TABLE [dbo].[OrganizationSponsorship] DROP COLUMN [Notes] +END +GO; + +ALTER TABLE [dbo].[OrganizationSponsorship] ADD [Notes] NVARCHAR(512) NULL +GO; + +CREATE OR ALTER PROCEDURE [dbo].[Organization_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @Identifier NVARCHAR(50), + @Name NVARCHAR(50), + @BusinessName NVARCHAR(50), + @BusinessAddress1 NVARCHAR(50), + @BusinessAddress2 NVARCHAR(50), + @BusinessAddress3 NVARCHAR(50), + @BusinessCountry VARCHAR(2), + @BusinessTaxNumber NVARCHAR(30), + @BillingEmail NVARCHAR(256), + @Plan NVARCHAR(50), + @PlanType TINYINT, + @Seats INT, + @MaxCollections SMALLINT, + @UsePolicies BIT, + @UseSso BIT, + @UseGroups BIT, + @UseDirectory BIT, + @UseEvents BIT, + @UseTotp BIT, + @Use2fa BIT, + @UseApi BIT, + @UseResetPassword BIT, + @SelfHost BIT, + @UsersGetPremium BIT, + @Storage BIGINT, + @MaxStorageGb SMALLINT, + @Gateway TINYINT, + @GatewayCustomerId VARCHAR(50), + @GatewaySubscriptionId VARCHAR(50), + @ReferenceData VARCHAR(MAX), + @Enabled BIT, + @LicenseKey VARCHAR(100), + @PublicKey VARCHAR(MAX), + @PrivateKey VARCHAR(MAX), + @TwoFactorProviders NVARCHAR(MAX), + @ExpirationDate DATETIME2(7), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @OwnersNotifiedOfAutoscaling DATETIME2(7), + @MaxAutoscaleSeats INT, + @UseKeyConnector BIT = 0, + @UseScim BIT = 0, + @UseCustomPermissions BIT = 0, + @UseSecretsManager BIT = 0, + @Status TINYINT = 0, + @UsePasswordManager BIT = 1, + @SmSeats INT = null, + @SmServiceAccounts INT = null, + @MaxAutoscaleSmSeats INT= null, + @MaxAutoscaleSmServiceAccounts INT = null, + @SecretsManagerBeta BIT = 0, + @LimitCollectionCreation BIT = NULL, + @LimitCollectionDeletion BIT = NULL, + @AllowAdminAccessToAllCollectionItems BIT = 0, + @UseRiskInsights BIT = 0, + @LimitItemDeletion BIT = 0, + @UseAdminSponsoredFamilies BIT = 0 +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[Organization] + ( + [Id], + [Identifier], + [Name], + [BusinessName], + [BusinessAddress1], + [BusinessAddress2], + [BusinessAddress3], + [BusinessCountry], + [BusinessTaxNumber], + [BillingEmail], + [Plan], + [PlanType], + [Seats], + [MaxCollections], + [UsePolicies], + [UseSso], + [UseGroups], + [UseDirectory], + [UseEvents], + [UseTotp], + [Use2fa], + [UseApi], + [UseResetPassword], + [SelfHost], + [UsersGetPremium], + [Storage], + [MaxStorageGb], + [Gateway], + [GatewayCustomerId], + [GatewaySubscriptionId], + [ReferenceData], + [Enabled], + [LicenseKey], + [PublicKey], + [PrivateKey], + [TwoFactorProviders], + [ExpirationDate], + [CreationDate], + [RevisionDate], + [OwnersNotifiedOfAutoscaling], + [MaxAutoscaleSeats], + [UseKeyConnector], + [UseScim], + [UseCustomPermissions], + [UseSecretsManager], + [Status], + [UsePasswordManager], + [SmSeats], + [SmServiceAccounts], + [MaxAutoscaleSmSeats], + [MaxAutoscaleSmServiceAccounts], + [SecretsManagerBeta], + [LimitCollectionCreation], + [LimitCollectionDeletion], + [AllowAdminAccessToAllCollectionItems], + [UseRiskInsights], + [LimitItemDeletion], + [UseAdminSponsoredFamilies] + ) + VALUES + ( + @Id, + @Identifier, + @Name, + @BusinessName, + @BusinessAddress1, + @BusinessAddress2, + @BusinessAddress3, + @BusinessCountry, + @BusinessTaxNumber, + @BillingEmail, + @Plan, + @PlanType, + @Seats, + @MaxCollections, + @UsePolicies, + @UseSso, + @UseGroups, + @UseDirectory, + @UseEvents, + @UseTotp, + @Use2fa, + @UseApi, + @UseResetPassword, + @SelfHost, + @UsersGetPremium, + @Storage, + @MaxStorageGb, + @Gateway, + @GatewayCustomerId, + @GatewaySubscriptionId, + @ReferenceData, + @Enabled, + @LicenseKey, + @PublicKey, + @PrivateKey, + @TwoFactorProviders, + @ExpirationDate, + @CreationDate, + @RevisionDate, + @OwnersNotifiedOfAutoscaling, + @MaxAutoscaleSeats, + @UseKeyConnector, + @UseScim, + @UseCustomPermissions, + @UseSecretsManager, + @Status, + @UsePasswordManager, + @SmSeats, + @SmServiceAccounts, + @MaxAutoscaleSmSeats, + @MaxAutoscaleSmServiceAccounts, + @SecretsManagerBeta, + @LimitCollectionCreation, + @LimitCollectionDeletion, + @AllowAdminAccessToAllCollectionItems, + @UseRiskInsights, + @LimitItemDeletion, + @UseAdminSponsoredFamilies + ) +END +GO; + +CREATE OR ALTER PROCEDURE [dbo].[Organization_ReadAbilities] +AS +BEGIN + SET NOCOUNT ON + +SELECT + [Id], + [UseEvents], + [Use2fa], + CASE + WHEN [Use2fa] = 1 AND [TwoFactorProviders] IS NOT NULL AND [TwoFactorProviders] != '{}' THEN + 1 + ELSE + 0 +END AS [Using2fa], + [UsersGetPremium], + [UseCustomPermissions], + [UseSso], + [UseKeyConnector], + [UseScim], + [UseResetPassword], + [UsePolicies], + [Enabled], + [LimitCollectionCreation], + [LimitCollectionDeletion], + [AllowAdminAccessToAllCollectionItems], + [UseRiskInsights], + [LimitItemDeletion], + [UseAdminSponsoredFamilies] + FROM + [dbo].[Organization] +END +GO; + +CREATE OR ALTER PROCEDURE [dbo].[Organization_Update] + @Id UNIQUEIDENTIFIER, + @Identifier NVARCHAR(50), + @Name NVARCHAR(50), + @BusinessName NVARCHAR(50), + @BusinessAddress1 NVARCHAR(50), + @BusinessAddress2 NVARCHAR(50), + @BusinessAddress3 NVARCHAR(50), + @BusinessCountry VARCHAR(2), + @BusinessTaxNumber NVARCHAR(30), + @BillingEmail NVARCHAR(256), + @Plan NVARCHAR(50), + @PlanType TINYINT, + @Seats INT, + @MaxCollections SMALLINT, + @UsePolicies BIT, + @UseSso BIT, + @UseGroups BIT, + @UseDirectory BIT, + @UseEvents BIT, + @UseTotp BIT, + @Use2fa BIT, + @UseApi BIT, + @UseResetPassword BIT, + @SelfHost BIT, + @UsersGetPremium BIT, + @Storage BIGINT, + @MaxStorageGb SMALLINT, + @Gateway TINYINT, + @GatewayCustomerId VARCHAR(50), + @GatewaySubscriptionId VARCHAR(50), + @ReferenceData VARCHAR(MAX), + @Enabled BIT, + @LicenseKey VARCHAR(100), + @PublicKey VARCHAR(MAX), + @PrivateKey VARCHAR(MAX), + @TwoFactorProviders NVARCHAR(MAX), + @ExpirationDate DATETIME2(7), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @OwnersNotifiedOfAutoscaling DATETIME2(7), + @MaxAutoscaleSeats INT, + @UseKeyConnector BIT = 0, + @UseScim BIT = 0, + @UseCustomPermissions BIT = 0, + @UseSecretsManager BIT = 0, + @Status TINYINT = 0, + @UsePasswordManager BIT = 1, + @SmSeats INT = null, + @SmServiceAccounts INT = null, + @MaxAutoscaleSmSeats INT = null, + @MaxAutoscaleSmServiceAccounts INT = null, + @SecretsManagerBeta BIT = 0, + @LimitCollectionCreation BIT = null, + @LimitCollectionDeletion BIT = null, + @AllowAdminAccessToAllCollectionItems BIT = 0, + @UseRiskInsights BIT = 0, + @LimitItemDeletion BIT = 0, + @UseAdminSponsoredFamilies BIT = 0 +AS +BEGIN + SET NOCOUNT ON + +UPDATE + [dbo].[Organization] +SET + [Identifier] = @Identifier, + [Name] = @Name, + [BusinessName] = @BusinessName, + [BusinessAddress1] = @BusinessAddress1, + [BusinessAddress2] = @BusinessAddress2, + [BusinessAddress3] = @BusinessAddress3, + [BusinessCountry] = @BusinessCountry, + [BusinessTaxNumber] = @BusinessTaxNumber, + [BillingEmail] = @BillingEmail, + [Plan] = @Plan, + [PlanType] = @PlanType, + [Seats] = @Seats, + [MaxCollections] = @MaxCollections, + [UsePolicies] = @UsePolicies, + [UseSso] = @UseSso, + [UseGroups] = @UseGroups, + [UseDirectory] = @UseDirectory, + [UseEvents] = @UseEvents, + [UseTotp] = @UseTotp, + [Use2fa] = @Use2fa, + [UseApi] = @UseApi, + [UseResetPassword] = @UseResetPassword, + [SelfHost] = @SelfHost, + [UsersGetPremium] = @UsersGetPremium, + [Storage] = @Storage, + [MaxStorageGb] = @MaxStorageGb, + [Gateway] = @Gateway, + [GatewayCustomerId] = @GatewayCustomerId, + [GatewaySubscriptionId] = @GatewaySubscriptionId, + [ReferenceData] = @ReferenceData, + [Enabled] = @Enabled, + [LicenseKey] = @LicenseKey, + [PublicKey] = @PublicKey, + [PrivateKey] = @PrivateKey, + [TwoFactorProviders] = @TwoFactorProviders, + [ExpirationDate] = @ExpirationDate, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate, + [OwnersNotifiedOfAutoscaling] = @OwnersNotifiedOfAutoscaling, + [MaxAutoscaleSeats] = @MaxAutoscaleSeats, + [UseKeyConnector] = @UseKeyConnector, + [UseScim] = @UseScim, + [UseCustomPermissions] = @UseCustomPermissions, + [UseSecretsManager] = @UseSecretsManager, + [Status] = @Status, + [UsePasswordManager] = @UsePasswordManager, + [SmSeats] = @SmSeats, + [SmServiceAccounts] = @SmServiceAccounts, + [MaxAutoscaleSmSeats] = @MaxAutoscaleSmSeats, + [MaxAutoscaleSmServiceAccounts] = @MaxAutoscaleSmServiceAccounts, + [SecretsManagerBeta] = @SecretsManagerBeta, + [LimitCollectionCreation] = @LimitCollectionCreation, + [LimitCollectionDeletion] = @LimitCollectionDeletion, + [AllowAdminAccessToAllCollectionItems] = @AllowAdminAccessToAllCollectionItems, + [UseRiskInsights] = @UseRiskInsights, + [LimitItemDeletion] = @LimitItemDeletion, + [UseAdminSponsoredFamilies] = @UseAdminSponsoredFamilies +WHERE + [Id] = @Id +END +GO; + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationSponsorship_Update] + @Id UNIQUEIDENTIFIER, + @SponsoringOrganizationId UNIQUEIDENTIFIER, + @SponsoringOrganizationUserID UNIQUEIDENTIFIER, + @SponsoredOrganizationId UNIQUEIDENTIFIER, + @FriendlyName NVARCHAR(256), + @OfferedToEmail NVARCHAR(256), + @PlanSponsorshipType TINYINT, + @ToDelete BIT, + @LastSyncDate DATETIME2 (7), + @ValidUntil DATETIME2 (7), + @IsAdminInitiated BIT = 0, + @Notes NVARCHAR(512) = NULL +AS +BEGIN + SET NOCOUNT ON + +UPDATE + [dbo].[OrganizationSponsorship] +SET + [SponsoringOrganizationId] = @SponsoringOrganizationId, + [SponsoringOrganizationUserID] = @SponsoringOrganizationUserID, + [SponsoredOrganizationId] = @SponsoredOrganizationId, + [FriendlyName] = @FriendlyName, + [OfferedToEmail] = @OfferedToEmail, + [PlanSponsorshipType] = @PlanSponsorshipType, + [ToDelete] = @ToDelete, + [LastSyncDate] = @LastSyncDate, + [ValidUntil] = @ValidUntil, + [IsAdminInitiated] = @IsAdminInitiated, + [Notes] = @Notes +WHERE + [Id] = @Id +END +GO; + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationSponsorship_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @SponsoringOrganizationId UNIQUEIDENTIFIER, + @SponsoringOrganizationUserID UNIQUEIDENTIFIER, + @SponsoredOrganizationId UNIQUEIDENTIFIER, + @FriendlyName NVARCHAR(256), + @OfferedToEmail NVARCHAR(256), + @PlanSponsorshipType TINYINT, + @ToDelete BIT, + @LastSyncDate DATETIME2 (7), + @ValidUntil DATETIME2 (7), + @IsAdminInitiated BIT = 0, + @Notes NVARCHAR(512) = NULL +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[OrganizationSponsorship] + ( + [Id], + [SponsoringOrganizationId], + [SponsoringOrganizationUserID], + [SponsoredOrganizationId], + [FriendlyName], + [OfferedToEmail], + [PlanSponsorshipType], + [ToDelete], + [LastSyncDate], + [ValidUntil], + [IsAdminInitiated], + [Notes] + ) + VALUES + ( + @Id, + @SponsoringOrganizationId, + @SponsoringOrganizationUserID, + @SponsoredOrganizationId, + @FriendlyName, + @OfferedToEmail, + @PlanSponsorshipType, + @ToDelete, + @LastSyncDate, + @ValidUntil, + @IsAdminInitiated, + @Notes + ) +END +GO; + +DROP PROCEDURE IF EXISTS [dbo].[OrganizationSponsorship_CreateMany]; +DROP PROCEDURE IF EXISTS [dbo].[OrganizationSponsorship_UpdateMany]; +DROP TYPE IF EXISTS [dbo].[OrganizationSponsorshipType] GO; + +CREATE TYPE [dbo].[OrganizationSponsorshipType] AS TABLE( + [Id] UNIQUEIDENTIFIER, + [SponsoringOrganizationId] UNIQUEIDENTIFIER, + [SponsoringOrganizationUserID] UNIQUEIDENTIFIER, + [SponsoredOrganizationId] UNIQUEIDENTIFIER, + [FriendlyName] NVARCHAR(256), + [OfferedToEmail] VARCHAR(256), + [PlanSponsorshipType] TINYINT, + [LastSyncDate] DATETIME2(7), + [ValidUntil] DATETIME2(7), + [ToDelete] BIT, + [IsAdminInitiated] BIT DEFAULT 0, + [Notes] NVARCHAR(512) NULL +); +GO; + +CREATE PROCEDURE [dbo].[OrganizationSponsorship_CreateMany] + @OrganizationSponsorshipsInput [dbo].[OrganizationSponsorshipType] READONLY +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[OrganizationSponsorship] + ( + [Id], + [SponsoringOrganizationId], + [SponsoringOrganizationUserID], + [SponsoredOrganizationId], + [FriendlyName], + [OfferedToEmail], + [PlanSponsorshipType], + [ToDelete], + [LastSyncDate], + [ValidUntil], + [IsAdminInitiated], + [Notes] + ) +SELECT + OS.[Id], + OS.[SponsoringOrganizationId], + OS.[SponsoringOrganizationUserID], + OS.[SponsoredOrganizationId], + OS.[FriendlyName], + OS.[OfferedToEmail], + OS.[PlanSponsorshipType], + OS.[ToDelete], + OS.[LastSyncDate], + OS.[ValidUntil], + OS.[IsAdminInitiated], + OS.[Notes] +FROM + @OrganizationSponsorshipsInput OS +END +GO; + +CREATE PROCEDURE [dbo].[OrganizationSponsorship_UpdateMany] + @OrganizationSponsorshipsInput [dbo].[OrganizationSponsorshipType] READONLY +AS +BEGIN + SET NOCOUNT ON + +UPDATE + OS +SET + [Id] = OSI.[Id], + [SponsoringOrganizationId] = OSI.[SponsoringOrganizationId], + [SponsoringOrganizationUserID] = OSI.[SponsoringOrganizationUserID], + [SponsoredOrganizationId] = OSI.[SponsoredOrganizationId], + [FriendlyName] = OSI.[FriendlyName], + [OfferedToEmail] = OSI.[OfferedToEmail], + [PlanSponsorshipType] = OSI.[PlanSponsorshipType], + [ToDelete] = OSI.[ToDelete], + [LastSyncDate] = OSI.[LastSyncDate], + [ValidUntil] = OSI.[ValidUntil], + [IsAdminInitiated] = OSI.[IsAdminInitiated], + [Notes] = OSI.[Notes] +FROM + [dbo].[OrganizationSponsorship] OS + INNER JOIN + @OrganizationSponsorshipsInput OSI ON OS.Id = OSI.Id + +END +GO; From f7e5759e7bdde9953518df6eb6f8515fc7f12a3f Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Thu, 17 Apr 2025 14:59:00 +0200 Subject: [PATCH 37/45] Remove GeneratorToolsModernization feature flag (#5660) Co-authored-by: Daniel James Smith --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 5f3e954a46..8071a933f6 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -190,7 +190,6 @@ public static class FeatureFlagKeys public const string EnableRiskInsightsNotifications = "enable-risk-insights-notifications"; public const string DesktopSendUIRefresh = "desktop-send-ui-refresh"; public const string ExportAttachments = "export-attachments"; - public const string GeneratorToolsModernization = "generator-tools-modernization"; /* Vault Team */ public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge"; From 60e7db7dbb9fbf07a6b48246f42fb22e38948202 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Thu, 17 Apr 2025 14:58:29 +0100 Subject: [PATCH 38/45] [PM-17823]Add feature toggle for admin sponsored families to admin portal (#5595) * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * Add `Notes` column to `OrganizationSponsorships` table * Add feature flag to `CreateAdminInitiatedSponsorshipHandler` * Unit tests for `CreateSponsorshipHandler` * More tests for `CreateSponsorshipHandler` * Forgot to add `Notes` column to `OrganizationSponsorships` table in the migration script * `CreateAdminInitiatedSponsorshipHandler` unit tests * Fix `CreateSponsorshipCommandTests` * Encrypt the notes field * Wrong business logic checking for invalid permissions. * Wrong business logic checking for invalid permissions. * Remove design patterns * duplicate definition in Constants.cs * Add the admin sponsored families to admin portal * Add a feature flag * Rename the migration file name * Resolve the existing conflict and remove added file * Add a migration for the change * Remove the migration Because is already added * Resolve the failing migration --------- Co-authored-by: Jonas Hendrickx --- .../Controllers/OrganizationsController.cs | 1 + .../Views/Shared/_OrganizationForm.cshtml | 9 ++ ...-02_00_UpdateUseAdminSponsoredFamilies.sql | 127 ++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 util/Migrator/DbScripts/2025-04-02_00_UpdateUseAdminSponsoredFamilies.sql diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index fdb4961d9b..cb163f400a 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -462,6 +462,7 @@ public class OrganizationsController : Controller organization.UsersGetPremium = model.UsersGetPremium; organization.UseSecretsManager = model.UseSecretsManager; organization.UseRiskInsights = model.UseRiskInsights; + organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies; //secrets organization.SmSeats = model.SmSeats; diff --git a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml index aeff65c900..7b19b19939 100644 --- a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml +++ b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml @@ -1,9 +1,11 @@ @using Bit.Admin.Enums; +@using Bit.Core @using Bit.Core.Enums @using Bit.Core.AdminConsole.Enums.Provider @using Bit.Core.Billing.Enums @using Bit.SharedWeb.Utilities @inject Bit.Admin.Services.IAccessControlService AccessControlService; +@inject Bit.Core.Services.IFeatureService FeatureService @model OrganizationEditModel @@ -146,6 +148,13 @@ + @if(FeatureService.IsEnabled(FeatureFlagKeys.PM17772_AdminInitiatedSponsorships)) + { +
+ + +
+ }

Password Manager

diff --git a/util/Migrator/DbScripts/2025-04-02_00_UpdateUseAdminSponsoredFamilies.sql b/util/Migrator/DbScripts/2025-04-02_00_UpdateUseAdminSponsoredFamilies.sql new file mode 100644 index 0000000000..3c7e4675e4 --- /dev/null +++ b/util/Migrator/DbScripts/2025-04-02_00_UpdateUseAdminSponsoredFamilies.sql @@ -0,0 +1,127 @@ +CREATE OR ALTER PROCEDURE [dbo].[Organization_Update] + @Id UNIQUEIDENTIFIER, + @Identifier NVARCHAR(50), + @Name NVARCHAR(50), + @BusinessName NVARCHAR(50), + @BusinessAddress1 NVARCHAR(50), + @BusinessAddress2 NVARCHAR(50), + @BusinessAddress3 NVARCHAR(50), + @BusinessCountry VARCHAR(2), + @BusinessTaxNumber NVARCHAR(30), + @BillingEmail NVARCHAR(256), + @Plan NVARCHAR(50), + @PlanType TINYINT, + @Seats INT, + @MaxCollections SMALLINT, + @UsePolicies BIT, + @UseSso BIT, + @UseGroups BIT, + @UseDirectory BIT, + @UseEvents BIT, + @UseTotp BIT, + @Use2fa BIT, + @UseApi BIT, + @UseResetPassword BIT, + @SelfHost BIT, + @UsersGetPremium BIT, + @Storage BIGINT, + @MaxStorageGb SMALLINT, + @Gateway TINYINT, + @GatewayCustomerId VARCHAR(50), + @GatewaySubscriptionId VARCHAR(50), + @ReferenceData VARCHAR(MAX), + @Enabled BIT, + @LicenseKey VARCHAR(100), + @PublicKey VARCHAR(MAX), + @PrivateKey VARCHAR(MAX), + @TwoFactorProviders NVARCHAR(MAX), + @ExpirationDate DATETIME2(7), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @OwnersNotifiedOfAutoscaling DATETIME2(7), + @MaxAutoscaleSeats INT, + @UseKeyConnector BIT = 0, + @UseScim BIT = 0, + @UseCustomPermissions BIT = 0, + @UseSecretsManager BIT = 0, + @Status TINYINT = 0, + @UsePasswordManager BIT = 1, + @SmSeats INT = null, + @SmServiceAccounts INT = null, + @MaxAutoscaleSmSeats INT = null, + @MaxAutoscaleSmServiceAccounts INT = null, + @SecretsManagerBeta BIT = 0, + @LimitCollectionCreation BIT = null, + @LimitCollectionDeletion BIT = null, + @AllowAdminAccessToAllCollectionItems BIT = 0, + @UseRiskInsights BIT = 0, + @LimitItemDeletion BIT = 0, + @UseAdminSponsoredFamilies BIT = 0 +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[Organization] + SET + [Identifier] = @Identifier, + [Name] = @Name, + [BusinessName] = @BusinessName, + [BusinessAddress1] = @BusinessAddress1, + [BusinessAddress2] = @BusinessAddress2, + [BusinessAddress3] = @BusinessAddress3, + [BusinessCountry] = @BusinessCountry, + [BusinessTaxNumber] = @BusinessTaxNumber, + [BillingEmail] = @BillingEmail, + [Plan] = @Plan, + [PlanType] = @PlanType, + [Seats] = @Seats, + [MaxCollections] = @MaxCollections, + [UsePolicies] = @UsePolicies, + [UseSso] = @UseSso, + [UseGroups] = @UseGroups, + [UseDirectory] = @UseDirectory, + [UseEvents] = @UseEvents, + [UseTotp] = @UseTotp, + [Use2fa] = @Use2fa, + [UseApi] = @UseApi, + [UseResetPassword] = @UseResetPassword, + [SelfHost] = @SelfHost, + [UsersGetPremium] = @UsersGetPremium, + [Storage] = @Storage, + [MaxStorageGb] = @MaxStorageGb, + [Gateway] = @Gateway, + [GatewayCustomerId] = @GatewayCustomerId, + [GatewaySubscriptionId] = @GatewaySubscriptionId, + [ReferenceData] = @ReferenceData, + [Enabled] = @Enabled, + [LicenseKey] = @LicenseKey, + [PublicKey] = @PublicKey, + [PrivateKey] = @PrivateKey, + [TwoFactorProviders] = @TwoFactorProviders, + [ExpirationDate] = @ExpirationDate, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate, + [OwnersNotifiedOfAutoscaling] = @OwnersNotifiedOfAutoscaling, + [MaxAutoscaleSeats] = @MaxAutoscaleSeats, + [UseKeyConnector] = @UseKeyConnector, + [UseScim] = @UseScim, + [UseCustomPermissions] = @UseCustomPermissions, + [UseSecretsManager] = @UseSecretsManager, + [Status] = @Status, + [UsePasswordManager] = @UsePasswordManager, + [SmSeats] = @SmSeats, + [SmServiceAccounts] = @SmServiceAccounts, + [MaxAutoscaleSmSeats] = @MaxAutoscaleSmSeats, + [MaxAutoscaleSmServiceAccounts] = @MaxAutoscaleSmServiceAccounts, + [SecretsManagerBeta] = @SecretsManagerBeta, + [LimitCollectionCreation] = @LimitCollectionCreation, + [LimitCollectionDeletion] = @LimitCollectionDeletion, + [AllowAdminAccessToAllCollectionItems] = @AllowAdminAccessToAllCollectionItems, + [UseRiskInsights] = @UseRiskInsights, + [LimitItemDeletion] = @LimitItemDeletion, + [UseAdminSponsoredFamilies] = @UseAdminSponsoredFamilies + WHERE + [Id] = @Id +END +GO \ No newline at end of file From bd90c34af2ff9fb0dc342c39a6c5e77d3c0761cb Mon Sep 17 00:00:00 2001 From: Jonas Hendrickx Date: Thu, 17 Apr 2025 17:33:16 +0200 Subject: [PATCH 39/45] [PM-19180] Calculate sales tax correctly for sponsored plans (#5611) * [PM-19180] Calculate sales tax correctly for sponsored plans * Cannot divide by zero if total amount excluding tax is zero. * Unit tests for families & families for enterprise --------- Co-authored-by: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> --- .../PreviewOrganizationInvoiceRequestModel.cs | 3 + .../Implementations/StripePaymentService.cs | 63 ++--- .../Services/StripePaymentServiceTests.cs | 227 ++++++++++++++++++ 3 files changed, 263 insertions(+), 30 deletions(-) create mode 100644 test/Core.Test/Services/StripePaymentServiceTests.cs diff --git a/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs b/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs index 466c32f42d..461a6dca65 100644 --- a/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs +++ b/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Billing.Enums; +using Bit.Core.Enums; namespace Bit.Core.Billing.Models.Api.Requests.Organizations; @@ -20,6 +21,8 @@ public class OrganizationPasswordManagerRequestModel { public PlanType Plan { get; set; } + public PlanSponsorshipType? SponsoredPlan { get; set; } + [Range(0, int.MaxValue)] public int Seats { get; set; } diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index d82a4d60a7..51be369527 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -1265,7 +1265,7 @@ public class StripePaymentService : IPaymentService { var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options); - var effectiveTaxRate = invoice.Tax != null && invoice.TotalExcludingTax != null + var effectiveTaxRate = invoice.Tax != null && invoice.TotalExcludingTax != null && invoice.TotalExcludingTax.Value != 0 ? invoice.Tax.Value.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor() : 0M; @@ -1300,6 +1300,7 @@ public class StripePaymentService : IPaymentService string gatewaySubscriptionId) { var plan = await _pricingClient.GetPlanOrThrow(parameters.PasswordManager.Plan); + var isSponsored = parameters.PasswordManager.SponsoredPlan.HasValue; var options = new InvoiceCreatePreviewOptions { @@ -1325,45 +1326,47 @@ public class StripePaymentService : IPaymentService }, }; - if (plan.PasswordManager.HasAdditionalSeatsOption) + if (isSponsored) { + var sponsoredPlan = Utilities.StaticStore.GetSponsoredPlan(parameters.PasswordManager.SponsoredPlan.Value); options.SubscriptionDetails.Items.Add( - new() - { - Quantity = parameters.PasswordManager.Seats, - Plan = plan.PasswordManager.StripeSeatPlanId - } + new() { Quantity = 1, Plan = sponsoredPlan.StripePlanId } ); } else { - options.SubscriptionDetails.Items.Add( - new() - { - Quantity = 1, - Plan = plan.PasswordManager.StripePlanId - } - ); - } - - if (plan.SupportsSecretsManager) - { - if (plan.SecretsManager.HasAdditionalSeatsOption) + if (plan.PasswordManager.HasAdditionalSeatsOption) { - options.SubscriptionDetails.Items.Add(new() - { - Quantity = parameters.SecretsManager?.Seats ?? 0, - Plan = plan.SecretsManager.StripeSeatPlanId - }); + options.SubscriptionDetails.Items.Add( + new() { Quantity = parameters.PasswordManager.Seats, Plan = plan.PasswordManager.StripeSeatPlanId } + ); + } + else + { + options.SubscriptionDetails.Items.Add( + new() { Quantity = 1, Plan = plan.PasswordManager.StripePlanId } + ); } - if (plan.SecretsManager.HasAdditionalServiceAccountOption) + if (plan.SupportsSecretsManager) { - options.SubscriptionDetails.Items.Add(new() + if (plan.SecretsManager.HasAdditionalSeatsOption) { - Quantity = parameters.SecretsManager?.AdditionalMachineAccounts ?? 0, - Plan = plan.SecretsManager.StripeServiceAccountPlanId - }); + options.SubscriptionDetails.Items.Add(new() + { + Quantity = parameters.SecretsManager?.Seats ?? 0, + Plan = plan.SecretsManager.StripeSeatPlanId + }); + } + + if (plan.SecretsManager.HasAdditionalServiceAccountOption) + { + options.SubscriptionDetails.Items.Add(new() + { + Quantity = parameters.SecretsManager?.AdditionalMachineAccounts ?? 0, + Plan = plan.SecretsManager.StripeServiceAccountPlanId + }); + } } } @@ -1420,7 +1423,7 @@ public class StripePaymentService : IPaymentService { var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options); - var effectiveTaxRate = invoice.Tax != null && invoice.TotalExcludingTax != null + var effectiveTaxRate = invoice.Tax != null && invoice.TotalExcludingTax != null && invoice.TotalExcludingTax.Value != 0 ? invoice.Tax.Value.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor() : 0M; diff --git a/test/Core.Test/Services/StripePaymentServiceTests.cs b/test/Core.Test/Services/StripePaymentServiceTests.cs new file mode 100644 index 0000000000..835f69b214 --- /dev/null +++ b/test/Core.Test/Services/StripePaymentServiceTests.cs @@ -0,0 +1,227 @@ +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models.Api.Requests; +using Bit.Core.Billing.Models.Api.Requests.Organizations; +using Bit.Core.Billing.Models.StaticStore.Plans; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; +using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Enums; +using Bit.Core.Services; +using Bit.Core.Test.Billing.Stubs; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Services; + +[SutProviderCustomize] +public class StripePaymentServiceTests +{ + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesWithoutAdditionalStorage( + SutProvider sutProvider) + { + sutProvider.GetDependency() + .CreateAsync(Arg.Is(p => p.PlanType == PlanType.FamiliesAnnually)) + .Returns(new FakeAutomaticTaxStrategy(true)); + + var familiesPlan = new FamiliesPlan(); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) + .Returns(familiesPlan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.FamiliesAnnually, + AdditionalStorage = 0 + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "FR", + PostalCode = "12345" + } + }; + + sutProvider.GetDependency() + .InvoiceCreatePreviewAsync(Arg.Is(p => + p.Currency == "usd" && + p.SubscriptionDetails.Items.Any(x => + x.Plan == familiesPlan.PasswordManager.StripePlanId && + x.Quantity == 1) && + p.SubscriptionDetails.Items.Any(x => + x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId && + x.Quantity == 0))) + .Returns(new Invoice + { + TotalExcludingTax = 4000, + Tax = 800, + Total = 4800 + }); + + var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + Assert.Equal(8M, actual.TaxAmount); + Assert.Equal(48M, actual.TotalAmount); + Assert.Equal(40M, actual.TaxableBaseAmount); + } + + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesWithAdditionalStorage( + SutProvider sutProvider) + { + sutProvider.GetDependency() + .CreateAsync(Arg.Is(p => p.PlanType == PlanType.FamiliesAnnually)) + .Returns(new FakeAutomaticTaxStrategy(true)); + + var familiesPlan = new FamiliesPlan(); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) + .Returns(familiesPlan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.FamiliesAnnually, + AdditionalStorage = 1 + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "FR", + PostalCode = "12345" + } + }; + + sutProvider.GetDependency() + .InvoiceCreatePreviewAsync(Arg.Is(p => + p.Currency == "usd" && + p.SubscriptionDetails.Items.Any(x => + x.Plan == familiesPlan.PasswordManager.StripePlanId && + x.Quantity == 1) && + p.SubscriptionDetails.Items.Any(x => + x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId && + x.Quantity == 1))) + .Returns(new Invoice + { + TotalExcludingTax = 4000, + Tax = 800, + Total = 4800 + }); + + var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + Assert.Equal(8M, actual.TaxAmount); + Assert.Equal(48M, actual.TotalAmount); + Assert.Equal(40M, actual.TaxableBaseAmount); + } + + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithoutAdditionalStorage( + SutProvider sutProvider) + { + sutProvider.GetDependency() + .CreateAsync(Arg.Is(p => p.PlanType == PlanType.FamiliesAnnually)) + .Returns(new FakeAutomaticTaxStrategy(true)); + + var familiesPlan = new FamiliesPlan(); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) + .Returns(familiesPlan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.FamiliesAnnually, + SponsoredPlan = PlanSponsorshipType.FamiliesForEnterprise, + AdditionalStorage = 0 + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "FR", + PostalCode = "12345" + } + }; + + sutProvider.GetDependency() + .InvoiceCreatePreviewAsync(Arg.Is(p => + p.Currency == "usd" && + p.SubscriptionDetails.Items.Any(x => + x.Plan == "2021-family-for-enterprise-annually" && + x.Quantity == 1) && + p.SubscriptionDetails.Items.Any(x => + x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId && + x.Quantity == 0))) + .Returns(new Invoice + { + TotalExcludingTax = 0, + Tax = 0, + Total = 0 + }); + + var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + Assert.Equal(0M, actual.TaxAmount); + Assert.Equal(0M, actual.TotalAmount); + Assert.Equal(0M, actual.TaxableBaseAmount); + } + + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithAdditionalStorage( + SutProvider sutProvider) + { + sutProvider.GetDependency() + .CreateAsync(Arg.Is(p => p.PlanType == PlanType.FamiliesAnnually)) + .Returns(new FakeAutomaticTaxStrategy(true)); + + var familiesPlan = new FamiliesPlan(); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) + .Returns(familiesPlan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.FamiliesAnnually, + SponsoredPlan = PlanSponsorshipType.FamiliesForEnterprise, + AdditionalStorage = 1 + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "FR", + PostalCode = "12345" + } + }; + + sutProvider.GetDependency() + .InvoiceCreatePreviewAsync(Arg.Is(p => + p.Currency == "usd" && + p.SubscriptionDetails.Items.Any(x => + x.Plan == "2021-family-for-enterprise-annually" && + x.Quantity == 1) && + p.SubscriptionDetails.Items.Any(x => + x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId && + x.Quantity == 1))) + .Returns(new Invoice + { + TotalExcludingTax = 400, + Tax = 8, + Total = 408 + }); + + var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + Assert.Equal(0.08M, actual.TaxAmount); + Assert.Equal(4.08M, actual.TotalAmount); + Assert.Equal(4M, actual.TaxableBaseAmount); + } +} From 4379e326a53a1cbc4178c4e8809c984ce887ae91 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Thu, 17 Apr 2025 14:37:11 -0400 Subject: [PATCH 40/45] =?UTF-8?q?Revert=20"[PM-20264]=20Replace=20`StaticS?= =?UTF-8?q?tore`=20with=20`PricingClient`=20in=20`MaxProjects=E2=80=A6"=20?= =?UTF-8?q?(#5665)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit e943a2f051a254c4a031f39f2638d418bdd2e4a2. --- .../Queries/Projects/MaxProjectsQuery.cs | 10 ++++------ .../Queries/Projects/MaxProjectsQueryTests.cs | 8 -------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs index 106483ec4a..d9a7d4a2ce 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs @@ -1,9 +1,9 @@ using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Pricing; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Queries.Projects.Interfaces; using Bit.Core.SecretsManager.Repositories; +using Bit.Core.Utilities; namespace Bit.Commercial.Core.SecretsManager.Queries.Projects; @@ -11,16 +11,13 @@ public class MaxProjectsQuery : IMaxProjectsQuery { private readonly IOrganizationRepository _organizationRepository; private readonly IProjectRepository _projectRepository; - private readonly IPricingClient _pricingClient; public MaxProjectsQuery( IOrganizationRepository organizationRepository, - IProjectRepository projectRepository, - IPricingClient pricingClient) + IProjectRepository projectRepository) { _organizationRepository = organizationRepository; _projectRepository = projectRepository; - _pricingClient = pricingClient; } public async Task<(short? max, bool? overMax)> GetByOrgIdAsync(Guid organizationId, int projectsToAdd) @@ -31,7 +28,8 @@ public class MaxProjectsQuery : IMaxProjectsQuery throw new NotFoundException(); } - var plan = await _pricingClient.GetPlan(org.PlanType); + // TODO: PRICING -> https://bitwarden.atlassian.net/browse/PM-17122 + var plan = StaticStore.GetPlan(org.PlanType); if (plan?.SecretsManager == null) { throw new BadRequestException("Existing plan not found."); diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs index afe9533292..347f5b2128 100644 --- a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs @@ -1,11 +1,9 @@ using Bit.Commercial.Core.SecretsManager.Queries.Projects; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Pricing; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; -using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -68,9 +66,6 @@ public class MaxProjectsQueryTests SutProvider sutProvider, Organization organization) { organization.PlanType = planType; - - sutProvider.GetDependency().GetPlan(planType).Returns(StaticStore.GetPlan(planType)); - sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); var (limit, overLimit) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1); @@ -111,9 +106,6 @@ public class MaxProjectsQueryTests SutProvider sutProvider, Organization organization) { organization.PlanType = planType; - - sutProvider.GetDependency().GetPlan(planType).Returns(StaticStore.GetPlan(planType)); - sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency().GetProjectCountByOrganizationIdAsync(organization.Id) .Returns(projects); From 89fc27b0148b76ee8b9b12c744b8013adb3a63b8 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Fri, 18 Apr 2025 08:13:55 -0500 Subject: [PATCH 41/45] [PM-20230] - Send owners email when autoscaling (#5658) * Added email when autoscaling. Added tests as well. * Wrote tests. Renamed methods. --- .../InviteOrganizationUsersCommand.cs | 20 +- .../InviteOrganizationUserCommandTests.cs | 304 ++++++++++++++++++ 2 files changed, 322 insertions(+), 2 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs index 4eacb9386a..1aff71c636 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs @@ -206,10 +206,26 @@ public class InviteOrganizationUsersCommand(IEventService eventService, private async Task SendAdditionalEmailsAsync(Valid validatedResult, Organization organization) { - await SendPasswordManagerMaxSeatLimitEmailsAsync(validatedResult, organization); + await NotifyOwnersIfAutoscaleOccursAsync(validatedResult, organization); + await NotifyOwnersIfPasswordManagerMaxSeatLimitReachedAsync(validatedResult, organization); } - private async Task SendPasswordManagerMaxSeatLimitEmailsAsync(Valid validatedResult, Organization organization) + private async Task NotifyOwnersIfAutoscaleOccursAsync(Valid validatedResult, Organization organization) + { + if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd > 0 + && !organization.OwnersNotifiedOfAutoscaling.HasValue) + { + await mailService.SendOrganizationAutoscaledEmailAsync( + organization, + validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats!.Value, + await GetOwnerEmailAddressesAsync(validatedResult.Value.InviteOrganization)); + + organization.OwnersNotifiedOfAutoscaling = validatedResult.Value.PerformedAt.UtcDateTime; + await organizationRepository.UpsertAsync(organization); + } + } + + private async Task NotifyOwnersIfPasswordManagerMaxSeatLimitReachedAsync(Valid validatedResult, Organization organization) { if (!validatedResult.Value.PasswordManagerSubscriptionUpdate.MaxSeatsReached) { diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs index ba7605d682..6ae2d58c73 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs @@ -287,6 +287,77 @@ public class InviteOrganizationUserCommandTests Arg.Is>(emails => emails.Any(email => email == ownerDetails.Email))); } + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenValidInviteCausesOrgToAutoscale_ThenOrganizationOwnersShouldBeNotified( + MailAddress address, + Organization organization, + OrganizationUser user, + FakeTimeProvider timeProvider, + string externalId, + OrganizationUserUserDetails ownerDetails, + SutProvider sutProvider) + { + // Arrange + user.Email = address.Address; + organization.Seats = 1; + organization.MaxAutoscaleSeats = 2; + organization.OwnersNotifiedOfAutoscaling = null; + ownerDetails.Type = OrganizationUserType.Owner; + + var inviteOrganization = new InviteOrganization(organization, new Enterprise2019Plan(true)); + + var request = new InviteOrganizationUsersRequest( + invites: + [ + new OrganizationUserInvite( + email: user.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + var orgUserRepository = sutProvider.GetDependency(); + + orgUserRepository + .SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any>(), false) + .Returns([]); + orgUserRepository + .GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner) + .Returns([ownerDetails]); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Valid( + GetInviteValidationRequestMock(request, inviteOrganization, organization) + .WithPasswordManagerUpdate( + new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1)))); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert + Assert.IsType>(result); + + Assert.NotNull(inviteOrganization.MaxAutoScaleSeats); + + await sutProvider.GetDependency() + .Received(1) + .SendOrganizationAutoscaledEmailAsync(organization, + inviteOrganization.Seats.Value, + Arg.Is>(emails => emails.Any(email => email == ownerDetails.Email))); + } + [Theory] [BitAutoData] public async Task InviteScimOrganizationUserAsync_WhenValidInviteIncreasesSeats_ThenSeatTotalShouldBeUpdated( @@ -610,4 +681,237 @@ public class InviteOrganizationUserCommandTests .SendOrganizationMaxSeatLimitReachedEmailAsync(organization, 2, Arg.Is>(emails => emails.Any(email => email == "provider@email.com"))); } + + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenAnOrganizationIsManagedByAProviderAndAutoscaleOccurs_ThenAnEmailShouldBeSentToTheProvider( + MailAddress address, + Organization organization, + OrganizationUser user, + FakeTimeProvider timeProvider, + string externalId, + OrganizationUserUserDetails ownerDetails, + ProviderOrganization providerOrganization, + SutProvider sutProvider) + { + // Arrange + user.Email = address.Address; + organization.Seats = 1; + organization.SmSeats = 1; + organization.MaxAutoscaleSeats = 2; + organization.MaxAutoscaleSmSeats = 2; + organization.OwnersNotifiedOfAutoscaling = null; + ownerDetails.Type = OrganizationUserType.Owner; + + providerOrganization.OrganizationId = organization.Id; + + var inviteOrganization = new InviteOrganization(organization, new Enterprise2019Plan(true)); + + var request = new InviteOrganizationUsersRequest( + invites: [ + new OrganizationUserInvite( + email: user.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + var secretsManagerSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(organization, inviteOrganization.Plan, true) + .AdjustSeats(request.Invites.Count(x => x.AccessSecretsManager)); + + var passwordManagerSubscriptionUpdate = + new PasswordManagerSubscriptionUpdate(inviteOrganization, 1, request.Invites.Length); + + var orgUserRepository = sutProvider.GetDependency(); + + orgUserRepository + .SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any>(), false) + .Returns([]); + orgUserRepository + .GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner) + .Returns([ownerDetails]); + + var orgRepository = sutProvider.GetDependency(); + + orgRepository.GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Valid(GetInviteValidationRequestMock(request, inviteOrganization, organization) + .WithPasswordManagerUpdate(passwordManagerSubscriptionUpdate) + .WithSecretsManagerUpdate(secretsManagerSubscriptionUpdate))); + + sutProvider.GetDependency() + .GetByOrganizationId(organization.Id) + .Returns(providerOrganization); + + sutProvider.GetDependency() + .GetManyDetailsByProviderAsync(providerOrganization.ProviderId, ProviderUserStatusType.Confirmed) + .Returns(new List + { + new() + { + Email = "provider@email.com" + } + }); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert + Assert.IsType>(result); + + sutProvider.GetDependency().Received(1) + .SendOrganizationAutoscaledEmailAsync(organization, 1, + Arg.Is>(emails => emails.Any(email => email == "provider@email.com"))); + } + + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenAnOrganizationAutoscalesButOwnersHaveAlreadyBeenNotified_ThenAnEmailShouldNotBeSent( + MailAddress address, + Organization organization, + OrganizationUser user, + FakeTimeProvider timeProvider, + string externalId, + OrganizationUserUserDetails ownerDetails, + SutProvider sutProvider) + { + // Arrange + user.Email = address.Address; + organization.Seats = 1; + organization.MaxAutoscaleSeats = 2; + organization.OwnersNotifiedOfAutoscaling = DateTime.UtcNow; + ownerDetails.Type = OrganizationUserType.Owner; + + var inviteOrganization = new InviteOrganization(organization, new Enterprise2019Plan(true)); + + var request = new InviteOrganizationUsersRequest( + invites: + [ + new OrganizationUserInvite( + email: user.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + var orgUserRepository = sutProvider.GetDependency(); + + orgUserRepository + .SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any>(), false) + .Returns([]); + orgUserRepository + .GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner) + .Returns([ownerDetails]); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Valid( + GetInviteValidationRequestMock(request, inviteOrganization, organization) + .WithPasswordManagerUpdate( + new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1)))); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert + Assert.IsType>(result); + + Assert.NotNull(inviteOrganization.MaxAutoScaleSeats); + + await sutProvider.GetDependency() + .DidNotReceive() + .SendOrganizationAutoscaledEmailAsync(Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task InviteScimOrganizationUserAsync_WhenAnOrganizationDoesNotAutoScale_ThenAnEmailShouldNotBeSent( + MailAddress address, + Organization organization, + OrganizationUser user, + FakeTimeProvider timeProvider, + string externalId, + OrganizationUserUserDetails ownerDetails, + SutProvider sutProvider) + { + // Arrange + user.Email = address.Address; + organization.Seats = 2; + organization.MaxAutoscaleSeats = 2; + organization.OwnersNotifiedOfAutoscaling = DateTime.UtcNow; + ownerDetails.Type = OrganizationUserType.Owner; + + var inviteOrganization = new InviteOrganization(organization, new Enterprise2019Plan(true)); + + var request = new InviteOrganizationUsersRequest( + invites: + [ + new OrganizationUserInvite( + email: user.Email, + assignedCollections: [], + groups: [], + type: OrganizationUserType.User, + permissions: new Permissions(), + externalId: externalId, + accessSecretsManager: true) + ], + inviteOrganization: inviteOrganization, + performedBy: Guid.Empty, + timeProvider.GetUtcNow()); + + var orgUserRepository = sutProvider.GetDependency(); + + orgUserRepository + .SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any>(), false) + .Returns([]); + orgUserRepository + .GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner) + .Returns([ownerDetails]); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any()) + .Returns(new Valid( + GetInviteValidationRequestMock(request, inviteOrganization, organization) + .WithPasswordManagerUpdate( + new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1)))); + + // Act + var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); + + // Assert + Assert.IsType>(result); + + Assert.NotNull(inviteOrganization.MaxAutoScaleSeats); + + await sutProvider.GetDependency() + .DidNotReceive() + .SendOrganizationAutoscaledEmailAsync(Arg.Any(), + Arg.Any(), + Arg.Any>()); + } } From bfd98c703a2dc5ea7fba103a835fd66605522542 Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> Date: Fri, 18 Apr 2025 16:26:51 +0200 Subject: [PATCH 42/45] [PM-18017] Move Key Connector endpoints into Key Management team ownership (#5563) * Move Key Connector controller endpoints into Key Management team ownership * revert new key management endpoints --- .../Auth/Controllers/AccountsController.cs | 46 ------- .../AccountsKeyManagementController.cs | 52 ++++++- .../SetKeyConnectorKeyRequestModel.cs | 2 +- .../Helpers/OrganizationTestHelpers.cs | 5 +- .../AccountsKeyManagementControllerTests.cs | 102 +++++++++++++- .../AccountsKeyManagementControllerTests.cs | 129 ++++++++++++++++++ 6 files changed, 282 insertions(+), 54 deletions(-) rename src/Api/{Auth/Models/Request/Accounts => KeyManagement/Models/Requests}/SetKeyConnectorKeyRequestModel.cs (94%) diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index b22d54fa55..621524228a 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -284,52 +284,6 @@ public class AccountsController : Controller throw new BadRequestException(ModelState); } - [HttpPost("set-key-connector-key")] - public async Task PostSetKeyConnectorKeyAsync([FromBody] SetKeyConnectorKeyRequestModel model) - { - var user = await _userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier); - if (result.Succeeded) - { - return; - } - - foreach (var error in result.Errors) - { - ModelState.AddModelError(string.Empty, error.Description); - } - - throw new BadRequestException(ModelState); - } - - [HttpPost("convert-to-key-connector")] - public async Task PostConvertToKeyConnector() - { - var user = await _userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - var result = await _userService.ConvertToKeyConnectorAsync(user); - if (result.Succeeded) - { - return; - } - - foreach (var error in result.Errors) - { - ModelState.AddModelError(string.Empty, error.Description); - } - - throw new BadRequestException(ModelState); - } - [HttpPost("kdf")] public async Task PostKdf([FromBody] KdfRequestModel model) { diff --git a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs index 0764e2ee28..9fc0e9a75a 100644 --- a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs +++ b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs @@ -24,7 +24,7 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Api.KeyManagement.Controllers; -[Route("accounts/key-management")] +[Route("accounts")] [Authorize("Application")] public class AccountsKeyManagementController : Controller { @@ -77,7 +77,7 @@ public class AccountsKeyManagementController : Controller _deviceValidator = deviceValidator; } - [HttpPost("regenerate-keys")] + [HttpPost("key-management/regenerate-keys")] public async Task RegenerateKeysAsync([FromBody] KeyRegenerationRequestModel request) { if (!_featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration)) @@ -93,7 +93,7 @@ public class AccountsKeyManagementController : Controller } - [HttpPost("rotate-user-account-keys")] + [HttpPost("key-management/rotate-user-account-keys")] public async Task RotateUserAccountKeysAsync([FromBody] RotateUserAccountKeysAndDataRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -133,4 +133,50 @@ public class AccountsKeyManagementController : Controller throw new BadRequestException(ModelState); } + + [HttpPost("set-key-connector-key")] + public async Task PostSetKeyConnectorKeyAsync([FromBody] SetKeyConnectorKeyRequestModel model) + { + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new UnauthorizedAccessException(); + } + + var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier); + if (result.Succeeded) + { + return; + } + + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + + throw new BadRequestException(ModelState); + } + + [HttpPost("convert-to-key-connector")] + public async Task PostConvertToKeyConnectorAsync() + { + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new UnauthorizedAccessException(); + } + + var result = await _userService.ConvertToKeyConnectorAsync(user); + if (result.Succeeded) + { + return; + } + + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + + throw new BadRequestException(ModelState); + } } diff --git a/src/Api/Auth/Models/Request/Accounts/SetKeyConnectorKeyRequestModel.cs b/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs similarity index 94% rename from src/Api/Auth/Models/Request/Accounts/SetKeyConnectorKeyRequestModel.cs rename to src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs index 25d543b916..bac42bc302 100644 --- a/src/Api/Auth/Models/Request/Accounts/SetKeyConnectorKeyRequestModel.cs +++ b/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs @@ -3,7 +3,7 @@ using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Entities; using Bit.Core.Enums; -namespace Bit.Api.Auth.Models.Request.Accounts; +namespace Bit.Api.KeyManagement.Models.Requests; public class SetKeyConnectorKeyRequestModel { diff --git a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs index 9370948a85..f2bc9f4bac 100644 --- a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs +++ b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs @@ -59,7 +59,8 @@ public static class OrganizationTestHelpers string userEmail, OrganizationUserType type, bool accessSecretsManager = false, - Permissions? permissions = null + Permissions? permissions = null, + OrganizationUserStatusType userStatusType = OrganizationUserStatusType.Confirmed ) where T : class { var userRepository = factory.GetService(); @@ -74,7 +75,7 @@ public static class OrganizationTestHelpers UserId = user.Id, Key = null, Type = type, - Status = OrganizationUserStatusType.Confirmed, + Status = userStatusType, ExternalId = null, AccessSecretsManager = accessSecretsManager, }; diff --git a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs index 1b065adbd6..bf27d7f0d1 100644 --- a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -1,4 +1,5 @@ -using System.Net; +#nullable enable +using System.Net; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Api.KeyManagement.Models.Requests; @@ -7,6 +8,7 @@ using Bit.Api.Vault.Models; using Bit.Api.Vault.Models.Request; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; @@ -31,6 +33,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture _passwordHasher; + private readonly IOrganizationRepository _organizationRepository; private string _ownerEmail = null!; public AccountsKeyManagementControllerTests(ApiApplicationFactory factory) @@ -45,6 +48,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture(); _organizationUserRepository = _factory.GetService(); _passwordHasher = _factory.GetService>(); + _organizationRepository = _factory.GetService(); } public async Task InitializeAsync() @@ -174,7 +178,8 @@ public class AccountsKeyManagementControllerTests : IClassFixture sutProvider, + SetKeyConnectorKeyRequestModel data) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).ReturnsNull(); + + await Assert.ThrowsAsync(() => sutProvider.Sut.PostSetKeyConnectorKeyAsync(data)); + + await sutProvider.GetDependency().ReceivedWithAnyArgs(0) + .SetKeyConnectorKeyAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task PostSetKeyConnectorKeyAsync_SetKeyConnectorKeyFails_ThrowsBadRequestWithErrorResponse( + SutProvider sutProvider, + SetKeyConnectorKeyRequestModel data, User expectedUser) + { + expectedUser.PublicKey = null; + expectedUser.PrivateKey = null; + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .Returns(expectedUser); + sutProvider.GetDependency() + .SetKeyConnectorKeyAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(IdentityResult.Failed(new IdentityError { Description = "set key connector key error" })); + + var badRequestException = + await Assert.ThrowsAsync(() => sutProvider.Sut.PostSetKeyConnectorKeyAsync(data)); + + Assert.Equal(1, badRequestException.ModelState.ErrorCount); + Assert.Equal("set key connector key error", badRequestException.ModelState.Root.Errors[0].ErrorMessage); + await sutProvider.GetDependency().Received(1) + .SetKeyConnectorKeyAsync(Arg.Do(user => + { + Assert.Equal(expectedUser.Id, user.Id); + Assert.Equal(data.Key, user.Key); + Assert.Equal(data.Kdf, user.Kdf); + Assert.Equal(data.KdfIterations, user.KdfIterations); + Assert.Equal(data.KdfMemory, user.KdfMemory); + Assert.Equal(data.KdfParallelism, user.KdfParallelism); + Assert.Equal(data.Keys.PublicKey, user.PublicKey); + Assert.Equal(data.Keys.EncryptedPrivateKey, user.PrivateKey); + }), Arg.Is(data.Key), Arg.Is(data.OrgIdentifier)); + } + + [Theory] + [BitAutoData] + public async Task PostSetKeyConnectorKeyAsync_SetKeyConnectorKeySucceeds_OkResponse( + SutProvider sutProvider, + SetKeyConnectorKeyRequestModel data, User expectedUser) + { + expectedUser.PublicKey = null; + expectedUser.PrivateKey = null; + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .Returns(expectedUser); + sutProvider.GetDependency() + .SetKeyConnectorKeyAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(IdentityResult.Success); + + await sutProvider.Sut.PostSetKeyConnectorKeyAsync(data); + + await sutProvider.GetDependency().Received(1) + .SetKeyConnectorKeyAsync(Arg.Do(user => + { + Assert.Equal(expectedUser.Id, user.Id); + Assert.Equal(data.Key, user.Key); + Assert.Equal(data.Kdf, user.Kdf); + Assert.Equal(data.KdfIterations, user.KdfIterations); + Assert.Equal(data.KdfMemory, user.KdfMemory); + Assert.Equal(data.KdfParallelism, user.KdfParallelism); + Assert.Equal(data.Keys.PublicKey, user.PublicKey); + Assert.Equal(data.Keys.EncryptedPrivateKey, user.PrivateKey); + }), Arg.Is(data.Key), Arg.Is(data.OrgIdentifier)); + } + + [Theory] + [BitAutoData] + public async Task PostConvertToKeyConnectorAsync_UserNull_Throws( + SutProvider sutProvider) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).ReturnsNull(); + + await Assert.ThrowsAsync(() => sutProvider.Sut.PostConvertToKeyConnectorAsync()); + + await sutProvider.GetDependency().ReceivedWithAnyArgs(0) + .ConvertToKeyConnectorAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task PostConvertToKeyConnectorAsync_ConvertToKeyConnectorFails_ThrowsBadRequestWithErrorResponse( + SutProvider sutProvider, + User expectedUser) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .Returns(expectedUser); + sutProvider.GetDependency() + .ConvertToKeyConnectorAsync(Arg.Any()) + .Returns(IdentityResult.Failed(new IdentityError { Description = "convert to key connector error" })); + + var badRequestException = + await Assert.ThrowsAsync(() => sutProvider.Sut.PostConvertToKeyConnectorAsync()); + + Assert.Equal(1, badRequestException.ModelState.ErrorCount); + Assert.Equal("convert to key connector error", badRequestException.ModelState.Root.Errors[0].ErrorMessage); + await sutProvider.GetDependency().Received(1) + .ConvertToKeyConnectorAsync(Arg.Is(expectedUser)); + } + + [Theory] + [BitAutoData] + public async Task PostConvertToKeyConnectorAsync_ConvertToKeyConnectorSucceeds_OkResponse( + SutProvider sutProvider, + User expectedUser) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .Returns(expectedUser); + sutProvider.GetDependency() + .ConvertToKeyConnectorAsync(Arg.Any()) + .Returns(IdentityResult.Success); + + await sutProvider.Sut.PostConvertToKeyConnectorAsync(); + + await sutProvider.GetDependency().Received(1) + .ConvertToKeyConnectorAsync(Arg.Is(expectedUser)); + } } From 9218ac0d7c6b42804921ca8b60288792e5957f23 Mon Sep 17 00:00:00 2001 From: Robyn MacCallum Date: Fri, 18 Apr 2025 12:47:54 -0400 Subject: [PATCH 43/45] Add android-chrome-autofill flag (#5668) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 8071a933f6..711078d1e4 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -176,6 +176,7 @@ public static class FeatureFlagKeys 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"; + public const string AndroidChromeAutofill = "android-chrome-autofill"; /* Platform Team */ public const string PersistPopupView = "persist-popup-view"; From dc758c5176613fe318d4db541e624a1cdcc6a4fe Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Fri, 18 Apr 2025 11:51:50 -0500 Subject: [PATCH 44/45] [PM-19128] - Optimize Update Collections (#5626) * added data clean up to test * Added indices and edited sproc to avoid merge commands * Forgot GO * Adding some more GOs --- .../Collection_UpdateWithGroupsAndUsers.sql | 166 ++++++++---------- src/Sql/dbo/Tables/CollectionGroup.sql | 6 + src/Sql/dbo/Tables/CollectionUser.sql | 6 + .../Repositories/CollectionRepositoryTests.cs | 6 + ...ns_UpdateWithGroupsAndUsers_AndIndices.sql | 118 +++++++++++++ 5 files changed, 214 insertions(+), 88 deletions(-) create mode 100644 util/Migrator/DbScripts/2025-04-07_00_Collections_UpdateWithGroupsAndUsers_AndIndices.sql diff --git a/src/Sql/dbo/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql b/src/Sql/dbo/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql index da7e77cc14..4a66b20d86 100644 --- a/src/Sql/dbo/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql +++ b/src/Sql/dbo/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql @@ -14,98 +14,88 @@ BEGIN EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate -- Groups - ;WITH [AvailableGroupsCTE] AS( - SELECT - Id - FROM - [dbo].[Group] - WHERE - OrganizationId = @OrganizationId + -- Delete groups that are no longer in source + DELETE cg + FROM [dbo].[CollectionGroup] cg + LEFT JOIN @Groups g ON cg.GroupId = g.Id + WHERE cg.CollectionId = @Id + AND g.Id IS NULL; + + -- Update existing groups + UPDATE cg + SET cg.ReadOnly = g.ReadOnly, + cg.HidePasswords = g.HidePasswords, + cg.Manage = g.Manage + FROM [dbo].[CollectionGroup] cg + INNER JOIN @Groups g ON cg.GroupId = g.Id + WHERE cg.CollectionId = @Id + AND (cg.ReadOnly != g.ReadOnly + OR cg.HidePasswords != g.HidePasswords + OR cg.Manage != g.Manage); + + -- Insert new groups + INSERT INTO [dbo].[CollectionGroup] + ( + [CollectionId], + [GroupId], + [ReadOnly], + [HidePasswords], + [Manage] ) - MERGE - [dbo].[CollectionGroup] AS [Target] - USING - @Groups AS [Source] - ON - [Target].[CollectionId] = @Id - AND [Target].[GroupId] = [Source].[Id] - WHEN NOT MATCHED BY TARGET - AND [Source].[Id] IN (SELECT [Id] FROM [AvailableGroupsCTE]) THEN - INSERT -- Add explicit column list - ( - [CollectionId], - [GroupId], - [ReadOnly], - [HidePasswords], - [Manage] - ) - VALUES - ( - @Id, - [Source].[Id], - [Source].[ReadOnly], - [Source].[HidePasswords], - [Source].[Manage] - ) - WHEN MATCHED AND ( - [Target].[ReadOnly] != [Source].[ReadOnly] - OR [Target].[HidePasswords] != [Source].[HidePasswords] - OR [Target].[Manage] != [Source].[Manage] - ) THEN - UPDATE SET [Target].[ReadOnly] = [Source].[ReadOnly], - [Target].[HidePasswords] = [Source].[HidePasswords], - [Target].[Manage] = [Source].[Manage] - WHEN NOT MATCHED BY SOURCE - AND [Target].[CollectionId] = @Id THEN - DELETE - ; + SELECT + @Id, + g.Id, + g.ReadOnly, + g.HidePasswords, + g.Manage + FROM @Groups g + INNER JOIN [dbo].[Group] grp ON grp.Id = g.Id + LEFT JOIN [dbo].[CollectionGroup] cg + ON cg.CollectionId = @Id AND cg.GroupId = g.Id + WHERE grp.OrganizationId = @OrganizationId + AND cg.CollectionId IS NULL; -- Users - ;WITH [AvailableGroupsCTE] AS( - SELECT - Id - FROM - [dbo].[OrganizationUser] - WHERE - OrganizationId = @OrganizationId + -- Delete users that are no longer in source + DELETE cu + FROM [dbo].[CollectionUser] cu + LEFT JOIN @Users u ON cu.OrganizationUserId = u.Id + WHERE cu.CollectionId = @Id + AND u.Id IS NULL; + + -- Update existing users + UPDATE cu + SET cu.ReadOnly = u.ReadOnly, + cu.HidePasswords = u.HidePasswords, + cu.Manage = u.Manage + FROM [dbo].[CollectionUser] cu + INNER JOIN @Users u ON cu.OrganizationUserId = u.Id + WHERE cu.CollectionId = @Id + AND (cu.ReadOnly != u.ReadOnly + OR cu.HidePasswords != u.HidePasswords + OR cu.Manage != u.Manage); + + -- Insert new users + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] ) - MERGE - [dbo].[CollectionUser] AS [Target] - USING - @Users AS [Source] - ON - [Target].[CollectionId] = @Id - AND [Target].[OrganizationUserId] = [Source].[Id] - WHEN NOT MATCHED BY TARGET - AND [Source].[Id] IN (SELECT [Id] FROM [AvailableGroupsCTE]) THEN - INSERT - ( - [CollectionId], - [OrganizationUserId], - [ReadOnly], - [HidePasswords], - [Manage] - ) - VALUES - ( - @Id, - [Source].[Id], - [Source].[ReadOnly], - [Source].[HidePasswords], - [Source].[Manage] - ) - WHEN MATCHED AND ( - [Target].[ReadOnly] != [Source].[ReadOnly] - OR [Target].[HidePasswords] != [Source].[HidePasswords] - OR [Target].[Manage] != [Source].[Manage] - ) THEN - UPDATE SET [Target].[ReadOnly] = [Source].[ReadOnly], - [Target].[HidePasswords] = [Source].[HidePasswords], - [Target].[Manage] = [Source].[Manage] - WHEN NOT MATCHED BY SOURCE - AND [Target].[CollectionId] = @Id THEN - DELETE - ; + SELECT + @Id, + u.Id, + u.ReadOnly, + u.HidePasswords, + u.Manage + FROM @Users u + INNER JOIN [dbo].[OrganizationUser] ou ON ou.Id = u.Id + LEFT JOIN [dbo].[CollectionUser] cu + ON cu.CollectionId = @Id AND cu.OrganizationUserId = u.Id + WHERE ou.OrganizationId = @OrganizationId + AND cu.CollectionId IS NULL; EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId END diff --git a/src/Sql/dbo/Tables/CollectionGroup.sql b/src/Sql/dbo/Tables/CollectionGroup.sql index 756cd79ece..72a6710e2a 100644 --- a/src/Sql/dbo/Tables/CollectionGroup.sql +++ b/src/Sql/dbo/Tables/CollectionGroup.sql @@ -9,3 +9,9 @@ CONSTRAINT [FK_CollectionGroup_Group] FOREIGN KEY ([GroupId]) REFERENCES [dbo].[Group] ([Id]) ON DELETE CASCADE ); +GO +CREATE NONCLUSTERED INDEX IX_CollectionGroup_GroupId + ON [dbo].[CollectionGroup] (GroupId) + INCLUDE (ReadOnly, HidePasswords, Manage) + +GO diff --git a/src/Sql/dbo/Tables/CollectionUser.sql b/src/Sql/dbo/Tables/CollectionUser.sql index 8e0c0ef035..afdb0f84a0 100644 --- a/src/Sql/dbo/Tables/CollectionUser.sql +++ b/src/Sql/dbo/Tables/CollectionUser.sql @@ -9,3 +9,9 @@ CONSTRAINT [FK_CollectionUser_OrganizationUser] FOREIGN KEY ([OrganizationUserId]) REFERENCES [dbo].[OrganizationUser] ([Id]) ); +GO +CREATE NONCLUSTERED INDEX IX_CollectionUser_OrganizationUserId + ON [dbo].[CollectionUser] (OrganizationUserId) + INCLUDE (ReadOnly, HidePasswords, Manage) + +GO diff --git a/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionRepositoryTests.cs index fa7197ff61..268d46ef6b 100644 --- a/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionRepositoryTests.cs @@ -599,5 +599,11 @@ public class CollectionRepositoryTests Assert.True(actualOrgUser3.Manage); Assert.False(actualOrgUser3.HidePasswords); Assert.True(actualOrgUser3.ReadOnly); + + // Clean up data + await userRepository.DeleteAsync(user); + await organizationRepository.DeleteAsync(organization); + await groupRepository.DeleteManyAsync([group1.Id, group2.Id, group3.Id]); + await organizationUserRepository.DeleteManyAsync([orgUser1.Id, orgUser2.Id, orgUser3.Id]); } } diff --git a/util/Migrator/DbScripts/2025-04-07_00_Collections_UpdateWithGroupsAndUsers_AndIndices.sql b/util/Migrator/DbScripts/2025-04-07_00_Collections_UpdateWithGroupsAndUsers_AndIndices.sql new file mode 100644 index 0000000000..5bdeb5b254 --- /dev/null +++ b/util/Migrator/DbScripts/2025-04-07_00_Collections_UpdateWithGroupsAndUsers_AndIndices.sql @@ -0,0 +1,118 @@ +CREATE OR ALTER PROCEDURE [dbo].[Collection_UpdateWithGroupsAndUsers] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name VARCHAR(MAX), + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Groups AS [dbo].[CollectionAccessSelectionType] READONLY, + @Users AS [dbo].[CollectionAccessSelectionType] READONLY +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate + + -- Groups + -- Delete groups that are no longer in source + DELETE cg + FROM [dbo].[CollectionGroup] cg + LEFT JOIN @Groups g ON cg.GroupId = g.Id + WHERE cg.CollectionId = @Id + AND g.Id IS NULL; + + -- Update existing groups + UPDATE cg + SET cg.ReadOnly = g.ReadOnly, + cg.HidePasswords = g.HidePasswords, + cg.Manage = g.Manage + FROM [dbo].[CollectionGroup] cg + INNER JOIN @Groups g ON cg.GroupId = g.Id + WHERE cg.CollectionId = @Id + AND (cg.ReadOnly != g.ReadOnly + OR cg.HidePasswords != g.HidePasswords + OR cg.Manage != g.Manage); + + -- Insert new groups + INSERT INTO [dbo].[CollectionGroup] + ( + [CollectionId], + [GroupId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + @Id, + g.Id, + g.ReadOnly, + g.HidePasswords, + g.Manage + FROM @Groups g + INNER JOIN [dbo].[Group] grp ON grp.Id = g.Id + LEFT JOIN [dbo].[CollectionGroup] cg + ON cg.CollectionId = @Id AND cg.GroupId = g.Id + WHERE grp.OrganizationId = @OrganizationId + AND cg.CollectionId IS NULL; + + -- Users + -- Delete users that are no longer in source + DELETE cu + FROM [dbo].[CollectionUser] cu + LEFT JOIN @Users u ON cu.OrganizationUserId = u.Id + WHERE cu.CollectionId = @Id + AND u.Id IS NULL; + + -- Update existing users + UPDATE cu + SET cu.ReadOnly = u.ReadOnly, + cu.HidePasswords = u.HidePasswords, + cu.Manage = u.Manage + FROM [dbo].[CollectionUser] cu + INNER JOIN @Users u ON cu.OrganizationUserId = u.Id + WHERE cu.CollectionId = @Id + AND (cu.ReadOnly != u.ReadOnly + OR cu.HidePasswords != u.HidePasswords + OR cu.Manage != u.Manage); + + -- Insert new users + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + @Id, + u.Id, + u.ReadOnly, + u.HidePasswords, + u.Manage + FROM @Users u + INNER JOIN [dbo].[OrganizationUser] ou ON ou.Id = u.Id + LEFT JOIN [dbo].[CollectionUser] cu + ON cu.CollectionId = @Id AND cu.OrganizationUserId = u.Id + WHERE ou.OrganizationId = @OrganizationId + AND cu.CollectionId IS NULL; + + EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId +END +GO + +IF NOT EXISTS(SELECT name FROM sys.indexes WHERE name = 'IX_CollectionGroup_GroupId') + BEGIN + CREATE NONCLUSTERED INDEX IX_CollectionGroup_GroupId + ON [dbo].[CollectionGroup] (GroupId) + INCLUDE (ReadOnly, HidePasswords, Manage) + END +GO + +IF NOT EXISTS(SELECT name FROM sys.indexes WHERE name = 'IX_CollectionUser_OrganizationUserId') + BEGIN + CREATE NONCLUSTERED INDEX IX_CollectionUser_OrganizationUserId + ON [dbo].[CollectionUser] (OrganizationUserId) + INCLUDE (ReadOnly, HidePasswords, Manage) + END +GO From 159e4fe5024cbe05ab953489ccf2e7023b5e579c Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Fri, 18 Apr 2025 14:38:15 -0500 Subject: [PATCH 45/45] Corrected the number sent to stripe. Corrected the test. (#5667) --- .../InviteOrganizationUsersCommand.cs | 48 +++++++++---------- .../InviteOrganizationUserCommandTests.cs | 2 +- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs index 1aff71c636..662ed314ce 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs @@ -159,13 +159,13 @@ public class InviteOrganizationUsersCommand(IEventService eventService, private async Task RevertPasswordManagerChangesAsync(Valid validatedResult, Organization organization) { - if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd > 0) + if (validatedResult.Value.PasswordManagerSubscriptionUpdate is { Seats: > 0, SeatsRequiredToAdd: > 0 }) { - // When reverting seats, we have to tell payments service that the seats are going back down by what we attempted to add. - // However, this might lead to a problem if we don't actually update stripe but throw any ways. - // stripe could not be updated, and then we would decrement the number of seats in stripe accidentally. - var seatsToRemove = validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd; - await paymentService.AdjustSeatsAsync(organization, validatedResult.Value.InviteOrganization.Plan, -seatsToRemove); + + + await paymentService.AdjustSeatsAsync(organization, + validatedResult.Value.InviteOrganization.Plan, + validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats.Value); organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats; @@ -274,25 +274,25 @@ public class InviteOrganizationUsersCommand(IEventService eventService, private async Task AdjustPasswordManagerSeatsAsync(Valid validatedResult, Organization organization) { - if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd <= 0) + if (validatedResult.Value.PasswordManagerSubscriptionUpdate is { SeatsRequiredToAdd: > 0, UpdatedSeatTotal: > 0 }) { - return; + await paymentService.AdjustSeatsAsync(organization, + validatedResult.Value.InviteOrganization.Plan, + validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal.Value); + + organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal; + + await organizationRepository.ReplaceAsync(organization); // could optimize this with only a property update + await applicationCacheService.UpsertOrganizationAbilityAsync(organization); + + await referenceEventService.RaiseEventAsync( + new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, currentContext) + { + PlanName = validatedResult.Value.InviteOrganization.Plan.Name, + PlanType = validatedResult.Value.InviteOrganization.Plan.Type, + Seats = validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal, + PreviousSeats = validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats + }); } - - await paymentService.AdjustSeatsAsync(organization, validatedResult.Value.InviteOrganization.Plan, validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd); - - organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal; - - await organizationRepository.ReplaceAsync(organization); // could optimize this with only a property update - await applicationCacheService.UpsertOrganizationAbilityAsync(organization); - - await referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, currentContext) - { - PlanName = validatedResult.Value.InviteOrganization.Plan.Name, - PlanType = validatedResult.Value.InviteOrganization.Plan.Type, - Seats = validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal, - PreviousSeats = validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats - }); } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs index 6ae2d58c73..0592b481d3 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs @@ -420,7 +420,7 @@ public class InviteOrganizationUserCommandTests Assert.IsType>(result); await sutProvider.GetDependency() - .AdjustSeatsAsync(organization, inviteOrganization.Plan, passwordManagerUpdate.SeatsRequiredToAdd); + .AdjustSeatsAsync(organization, inviteOrganization.Plan, passwordManagerUpdate.UpdatedSeatTotal!.Value); await orgRepository.Received(1).ReplaceAsync(Arg.Is(x => x.Seats == passwordManagerUpdate.UpdatedSeatTotal));