From 505508a4163c61f052c1aa4c435b5d6be9c2c959 Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Fri, 12 Jan 2024 16:13:29 +0100 Subject: [PATCH 001/117] [PM-5553] Move Org-Export to tools (#3639) * Move Org-Export to tools * Make linter happy --------- Co-authored-by: Daniel James Smith --- .../{ => Tools}/Controllers/OrganizationExportController.cs | 3 ++- .../Models/Response/OrganizationExportResponseModel.cs | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) rename src/Api/{ => Tools}/Controllers/OrganizationExportController.cs (97%) rename src/Api/{ => Tools}/Models/Response/OrganizationExportResponseModel.cs (85%) diff --git a/src/Api/Controllers/OrganizationExportController.cs b/src/Api/Tools/Controllers/OrganizationExportController.cs similarity index 97% rename from src/Api/Controllers/OrganizationExportController.cs rename to src/Api/Tools/Controllers/OrganizationExportController.cs index be5aa0e14a..b3c0643b28 100644 --- a/src/Api/Controllers/OrganizationExportController.cs +++ b/src/Api/Tools/Controllers/OrganizationExportController.cs @@ -1,4 +1,5 @@ using Bit.Api.Models.Response; +using Bit.Api.Tools.Models.Response; using Bit.Api.Vault.Models.Response; using Bit.Core.Context; using Bit.Core.Entities; @@ -9,7 +10,7 @@ using Bit.Core.Vault.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace Bit.Api.Controllers; +namespace Bit.Api.Tools.Controllers; [Route("organizations/{organizationId}")] [Authorize("Application")] diff --git a/src/Api/Models/Response/OrganizationExportResponseModel.cs b/src/Api/Tools/Models/Response/OrganizationExportResponseModel.cs similarity index 85% rename from src/Api/Models/Response/OrganizationExportResponseModel.cs rename to src/Api/Tools/Models/Response/OrganizationExportResponseModel.cs index 1bfca7e3d5..a4b35d8de1 100644 --- a/src/Api/Models/Response/OrganizationExportResponseModel.cs +++ b/src/Api/Tools/Models/Response/OrganizationExportResponseModel.cs @@ -1,7 +1,8 @@ -using Bit.Api.Vault.Models.Response; +using Bit.Api.Models.Response; +using Bit.Api.Vault.Models.Response; using Bit.Core.Models.Api; -namespace Bit.Api.Models.Response; +namespace Bit.Api.Tools.Models.Response; public class OrganizationExportResponseModel : ResponseModel { From 95139def0f2aa5865bedd66d605903e4030cae7f Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Fri, 12 Jan 2024 10:38:47 -0500 Subject: [PATCH 002/117] [AC-1758] Implement `RemoveOrganizationFromProviderCommand` (#3515) * Add RemovePaymentMethod to StripePaymentService * Add SendProviderUpdatePaymentMethod to HandlebarsMailService * Add RemoveOrganizationFromProviderCommand * Use RemoveOrganizationFromProviderCommand in ProviderOrganizationController * Remove RemoveOrganizationAsync from ProviderService * Add RemoveOrganizationFromProviderCommandTests * PR review feedback and refactoring * Remove RemovePaymentMethod from StripePaymentService * Review feedback * Add Organization RisksSubscriptionFailure endpoint * fix build error * Review feedback * [AC-1359] Bitwarden Portal Unlink Provider Buttons (#3588) * Added ability to unlink organization from provider from provider edit page * Refreshing provider edit page after removing an org * Added button to organization to remove the org from the provider * Updated based on product feedback * Removed organization name from alert message * Temporary logging * Remove coupon from Stripe org after disconnected from MSP * Updated test * Change payment terms on org disconnect from MSP * Set Stripe account email to new billing email * Remove logging --------- Co-authored-by: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Co-authored-by: Conner Turnbull --- .../RemoveOrganizationFromProviderCommand.cs | 98 +++++ .../AdminConsole/Services/ProviderService.cs | 17 - .../Utilities/ServiceCollectionExtensions.cs | 1 + ...oveOrganizationFromProviderCommandTests.cs | 132 +++++++ .../Services/ProviderServiceTests.cs | 59 --- .../Controllers/OrganizationsController.cs | 45 ++- .../ProviderOrganizationsController.cs | 67 ++++ src/Admin/Startup.cs | 2 + src/Admin/Views/Organizations/Edit.cshtml | 12 +- .../Views/Providers/Organizations.cshtml | 26 +- .../_ProviderOrganizationScripts.cshtml | 21 + .../Shared/_OrganizationFormScripts.cshtml | 20 + .../Controllers/OrganizationsController.cs | 21 +- .../ProviderOrganizationsController.cs | 46 ++- ...onRisksSubscriptionFailureResponseModel.cs | 17 + src/Api/Startup.cs | 2 + .../IRemoveOrganizationFromProviderCommand.cs | 12 + .../AdminConsole/Services/IProviderService.cs | 1 - .../Commands/IRemovePaymentMethodCommand.cs | 8 + .../RemovePaymentMethodCommand.cs | 140 +++++++ .../Extensions/ServiceCollectionExtensions.cs | 14 + .../ProviderUpdatePaymentMethod.html.hbs | 27 ++ .../ProviderUpdatePaymentMethod.text.hbs | 7 + .../ProviderUpdatePaymentMethodViewModel.cs | 11 + src/Core/Services/IMailService.cs | 5 + src/Core/Services/IPaymentService.cs | 1 + src/Core/Services/IStripeAdapter.cs | 1 + .../Implementations/HandlebarsMailService.cs | 24 ++ .../Services/Implementations/StripeAdapter.cs | 3 + .../Implementations/StripePaymentService.cs | 17 + .../NoopImplementations/NoopMailService.cs | 3 + .../Repositories/OrganizationRepository.cs | 13 +- .../Repositories/OrganizationRepository.cs | 16 +- .../OrganizationsControllerTests.cs | 31 +- .../RemovePaymentMethodCommandTests.cs | 367 ++++++++++++++++++ 35 files changed, 1168 insertions(+), 119 deletions(-) create mode 100644 bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs create mode 100644 bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs create mode 100644 src/Admin/Controllers/ProviderOrganizationsController.cs create mode 100644 src/Admin/Views/Providers/_ProviderOrganizationScripts.cshtml create mode 100644 src/Api/AdminConsole/Models/Response/Organizations/OrganizationRisksSubscriptionFailureResponseModel.cs create mode 100644 src/Core/AdminConsole/Providers/Interfaces/IRemoveOrganizationFromProviderCommand.cs create mode 100644 src/Core/Billing/Commands/IRemovePaymentMethodCommand.cs create mode 100644 src/Core/Billing/Commands/Implementations/RemovePaymentMethodCommand.cs create mode 100644 src/Core/Billing/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/Core/MailTemplates/Handlebars/Provider/ProviderUpdatePaymentMethod.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/Provider/ProviderUpdatePaymentMethod.text.hbs create mode 100644 src/Core/Models/Mail/Provider/ProviderUpdatePaymentMethodViewModel.cs create mode 100644 test/Core.Test/Billing/Commands/RemovePaymentMethodCommandTests.cs diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs new file mode 100644 index 0000000000..778cd62c26 --- /dev/null +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs @@ -0,0 +1,98 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Providers.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using Stripe; + +namespace Bit.Commercial.Core.AdminConsole.Providers; + +public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProviderCommand +{ + private readonly IEventService _eventService; + private readonly ILogger _logger; + private readonly IMailService _mailService; + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationService _organizationService; + private readonly IProviderOrganizationRepository _providerOrganizationRepository; + private readonly IStripeAdapter _stripeAdapter; + + public RemoveOrganizationFromProviderCommand( + IEventService eventService, + ILogger logger, + IMailService mailService, + IOrganizationRepository organizationRepository, + IOrganizationService organizationService, + IProviderOrganizationRepository providerOrganizationRepository, + IStripeAdapter stripeAdapter) + { + _eventService = eventService; + _logger = logger; + _mailService = mailService; + _organizationRepository = organizationRepository; + _organizationService = organizationService; + _providerOrganizationRepository = providerOrganizationRepository; + _stripeAdapter = stripeAdapter; + } + + public async Task RemoveOrganizationFromProvider( + Provider provider, + ProviderOrganization providerOrganization, + Organization organization) + { + if (provider == null || + providerOrganization == null || + organization == null || + providerOrganization.ProviderId != provider.Id) + { + throw new BadRequestException("Failed to remove organization. Please contact support."); + } + + if (!await _organizationService.HasConfirmedOwnersExceptAsync( + providerOrganization.OrganizationId, + Array.Empty(), + includeProvider: false)) + { + throw new BadRequestException("Organization must have at least one confirmed owner."); + } + + var organizationOwnerEmails = + (await _organizationRepository.GetOwnerEmailAddressesById(organization.Id)).ToList(); + + organization.BillingEmail = organizationOwnerEmails.MinBy(email => email); + + await _organizationRepository.ReplaceAsync(organization); + + var customerUpdateOptions = new CustomerUpdateOptions + { + Coupon = string.Empty, + Email = organization.BillingEmail + }; + + await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, customerUpdateOptions); + + var subscriptionUpdateOptions = new SubscriptionUpdateOptions + { + CollectionMethod = "send_invoice", + DaysUntilDue = 30 + }; + + await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, subscriptionUpdateOptions); + + await _mailService.SendProviderUpdatePaymentMethod( + organization.Id, + organization.Name, + provider.Name, + organizationOwnerEmails); + + await _providerOrganizationRepository.DeleteAsync(providerOrganization); + + await _eventService.LogProviderOrganizationEventAsync( + providerOrganization, + EventType.ProviderOrganization_Removed); + } +} diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index c8b64da19a..f9049de072 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -527,23 +527,6 @@ public class ProviderService : IProviderService return providerOrganization; } - public async Task RemoveOrganizationAsync(Guid providerId, Guid providerOrganizationId, Guid removingUserId) - { - var providerOrganization = await _providerOrganizationRepository.GetByIdAsync(providerOrganizationId); - if (providerOrganization == null || providerOrganization.ProviderId != providerId) - { - throw new BadRequestException("Invalid organization."); - } - - if (!await _organizationService.HasConfirmedOwnersExceptAsync(providerOrganization.OrganizationId, new Guid[] { }, includeProvider: false)) - { - throw new BadRequestException("Organization needs to have at least one confirmed owner."); - } - - await _providerOrganizationRepository.DeleteAsync(providerOrganization); - await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed); - } - public async Task ResendProviderSetupInviteEmailAsync(Guid providerId, Guid ownerId) { var provider = await _providerRepository.GetByIdAsync(providerId); diff --git a/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs b/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs index 09788406de..53c089f9fa 100644 --- a/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs @@ -12,5 +12,6 @@ public static class ServiceCollectionExtensions { services.AddScoped(); services.AddScoped(); + services.AddScoped(); } } diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs new file mode 100644 index 0000000000..7148bcb17b --- /dev/null +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs @@ -0,0 +1,132 @@ +using Bit.Commercial.Core.AdminConsole.Providers; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Enums; +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 Stripe; +using Xunit; + +namespace Bit.Commercial.Core.Test.AdminConsole.ProviderFeatures; + +[SutProviderCustomize] +public class RemoveOrganizationFromProviderCommandTests +{ + [Theory, BitAutoData] + public async Task RemoveOrganizationFromProvider_NoProvider_BadRequest( + SutProvider sutProvider) + { + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.RemoveOrganizationFromProvider(null, null, null)); + + Assert.Equal("Failed to remove organization. Please contact support.", exception.Message); + } + + [Theory, BitAutoData] + public async Task RemoveOrganizationFromProvider_NoProviderOrganization_BadRequest( + Provider provider, + SutProvider sutProvider) + { + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.RemoveOrganizationFromProvider(provider, null, null)); + + Assert.Equal("Failed to remove organization. Please contact support.", exception.Message); + } + + [Theory, BitAutoData] + public async Task RemoveOrganizationFromProvider_NoOrganization_BadRequest( + Provider provider, + ProviderOrganization providerOrganization, + SutProvider sutProvider) + { + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.RemoveOrganizationFromProvider( + provider, providerOrganization, null)); + + Assert.Equal("Failed to remove organization. Please contact support.", exception.Message); + } + + [Theory, BitAutoData] + public async Task RemoveOrganizationFromProvider_MismatchedProviderOrganization_BadRequest( + Provider provider, + ProviderOrganization providerOrganization, + Organization organization, + SutProvider sutProvider) + { + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization)); + + Assert.Equal("Failed to remove organization. Please contact support.", exception.Message); + } + + [Theory, BitAutoData] + public async Task RemoveOrganizationFromProvider_NoConfirmedOwners_BadRequest( + Provider provider, + ProviderOrganization providerOrganization, + Organization organization, + SutProvider sutProvider) + { + providerOrganization.ProviderId = provider.Id; + + sutProvider.GetDependency().HasConfirmedOwnersExceptAsync( + providerOrganization.OrganizationId, + Array.Empty(), + includeProvider: false) + .Returns(false); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization)); + + Assert.Equal("Organization must have at least one confirmed owner.", exception.Message); + } + + [Theory, BitAutoData] + public async Task RemoveOrganizationFromProvider_MakesCorrectInvocations( + Provider provider, + ProviderOrganization providerOrganization, + Organization organization, + SutProvider sutProvider) + { + providerOrganization.ProviderId = provider.Id; + + var organizationRepository = sutProvider.GetDependency(); + + sutProvider.GetDependency().HasConfirmedOwnersExceptAsync( + providerOrganization.OrganizationId, + Array.Empty(), + includeProvider: false) + .Returns(true); + + var organizationOwnerEmails = new List { "a@gmail.com", "b@gmail.com" }; + + organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns(organizationOwnerEmails); + + await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization); + + await organizationRepository.Received(1).ReplaceAsync(Arg.Is( + org => org.Id == organization.Id && org.BillingEmail == "a@gmail.com")); + + var stripeAdapter = sutProvider.GetDependency(); + + await stripeAdapter.Received(1).CustomerUpdateAsync( + organization.GatewayCustomerId, Arg.Is( + options => options.Coupon == string.Empty && options.Email == "a@gmail.com")); + + await stripeAdapter.Received(1).SubscriptionUpdateAsync( + organization.GatewaySubscriptionId, Arg.Is( + options => options.CollectionMethod == "send_invoice" && options.DaysUntilDue == 30)); + + await sutProvider.GetDependency().Received(1).SendProviderUpdatePaymentMethod( + organization.Id, + organization.Name, + provider.Name, + Arg.Is>(emails => emails.Contains("a@gmail.com") && emails.Contains("b@gmail.com"))); + + await sutProvider.GetDependency().Received(1) + .DeleteAsync(providerOrganization); + + await sutProvider.GetDependency().Received(1).LogProviderOrganizationEventAsync( + providerOrganization, + EventType.ProviderOrganization_Removed); + } +} diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs index 24167e7141..eea0ac53f0 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs @@ -541,65 +541,6 @@ public class ProviderServiceTests t.First().Item2 == null)); } - [Theory, BitAutoData] - public async Task RemoveOrganization_ProviderOrganizationIsInvalid_Throws(Provider provider, - ProviderOrganization providerOrganization, User user, SutProvider sutProvider) - { - sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); - sutProvider.GetDependency().GetByIdAsync(providerOrganization.Id) - .ReturnsNull(); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RemoveOrganizationAsync(provider.Id, providerOrganization.Id, user.Id)); - Assert.Equal("Invalid organization.", exception.Message); - } - - [Theory, BitAutoData] - public async Task RemoveOrganization_ProviderOrganizationBelongsToWrongProvider_Throws(Provider provider, - ProviderOrganization providerOrganization, User user, SutProvider sutProvider) - { - sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); - sutProvider.GetDependency().GetByIdAsync(providerOrganization.Id) - .Returns(providerOrganization); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RemoveOrganizationAsync(provider.Id, providerOrganization.Id, user.Id)); - Assert.Equal("Invalid organization.", exception.Message); - } - - [Theory, BitAutoData] - public async Task RemoveOrganization_HasNoOwners_Throws(Provider provider, - ProviderOrganization providerOrganization, User user, SutProvider sutProvider) - { - providerOrganization.ProviderId = provider.Id; - sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); - sutProvider.GetDependency().GetByIdAsync(providerOrganization.Id) - .Returns(providerOrganization); - sutProvider.GetDependency().HasConfirmedOwnersExceptAsync(default, default, default) - .ReturnsForAnyArgs(false); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RemoveOrganizationAsync(provider.Id, providerOrganization.Id, user.Id)); - Assert.Equal("Organization needs to have at least one confirmed owner.", exception.Message); - } - - [Theory, BitAutoData] - public async Task RemoveOrganization_Success(Provider provider, - ProviderOrganization providerOrganization, User user, SutProvider sutProvider) - { - providerOrganization.ProviderId = provider.Id; - sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); - var providerOrganizationRepository = sutProvider.GetDependency(); - providerOrganizationRepository.GetByIdAsync(providerOrganization.Id).Returns(providerOrganization); - sutProvider.GetDependency().HasConfirmedOwnersExceptAsync(default, default, default) - .ReturnsForAnyArgs(true); - - await sutProvider.Sut.RemoveOrganizationAsync(provider.Id, providerOrganization.Id, user.Id); - await providerOrganizationRepository.Received().DeleteAsync(providerOrganization); - await sutProvider.GetDependency().Received() - .LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed); - } - [Theory, BitAutoData] public async Task AddOrganization_CreateAfterNov162023_PlanTypeDoesNotUpdated(Provider provider, Organization organization, string key, SutProvider sutProvider) diff --git a/src/Admin/Controllers/OrganizationsController.cs b/src/Admin/Controllers/OrganizationsController.cs index aebdcefe4f..d665ebddef 100644 --- a/src/Admin/Controllers/OrganizationsController.cs +++ b/src/Admin/Controllers/OrganizationsController.cs @@ -3,7 +3,9 @@ using Bit.Admin.Models; using Bit.Admin.Services; using Bit.Admin.Utilities; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Commands; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -48,6 +50,9 @@ public class OrganizationsController : Controller private readonly ISecretRepository _secretRepository; private readonly IProjectRepository _projectRepository; private readonly IServiceAccountRepository _serviceAccountRepository; + private readonly IProviderOrganizationRepository _providerOrganizationRepository; + private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand; + private readonly IRemovePaymentMethodCommand _removePaymentMethodCommand; public OrganizationsController( IOrganizationService organizationService, @@ -71,7 +76,10 @@ public class OrganizationsController : Controller ICurrentContext currentContext, ISecretRepository secretRepository, IProjectRepository projectRepository, - IServiceAccountRepository serviceAccountRepository) + IServiceAccountRepository serviceAccountRepository, + IProviderOrganizationRepository providerOrganizationRepository, + IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand, + IRemovePaymentMethodCommand removePaymentMethodCommand) { _organizationService = organizationService; _organizationRepository = organizationRepository; @@ -95,6 +103,9 @@ public class OrganizationsController : Controller _secretRepository = secretRepository; _projectRepository = projectRepository; _serviceAccountRepository = serviceAccountRepository; + _providerOrganizationRepository = providerOrganizationRepository; + _removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand; + _removePaymentMethodCommand = removePaymentMethodCommand; } [RequirePermission(Permission.Org_List_View)] @@ -286,6 +297,38 @@ public class OrganizationsController : Controller return Json(null); } + + [HttpPost] + [RequirePermission(Permission.Provider_Edit)] + public async Task UnlinkOrganizationFromProviderAsync(Guid id) + { + var organization = await _organizationRepository.GetByIdAsync(id); + if (organization is null) + { + return RedirectToAction("Index"); + } + + var provider = await _providerRepository.GetByOrganizationIdAsync(id); + if (provider is null) + { + return RedirectToAction("Edit", new { id }); + } + + var providerOrganization = await _providerOrganizationRepository.GetByOrganizationId(id); + if (providerOrganization is null) + { + return RedirectToAction("Edit", new { id }); + } + + await _removeOrganizationFromProviderCommand.RemoveOrganizationFromProvider( + provider, + providerOrganization, + organization); + + await _removePaymentMethodCommand.RemovePaymentMethod(organization); + + return Json(null); + } private async Task GetOrganization(Guid id, OrganizationEditModel model) { var organization = await _organizationRepository.GetByIdAsync(id); diff --git a/src/Admin/Controllers/ProviderOrganizationsController.cs b/src/Admin/Controllers/ProviderOrganizationsController.cs new file mode 100644 index 0000000000..e21e1297f6 --- /dev/null +++ b/src/Admin/Controllers/ProviderOrganizationsController.cs @@ -0,0 +1,67 @@ +using Bit.Admin.Enums; +using Bit.Admin.Utilities; +using Bit.Core.AdminConsole.Providers.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Commands; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Admin.Controllers; + +[Authorize] +[SelfHosted(NotSelfHostedOnly = true)] +public class ProviderOrganizationsController : Controller +{ + private readonly IProviderRepository _providerRepository; + private readonly IProviderOrganizationRepository _providerOrganizationRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand; + private readonly IRemovePaymentMethodCommand _removePaymentMethodCommand; + + public ProviderOrganizationsController(IProviderRepository providerRepository, + IProviderOrganizationRepository providerOrganizationRepository, + IOrganizationRepository organizationRepository, + IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand, + IRemovePaymentMethodCommand removePaymentMethodCommand) + { + _providerRepository = providerRepository; + _providerOrganizationRepository = providerOrganizationRepository; + _organizationRepository = organizationRepository; + _removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand; + _removePaymentMethodCommand = removePaymentMethodCommand; + } + + [HttpPost] + [RequirePermission(Permission.Provider_Edit)] + public async Task DeleteAsync(Guid providerId, Guid id) + { + var provider = await _providerRepository.GetByIdAsync(providerId); + if (provider is null) + { + return RedirectToAction("Index", "Providers"); + } + + var providerOrganization = await _providerOrganizationRepository.GetByIdAsync(id); + if (providerOrganization is null) + { + return RedirectToAction("View", "Providers", new { id = providerId }); + } + + var organization = await _organizationRepository.GetByIdAsync(providerOrganization.OrganizationId); + if (organization == null) + { + return RedirectToAction("View", "Providers", new { id = providerId }); + } + + await _removeOrganizationFromProviderCommand.RemoveOrganizationFromProvider( + provider, + providerOrganization, + organization); + + await _removePaymentMethodCommand.RemovePaymentMethod(organization); + + return Json(null); + } +} diff --git a/src/Admin/Startup.cs b/src/Admin/Startup.cs index a10cd4d2de..4c2bfdb7df 100644 --- a/src/Admin/Startup.cs +++ b/src/Admin/Startup.cs @@ -9,6 +9,7 @@ using Stripe; using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.Extensions.DependencyInjection.Extensions; using Bit.Admin.Services; +using Bit.Core.Billing.Extensions; #if !OSS using Bit.Commercial.Core.Utilities; @@ -87,6 +88,7 @@ public class Startup services.AddBaseServices(globalSettings); services.AddDefaultServices(globalSettings); services.AddScoped(); + services.AddBillingCommands(); #if OSS services.AddOosServices(); diff --git a/src/Admin/Views/Organizations/Edit.cshtml b/src/Admin/Views/Organizations/Edit.cshtml index e3f6d50905..ad4e4f8482 100644 --- a/src/Admin/Views/Organizations/Edit.cshtml +++ b/src/Admin/Views/Organizations/Edit.cshtml @@ -8,6 +8,7 @@ var canViewBillingInformation = AccessControlService.UserHasPermission(Permission.Org_BillingInformation_View); var canInitiateTrial = AccessControlService.UserHasPermission(Permission.Org_InitiateTrial); var canDelete = AccessControlService.UserHasPermission(Permission.Org_Delete); + var canUnlinkFromProvider = AccessControlService.UserHasPermission(Permission.Provider_Edit); } @section Scripts { @@ -81,7 +82,7 @@
- @if (canInitiateTrial) + @if (canInitiateTrial && Model.Provider is null) { } + @if (canUnlinkFromProvider && Model.Provider is not null) + { + + } @if (canDelete) {
Provider Organizations
@@ -32,26 +40,28 @@ } else { - @foreach (var org in Model.ProviderOrganizations) + @foreach (var providerOrganization in Model.ProviderOrganizations) { - @org.OrganizationName + @providerOrganization.OrganizationName - @org.Status + @providerOrganization.Status
- @if (org.Status == OrganizationStatusType.Pending) + @if (canUnlinkFromProvider) { - - + + Unlink provider } - else + @if (providerOrganization.Status == OrganizationStatusType.Pending) { - + + Resend invitation + }
diff --git a/src/Admin/Views/Providers/_ProviderOrganizationScripts.cshtml b/src/Admin/Views/Providers/_ProviderOrganizationScripts.cshtml new file mode 100644 index 0000000000..b8fefb4c14 --- /dev/null +++ b/src/Admin/Views/Providers/_ProviderOrganizationScripts.cshtml @@ -0,0 +1,21 @@ + diff --git a/src/Admin/Views/Shared/_OrganizationFormScripts.cshtml b/src/Admin/Views/Shared/_OrganizationFormScripts.cshtml index fc527750aa..85d62f6b08 100644 --- a/src/Admin/Views/Shared/_OrganizationFormScripts.cshtml +++ b/src/Admin/Views/Shared/_OrganizationFormScripts.cshtml @@ -113,6 +113,26 @@ } } + function unlinkProvider(id) { + if (confirm('Are you sure you want to unlink this organization from its provider?')) { + $.ajax({ + type: "POST", + url: `@Url.Action("UnlinkOrganizationFromProvider", "Organizations")?id=${id}`, + dataType: 'json', + contentType: false, + processData: false, + success: function (response) { + alert("Successfully unlinked provider"); + window.location.href = `@Url.Action("Edit", "Organizations")?id=${id}`; + }, + error: function (response) { + alert("Error!"); + } + }); + } + return false; + } + /*** * Set Secrets Manager values based on current usage (for migrating from SM beta or reinstating an old subscription) */ diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 3293041a2b..1683af2b68 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -40,7 +40,6 @@ public class OrganizationsController : Controller private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IPolicyRepository _policyRepository; - private readonly IProviderRepository _providerRepository; private readonly IOrganizationService _organizationService; private readonly IUserService _userService; private readonly IPaymentService _paymentService; @@ -51,7 +50,6 @@ public class OrganizationsController : Controller private readonly IRotateOrganizationApiKeyCommand _rotateOrganizationApiKeyCommand; private readonly ICreateOrganizationApiKeyCommand _createOrganizationApiKeyCommand; private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; - private readonly IUpdateOrganizationLicenseCommand _updateOrganizationLicenseCommand; private readonly ICloudGetOrganizationLicenseQuery _cloudGetOrganizationLicenseQuery; private readonly IFeatureService _featureService; private readonly GlobalSettings _globalSettings; @@ -64,7 +62,6 @@ public class OrganizationsController : Controller IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, IPolicyRepository policyRepository, - IProviderRepository providerRepository, IOrganizationService organizationService, IUserService userService, IPaymentService paymentService, @@ -75,7 +72,6 @@ public class OrganizationsController : Controller IRotateOrganizationApiKeyCommand rotateOrganizationApiKeyCommand, ICreateOrganizationApiKeyCommand createOrganizationApiKeyCommand, IOrganizationApiKeyRepository organizationApiKeyRepository, - IUpdateOrganizationLicenseCommand updateOrganizationLicenseCommand, ICloudGetOrganizationLicenseQuery cloudGetOrganizationLicenseQuery, IFeatureService featureService, GlobalSettings globalSettings, @@ -87,7 +83,6 @@ public class OrganizationsController : Controller _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; _policyRepository = policyRepository; - _providerRepository = providerRepository; _organizationService = organizationService; _userService = userService; _paymentService = paymentService; @@ -98,7 +93,6 @@ public class OrganizationsController : Controller _rotateOrganizationApiKeyCommand = rotateOrganizationApiKeyCommand; _createOrganizationApiKeyCommand = createOrganizationApiKeyCommand; _organizationApiKeyRepository = organizationApiKeyRepository; - _updateOrganizationLicenseCommand = updateOrganizationLicenseCommand; _cloudGetOrganizationLicenseQuery = cloudGetOrganizationLicenseQuery; _featureService = featureService; _globalSettings = globalSettings; @@ -245,6 +239,21 @@ public class OrganizationsController : Controller return new OrganizationAutoEnrollStatusResponseModel(organization.Id, data?.AutoEnrollEnabled ?? false); } + [HttpGet("{id}/risks-subscription-failure")] + public async Task RisksSubscriptionFailure(Guid id) + { + if (!await _currentContext.EditPaymentMethods(id)) + { + return new OrganizationRisksSubscriptionFailureResponseModel(id, false); + } + + var organization = await _organizationRepository.GetByIdAsync(id); + + var risksSubscriptionFailure = await _paymentService.RisksSubscriptionFailure(organization); + + return new OrganizationRisksSubscriptionFailureResponseModel(id, risksSubscriptionFailure); + } + [HttpPost("")] [SelfHosted(NotSelfHostedOnly = true)] public async Task Post([FromBody] OrganizationCreateRequestModel model) diff --git a/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs b/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs index 4d734e7cad..136119848a 100644 --- a/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs @@ -1,10 +1,13 @@ using Bit.Api.AdminConsole.Models.Request.Providers; using Bit.Api.AdminConsole.Models.Response.Providers; using Bit.Api.Models.Response; +using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Commands; using Bit.Core.Context; using Bit.Core.Exceptions; +using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; @@ -16,22 +19,33 @@ namespace Bit.Api.AdminConsole.Controllers; [Authorize("Application")] public class ProviderOrganizationsController : Controller { - - private readonly IProviderOrganizationRepository _providerOrganizationRepository; - private readonly IProviderService _providerService; - private readonly IUserService _userService; private readonly ICurrentContext _currentContext; + private readonly IOrganizationRepository _organizationRepository; + private readonly IProviderOrganizationRepository _providerOrganizationRepository; + private readonly IProviderRepository _providerRepository; + private readonly IProviderService _providerService; + private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand; + private readonly IRemovePaymentMethodCommand _removePaymentMethodCommand; + private readonly IUserService _userService; public ProviderOrganizationsController( + ICurrentContext currentContext, + IOrganizationRepository organizationRepository, IProviderOrganizationRepository providerOrganizationRepository, + IProviderRepository providerRepository, IProviderService providerService, - IUserService userService, - ICurrentContext currentContext) + IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand, + IRemovePaymentMethodCommand removePaymentMethodCommand, + IUserService userService) { - _providerOrganizationRepository = providerOrganizationRepository; - _providerService = providerService; - _userService = userService; _currentContext = currentContext; + _organizationRepository = organizationRepository; + _providerOrganizationRepository = providerOrganizationRepository; + _providerRepository = providerRepository; + _providerService = providerService; + _removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand; + _removePaymentMethodCommand = removePaymentMethodCommand; + _userService = userService; } [HttpGet("")] @@ -87,7 +101,17 @@ public class ProviderOrganizationsController : Controller throw new NotFoundException(); } - var userId = _userService.GetProperUserId(User); - await _providerService.RemoveOrganizationAsync(providerId, id, userId.Value); + var provider = await _providerRepository.GetByIdAsync(providerId); + + var providerOrganization = await _providerOrganizationRepository.GetByIdAsync(id); + + var organization = await _organizationRepository.GetByIdAsync(providerOrganization.OrganizationId); + + await _removeOrganizationFromProviderCommand.RemoveOrganizationFromProvider( + provider, + providerOrganization, + organization); + + await _removePaymentMethodCommand.RemovePaymentMethod(organization); } } diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationRisksSubscriptionFailureResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationRisksSubscriptionFailureResponseModel.cs new file mode 100644 index 0000000000..e91275da3c --- /dev/null +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationRisksSubscriptionFailureResponseModel.cs @@ -0,0 +1,17 @@ +using Bit.Core.Models.Api; + +namespace Bit.Api.AdminConsole.Models.Response.Organizations; + +public class OrganizationRisksSubscriptionFailureResponseModel : ResponseModel +{ + public Guid OrganizationId { get; } + public bool RisksSubscriptionFailure { get; } + + public OrganizationRisksSubscriptionFailureResponseModel( + Guid organizationId, + bool risksSubscriptionFailure) : base("organizationRisksSubscriptionFailure") + { + OrganizationId = organizationId; + RisksSubscriptionFailure = risksSubscriptionFailure; + } +} diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index b82b2e1c27..7b5067f3fe 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -26,6 +26,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Bit.Core.Auth.Identity; using Bit.Core.Auth.UserFeatures; using Bit.Core.Entities; +using Bit.Core.Billing.Extensions; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions; using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; @@ -169,6 +170,7 @@ public class Startup services.AddDefaultServices(globalSettings); services.AddOrganizationSubscriptionServices(); services.AddCoreLocalizationServices(); + services.AddBillingCommands(); // Authorization Handlers services.AddAuthorizationHandlers(); diff --git a/src/Core/AdminConsole/Providers/Interfaces/IRemoveOrganizationFromProviderCommand.cs b/src/Core/AdminConsole/Providers/Interfaces/IRemoveOrganizationFromProviderCommand.cs new file mode 100644 index 0000000000..84013adc19 --- /dev/null +++ b/src/Core/AdminConsole/Providers/Interfaces/IRemoveOrganizationFromProviderCommand.cs @@ -0,0 +1,12 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; + +namespace Bit.Core.AdminConsole.Providers.Interfaces; + +public interface IRemoveOrganizationFromProviderCommand +{ + Task RemoveOrganizationFromProvider( + Provider provider, + ProviderOrganization providerOrganization, + Organization organization); +} diff --git a/src/Core/AdminConsole/Services/IProviderService.cs b/src/Core/AdminConsole/Services/IProviderService.cs index f71403e80c..fdaef4c03b 100644 --- a/src/Core/AdminConsole/Services/IProviderService.cs +++ b/src/Core/AdminConsole/Services/IProviderService.cs @@ -23,7 +23,6 @@ public interface IProviderService Task AddOrganizationsToReseller(Guid providerId, IEnumerable organizationIds); Task CreateOrganizationAsync(Guid providerId, OrganizationSignup organizationSignup, string clientOwnerEmail, User user); - Task RemoveOrganizationAsync(Guid providerId, Guid providerOrganizationId, Guid removingUserId); Task LogProviderAccessToOrganizationAsync(Guid organizationId); Task ResendProviderSetupInviteEmailAsync(Guid providerId, Guid ownerId); Task SendProviderSetupInviteEmailAsync(Provider provider, string ownerEmail); diff --git a/src/Core/Billing/Commands/IRemovePaymentMethodCommand.cs b/src/Core/Billing/Commands/IRemovePaymentMethodCommand.cs new file mode 100644 index 0000000000..62bf0d0926 --- /dev/null +++ b/src/Core/Billing/Commands/IRemovePaymentMethodCommand.cs @@ -0,0 +1,8 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.Billing.Commands; + +public interface IRemovePaymentMethodCommand +{ + Task RemovePaymentMethod(Organization organization); +} diff --git a/src/Core/Billing/Commands/Implementations/RemovePaymentMethodCommand.cs b/src/Core/Billing/Commands/Implementations/RemovePaymentMethodCommand.cs new file mode 100644 index 0000000000..c5dbb6d927 --- /dev/null +++ b/src/Core/Billing/Commands/Implementations/RemovePaymentMethodCommand.cs @@ -0,0 +1,140 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Services; +using Braintree; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Billing.Commands.Implementations; + +public class RemovePaymentMethodCommand : IRemovePaymentMethodCommand +{ + private readonly IBraintreeGateway _braintreeGateway; + private readonly ILogger _logger; + private readonly IStripeAdapter _stripeAdapter; + + public RemovePaymentMethodCommand( + IBraintreeGateway braintreeGateway, + ILogger logger, + IStripeAdapter stripeAdapter) + { + _braintreeGateway = braintreeGateway; + _logger = logger; + _stripeAdapter = stripeAdapter; + } + + public async Task RemovePaymentMethod(Organization organization) + { + const string braintreeCustomerIdKey = "btCustomerId"; + + if (organization == null) + { + throw new ArgumentNullException(nameof(organization)); + } + + if (organization.Gateway is not GatewayType.Stripe || string.IsNullOrEmpty(organization.GatewayCustomerId)) + { + throw ContactSupport(); + } + + var stripeCustomer = await _stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId, new Stripe.CustomerGetOptions + { + Expand = new List { "invoice_settings.default_payment_method", "sources" } + }); + + if (stripeCustomer == null) + { + _logger.LogError("Could not find Stripe customer ({ID}) when removing payment method", organization.GatewayCustomerId); + + throw ContactSupport(); + } + + if (stripeCustomer.Metadata?.TryGetValue(braintreeCustomerIdKey, out var braintreeCustomerId) ?? false) + { + await RemoveBraintreePaymentMethodAsync(braintreeCustomerId); + } + else + { + await RemoveStripePaymentMethodsAsync(stripeCustomer); + } + } + + private async Task RemoveBraintreePaymentMethodAsync(string braintreeCustomerId) + { + var customer = await _braintreeGateway.Customer.FindAsync(braintreeCustomerId); + + if (customer == null) + { + _logger.LogError("Failed to retrieve Braintree customer ({ID}) when removing payment method", braintreeCustomerId); + + throw ContactSupport(); + } + + if (customer.DefaultPaymentMethod != null) + { + var existingDefaultPaymentMethod = customer.DefaultPaymentMethod; + + var updateCustomerResult = await _braintreeGateway.Customer.UpdateAsync( + braintreeCustomerId, + new CustomerRequest { DefaultPaymentMethodToken = null }); + + if (!updateCustomerResult.IsSuccess()) + { + _logger.LogError("Failed to update payment method for Braintree customer ({ID}) | Message: {Message}", + braintreeCustomerId, updateCustomerResult.Message); + + throw ContactSupport(); + } + + var deletePaymentMethodResult = await _braintreeGateway.PaymentMethod.DeleteAsync(existingDefaultPaymentMethod.Token); + + if (!deletePaymentMethodResult.IsSuccess()) + { + await _braintreeGateway.Customer.UpdateAsync( + braintreeCustomerId, + new CustomerRequest { DefaultPaymentMethodToken = existingDefaultPaymentMethod.Token }); + + _logger.LogError( + "Failed to delete Braintree payment method for Customer ({ID}), re-linked payment method. Message: {Message}", + braintreeCustomerId, deletePaymentMethodResult.Message); + + throw ContactSupport(); + } + } + else + { + _logger.LogWarning("Tried to remove non-existent Braintree payment method for Customer ({ID})", braintreeCustomerId); + } + } + + private async Task RemoveStripePaymentMethodsAsync(Stripe.Customer customer) + { + if (customer.Sources != null && customer.Sources.Any()) + { + foreach (var source in customer.Sources) + { + switch (source) + { + case Stripe.BankAccount: + await _stripeAdapter.BankAccountDeleteAsync(customer.Id, source.Id); + break; + case Stripe.Card: + await _stripeAdapter.CardDeleteAsync(customer.Id, source.Id); + break; + } + } + } + + var paymentMethods = _stripeAdapter.PaymentMethodListAutoPagingAsync(new Stripe.PaymentMethodListOptions + { + Customer = customer.Id + }); + + await foreach (var paymentMethod in paymentMethods) + { + await _stripeAdapter.PaymentMethodDetachAsync(paymentMethod.Id, new Stripe.PaymentMethodDetachOptions()); + } + } + + private static GatewayException ContactSupport() => new("Could not remove your payment method. Please contact support for assistance."); +} diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..37857cf3ce --- /dev/null +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,14 @@ +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Commands.Implementations; + +namespace Bit.Core.Billing.Extensions; + +using Microsoft.Extensions.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static void AddBillingCommands(this IServiceCollection services) + { + services.AddSingleton(); + } +} diff --git a/src/Core/MailTemplates/Handlebars/Provider/ProviderUpdatePaymentMethod.html.hbs b/src/Core/MailTemplates/Handlebars/Provider/ProviderUpdatePaymentMethod.html.hbs new file mode 100644 index 0000000000..7b666cdd9a --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Provider/ProviderUpdatePaymentMethod.html.hbs @@ -0,0 +1,27 @@ +{{#>FullHtmlLayout}} + + + + + + + + + + + + + +
+ Your organization, {{OrganizationName}}, is no longer managed by {{ProviderName}}. Please update your billing information. +
+ To maintain your subscription, update your organization billing information by navigating to the web vault -> Organization -> Billing -> Payment Method. +
+ For more information, please refer to the following help article: Update billing information for organizations +
+ + Add payment method + +
+
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/Provider/ProviderUpdatePaymentMethod.text.hbs b/src/Core/MailTemplates/Handlebars/Provider/ProviderUpdatePaymentMethod.text.hbs new file mode 100644 index 0000000000..56a857a6e4 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Provider/ProviderUpdatePaymentMethod.text.hbs @@ -0,0 +1,7 @@ +{{#>BasicTextLayout}} + Your organization, {{OrganizationName}}, is no longer managed by {{ProviderName}}. Please update your billing information. + + To maintain your subscription, update your organization billing information by navigating to the web vault -> Organization -> Billing -> Payment Method. + + Or click the following link: {{{link PaymentMethodUrl}}} +{{/BasicTextLayout}} diff --git a/src/Core/Models/Mail/Provider/ProviderUpdatePaymentMethodViewModel.cs b/src/Core/Models/Mail/Provider/ProviderUpdatePaymentMethodViewModel.cs new file mode 100644 index 0000000000..114aaa7c95 --- /dev/null +++ b/src/Core/Models/Mail/Provider/ProviderUpdatePaymentMethodViewModel.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.Models.Mail.Provider; + +public class ProviderUpdatePaymentMethodViewModel : BaseMailModel +{ + public string OrganizationId { get; set; } + public string OrganizationName { get; set; } + public string ProviderName { get; set; } + + public string PaymentMethodUrl => + $"{WebVaultUrl}/organizations/{OrganizationId}/billing/payment-method"; +} diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index c2d81d6edb..93c6fd6e33 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -60,6 +60,11 @@ public interface IMailService Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email); Task SendProviderConfirmedEmailAsync(string providerName, string email); Task SendProviderUserRemoved(string providerName, string email); + Task SendProviderUpdatePaymentMethod( + Guid organizationId, + string organizationName, + string providerName, + IEnumerable emails); Task SendUpdatedTempPasswordEmailAsync(string email, string userName); Task SendFamiliesForEnterpriseOfferEmailAsync(string sponsorOrgName, string email, bool existingAccount, string token); Task BulkSendFamiliesForEnterpriseOfferEmailAsync(string SponsorOrgName, IEnumerable<(string Email, bool ExistingAccount, string Token)> invites); diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index a66d227d3e..70cc88c206 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -49,4 +49,5 @@ public interface IPaymentService Task ArchiveTaxRateAsync(TaxRate taxRate); Task AddSecretsManagerToSubscription(Organization org, Plan plan, int additionalSmSeats, int additionalServiceAccount, DateTime? prorationDate = null); + Task RisksSubscriptionFailure(Organization organization); } diff --git a/src/Core/Services/IStripeAdapter.cs b/src/Core/Services/IStripeAdapter.cs index 60d14ffad8..073d5cdacd 100644 --- a/src/Core/Services/IStripeAdapter.cs +++ b/src/Core/Services/IStripeAdapter.cs @@ -23,6 +23,7 @@ public interface IStripeAdapter Task InvoiceDeleteAsync(string id, Stripe.InvoiceDeleteOptions options = null); Task InvoiceVoidInvoiceAsync(string id, Stripe.InvoiceVoidOptions options = null); IEnumerable PaymentMethodListAutoPaging(Stripe.PaymentMethodListOptions options); + IAsyncEnumerable PaymentMethodListAutoPagingAsync(Stripe.PaymentMethodListOptions options); Task PaymentMethodAttachAsync(string id, Stripe.PaymentMethodAttachOptions options = null); Task PaymentMethodDetachAsync(string id, Stripe.PaymentMethodDetachOptions options = null); Task TaxRateCreateAsync(Stripe.TaxRateCreateOptions options); diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 8805e3af56..90b273bed2 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -754,6 +754,30 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendProviderUpdatePaymentMethod( + Guid organizationId, + string organizationName, + string providerName, + IEnumerable emails) + { + var message = CreateDefaultMessage("Update your billing information", emails); + + var model = new ProviderUpdatePaymentMethodViewModel + { + OrganizationId = organizationId.ToString(), + OrganizationName = CoreHelpers.SanitizeForEmail(organizationName), + ProviderName = CoreHelpers.SanitizeForEmail(providerName), + SiteName = _globalSettings.SiteName, + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash + }; + + await AddMessageContentAsync(message, "Provider.ProviderUpdatePaymentMethod", model); + + message.Category = "ProviderUpdatePaymentMethod"; + + await _mailDeliveryService.SendEmailAsync(message); + } + public async Task SendUpdatedTempPasswordEmailAsync(string email, string userName) { var message = CreateDefaultMessage("Master Password Has Been Changed", email); diff --git a/src/Core/Services/Implementations/StripeAdapter.cs b/src/Core/Services/Implementations/StripeAdapter.cs index 747510d052..ef8d13aea8 100644 --- a/src/Core/Services/Implementations/StripeAdapter.cs +++ b/src/Core/Services/Implementations/StripeAdapter.cs @@ -138,6 +138,9 @@ public class StripeAdapter : IStripeAdapter return _paymentMethodService.ListAutoPaging(options); } + public IAsyncEnumerable PaymentMethodListAutoPagingAsync(Stripe.PaymentMethodListOptions options) + => _paymentMethodService.ListAutoPagingAsync(options); + public Task PaymentMethodAttachAsync(string id, Stripe.PaymentMethodAttachOptions options = null) { return _paymentMethodService.AttachAsync(id, options); diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 8eae90ea2c..1aeda88076 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -1614,6 +1614,23 @@ public class StripePaymentService : IPaymentService return await FinalizeSubscriptionChangeAsync(org, new SecretsManagerSubscribeUpdate(org, plan, additionalSmSeats, additionalServiceAccount), prorationDate); } + public async Task RisksSubscriptionFailure(Organization organization) + { + var subscriptionInfo = await GetSubscriptionAsync(organization); + + if (subscriptionInfo.Subscription is not { Status: "active" or "trialing" or "past_due" } || + subscriptionInfo.UpcomingInvoice == null) + { + return false; + } + + var customer = await GetCustomerAsync(organization.GatewayCustomerId); + + var paymentSource = await GetBillingPaymentSourceAsync(customer); + + return paymentSource == null; + } + private Stripe.PaymentMethod GetLatestCardPaymentMethod(string customerId) { var cardPaymentMethods = _stripeAdapter.PaymentMethodListAutoPaging( diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index 92e548e0d5..81419b1864 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -197,6 +197,9 @@ public class NoopMailService : IMailService return Task.FromResult(0); } + public Task SendProviderUpdatePaymentMethod(Guid organizationId, string organizationName, string providerName, + IEnumerable emails) => Task.FromResult(0); + public Task SendUpdatedTempPasswordEmailAsync(string email, string userName) { return Task.FromResult(0); diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs index 467fb8f8a8..f4c771adec 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs @@ -7,14 +7,21 @@ using Bit.Core.Repositories; using Bit.Core.Settings; using Dapper; using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; namespace Bit.Infrastructure.Dapper.Repositories; public class OrganizationRepository : Repository, IOrganizationRepository { - public OrganizationRepository(GlobalSettings globalSettings) + private readonly ILogger _logger; + + public OrganizationRepository( + GlobalSettings globalSettings, + ILogger logger) : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) - { } + { + _logger = logger; + } public OrganizationRepository(string connectionString, string readOnlyConnectionString) : base(connectionString, readOnlyConnectionString) @@ -153,6 +160,8 @@ public class OrganizationRepository : Repository, IOrganizat public async Task> GetOwnerEmailAddressesById(Guid organizationId) { + _logger.LogInformation("AC-1758: Executing GetOwnerEmailAddressesById (Dapper)"); + await using var connection = new SqlConnection(ConnectionString); return await connection.QueryAsync( diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index 6ad8cfbb4e..acc36c9449 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -5,15 +5,23 @@ using Bit.Core.Models.Data.Organizations; using Bit.Core.Repositories; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Organization = Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization; namespace Bit.Infrastructure.EntityFramework.Repositories; public class OrganizationRepository : Repository, IOrganizationRepository { - public OrganizationRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper) - : base(serviceScopeFactory, mapper, (DatabaseContext context) => context.Organizations) - { } + private readonly ILogger _logger; + + public OrganizationRepository( + IServiceScopeFactory serviceScopeFactory, + IMapper mapper, + ILogger logger) + : base(serviceScopeFactory, mapper, context => context.Organizations) + { + _logger = logger; + } public async Task GetByIdentifierAsync(string identifier) { @@ -240,6 +248,8 @@ public class OrganizationRepository : Repository> GetOwnerEmailAddressesById(Guid organizationId) { + _logger.LogInformation("AC-1758: Executing GetOwnerEmailAddressesById (Entity Framework)"); + using var scope = ServiceScopeFactory.CreateScope(); var dbContext = GetDatabaseContext(scope); diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index fd24c47af2..0e4ae48877 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -37,7 +37,6 @@ public class OrganizationsControllerTests : IDisposable private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IPaymentService _paymentService; private readonly IPolicyRepository _policyRepository; - private readonly IProviderRepository _providerRepository; private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ISsoConfigService _ssoConfigService; private readonly IUserService _userService; @@ -46,7 +45,6 @@ public class OrganizationsControllerTests : IDisposable private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; private readonly ICloudGetOrganizationLicenseQuery _cloudGetOrganizationLicenseQuery; private readonly ICreateOrganizationApiKeyCommand _createOrganizationApiKeyCommand; - private readonly IUpdateOrganizationLicenseCommand _updateOrganizationLicenseCommand; private readonly IFeatureService _featureService; private readonly ILicensingService _licensingService; private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; @@ -64,7 +62,6 @@ public class OrganizationsControllerTests : IDisposable _organizationUserRepository = Substitute.For(); _paymentService = Substitute.For(); _policyRepository = Substitute.For(); - _providerRepository = Substitute.For(); _ssoConfigRepository = Substitute.For(); _ssoConfigService = Substitute.For(); _getOrganizationApiKeyQuery = Substitute.For(); @@ -73,19 +70,33 @@ public class OrganizationsControllerTests : IDisposable _userService = Substitute.For(); _cloudGetOrganizationLicenseQuery = Substitute.For(); _createOrganizationApiKeyCommand = Substitute.For(); - _updateOrganizationLicenseCommand = Substitute.For(); _featureService = Substitute.For(); _licensingService = Substitute.For(); _updateSecretsManagerSubscriptionCommand = Substitute.For(); _upgradeOrganizationPlanCommand = Substitute.For(); _addSecretsManagerSubscriptionCommand = Substitute.For(); - _sut = new OrganizationsController(_organizationRepository, _organizationUserRepository, - _policyRepository, _providerRepository, _organizationService, _userService, _paymentService, _currentContext, - _ssoConfigRepository, _ssoConfigService, _getOrganizationApiKeyQuery, _rotateOrganizationApiKeyCommand, - _createOrganizationApiKeyCommand, _organizationApiKeyRepository, _updateOrganizationLicenseCommand, - _cloudGetOrganizationLicenseQuery, _featureService, _globalSettings, _licensingService, - _updateSecretsManagerSubscriptionCommand, _upgradeOrganizationPlanCommand, _addSecretsManagerSubscriptionCommand); + _sut = new OrganizationsController( + _organizationRepository, + _organizationUserRepository, + _policyRepository, + _organizationService, + _userService, + _paymentService, + _currentContext, + _ssoConfigRepository, + _ssoConfigService, + _getOrganizationApiKeyQuery, + _rotateOrganizationApiKeyCommand, + _createOrganizationApiKeyCommand, + _organizationApiKeyRepository, + _cloudGetOrganizationLicenseQuery, + _featureService, + _globalSettings, + _licensingService, + _updateSecretsManagerSubscriptionCommand, + _upgradeOrganizationPlanCommand, + _addSecretsManagerSubscriptionCommand); } public void Dispose() diff --git a/test/Core.Test/Billing/Commands/RemovePaymentMethodCommandTests.cs b/test/Core.Test/Billing/Commands/RemovePaymentMethodCommandTests.cs new file mode 100644 index 0000000000..5de14f006f --- /dev/null +++ b/test/Core.Test/Billing/Commands/RemovePaymentMethodCommandTests.cs @@ -0,0 +1,367 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Commands.Implementations; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; +using BT = Braintree; +using S = Stripe; + +namespace Bit.Core.Test.Billing.Commands; + +[SutProviderCustomize] +public class RemovePaymentMethodCommandTests +{ + [Theory, BitAutoData] + public async Task RemovePaymentMethod_NullOrganization_ArgumentNullException( + SutProvider sutProvider) => + await Assert.ThrowsAsync(() => sutProvider.Sut.RemovePaymentMethod(null)); + + [Theory, BitAutoData] + public async Task RemovePaymentMethod_NonStripeGateway_ContactSupport( + Organization organization, + SutProvider sutProvider) + { + organization.Gateway = GatewayType.BitPay; + + await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization)); + } + + [Theory, BitAutoData] + public async Task RemovePaymentMethod_NoGatewayCustomerId_ContactSupport( + Organization organization, + SutProvider sutProvider) + { + organization.Gateway = GatewayType.Stripe; + organization.GatewayCustomerId = null; + + await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization)); + } + + [Theory, BitAutoData] + public async Task RemovePaymentMethod_NoStripeCustomer_ContactSupport( + Organization organization, + SutProvider sutProvider) + { + organization.Gateway = GatewayType.Stripe; + + sutProvider.GetDependency() + .CustomerGetAsync(organization.GatewayCustomerId, Arg.Any()) + .ReturnsNull(); + + await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization)); + } + + [Theory, BitAutoData] + public async Task RemovePaymentMethod_Braintree_NoCustomer_ContactSupport( + Organization organization, + SutProvider sutProvider) + { + organization.Gateway = GatewayType.Stripe; + + const string braintreeCustomerId = "1"; + + var stripeCustomer = new S.Customer + { + Metadata = new Dictionary + { + { "btCustomerId", braintreeCustomerId } + } + }; + + sutProvider.GetDependency() + .CustomerGetAsync(organization.GatewayCustomerId, Arg.Any()) + .Returns(stripeCustomer); + + var (braintreeGateway, customerGateway, paymentMethodGateway) = Setup(sutProvider.GetDependency()); + + customerGateway.FindAsync(braintreeCustomerId).ReturnsNull(); + + braintreeGateway.Customer.Returns(customerGateway); + + await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization)); + + await customerGateway.Received(1).FindAsync(braintreeCustomerId); + + await customerGateway.DidNotReceiveWithAnyArgs() + .UpdateAsync(Arg.Any(), Arg.Any()); + + await paymentMethodGateway.DidNotReceiveWithAnyArgs().DeleteAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RemovePaymentMethod_Braintree_NoPaymentMethod_NoOp( + Organization organization, + SutProvider sutProvider) + { + organization.Gateway = GatewayType.Stripe; + + const string braintreeCustomerId = "1"; + + var stripeCustomer = new S.Customer + { + Metadata = new Dictionary + { + { "btCustomerId", braintreeCustomerId } + } + }; + + sutProvider.GetDependency() + .CustomerGetAsync(organization.GatewayCustomerId, Arg.Any()) + .Returns(stripeCustomer); + + var (_, customerGateway, paymentMethodGateway) = Setup(sutProvider.GetDependency()); + + var braintreeCustomer = Substitute.For(); + + braintreeCustomer.PaymentMethods.Returns(Array.Empty()); + + customerGateway.FindAsync(braintreeCustomerId).Returns(braintreeCustomer); + + await sutProvider.Sut.RemovePaymentMethod(organization); + + await customerGateway.Received(1).FindAsync(braintreeCustomerId); + + await customerGateway.DidNotReceiveWithAnyArgs().UpdateAsync(Arg.Any(), Arg.Any()); + + await paymentMethodGateway.DidNotReceiveWithAnyArgs().DeleteAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RemovePaymentMethod_Braintree_CustomerUpdateFails_ContactSupport( + Organization organization, + SutProvider sutProvider) + { + organization.Gateway = GatewayType.Stripe; + + const string braintreeCustomerId = "1"; + const string braintreePaymentMethodToken = "TOKEN"; + + var stripeCustomer = new S.Customer + { + Metadata = new Dictionary + { + { "btCustomerId", braintreeCustomerId } + } + }; + + sutProvider.GetDependency() + .CustomerGetAsync(organization.GatewayCustomerId, Arg.Any()) + .Returns(stripeCustomer); + + var (_, customerGateway, paymentMethodGateway) = Setup(sutProvider.GetDependency()); + + var braintreeCustomer = Substitute.For(); + + var paymentMethod = Substitute.For(); + paymentMethod.Token.Returns(braintreePaymentMethodToken); + paymentMethod.IsDefault.Returns(true); + + braintreeCustomer.PaymentMethods.Returns(new[] + { + paymentMethod + }); + + customerGateway.FindAsync(braintreeCustomerId).Returns(braintreeCustomer); + + var updateBraintreeCustomerResult = Substitute.For>(); + updateBraintreeCustomerResult.IsSuccess().Returns(false); + + customerGateway.UpdateAsync( + braintreeCustomerId, + Arg.Is(request => request.DefaultPaymentMethodToken == null)) + .Returns(updateBraintreeCustomerResult); + + await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization)); + + await customerGateway.Received(1).FindAsync(braintreeCustomerId); + + await customerGateway.Received(1).UpdateAsync(braintreeCustomerId, Arg.Is(request => + request.DefaultPaymentMethodToken == null)); + + await paymentMethodGateway.DidNotReceiveWithAnyArgs().DeleteAsync(paymentMethod.Token); + + await customerGateway.DidNotReceive().UpdateAsync(braintreeCustomerId, Arg.Is(request => + request.DefaultPaymentMethodToken == paymentMethod.Token)); + } + + [Theory, BitAutoData] + public async Task RemovePaymentMethod_Braintree_PaymentMethodDeleteFails_RollBack_ContactSupport( + Organization organization, + SutProvider sutProvider) + { + organization.Gateway = GatewayType.Stripe; + + const string braintreeCustomerId = "1"; + const string braintreePaymentMethodToken = "TOKEN"; + + var stripeCustomer = new S.Customer + { + Metadata = new Dictionary + { + { "btCustomerId", braintreeCustomerId } + } + }; + + sutProvider.GetDependency() + .CustomerGetAsync(organization.GatewayCustomerId, Arg.Any()) + .Returns(stripeCustomer); + + var (_, customerGateway, paymentMethodGateway) = Setup(sutProvider.GetDependency()); + + var braintreeCustomer = Substitute.For(); + + var paymentMethod = Substitute.For(); + paymentMethod.Token.Returns(braintreePaymentMethodToken); + paymentMethod.IsDefault.Returns(true); + + braintreeCustomer.PaymentMethods.Returns(new[] + { + paymentMethod + }); + + customerGateway.FindAsync(braintreeCustomerId).Returns(braintreeCustomer); + + var updateBraintreeCustomerResult = Substitute.For>(); + updateBraintreeCustomerResult.IsSuccess().Returns(true); + + customerGateway.UpdateAsync(braintreeCustomerId, Arg.Any()) + .Returns(updateBraintreeCustomerResult); + + var deleteBraintreePaymentMethodResult = Substitute.For>(); + deleteBraintreePaymentMethodResult.IsSuccess().Returns(false); + + paymentMethodGateway.DeleteAsync(paymentMethod.Token).Returns(deleteBraintreePaymentMethodResult); + + await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization)); + + await customerGateway.Received(1).FindAsync(braintreeCustomerId); + + await customerGateway.Received(1).UpdateAsync(braintreeCustomerId, Arg.Is(request => + request.DefaultPaymentMethodToken == null)); + + await paymentMethodGateway.Received(1).DeleteAsync(paymentMethod.Token); + + await customerGateway.Received(1).UpdateAsync(braintreeCustomerId, Arg.Is(request => + request.DefaultPaymentMethodToken == paymentMethod.Token)); + } + + [Theory, BitAutoData] + public async Task RemovePaymentMethod_Stripe_Legacy_RemovesSources( + Organization organization, + SutProvider sutProvider) + { + organization.Gateway = GatewayType.Stripe; + + const string bankAccountId = "bank_account_id"; + const string cardId = "card_id"; + + var sources = new List + { + new S.BankAccount { Id = bankAccountId }, new S.Card { Id = cardId } + }; + + var stripeCustomer = new S.Customer { Sources = new S.StripeList { Data = sources } }; + + var stripeAdapter = sutProvider.GetDependency(); + + stripeAdapter + .CustomerGetAsync(organization.GatewayCustomerId, Arg.Any()) + .Returns(stripeCustomer); + + stripeAdapter + .PaymentMethodListAutoPagingAsync(Arg.Any()) + .Returns(GetPaymentMethodsAsync(new List())); + + await sutProvider.Sut.RemovePaymentMethod(organization); + + await stripeAdapter.Received(1).BankAccountDeleteAsync(stripeCustomer.Id, bankAccountId); + + await stripeAdapter.Received(1).CardDeleteAsync(stripeCustomer.Id, cardId); + + await stripeAdapter.DidNotReceiveWithAnyArgs() + .PaymentMethodDetachAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RemovePaymentMethod_Stripe_DetachesPaymentMethods( + Organization organization, + SutProvider sutProvider) + { + organization.Gateway = GatewayType.Stripe; + const string bankAccountId = "bank_account_id"; + const string cardId = "card_id"; + + var sources = new List(); + + var stripeCustomer = new S.Customer { Sources = new S.StripeList { Data = sources } }; + + var stripeAdapter = sutProvider.GetDependency(); + + stripeAdapter + .CustomerGetAsync(organization.GatewayCustomerId, Arg.Any()) + .Returns(stripeCustomer); + + stripeAdapter + .PaymentMethodListAutoPagingAsync(Arg.Any()) + .Returns(GetPaymentMethodsAsync(new List + { + new () + { + Id = bankAccountId + }, + new () + { + Id = cardId + } + })); + + await sutProvider.Sut.RemovePaymentMethod(organization); + + await stripeAdapter.DidNotReceiveWithAnyArgs().BankAccountDeleteAsync(Arg.Any(), Arg.Any()); + + await stripeAdapter.DidNotReceiveWithAnyArgs().CardDeleteAsync(Arg.Any(), Arg.Any()); + + await stripeAdapter.Received(1) + .PaymentMethodDetachAsync(bankAccountId, Arg.Any()); + + await stripeAdapter.Received(1) + .PaymentMethodDetachAsync(cardId, Arg.Any()); + } + + private static async IAsyncEnumerable GetPaymentMethodsAsync( + IEnumerable paymentMethods) + { + foreach (var paymentMethod in paymentMethods) + { + yield return paymentMethod; + } + + await Task.CompletedTask; + } + + private static (BT.IBraintreeGateway, BT.ICustomerGateway, BT.IPaymentMethodGateway) Setup( + BT.IBraintreeGateway braintreeGateway) + { + var customerGateway = Substitute.For(); + var paymentMethodGateway = Substitute.For(); + + braintreeGateway.Customer.Returns(customerGateway); + braintreeGateway.PaymentMethod.Returns(paymentMethodGateway); + + return (braintreeGateway, customerGateway, paymentMethodGateway); + } + + private static async Task ThrowsContactSupportAsync(Func function) + { + const string message = "Could not remove your payment method. Please contact support for assistance."; + + var exception = await Assert.ThrowsAsync(function); + + Assert.Equal(message, exception.Message); + } +} From da907c879b9560c82cce6ae6d99bf208a1712700 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 12 Jan 2024 16:18:05 -0500 Subject: [PATCH 003/117] [deps] SM: Update Dapper to v2.1.28 (#3665) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Infrastructure.Dapper/Infrastructure.Dapper.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj b/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj index d8a57b3303..6c7ad57d19 100644 --- a/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj +++ b/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj @@ -5,7 +5,7 @@ - + From 2df5fe1340ec968d427ae24207027aeb607a8ad0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 12 Jan 2024 16:30:23 -0700 Subject: [PATCH 004/117] [deps] SM: Update EntityFrameworkCore to v7.0.15 (#3666) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .config/dotnet-tools.json | 2 +- .../Infrastructure.EntityFramework.csproj | 6 +++--- util/MySqlMigrations/MySqlMigrations.csproj | 2 +- util/PostgresMigrations/PostgresMigrations.csproj | 2 +- util/SqlServerEFScaffold/SqlServerEFScaffold.csproj | 2 +- util/SqliteMigrations/SqliteMigrations.csproj | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 3a7def9a18..a3850de029 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -7,7 +7,7 @@ "commands": ["swagger"] }, "dotnet-ef": { - "version": "7.0.14", + "version": "7.0.15", "commands": ["dotnet-ef"] } } diff --git a/src/Infrastructure.EntityFramework/Infrastructure.EntityFramework.csproj b/src/Infrastructure.EntityFramework/Infrastructure.EntityFramework.csproj index 51e7d935db..0a5cdaed5b 100644 --- a/src/Infrastructure.EntityFramework/Infrastructure.EntityFramework.csproj +++ b/src/Infrastructure.EntityFramework/Infrastructure.EntityFramework.csproj @@ -3,9 +3,9 @@ - - - + + + diff --git a/util/MySqlMigrations/MySqlMigrations.csproj b/util/MySqlMigrations/MySqlMigrations.csproj index 4a10d7119a..0bf3bd76a5 100644 --- a/util/MySqlMigrations/MySqlMigrations.csproj +++ b/util/MySqlMigrations/MySqlMigrations.csproj @@ -10,7 +10,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/util/PostgresMigrations/PostgresMigrations.csproj b/util/PostgresMigrations/PostgresMigrations.csproj index f81892da62..370d3e8db5 100644 --- a/util/PostgresMigrations/PostgresMigrations.csproj +++ b/util/PostgresMigrations/PostgresMigrations.csproj @@ -6,7 +6,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/util/SqlServerEFScaffold/SqlServerEFScaffold.csproj b/util/SqlServerEFScaffold/SqlServerEFScaffold.csproj index ceb6a8199d..179f291e43 100644 --- a/util/SqlServerEFScaffold/SqlServerEFScaffold.csproj +++ b/util/SqlServerEFScaffold/SqlServerEFScaffold.csproj @@ -1,6 +1,6 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/util/SqliteMigrations/SqliteMigrations.csproj b/util/SqliteMigrations/SqliteMigrations.csproj index 7c41eae070..6973cdee90 100644 --- a/util/SqliteMigrations/SqliteMigrations.csproj +++ b/util/SqliteMigrations/SqliteMigrations.csproj @@ -12,7 +12,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all From 52f3fa0f95dc1f767a1c41fb8422fdcc9d5710d7 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 16 Jan 2024 08:38:20 -0500 Subject: [PATCH 005/117] Make billing email field uneditable for organizations' (#3591) Co-authored-by: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> --- src/Admin/Views/Shared/_OrganizationForm.cshtml | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/src/Admin/Views/Shared/_OrganizationForm.cshtml b/src/Admin/Views/Shared/_OrganizationForm.cshtml index 72076cbd37..97b6b949e0 100644 --- a/src/Admin/Views/Shared/_OrganizationForm.cshtml +++ b/src/Admin/Views/Shared/_OrganizationForm.cshtml @@ -276,20 +276,7 @@
- @if (Model.Provider?.Type == ProviderType.Reseller) - { - - } - else - { - - } +
From c12c09897bd3359434e2f84ee4080c8b296299f8 Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Tue, 16 Jan 2024 09:08:09 -0500 Subject: [PATCH 006/117] Remove Renovate .NET constraint (#3670) --- .github/renovate.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/renovate.json b/.github/renovate.json index 714baad8e1..8fb079a7de 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -203,10 +203,5 @@ "reviewers": ["team:team-vault-dev"] } ], - "force": { - "constraints": { - "dotnet": "6.0.100" - } - }, "ignoreDeps": ["dotnet-sdk"] } From b97a1a9ed2dd5a84cd87d018634fe65da736b01f Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Tue, 16 Jan 2024 09:08:55 -0500 Subject: [PATCH 007/117] [PM-5519] [PM-5526] [PM-5624] [PM-5600] More Grant SQL fixes (#3668) * SQLite scripts to apply autoincrementing Id key * Drop erroneous Id column if created --- .../GrantEntityTypeConfiguration.cs | 1 + .../20231214162533_GrantIdWithIndexes.cs | 17 +++- .../2023-12-04_00_Down_GrantIndexes.sql | 45 +++++++++ .../2023-12-04_00_Up_GrantIndexes.sql | 46 +++++++++ .../20231214162537_GrantIdWithIndexes.cs | 97 ++----------------- util/SqliteMigrations/SqliteMigrations.csproj | 5 + 6 files changed, 119 insertions(+), 92 deletions(-) create mode 100644 util/SqliteMigrations/HelperScripts/2023-12-04_00_Down_GrantIndexes.sql create mode 100644 util/SqliteMigrations/HelperScripts/2023-12-04_00_Up_GrantIndexes.sql diff --git a/src/Infrastructure.EntityFramework/Auth/Configurations/GrantEntityTypeConfiguration.cs b/src/Infrastructure.EntityFramework/Auth/Configurations/GrantEntityTypeConfiguration.cs index 599be37a84..77d8d1eb9f 100644 --- a/src/Infrastructure.EntityFramework/Auth/Configurations/GrantEntityTypeConfiguration.cs +++ b/src/Infrastructure.EntityFramework/Auth/Configurations/GrantEntityTypeConfiguration.cs @@ -10,6 +10,7 @@ public class GrantEntityTypeConfiguration : IEntityTypeConfiguration { builder .HasKey(s => s.Id) + .HasName("PK_Grant") .IsClustered(); builder diff --git a/util/MySqlMigrations/Migrations/20231214162533_GrantIdWithIndexes.cs b/util/MySqlMigrations/Migrations/20231214162533_GrantIdWithIndexes.cs index c9fd5638b0..1e4c178ade 100644 --- a/util/MySqlMigrations/Migrations/20231214162533_GrantIdWithIndexes.cs +++ b/util/MySqlMigrations/Migrations/20231214162533_GrantIdWithIndexes.cs @@ -72,7 +72,22 @@ public partial class GrantIdWithIndexes : Migration .Annotation("MySql:CharSet", "utf8mb4") .OldAnnotation("MySql:CharSet", "utf8mb4"); - migrationBuilder.Sql("ALTER TABLE `Grant` ADD COLUMN `Id` INT AUTO_INCREMENT UNIQUE;"); + migrationBuilder.Sql(@" + DROP PROCEDURE IF EXISTS GrantSchemaChange; + + CREATE PROCEDURE GrantSchemaChange() + BEGIN + IF EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'Grant' AND COLUMN_NAME = 'Id') THEN + ALTER TABLE `Grant` DROP COLUMN `Id`; + END IF; + + ALTER TABLE `Grant` ADD COLUMN `Id` INT AUTO_INCREMENT UNIQUE; + END; + + CALL GrantSchemaChange(); + + DROP PROCEDURE GrantSchemaChange;" + ); migrationBuilder.AddPrimaryKey( name: "PK_Grant", diff --git a/util/SqliteMigrations/HelperScripts/2023-12-04_00_Down_GrantIndexes.sql b/util/SqliteMigrations/HelperScripts/2023-12-04_00_Down_GrantIndexes.sql new file mode 100644 index 0000000000..5ea59ef753 --- /dev/null +++ b/util/SqliteMigrations/HelperScripts/2023-12-04_00_Down_GrantIndexes.sql @@ -0,0 +1,45 @@ +ALTER TABLE +"Grant" RENAME TO "Old_Grant"; + +CREATE TABLE "Grant" +( + "Key" TEXT NOT NULL CONSTRAINT "PK_Grant" PRIMARY KEY, + "Type" TEXT NULL, + "SubjectId" TEXT NULL, + "SessionId" TEXT NULL, + "ClientId" TEXT NULL, + "Description" TEXT NULL, + "CreationDate" TEXT NOT NULL, + "ExpirationDate" TEXT NULL, + "ConsumedDate" TEXT NULL, + "Data" TEXT NULL +); + +INSERT INTO +"Grant" + ( + "Key", + "Type", + "SubjectId", + "SessionId", + "ClientId", + "Description", + "CreationDate", + "ExpirationDate", + "ConsumedDate", + "Data" + ) +SELECT + "Key", + "Type", + "SubjectId", + "SessionId", + "ClientId", + "Description", + "CreationDate", + "ExpirationDate", + "ConsumedDate", + "Data" +FROM "Old_Grant"; + +DROP TABLE "Old_Grant"; diff --git a/util/SqliteMigrations/HelperScripts/2023-12-04_00_Up_GrantIndexes.sql b/util/SqliteMigrations/HelperScripts/2023-12-04_00_Up_GrantIndexes.sql new file mode 100644 index 0000000000..028214df67 --- /dev/null +++ b/util/SqliteMigrations/HelperScripts/2023-12-04_00_Up_GrantIndexes.sql @@ -0,0 +1,46 @@ +ALTER TABLE +"Grant" RENAME TO "Old_Grant"; + +CREATE TABLE "Grant" +( + "Id" INTEGER PRIMARY KEY AUTOINCREMENT, + "Key" TEXT NOT NULL, + "Type" TEXT NOT NULL, + "SubjectId" TEXT NULL, + "SessionId" TEXT NULL, + "ClientId" TEXT NOT NULL, + "Description" TEXT NULL, + "CreationDate" TEXT NOT NULL, + "ExpirationDate" TEXT NULL, + "ConsumedDate" TEXT NULL, + "Data" TEXT NOT NULL +); + +INSERT INTO +"Grant" + ( + "Key", + "Type", + "SubjectId", + "SessionId", + "ClientId", + "Description", + "CreationDate", + "ExpirationDate", + "ConsumedDate", + "Data" + ) +SELECT + "Key", + "Type", + "SubjectId", + "SessionId", + "ClientId", + "Description", + "CreationDate", + "ExpirationDate", + "ConsumedDate", + "Data" +FROM "Old_Grant"; + +DROP TABLE "Old_Grant"; diff --git a/util/SqliteMigrations/Migrations/20231214162537_GrantIdWithIndexes.cs b/util/SqliteMigrations/Migrations/20231214162537_GrantIdWithIndexes.cs index 1409fd055d..74f8d9b608 100644 --- a/util/SqliteMigrations/Migrations/20231214162537_GrantIdWithIndexes.cs +++ b/util/SqliteMigrations/Migrations/20231214162537_GrantIdWithIndexes.cs @@ -1,4 +1,5 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Bit.EfShared; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -7,59 +8,12 @@ namespace Bit.SqliteMigrations.Migrations; /// public partial class GrantIdWithIndexes : Migration { + private const string _scriptLocationTemplate = "2023-12-04_00_{0}_GrantIndexes.sql"; + /// protected override void Up(MigrationBuilder migrationBuilder) { - migrationBuilder.DropPrimaryKey( - name: "PK_Grant", - table: "Grant"); - - migrationBuilder.AlterColumn( - name: "Type", - table: "Grant", - type: "TEXT", - maxLength: 50, - nullable: false, - defaultValue: "", - oldClrType: typeof(string), - oldType: "TEXT", - oldMaxLength: 50, - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "Data", - table: "Grant", - type: "TEXT", - nullable: false, - defaultValue: "", - oldClrType: typeof(string), - oldType: "TEXT", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "ClientId", - table: "Grant", - type: "TEXT", - maxLength: 200, - nullable: false, - defaultValue: "", - oldClrType: typeof(string), - oldType: "TEXT", - oldMaxLength: 200, - oldNullable: true); - - migrationBuilder.AddColumn( - name: "Id", - table: "Grant", - type: "INTEGER", - nullable: false, - defaultValue: 0) - .Annotation("Sqlite:Autoincrement", true); - - migrationBuilder.AddPrimaryKey( - name: "PK_Grant", - table: "Grant", - column: "Id"); + migrationBuilder.SqlResource(_scriptLocationTemplate); migrationBuilder.CreateIndex( name: "IX_Grant_Key", @@ -71,49 +25,10 @@ public partial class GrantIdWithIndexes : Migration /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropPrimaryKey( - name: "PK_Grant", - table: "Grant"); + migrationBuilder.SqlResource(_scriptLocationTemplate); migrationBuilder.DropIndex( name: "IX_Grant_Key", table: "Grant"); - - migrationBuilder.DropColumn( - name: "Id", - table: "Grant"); - - migrationBuilder.AlterColumn( - name: "Type", - table: "Grant", - type: "TEXT", - maxLength: 50, - nullable: true, - oldClrType: typeof(string), - oldType: "TEXT", - oldMaxLength: 50); - - migrationBuilder.AlterColumn( - name: "Data", - table: "Grant", - type: "TEXT", - nullable: true, - oldClrType: typeof(string), - oldType: "TEXT"); - - migrationBuilder.AlterColumn( - name: "ClientId", - table: "Grant", - type: "TEXT", - maxLength: 200, - nullable: true, - oldClrType: typeof(string), - oldType: "TEXT", - oldMaxLength: 200); - - migrationBuilder.AddPrimaryKey( - name: "PK_Grant", - table: "Grant", - column: "Key"); } } diff --git a/util/SqliteMigrations/SqliteMigrations.csproj b/util/SqliteMigrations/SqliteMigrations.csproj index 6973cdee90..ba322375c4 100644 --- a/util/SqliteMigrations/SqliteMigrations.csproj +++ b/util/SqliteMigrations/SqliteMigrations.csproj @@ -22,4 +22,9 @@ + + + + + From 40d5e6ac7311e20bca291e670934d7556285d58d Mon Sep 17 00:00:00 2001 From: Bitwarden DevOps <106330231+bitwarden-devops-bot@users.noreply.github.com> Date: Tue, 16 Jan 2024 09:39:33 -0500 Subject: [PATCH 008/117] Bumped version to 2024.1.1 (#3673) --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 98d030daa7..c677489378 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,7 +2,7 @@ net6.0 - 2024.1.0 + 2024.1.1 Bit.$(MSBuildProjectName) enable false From dca8d00f5447e0bf0233dcd51d5d717d29435415 Mon Sep 17 00:00:00 2001 From: Bitwarden DevOps <106330231+bitwarden-devops-bot@users.noreply.github.com> Date: Tue, 16 Jan 2024 12:02:24 -0500 Subject: [PATCH 009/117] Bumped version to 2024.1.2 (#3674) --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index c677489378..5c11359b54 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,7 +2,7 @@ net6.0 - 2024.1.1 + 2024.1.2 Bit.$(MSBuildProjectName) enable false From f09bc43b047448301b3cc69651d3ec1e501dd064 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 16 Jan 2024 15:46:22 -0500 Subject: [PATCH 010/117] [deps] Billing: Update BenchmarkDotNet to v0.13.12 (#3677) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- perf/MicroBenchmarks/MicroBenchmarks.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/perf/MicroBenchmarks/MicroBenchmarks.csproj b/perf/MicroBenchmarks/MicroBenchmarks.csproj index 7a2bdb02d9..d3ee14a684 100644 --- a/perf/MicroBenchmarks/MicroBenchmarks.csproj +++ b/perf/MicroBenchmarks/MicroBenchmarks.csproj @@ -8,7 +8,7 @@ - + From ef37cdc71a503c059a6b11af7ce124b98a596749 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 16 Jan 2024 15:47:26 -0500 Subject: [PATCH 011/117] [deps] Billing: Update Braintree to v5.23.0 (#3678) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Core/Core.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 9b664a788d..6752cca078 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -52,7 +52,7 @@ - + From 96f9fbb9511abbec0b68107404ddf256d23add96 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Wed, 17 Jan 2024 22:33:35 +1000 Subject: [PATCH 012/117] [AC-2027] Update Flexible Collections logic to use organization property (#3644) * Update optionality to use org.FlexibleCollections Also break old feature flag key to ensure it's never enabled * Add logic to set defaults for collection management setting * Update optionality logic to use org property * Add comments * Add helper method for getting individual orgAbility * Fix validate user update permissions interface * Fix tests * dotnet format * Fix more tests * Simplify self-hosted update logic * Fix mapping * Use new getOrganizationAbility method * Refactor invite and save orgUser methods Pass in whole organization object instead of using OrganizationAbility * fix CipherService tests * dotnet format * Remove manager check to simplify this set of changes * Misc cleanup before review * Fix undefined variable * Refactor bulk-access endpoint to avoid early repo call * Restore manager check * Add tests for UpdateOrganizationLicenseCommand * Add nullable regions * Delete unused dependency * dotnet format * Fix test --- .../Controllers/GroupsController.cs | 18 +- .../OrganizationUsersController.cs | 19 +- .../Controllers/OrganizationsController.cs | 6 +- src/Api/Controllers/CollectionsController.cs | 96 ++++----- .../BulkCollectionAuthorizationHandler.cs | 17 +- .../CollectionAuthorizationHandler.cs | 10 - .../Groups/GroupAuthorizationHandler.cs | 15 +- .../OrganizationUserAuthorizationHandler.cs | 15 +- .../AdminConsole/Entities/Organization.cs | 14 +- .../SelfHostedOrganizationDetails.cs | 3 +- .../Implementations/OrganizationService.cs | 48 +++-- src/Core/Constants.cs | 7 +- src/Core/Context/CurrentContext.cs | 23 -- .../UpdateOrganizationLicenseCommand.cs | 13 +- src/Core/Services/IApplicationCacheService.cs | 3 + .../InMemoryApplicationCacheService.cs | 9 + .../Services/Implementations/CipherService.cs | 2 +- src/Events/Controllers/CollectController.cs | 2 - .../Controllers/CollectionsControllerTests.cs | 202 ++++++++++++++---- ...BulkCollectionAuthorizationHandlerTests.cs | 56 ++--- .../CollectionAuthorizationHandlerTests.cs | 3 - .../GroupAuthorizationHandlerTests.cs | 43 ++-- ...ganizationUserAuthorizationHandlerTests.cs | 43 ++-- .../Services/OrganizationServiceTests.cs | 30 +-- .../AutoFixture/FeatureServiceFixtures.cs | 75 ------- .../UpdateOrganizationLicenseCommandTests.cs | 100 +++++++++ .../Vault/Services/CipherServiceTests.cs | 11 +- 27 files changed, 472 insertions(+), 411 deletions(-) delete mode 100644 test/Core.Test/AutoFixture/FeatureServiceFixtures.cs create mode 100644 test/Core.Test/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommandTests.cs diff --git a/src/Api/AdminConsole/Controllers/GroupsController.cs b/src/Api/AdminConsole/Controllers/GroupsController.cs index 447ea4bdc7..3a256043a0 100644 --- a/src/Api/AdminConsole/Controllers/GroupsController.cs +++ b/src/Api/AdminConsole/Controllers/GroupsController.cs @@ -3,7 +3,6 @@ using Bit.Api.AdminConsole.Models.Response; using Bit.Api.Models.Response; using Bit.Api.Utilities; using Bit.Api.Vault.AuthorizationHandlers.Groups; -using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; @@ -27,10 +26,8 @@ public class GroupsController : Controller private readonly ICurrentContext _currentContext; private readonly ICreateGroupCommand _createGroupCommand; private readonly IUpdateGroupCommand _updateGroupCommand; - private readonly IFeatureService _featureService; private readonly IAuthorizationService _authorizationService; - - private bool UseFlexibleCollections => _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext); + private readonly IApplicationCacheService _applicationCacheService; public GroupsController( IGroupRepository groupRepository, @@ -41,7 +38,8 @@ public class GroupsController : Controller IUpdateGroupCommand updateGroupCommand, IDeleteGroupCommand deleteGroupCommand, IFeatureService featureService, - IAuthorizationService authorizationService) + IAuthorizationService authorizationService, + IApplicationCacheService applicationCacheService) { _groupRepository = groupRepository; _groupService = groupService; @@ -50,8 +48,8 @@ public class GroupsController : Controller _createGroupCommand = createGroupCommand; _updateGroupCommand = updateGroupCommand; _deleteGroupCommand = deleteGroupCommand; - _featureService = featureService; _authorizationService = authorizationService; + _applicationCacheService = applicationCacheService; } [HttpGet("{id}")] @@ -81,7 +79,7 @@ public class GroupsController : Controller [HttpGet("")] public async Task> Get(Guid orgId) { - if (UseFlexibleCollections) + if (await FlexibleCollectionsIsEnabledAsync(orgId)) { // New flexible collections logic return await Get_vNext(orgId); @@ -217,4 +215,10 @@ public class GroupsController : Controller var responses = groups.Select(g => new GroupDetailsResponseModel(g.Item1, g.Item2)); return new ListResponseModel(responses); } + + private async Task FlexibleCollectionsIsEnabledAsync(Guid organizationId) + { + var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId); + return organizationAbility?.FlexibleCollections ?? false; + } } diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 703696c6ad..1eacab68b8 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -4,7 +4,6 @@ using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Response; using Bit.Api.Utilities; using Bit.Api.Vault.AuthorizationHandlers.OrganizationUsers; -using Bit.Core; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; @@ -39,10 +38,8 @@ public class OrganizationUsersController : Controller private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; private readonly IUpdateOrganizationUserGroupsCommand _updateOrganizationUserGroupsCommand; private readonly IAcceptOrgUserCommand _acceptOrgUserCommand; - private readonly IFeatureService _featureService; private readonly IAuthorizationService _authorizationService; - - private bool UseFlexibleCollections => _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext); + private readonly IApplicationCacheService _applicationCacheService; public OrganizationUsersController( IOrganizationRepository organizationRepository, @@ -57,8 +54,8 @@ public class OrganizationUsersController : Controller IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, IUpdateOrganizationUserGroupsCommand updateOrganizationUserGroupsCommand, IAcceptOrgUserCommand acceptOrgUserCommand, - IFeatureService featureService, - IAuthorizationService authorizationService) + IAuthorizationService authorizationService, + IApplicationCacheService applicationCacheService) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -72,8 +69,8 @@ public class OrganizationUsersController : Controller _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; _updateOrganizationUserGroupsCommand = updateOrganizationUserGroupsCommand; _acceptOrgUserCommand = acceptOrgUserCommand; - _featureService = featureService; _authorizationService = authorizationService; + _applicationCacheService = applicationCacheService; } [HttpGet("{id}")] @@ -98,7 +95,7 @@ public class OrganizationUsersController : Controller [HttpGet("")] public async Task> Get(Guid orgId, bool includeGroups = false, bool includeCollections = false) { - var authorized = UseFlexibleCollections + var authorized = await FlexibleCollectionsIsEnabledAsync(orgId) ? (await _authorizationService.AuthorizeAsync(User, OrganizationUserOperations.ReadAll(orgId))).Succeeded : await _currentContext.ViewAllCollections(orgId) || await _currentContext.ViewAssignedCollections(orgId) || @@ -518,4 +515,10 @@ public class OrganizationUsersController : Controller return new ListResponseModel(result.Select(r => new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2))); } + + private async Task FlexibleCollectionsIsEnabledAsync(Guid organizationId) + { + var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId); + return organizationAbility?.FlexibleCollections ?? false; + } } diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 1683af2b68..e4f0c3aa50 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -784,7 +784,6 @@ public class OrganizationsController : Controller } [HttpPut("{id}/collection-management")] - [RequireFeature(FeatureFlagKeys.FlexibleCollections)] [SelfHosted(NotSelfHostedOnly = true)] public async Task PutCollectionManagement(Guid id, [FromBody] OrganizationCollectionManagementUpdateRequestModel model) { @@ -799,6 +798,11 @@ public class OrganizationsController : Controller throw new NotFoundException(); } + if (!organization.FlexibleCollections) + { + throw new BadRequestException("Organization does not have collection enhancements enabled"); + } + var v1Enabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1, _currentContext); if (!v1Enabled) diff --git a/src/Api/Controllers/CollectionsController.cs b/src/Api/Controllers/CollectionsController.cs index 7ae76ba750..4072518017 100644 --- a/src/Api/Controllers/CollectionsController.cs +++ b/src/Api/Controllers/CollectionsController.cs @@ -28,8 +28,8 @@ public class CollectionsController : Controller private readonly IAuthorizationService _authorizationService; private readonly ICurrentContext _currentContext; private readonly IBulkAddCollectionAccessCommand _bulkAddCollectionAccessCommand; - private readonly IFeatureService _featureService; private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IApplicationCacheService _applicationCacheService; public CollectionsController( ICollectionRepository collectionRepository, @@ -39,8 +39,8 @@ public class CollectionsController : Controller IAuthorizationService authorizationService, ICurrentContext currentContext, IBulkAddCollectionAccessCommand bulkAddCollectionAccessCommand, - IFeatureService featureService, - IOrganizationUserRepository organizationUserRepository) + IOrganizationUserRepository organizationUserRepository, + IApplicationCacheService applicationCacheService) { _collectionRepository = collectionRepository; _organizationUserRepository = organizationUserRepository; @@ -50,16 +50,14 @@ public class CollectionsController : Controller _authorizationService = authorizationService; _currentContext = currentContext; _bulkAddCollectionAccessCommand = bulkAddCollectionAccessCommand; - _featureService = featureService; _organizationUserRepository = organizationUserRepository; + _applicationCacheService = applicationCacheService; } - private bool FlexibleCollectionsIsEnabled => _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext); - [HttpGet("{id}")] public async Task Get(Guid orgId, Guid id) { - if (FlexibleCollectionsIsEnabled) + if (await FlexibleCollectionsIsEnabledAsync(orgId)) { // New flexible collections logic return await Get_vNext(id); @@ -79,7 +77,7 @@ public class CollectionsController : Controller [HttpGet("{id}/details")] public async Task GetDetails(Guid orgId, Guid id) { - if (FlexibleCollectionsIsEnabled) + if (await FlexibleCollectionsIsEnabledAsync(orgId)) { // New flexible collections logic return await GetDetails_vNext(id); @@ -104,7 +102,7 @@ public class CollectionsController : Controller else { (var collection, var access) = await _collectionRepository.GetByIdWithAccessAsync(id, - _currentContext.UserId.Value, FlexibleCollectionsIsEnabled); + _currentContext.UserId.Value, false); if (collection == null || collection.OrganizationId != orgId) { throw new NotFoundException(); @@ -117,7 +115,7 @@ public class CollectionsController : Controller [HttpGet("details")] public async Task> GetManyWithDetails(Guid orgId) { - if (FlexibleCollectionsIsEnabled) + if (await FlexibleCollectionsIsEnabledAsync(orgId)) { // New flexible collections logic return await GetManyWithDetails_vNext(orgId); @@ -132,7 +130,7 @@ public class CollectionsController : Controller // We always need to know which collections the current user is assigned to var assignedOrgCollections = await _collectionRepository.GetManyByUserIdWithAccessAsync(_currentContext.UserId.Value, orgId, - FlexibleCollectionsIsEnabled); + false); if (await _currentContext.ViewAllCollections(orgId) || await _currentContext.ManageUsers(orgId)) { @@ -159,7 +157,7 @@ public class CollectionsController : Controller [HttpGet("")] public async Task> Get(Guid orgId) { - if (FlexibleCollectionsIsEnabled) + if (await FlexibleCollectionsIsEnabledAsync(orgId)) { // New flexible collections logic return await GetByOrgId_vNext(orgId); @@ -191,7 +189,7 @@ public class CollectionsController : Controller public async Task> GetUser() { var collections = await _collectionRepository.GetManyByUserIdAsync( - _userService.GetProperUserId(User).Value, FlexibleCollectionsIsEnabled); + _userService.GetProperUserId(User).Value, false); var responses = collections.Select(c => new CollectionDetailsResponseModel(c)); return new ListResponseModel(responses); } @@ -199,7 +197,7 @@ public class CollectionsController : Controller [HttpGet("{id}/users")] public async Task> GetUsers(Guid orgId, Guid id) { - if (FlexibleCollectionsIsEnabled) + if (await FlexibleCollectionsIsEnabledAsync(orgId)) { // New flexible collections logic return await GetUsers_vNext(id); @@ -217,7 +215,8 @@ public class CollectionsController : Controller { var collection = model.ToCollection(orgId); - var authorized = FlexibleCollectionsIsEnabled + var flexibleCollectionsIsEnabled = await FlexibleCollectionsIsEnabledAsync(orgId); + var authorized = flexibleCollectionsIsEnabled ? (await _authorizationService.AuthorizeAsync(User, collection, BulkCollectionOperations.Create)).Succeeded : await CanCreateCollection(orgId, collection.Id) || await CanEditCollectionAsync(orgId, collection.Id); if (!authorized) @@ -230,7 +229,7 @@ public class CollectionsController : Controller // Pre-flexible collections logic assigned Managers to collections they create var assignUserToCollection = - !FlexibleCollectionsIsEnabled && + !flexibleCollectionsIsEnabled && !await _currentContext.EditAnyCollection(orgId) && await _currentContext.EditAssignedCollections(orgId); var isNewCollection = collection.Id == default; @@ -258,7 +257,7 @@ public class CollectionsController : Controller [HttpPost("{id}")] public async Task Put(Guid orgId, Guid id, [FromBody] CollectionRequestModel model) { - if (FlexibleCollectionsIsEnabled) + if (await FlexibleCollectionsIsEnabledAsync(orgId)) { // New flexible collections logic return await Put_vNext(id, model); @@ -280,7 +279,7 @@ public class CollectionsController : Controller [HttpPut("{id}/users")] public async Task PutUsers(Guid orgId, Guid id, [FromBody] IEnumerable model) { - if (FlexibleCollectionsIsEnabled) + if (await FlexibleCollectionsIsEnabledAsync(orgId)) { // New flexible collections logic await PutUsers_vNext(id, model); @@ -299,14 +298,17 @@ public class CollectionsController : Controller [HttpPost("bulk-access")] [RequireFeature(FeatureFlagKeys.BulkCollectionAccess)] - // Also gated behind Flexible Collections flag because it only has new authorization logic. - // Could be removed if legacy authorization logic were implemented for many collections. - [RequireFeature(FeatureFlagKeys.FlexibleCollections)] - public async Task PostBulkCollectionAccess([FromBody] BulkCollectionAccessRequestModel model) + public async Task PostBulkCollectionAccess(Guid orgId, [FromBody] BulkCollectionAccessRequestModel model) { - var collections = await _collectionRepository.GetManyByManyIdsAsync(model.CollectionIds); + // Authorization logic assumes flexible collections is enabled + // Remove after all organizations have been migrated + if (!await FlexibleCollectionsIsEnabledAsync(orgId)) + { + throw new NotFoundException("Feature disabled."); + } - if (collections.Count != model.CollectionIds.Count()) + var collections = await _collectionRepository.GetManyByManyIdsAsync(model.CollectionIds); + if (collections.Count(c => c.OrganizationId == orgId) != model.CollectionIds.Count()) { throw new NotFoundException("One or more collections not found."); } @@ -328,7 +330,7 @@ public class CollectionsController : Controller [HttpPost("{id}/delete")] public async Task Delete(Guid orgId, Guid id) { - if (FlexibleCollectionsIsEnabled) + if (await FlexibleCollectionsIsEnabledAsync(orgId)) { // New flexible collections logic await Delete_vNext(id); @@ -349,7 +351,7 @@ public class CollectionsController : Controller [HttpPost("delete")] public async Task DeleteMany(Guid orgId, [FromBody] CollectionBulkDeleteRequestModel model) { - if (FlexibleCollectionsIsEnabled) + if (await FlexibleCollectionsIsEnabledAsync(orgId)) { // New flexible collections logic var collections = await _collectionRepository.GetManyByManyIdsAsync(model.Ids); @@ -385,7 +387,7 @@ public class CollectionsController : Controller [HttpPost("{id}/delete-user/{orgUserId}")] public async Task DeleteUser(Guid orgId, Guid id, Guid orgUserId) { - if (FlexibleCollectionsIsEnabled) + if (await FlexibleCollectionsIsEnabledAsync(orgId)) { // New flexible collections logic await DeleteUser_vNext(id, orgUserId); @@ -397,19 +399,9 @@ public class CollectionsController : Controller await _collectionService.DeleteUserAsync(collection, orgUserId); } - private void DeprecatedPermissionsGuard() - { - if (FlexibleCollectionsIsEnabled) - { - throw new FeatureUnavailableException("Flexible Collections is ON when it should be OFF."); - } - } - [Obsolete("Pre-Flexible Collections logic. Will be replaced by CollectionsAuthorizationHandler.")] private async Task GetCollectionAsync(Guid id, Guid orgId) { - DeprecatedPermissionsGuard(); - Collection collection = default; if (await _currentContext.ViewAllCollections(orgId)) { @@ -417,7 +409,7 @@ public class CollectionsController : Controller } else if (await _currentContext.ViewAssignedCollections(orgId)) { - collection = await _collectionRepository.GetByIdAsync(id, _currentContext.UserId.Value, FlexibleCollectionsIsEnabled); + collection = await _collectionRepository.GetByIdAsync(id, _currentContext.UserId.Value, false); } if (collection == null || collection.OrganizationId != orgId) @@ -431,8 +423,6 @@ public class CollectionsController : Controller [Obsolete("Pre-Flexible Collections logic. Will be replaced by CollectionsAuthorizationHandler.")] private async Task CanCreateCollection(Guid orgId, Guid collectionId) { - DeprecatedPermissionsGuard(); - if (collectionId != default) { return false; @@ -445,8 +435,6 @@ public class CollectionsController : Controller [Obsolete("Pre-Flexible Collections logic. Will be replaced by CollectionsAuthorizationHandler.")] private async Task CanEditCollectionAsync(Guid orgId, Guid collectionId) { - DeprecatedPermissionsGuard(); - if (collectionId == default) { return false; @@ -460,7 +448,7 @@ public class CollectionsController : Controller if (await _currentContext.EditAssignedCollections(orgId)) { var collectionDetails = - await _collectionRepository.GetByIdAsync(collectionId, _currentContext.UserId.Value, FlexibleCollectionsIsEnabled); + await _collectionRepository.GetByIdAsync(collectionId, _currentContext.UserId.Value, false); return collectionDetails != null; } @@ -470,8 +458,6 @@ public class CollectionsController : Controller [Obsolete("Pre-Flexible Collections logic. Will be replaced by CollectionsAuthorizationHandler.")] private async Task CanDeleteCollectionAsync(Guid orgId, Guid collectionId) { - DeprecatedPermissionsGuard(); - if (collectionId == default) { return false; @@ -485,7 +471,7 @@ public class CollectionsController : Controller if (await _currentContext.DeleteAssignedCollections(orgId)) { var collectionDetails = - await _collectionRepository.GetByIdAsync(collectionId, _currentContext.UserId.Value, FlexibleCollectionsIsEnabled); + await _collectionRepository.GetByIdAsync(collectionId, _currentContext.UserId.Value, false); return collectionDetails != null; } @@ -495,8 +481,6 @@ public class CollectionsController : Controller [Obsolete("Pre-Flexible Collections logic. Will be replaced by CollectionsAuthorizationHandler.")] private async Task DeleteAnyCollection(Guid orgId) { - DeprecatedPermissionsGuard(); - return await _currentContext.OrganizationAdmin(orgId) || (_currentContext.Organizations?.Any(o => o.Id == orgId && (o.Permissions?.DeleteAnyCollection ?? false)) ?? false); @@ -505,8 +489,6 @@ public class CollectionsController : Controller [Obsolete("Pre-Flexible Collections logic. Will be replaced by CollectionsAuthorizationHandler.")] private async Task CanViewCollectionAsync(Guid orgId, Guid collectionId) { - DeprecatedPermissionsGuard(); - if (collectionId == default) { return false; @@ -520,7 +502,7 @@ public class CollectionsController : Controller if (await _currentContext.ViewAssignedCollections(orgId)) { var collectionDetails = - await _collectionRepository.GetByIdAsync(collectionId, _currentContext.UserId.Value, FlexibleCollectionsIsEnabled); + await _collectionRepository.GetByIdAsync(collectionId, _currentContext.UserId.Value, false); return collectionDetails != null; } @@ -530,8 +512,6 @@ public class CollectionsController : Controller [Obsolete("Pre-Flexible Collections logic. Will be replaced by CollectionsAuthorizationHandler.")] private async Task ViewAtLeastOneCollectionAsync(Guid orgId) { - DeprecatedPermissionsGuard(); - return await _currentContext.ViewAllCollections(orgId) || await _currentContext.ViewAssignedCollections(orgId); } @@ -564,7 +544,7 @@ public class CollectionsController : Controller { // We always need to know which collections the current user is assigned to var assignedOrgCollections = await _collectionRepository - .GetManyByUserIdWithAccessAsync(_currentContext.UserId.Value, orgId, FlexibleCollectionsIsEnabled); + .GetManyByUserIdWithAccessAsync(_currentContext.UserId.Value, orgId, false); var readAllAuthorized = (await _authorizationService.AuthorizeAsync(User, CollectionOperations.ReadAllWithAccess(orgId))).Succeeded; @@ -604,7 +584,7 @@ public class CollectionsController : Controller } else { - var assignedCollections = await _collectionRepository.GetManyByUserIdAsync(_currentContext.UserId.Value, FlexibleCollectionsIsEnabled); + var assignedCollections = await _collectionRepository.GetManyByUserIdAsync(_currentContext.UserId.Value, false); orgCollections = assignedCollections.Where(c => c.OrganizationId == orgId && c.Manage).ToList(); } @@ -676,4 +656,10 @@ public class CollectionsController : Controller await _collectionService.DeleteUserAsync(collection, orgUserId); } + + private async Task FlexibleCollectionsIsEnabledAsync(Guid organizationId) + { + var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId); + return organizationAbility?.FlexibleCollections ?? false; + } } diff --git a/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs b/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs index 45e8bc9458..fb47602bc9 100644 --- a/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs +++ b/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs @@ -1,5 +1,4 @@ #nullable enable -using Bit.Core; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -20,33 +19,22 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext); - public BulkCollectionAuthorizationHandler( ICurrentContext currentContext, ICollectionRepository collectionRepository, - IFeatureService featureService, IApplicationCacheService applicationCacheService) { _currentContext = currentContext; _collectionRepository = collectionRepository; - _featureService = featureService; _applicationCacheService = applicationCacheService; } protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, BulkCollectionOperationRequirement requirement, ICollection? resources) { - if (!FlexibleCollectionsIsEnabled) - { - // Flexible collections is OFF, should not be using this handler - throw new FeatureUnavailableException("Flexible collections is OFF when it should be ON."); - } - // Establish pattern of authorization handler null checking passed resources if (resources == null || !resources.Any()) { @@ -281,9 +269,6 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext); - public CollectionAuthorizationHandler( ICurrentContext currentContext, IFeatureService featureService) @@ -30,12 +26,6 @@ public class CollectionAuthorizationHandler : AuthorizationHandler _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext); - public GroupAuthorizationHandler( ICurrentContext currentContext, IFeatureService featureService, @@ -34,12 +30,6 @@ public class GroupAuthorizationHandler : AuthorizationHandler _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext); - public OrganizationUserAuthorizationHandler( ICurrentContext currentContext, IFeatureService featureService, @@ -34,12 +30,6 @@ public class OrganizationUserAuthorizationHandler : AuthorizationHandler, ISubscriber, IStorable, IStorabl return providers[provider]; } - public void UpdateFromLicense( - OrganizationLicense license, - bool flexibleCollectionsMvpIsEnabled, - bool flexibleCollectionsV1IsEnabled) + public void UpdateFromLicense(OrganizationLicense license) { + // The following properties are intentionally excluded from being updated: + // - Id - self-hosted org will have its own unique Guid + // - MaxStorageGb - not enforced for self-hosted because we're not providing the storage + // - FlexibleCollections - the self-hosted organization must do its own data migration to set this property, it cannot be updated from cloud + Name = license.Name; BusinessName = license.BusinessName; BillingEmail = license.BillingEmail; @@ -275,7 +277,7 @@ public class Organization : ITableObject, ISubscriber, IStorable, IStorabl UseSecretsManager = license.UseSecretsManager; SmSeats = license.SmSeats; SmServiceAccounts = license.SmServiceAccounts; - LimitCollectionCreationDeletion = !flexibleCollectionsMvpIsEnabled || license.LimitCollectionCreationDeletion; - AllowAdminAccessToAllCollectionItems = !flexibleCollectionsV1IsEnabled || license.AllowAdminAccessToAllCollectionItems; + LimitCollectionCreationDeletion = license.LimitCollectionCreationDeletion; + AllowAdminAccessToAllCollectionItems = license.AllowAdminAccessToAllCollectionItems; } } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs index ddca0d3c8a..39cc5a1d98 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs @@ -145,7 +145,8 @@ public class SelfHostedOrganizationDetails : Organization MaxAutoscaleSeats = MaxAutoscaleSeats, OwnersNotifiedOfAutoscaling = OwnersNotifiedOfAutoscaling, LimitCollectionCreationDeletion = LimitCollectionCreationDeletion, - AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems + AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems, + FlexibleCollections = FlexibleCollections }; } } diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 3bd493e309..1355a15120 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -65,8 +65,6 @@ public class OrganizationService : IOrganizationService private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; private readonly IFeatureService _featureService; - private bool FlexibleCollectionsIsEnabled => _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext); - public OrganizationService( IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, @@ -418,6 +416,9 @@ public class OrganizationService : IOrganizationService } } + /// + /// Create a new organization in a cloud environment + /// public async Task> SignUpAsync(OrganizationSignup signup, bool provider = false) { @@ -440,8 +441,9 @@ public class OrganizationService : IOrganizationService await ValidateSignUpPoliciesAsync(signup.Owner.Id); } - var flexibleCollectionsIsEnabled = - _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext); + var flexibleCollectionsSignupEnabled = + _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup, _currentContext); + var flexibleCollectionsV1IsEnabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1, _currentContext); @@ -482,7 +484,15 @@ public class OrganizationService : IOrganizationService Status = OrganizationStatusType.Created, UsePasswordManager = true, UseSecretsManager = signup.UseSecretsManager, - LimitCollectionCreationDeletion = !flexibleCollectionsIsEnabled, + + // This feature flag indicates that new organizations should be automatically onboarded to + // Flexible Collections enhancements + FlexibleCollections = flexibleCollectionsSignupEnabled, + + // These collection management settings smooth the migration for existing organizations by disabling some FC behavior. + // If the organization is onboarded to Flexible Collections on signup, we turn them OFF to enable all new behaviour. + // If the organization is NOT onboarded now, they will have to be migrated later, so they default to ON to limit FC changes on migration. + LimitCollectionCreationDeletion = !flexibleCollectionsSignupEnabled, AllowAdminAccessToAllCollectionItems = !flexibleCollectionsV1IsEnabled }; @@ -534,6 +544,9 @@ public class OrganizationService : IOrganizationService } } + /// + /// Create a new organization on a self-hosted instance + /// public async Task> SignUpAsync( OrganizationLicense license, User owner, string ownerKey, string collectionName, string publicKey, string privateKey) @@ -558,10 +571,8 @@ public class OrganizationService : IOrganizationService await ValidateSignUpPoliciesAsync(owner.Id); - var flexibleCollectionsMvpIsEnabled = - _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext); - var flexibleCollectionsV1IsEnabled = - _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1, _currentContext); + var flexibleCollectionsSignupEnabled = + _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup, _currentContext); var organization = new Organization { @@ -603,8 +614,12 @@ public class OrganizationService : IOrganizationService UseSecretsManager = license.UseSecretsManager, SmSeats = license.SmSeats, SmServiceAccounts = license.SmServiceAccounts, - LimitCollectionCreationDeletion = !flexibleCollectionsMvpIsEnabled || license.LimitCollectionCreationDeletion, - AllowAdminAccessToAllCollectionItems = !flexibleCollectionsV1IsEnabled || license.AllowAdminAccessToAllCollectionItems + LimitCollectionCreationDeletion = license.LimitCollectionCreationDeletion, + AllowAdminAccessToAllCollectionItems = license.AllowAdminAccessToAllCollectionItems, + + // This feature flag indicates that new organizations should be automatically onboarded to + // Flexible Collections enhancements + FlexibleCollections = flexibleCollectionsSignupEnabled, }; var result = await SignUpAsync(organization, owner.Id, ownerKey, collectionName, false); @@ -616,6 +631,10 @@ public class OrganizationService : IOrganizationService return result; } + /// + /// Private helper method to create a new organization. + /// This is common code used by both the cloud and self-hosted methods. + /// private async Task> SignUpAsync(Organization organization, Guid ownerId, string ownerKey, string collectionName, bool withPayment) { @@ -829,6 +848,7 @@ public class OrganizationService : IOrganizationService { var inviteTypes = new HashSet(invites.Where(i => i.invite.Type.HasValue) .Select(i => i.invite.Type.Value)); + if (invitingUserId.HasValue && inviteTypes.Count > 0) { foreach (var (invite, _) in invites) @@ -2008,7 +2028,11 @@ public class OrganizationService : IOrganizationService throw new BadRequestException("Custom users can only grant the same custom permissions that they have."); } - if (FlexibleCollectionsIsEnabled && newType == OrganizationUserType.Manager && oldType is not OrganizationUserType.Manager) + // TODO: pass in the whole organization object when this is refactored into a command/query + // See AC-2036 + var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId); + var flexibleCollectionsEnabled = organizationAbility?.FlexibleCollections ?? false; + if (flexibleCollectionsEnabled && newType == OrganizationUserType.Manager && oldType is not OrganizationUserType.Manager) { throw new BadRequestException("Manager role is deprecated after Flexible Collections."); } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index a71032ea23..3f5d618e6c 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -96,12 +96,17 @@ public static class FeatureFlagKeys public const string VaultOnboarding = "vault-onboarding"; public const string AutofillV2 = "autofill-v2"; public const string BrowserFilelessImport = "browser-fileless-import"; - public const string FlexibleCollections = "flexible-collections"; + /// + /// Deprecated - never used, do not use. Will always default to false. Will be deleted as part of Flexible Collections cleanup + /// + public const string FlexibleCollections = "flexible-collections-disabled-do-not-use"; public const string FlexibleCollectionsV1 = "flexible-collections-v-1"; // v-1 is intentional public const string BulkCollectionAccess = "bulk-collection-access"; public const string AutofillOverlay = "autofill-overlay"; public const string ItemShare = "item-share"; public const string KeyRotationImprovements = "key-rotation-improvements"; + public const string FlexibleCollectionsMigration = "flexible-collections-migration"; + public const string FlexibleCollectionsSignup = "flexible-collections-signup"; public static List GetAllKeys() { diff --git a/src/Core/Context/CurrentContext.cs b/src/Core/Context/CurrentContext.cs index b346c20f3e..129e90e39d 100644 --- a/src/Core/Context/CurrentContext.cs +++ b/src/Core/Context/CurrentContext.cs @@ -5,7 +5,6 @@ using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Exceptions; using Bit.Core.Identity; using Bit.Core.Models.Data; using Bit.Core.Repositories; @@ -26,8 +25,6 @@ public class CurrentContext : ICurrentContext private IEnumerable _providerOrganizationProviderDetails; private IEnumerable _providerUserOrganizations; - private bool FlexibleCollectionsIsEnabled => _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, this); - public virtual HttpContext HttpContext { get; set; } public virtual Guid? UserId { get; set; } public virtual User User { get; set; } @@ -283,11 +280,6 @@ public class CurrentContext : ICurrentContext public async Task OrganizationManager(Guid orgId) { - if (FlexibleCollectionsIsEnabled) - { - throw new FeatureUnavailableException("Flexible Collections is ON when it should be OFF."); - } - return await OrganizationAdmin(orgId) || (Organizations?.Any(o => o.Id == orgId && o.Type == OrganizationUserType.Manager) ?? false); } @@ -350,22 +342,12 @@ public class CurrentContext : ICurrentContext public async Task EditAssignedCollections(Guid orgId) { - if (FlexibleCollectionsIsEnabled) - { - throw new FeatureUnavailableException("Flexible Collections is ON when it should be OFF."); - } - return await OrganizationManager(orgId) || (Organizations?.Any(o => o.Id == orgId && (o.Permissions?.EditAssignedCollections ?? false)) ?? false); } public async Task DeleteAssignedCollections(Guid orgId) { - if (FlexibleCollectionsIsEnabled) - { - throw new FeatureUnavailableException("Flexible Collections is ON when it should be OFF."); - } - return await OrganizationManager(orgId) || (Organizations?.Any(o => o.Id == orgId && (o.Permissions?.DeleteAssignedCollections ?? false)) ?? false); } @@ -378,11 +360,6 @@ public class CurrentContext : ICurrentContext * This entire method will be moved to the CollectionAuthorizationHandler in the future */ - if (FlexibleCollectionsIsEnabled) - { - throw new FeatureUnavailableException("Flexible Collections is ON when it should be OFF."); - } - var org = GetOrganization(orgId); return await EditAssignedCollections(orgId) || await DeleteAssignedCollections(orgId) diff --git a/src/Core/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommand.cs b/src/Core/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommand.cs index ac2e1b1012..62c46460aa 100644 --- a/src/Core/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommand.cs @@ -2,7 +2,6 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities; -using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Models.Data.Organizations; @@ -18,21 +17,15 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman private readonly ILicensingService _licensingService; private readonly IGlobalSettings _globalSettings; private readonly IOrganizationService _organizationService; - private readonly IFeatureService _featureService; - private readonly ICurrentContext _currentContext; public UpdateOrganizationLicenseCommand( ILicensingService licensingService, IGlobalSettings globalSettings, - IOrganizationService organizationService, - IFeatureService featureService, - ICurrentContext currentContext) + IOrganizationService organizationService) { _licensingService = licensingService; _globalSettings = globalSettings; _organizationService = organizationService; - _featureService = featureService; - _currentContext = currentContext; } public async Task UpdateLicenseAsync(SelfHostedOrganizationDetails selfHostedOrganization, @@ -65,10 +58,8 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman private async Task UpdateOrganizationAsync(SelfHostedOrganizationDetails selfHostedOrganizationDetails, OrganizationLicense license) { - var flexibleCollectionsMvpIsEnabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext); - var flexibleCollectionsV1IsEnabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1, _currentContext); var organization = selfHostedOrganizationDetails.ToOrganization(); - organization.UpdateFromLicense(license, flexibleCollectionsMvpIsEnabled, flexibleCollectionsV1IsEnabled); + organization.UpdateFromLicense(license); await _organizationService.ReplaceAndUpdateCacheAsync(organization); } diff --git a/src/Core/Services/IApplicationCacheService.cs b/src/Core/Services/IApplicationCacheService.cs index 9c9b8ca550..ee47cf29fd 100644 --- a/src/Core/Services/IApplicationCacheService.cs +++ b/src/Core/Services/IApplicationCacheService.cs @@ -8,6 +8,9 @@ namespace Bit.Core.Services; public interface IApplicationCacheService { Task> GetOrganizationAbilitiesAsync(); +#nullable enable + Task GetOrganizationAbilityAsync(Guid orgId); +#nullable disable Task> GetProviderAbilitiesAsync(); Task UpsertOrganizationAbilityAsync(Organization organization); Task UpsertProviderAbilityAsync(Provider provider); diff --git a/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs b/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs index 63db4a88b6..256a9a08a4 100644 --- a/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs +++ b/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs @@ -30,6 +30,15 @@ public class InMemoryApplicationCacheService : IApplicationCacheService return _orgAbilities; } +#nullable enable + public async Task GetOrganizationAbilityAsync(Guid organizationId) + { + (await GetOrganizationAbilitiesAsync()) + .TryGetValue(organizationId, out var organizationAbility); + return organizationAbility; + } +#nullable disable + public virtual async Task> GetProviderAbilitiesAsync() { await InitProviderAbilitiesAsync(); diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index 5517be1689..665f99aadf 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -788,7 +788,7 @@ public class CipherService : ICipherService { collection.SetNewId(); newCollections.Add(collection); - if (UseFlexibleCollections) + if (org.FlexibleCollections) { newCollectionUsers.Add(new CollectionUser { diff --git a/src/Events/Controllers/CollectController.cs b/src/Events/Controllers/CollectController.cs index 7c7962309c..144c248e46 100644 --- a/src/Events/Controllers/CollectController.cs +++ b/src/Events/Controllers/CollectController.cs @@ -35,8 +35,6 @@ public class CollectController : Controller _featureService = featureService; } - bool UseFlexibleCollections => _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext); - [HttpPost] public async Task Post([FromBody] IEnumerable model) { diff --git a/test/Api.Test/Controllers/CollectionsControllerTests.cs b/test/Api.Test/Controllers/CollectionsControllerTests.cs index f8f3b890bb..6155ad0f77 100644 --- a/test/Api.Test/Controllers/CollectionsControllerTests.cs +++ b/test/Api.Test/Controllers/CollectionsControllerTests.cs @@ -2,16 +2,14 @@ using Bit.Api.Controllers; using Bit.Api.Models.Request; using Bit.Api.Vault.AuthorizationHandlers.Collections; -using Bit.Core; -using Bit.Core.AdminConsole.Entities; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations; using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Test.AutoFixture; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Authorization; @@ -22,16 +20,17 @@ namespace Bit.Api.Test.Controllers; [ControllerCustomize(typeof(CollectionsController))] [SutProviderCustomize] -[FeatureServiceCustomize(FeatureFlagKeys.FlexibleCollections)] public class CollectionsControllerTests { [Theory, BitAutoData] - public async Task Post_Success(Guid orgId, CollectionRequestModel collectionRequest, + public async Task Post_Success(OrganizationAbility organizationAbility, CollectionRequestModel collectionRequest, SutProvider sutProvider) { + ArrangeOrganizationAbility(sutProvider, organizationAbility); + Collection ExpectedCollection() => Arg.Is(c => c.Name == collectionRequest.Name && c.ExternalId == collectionRequest.ExternalId && - c.OrganizationId == orgId); + c.OrganizationId == organizationAbility.Id); sutProvider.GetDependency() .AuthorizeAsync(Arg.Any(), @@ -39,7 +38,7 @@ public class CollectionsControllerTests Arg.Is>(r => r.Contains(BulkCollectionOperations.Create))) .Returns(AuthorizationResult.Success()); - _ = await sutProvider.Sut.Post(orgId, collectionRequest); + _ = await sutProvider.Sut.Post(organizationAbility.Id, collectionRequest); await sutProvider.GetDependency() .Received(1) @@ -49,8 +48,11 @@ public class CollectionsControllerTests [Theory, BitAutoData] public async Task Put_Success(Collection collection, CollectionRequestModel collectionRequest, - SutProvider sutProvider) + SutProvider sutProvider, OrganizationAbility organizationAbility) { + ArrangeOrganizationAbility(sutProvider, organizationAbility); + collection.OrganizationId = organizationAbility.Id; + Collection ExpectedCollection() => Arg.Is(c => c.Id == collection.Id && c.Name == collectionRequest.Name && c.ExternalId == collectionRequest.ExternalId && c.OrganizationId == collection.OrganizationId); @@ -75,8 +77,11 @@ public class CollectionsControllerTests [Theory, BitAutoData] public async Task Put_WithNoCollectionPermission_ThrowsNotFound(Collection collection, CollectionRequestModel collectionRequest, - SutProvider sutProvider) + SutProvider sutProvider, OrganizationAbility organizationAbility) { + ArrangeOrganizationAbility(sutProvider, organizationAbility); + collection.OrganizationId = organizationAbility.Id; + sutProvider.GetDependency() .AuthorizeAsync(Arg.Any(), collection, @@ -91,8 +96,11 @@ public class CollectionsControllerTests } [Theory, BitAutoData] - public async Task GetOrganizationCollectionsWithGroups_WithReadAllPermissions_GetsAllCollections(Organization organization, Guid userId, SutProvider sutProvider) + public async Task GetOrganizationCollectionsWithGroups_WithReadAllPermissions_GetsAllCollections(OrganizationAbility organizationAbility, + Guid userId, SutProvider sutProvider) { + ArrangeOrganizationAbility(sutProvider, organizationAbility); + sutProvider.GetDependency().UserId.Returns(userId); sutProvider.GetDependency() @@ -102,18 +110,20 @@ public class CollectionsControllerTests Arg.Is>(requirements => requirements.Cast().All(operation => operation.Name == nameof(CollectionOperations.ReadAllWithAccess) - && operation.OrganizationId == organization.Id))) + && operation.OrganizationId == organizationAbility.Id))) .Returns(AuthorizationResult.Success()); - await sutProvider.Sut.GetManyWithDetails(organization.Id); + await sutProvider.Sut.GetManyWithDetails(organizationAbility.Id); - await sutProvider.GetDependency().Received(1).GetManyByUserIdWithAccessAsync(userId, organization.Id, Arg.Any()); - await sutProvider.GetDependency().Received(1).GetManyByOrganizationIdWithAccessAsync(organization.Id); + await sutProvider.GetDependency().Received(1).GetManyByUserIdWithAccessAsync(userId, organizationAbility.Id, Arg.Any()); + await sutProvider.GetDependency().Received(1).GetManyByOrganizationIdWithAccessAsync(organizationAbility.Id); } [Theory, BitAutoData] - public async Task GetOrganizationCollectionsWithGroups_MissingReadAllPermissions_GetsAssignedCollections(Organization organization, Guid userId, SutProvider sutProvider) + public async Task GetOrganizationCollectionsWithGroups_MissingReadAllPermissions_GetsAssignedCollections( + OrganizationAbility organizationAbility, Guid userId, SutProvider sutProvider) { + ArrangeOrganizationAbility(sutProvider, organizationAbility); sutProvider.GetDependency().UserId.Returns(userId); sutProvider.GetDependency() @@ -123,7 +133,7 @@ public class CollectionsControllerTests Arg.Is>(requirements => requirements.Cast().All(operation => operation.Name == nameof(CollectionOperations.ReadAllWithAccess) - && operation.OrganizationId == organization.Id))) + && operation.OrganizationId == organizationAbility.Id))) .Returns(AuthorizationResult.Failed()); sutProvider.GetDependency() @@ -135,15 +145,19 @@ public class CollectionsControllerTests operation.Name == nameof(BulkCollectionOperations.ReadWithAccess)))) .Returns(AuthorizationResult.Success()); - await sutProvider.Sut.GetManyWithDetails(organization.Id); + await sutProvider.Sut.GetManyWithDetails(organizationAbility.Id); - await sutProvider.GetDependency().Received(1).GetManyByUserIdWithAccessAsync(userId, organization.Id, Arg.Any()); - await sutProvider.GetDependency().DidNotReceive().GetManyByOrganizationIdWithAccessAsync(organization.Id); + await sutProvider.GetDependency().Received(1).GetManyByUserIdWithAccessAsync(userId, organizationAbility.Id, Arg.Any()); + await sutProvider.GetDependency().DidNotReceive().GetManyByOrganizationIdWithAccessAsync(organizationAbility.Id); } [Theory, BitAutoData] - public async Task GetOrganizationCollections_WithReadAllPermissions_GetsAllCollections(Organization organization, ICollection collections, Guid userId, SutProvider sutProvider) + public async Task GetOrganizationCollections_WithReadAllPermissions_GetsAllCollections( + OrganizationAbility organizationAbility, List collections, Guid userId, SutProvider sutProvider) { + ArrangeOrganizationAbility(sutProvider, organizationAbility); + collections.ForEach(c => c.OrganizationId = organizationAbility.Id); + sutProvider.GetDependency().UserId.Returns(userId); sutProvider.GetDependency() @@ -153,26 +167,30 @@ public class CollectionsControllerTests Arg.Is>(requirements => requirements.Cast().All(operation => operation.Name == nameof(CollectionOperations.ReadAll) - && operation.OrganizationId == organization.Id))) + && operation.OrganizationId == organizationAbility.Id))) .Returns(AuthorizationResult.Success()); sutProvider.GetDependency() - .GetManyByOrganizationIdAsync(organization.Id) + .GetManyByOrganizationIdAsync(organizationAbility.Id) .Returns(collections); - var response = await sutProvider.Sut.Get(organization.Id); + var response = await sutProvider.Sut.Get(organizationAbility.Id); - await sutProvider.GetDependency().Received(1).GetManyByOrganizationIdAsync(organization.Id); + await sutProvider.GetDependency().Received(1).GetManyByOrganizationIdAsync(organizationAbility.Id); Assert.Equal(collections.Count, response.Data.Count()); } [Theory, BitAutoData] - public async Task GetOrganizationCollections_MissingReadAllPermissions_GetsManageableCollections(Organization organization, ICollection collections, Guid userId, SutProvider sutProvider) + public async Task GetOrganizationCollections_MissingReadAllPermissions_GetsManageableCollections( + OrganizationAbility organizationAbility, List collections, Guid userId, SutProvider sutProvider) { - collections.First().OrganizationId = organization.Id; - collections.First().Manage = true; - collections.Skip(1).First().OrganizationId = organization.Id; + ArrangeOrganizationAbility(sutProvider, organizationAbility); + collections.ForEach(c => c.OrganizationId = organizationAbility.Id); + collections.ForEach(c => c.Manage = false); + + var managedCollection = collections.First(); + managedCollection.Manage = true; sutProvider.GetDependency().UserId.Returns(userId); @@ -183,7 +201,7 @@ public class CollectionsControllerTests Arg.Is>(requirements => requirements.Cast().All(operation => operation.Name == nameof(CollectionOperations.ReadAll) - && operation.OrganizationId == organization.Id))) + && operation.OrganizationId == organizationAbility.Id))) .Returns(AuthorizationResult.Failed()); sutProvider.GetDependency() @@ -196,22 +214,27 @@ public class CollectionsControllerTests .Returns(AuthorizationResult.Success()); sutProvider.GetDependency() - .GetManyByUserIdAsync(userId, true) + .GetManyByUserIdAsync(userId, false) .Returns(collections); - var result = await sutProvider.Sut.Get(organization.Id); + var result = await sutProvider.Sut.Get(organizationAbility.Id); - await sutProvider.GetDependency().DidNotReceive().GetManyByOrganizationIdAsync(organization.Id); - await sutProvider.GetDependency().Received(1).GetManyByUserIdAsync(userId, true); + await sutProvider.GetDependency().DidNotReceive().GetManyByOrganizationIdAsync(organizationAbility.Id); + await sutProvider.GetDependency().Received(1).GetManyByUserIdAsync(userId, false); Assert.Single(result.Data); - Assert.All(result.Data, c => Assert.Equal(organization.Id, c.OrganizationId)); + Assert.All(result.Data, c => Assert.Equal(organizationAbility.Id, c.OrganizationId)); + Assert.All(result.Data, c => Assert.Equal(managedCollection.Id, c.Id)); } [Theory, BitAutoData] - public async Task DeleteMany_Success(Guid orgId, Collection collection1, Collection collection2, SutProvider sutProvider) + public async Task DeleteMany_Success(OrganizationAbility organizationAbility, Collection collection1, Collection collection2, + SutProvider sutProvider) { // Arrange + var orgId = organizationAbility.Id; + ArrangeOrganizationAbility(sutProvider, organizationAbility); + var model = new CollectionBulkDeleteRequestModel { Ids = new[] { collection1.Id, collection2.Id } @@ -251,9 +274,13 @@ public class CollectionsControllerTests } [Theory, BitAutoData] - public async Task DeleteMany_PermissionDenied_ThrowsNotFound(Guid orgId, Collection collection1, Collection collection2, SutProvider sutProvider) + public async Task DeleteMany_PermissionDenied_ThrowsNotFound(OrganizationAbility organizationAbility, Collection collection1, + Collection collection2, SutProvider sutProvider) { // Arrange + var orgId = organizationAbility.Id; + ArrangeOrganizationAbility(sutProvider, organizationAbility); + var model = new CollectionBulkDeleteRequestModel { Ids = new[] { collection1.Id, collection2.Id } @@ -292,9 +319,13 @@ public class CollectionsControllerTests } [Theory, BitAutoData] - public async Task PostBulkCollectionAccess_Success(User actingUser, ICollection collections, SutProvider sutProvider) + public async Task PostBulkCollectionAccess_Success(User actingUser, List collections, + OrganizationAbility organizationAbility, SutProvider sutProvider) { // Arrange + ArrangeOrganizationAbility(sutProvider, organizationAbility); + collections.ForEach(c => c.OrganizationId = organizationAbility.Id); + var userId = Guid.NewGuid(); var groupId = Guid.NewGuid(); var model = new BulkCollectionAccessRequestModel @@ -321,7 +352,7 @@ public class CollectionsControllerTests IEnumerable ExpectedCollectionAccess() => Arg.Is>(cols => cols.SequenceEqual(collections)); // Act - await sutProvider.Sut.PostBulkCollectionAccess(model); + await sutProvider.Sut.PostBulkCollectionAccess(organizationAbility.Id, model); // Assert await sutProvider.GetDependency().Received().AuthorizeAsync( @@ -338,8 +369,13 @@ public class CollectionsControllerTests } [Theory, BitAutoData] - public async Task PostBulkCollectionAccess_CollectionsNotFound_Throws(User actingUser, ICollection collections, SutProvider sutProvider) + public async Task PostBulkCollectionAccess_CollectionsNotFound_Throws(User actingUser, + OrganizationAbility organizationAbility, List collections, + SutProvider sutProvider) { + ArrangeOrganizationAbility(sutProvider, organizationAbility); + collections.ForEach(c => c.OrganizationId = organizationAbility.Id); + var userId = Guid.NewGuid(); var groupId = Guid.NewGuid(); var model = new BulkCollectionAccessRequestModel @@ -356,7 +392,8 @@ public class CollectionsControllerTests .GetManyByManyIdsAsync(model.CollectionIds) .Returns(collections.Skip(1).ToList()); - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PostBulkCollectionAccess(model)); + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.PostBulkCollectionAccess(organizationAbility.Id, model)); Assert.Equal("One or more collections not found.", exception.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().AuthorizeAsync( @@ -369,8 +406,81 @@ public class CollectionsControllerTests } [Theory, BitAutoData] - public async Task PostBulkCollectionAccess_AccessDenied_Throws(User actingUser, ICollection collections, SutProvider sutProvider) + public async Task PostBulkCollectionAccess_CollectionsBelongToDifferentOrganizations_Throws(User actingUser, + OrganizationAbility organizationAbility, List collections, + SutProvider sutProvider) { + ArrangeOrganizationAbility(sutProvider, organizationAbility); + + // First collection has a different orgId + collections.Skip(1).ToList().ForEach(c => c.OrganizationId = organizationAbility.Id); + + var userId = Guid.NewGuid(); + var groupId = Guid.NewGuid(); + var model = new BulkCollectionAccessRequestModel + { + CollectionIds = collections.Select(c => c.Id), + Users = new[] { new SelectionReadOnlyRequestModel { Id = userId, Manage = true } }, + Groups = new[] { new SelectionReadOnlyRequestModel { Id = groupId, ReadOnly = true } }, + }; + + sutProvider.GetDependency() + .UserId.Returns(actingUser.Id); + + sutProvider.GetDependency() + .GetManyByManyIdsAsync(model.CollectionIds) + .Returns(collections); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.PostBulkCollectionAccess(organizationAbility.Id, model)); + + Assert.Equal("One or more collections not found.", exception.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().AuthorizeAsync( + Arg.Any(), + Arg.Any>(), + Arg.Any>() + ); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .AddAccessAsync(default, default, default); + } + + [Theory, BitAutoData] + public async Task PostBulkCollectionAccess_FlexibleCollectionsDisabled_Throws(OrganizationAbility organizationAbility, List collections, + SutProvider sutProvider) + { + organizationAbility.FlexibleCollections = false; + sutProvider.GetDependency().GetOrganizationAbilityAsync(organizationAbility.Id) + .Returns(organizationAbility); + + var userId = Guid.NewGuid(); + var groupId = Guid.NewGuid(); + var model = new BulkCollectionAccessRequestModel + { + CollectionIds = collections.Select(c => c.Id), + Users = new[] { new SelectionReadOnlyRequestModel { Id = userId, Manage = true } }, + Groups = new[] { new SelectionReadOnlyRequestModel { Id = groupId, ReadOnly = true } }, + }; + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.PostBulkCollectionAccess(organizationAbility.Id, model)); + + Assert.Equal("Feature disabled.", exception.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().AuthorizeAsync( + Arg.Any(), + Arg.Any>(), + Arg.Any>() + ); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .AddAccessAsync(default, default, default); + } + + [Theory, BitAutoData] + public async Task PostBulkCollectionAccess_AccessDenied_Throws(User actingUser, List collections, + OrganizationAbility organizationAbility, SutProvider sutProvider) + { + ArrangeOrganizationAbility(sutProvider, organizationAbility); + collections.ForEach(c => c.OrganizationId = organizationAbility.Id); + var userId = Guid.NewGuid(); var groupId = Guid.NewGuid(); var model = new BulkCollectionAccessRequestModel @@ -396,7 +506,7 @@ public class CollectionsControllerTests IEnumerable ExpectedCollectionAccess() => Arg.Is>(cols => cols.SequenceEqual(collections)); - await Assert.ThrowsAsync(() => sutProvider.Sut.PostBulkCollectionAccess(model)); + await Assert.ThrowsAsync(() => sutProvider.Sut.PostBulkCollectionAccess(organizationAbility.Id, model)); await sutProvider.GetDependency().Received().AuthorizeAsync( Arg.Any(), ExpectedCollectionAccess(), @@ -406,4 +516,12 @@ public class CollectionsControllerTests await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .AddAccessAsync(default, default, default); } + + private void ArrangeOrganizationAbility(SutProvider sutProvider, OrganizationAbility organizationAbility) + { + organizationAbility.FlexibleCollections = true; + + sutProvider.GetDependency().GetOrganizationAbilityAsync(organizationAbility.Id) + .Returns(organizationAbility); + } } diff --git a/test/Api.Test/Vault/AuthorizationHandlers/BulkCollectionAuthorizationHandlerTests.cs b/test/Api.Test/Vault/AuthorizationHandlers/BulkCollectionAuthorizationHandlerTests.cs index 092412a3fb..63406c00db 100644 --- a/test/Api.Test/Vault/AuthorizationHandlers/BulkCollectionAuthorizationHandlerTests.cs +++ b/test/Api.Test/Vault/AuthorizationHandlers/BulkCollectionAuthorizationHandlerTests.cs @@ -1,6 +1,5 @@ using System.Security.Claims; using Bit.Api.Vault.AuthorizationHandlers.Collections; -using Bit.Core; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -9,7 +8,6 @@ using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Test.AutoFixture; using Bit.Core.Test.Vault.AutoFixture; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -20,7 +18,6 @@ using Xunit; namespace Bit.Api.Test.Vault.AuthorizationHandlers; [SutProviderCustomize] -[FeatureServiceCustomize(FeatureFlagKeys.FlexibleCollections)] public class BulkCollectionAuthorizationHandlerTests { [Theory, CollectionCustomization] @@ -35,7 +32,7 @@ public class BulkCollectionAuthorizationHandlerTests organization.Type = userType; organization.Permissions = new Permissions(); - var organizationAbilities = ArrangeOrganizationAbilitiesDictionary(organization.Id, true); + ArrangeOrganizationAbility(sutProvider, organization, true); var context = new AuthorizationHandlerContext( new[] { BulkCollectionOperations.Create }, @@ -44,7 +41,6 @@ public class BulkCollectionAuthorizationHandlerTests sutProvider.GetDependency().UserId.Returns(userId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - sutProvider.GetDependency().GetOrganizationAbilitiesAsync().Returns(organizationAbilities); await sutProvider.Sut.HandleAsync(context); @@ -61,7 +57,7 @@ public class BulkCollectionAuthorizationHandlerTests organization.Type = OrganizationUserType.User; - var organizationAbilities = ArrangeOrganizationAbilitiesDictionary(organization.Id, false); + ArrangeOrganizationAbility(sutProvider, organization, false); var context = new AuthorizationHandlerContext( new[] { BulkCollectionOperations.Create }, @@ -70,7 +66,6 @@ public class BulkCollectionAuthorizationHandlerTests sutProvider.GetDependency().UserId.Returns(actingUserId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - sutProvider.GetDependency().GetOrganizationAbilitiesAsync().Returns(organizationAbilities); await sutProvider.Sut.HandleAsync(context); @@ -97,7 +92,7 @@ public class BulkCollectionAuthorizationHandlerTests ManageUsers = false }; - var organizationAbilities = ArrangeOrganizationAbilitiesDictionary(organization.Id, true); + ArrangeOrganizationAbility(sutProvider, organization, true); var context = new AuthorizationHandlerContext( new[] { BulkCollectionOperations.Create }, @@ -106,7 +101,6 @@ public class BulkCollectionAuthorizationHandlerTests sutProvider.GetDependency().UserId.Returns(actingUserId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - sutProvider.GetDependency().GetOrganizationAbilitiesAsync().Returns(organizationAbilities); sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); await sutProvider.Sut.HandleAsync(context); @@ -117,10 +111,12 @@ public class BulkCollectionAuthorizationHandlerTests [Theory, BitAutoData, CollectionCustomization] public async Task CanCreateAsync_WhenMissingOrgAccess_NoSuccess( Guid userId, - ICollection collections, + CurrentContextOrganization organization, + List collections, SutProvider sutProvider) { - var organizationAbilities = ArrangeOrganizationAbilitiesDictionary(collections.First().OrganizationId, true); + collections.ForEach(c => c.OrganizationId = organization.Id); + ArrangeOrganizationAbility(sutProvider, organization, true); var context = new AuthorizationHandlerContext( new[] { BulkCollectionOperations.Create }, @@ -130,7 +126,6 @@ public class BulkCollectionAuthorizationHandlerTests sutProvider.GetDependency().UserId.Returns(userId); sutProvider.GetDependency().GetOrganization(Arg.Any()).Returns((CurrentContextOrganization)null); - sutProvider.GetDependency().GetOrganizationAbilitiesAsync().Returns(organizationAbilities); sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); await sutProvider.Sut.HandleAsync(context); @@ -747,7 +742,7 @@ public class BulkCollectionAuthorizationHandlerTests organization.Type = userType; organization.Permissions = new Permissions(); - var organizationAbilities = ArrangeOrganizationAbilitiesDictionary(organization.Id, true); + ArrangeOrganizationAbility(sutProvider, organization, true); var context = new AuthorizationHandlerContext( new[] { BulkCollectionOperations.Delete }, @@ -756,8 +751,6 @@ public class BulkCollectionAuthorizationHandlerTests sutProvider.GetDependency().UserId.Returns(userId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - sutProvider.GetDependency().GetOrganizationAbilitiesAsync() - .Returns(organizationAbilities); await sutProvider.Sut.HandleAsync(context); @@ -778,7 +771,7 @@ public class BulkCollectionAuthorizationHandlerTests DeleteAnyCollection = true }; - var organizationAbilities = ArrangeOrganizationAbilitiesDictionary(organization.Id, true); + ArrangeOrganizationAbility(sutProvider, organization, true); var context = new AuthorizationHandlerContext( new[] { BulkCollectionOperations.Delete }, @@ -787,8 +780,6 @@ public class BulkCollectionAuthorizationHandlerTests sutProvider.GetDependency().UserId.Returns(actingUserId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - sutProvider.GetDependency().GetOrganizationAbilitiesAsync() - .Returns(organizationAbilities); await sutProvider.Sut.HandleAsync(context); @@ -806,13 +797,11 @@ public class BulkCollectionAuthorizationHandlerTests organization.Type = OrganizationUserType.User; organization.Permissions = new Permissions(); - var organizationAbilities = ArrangeOrganizationAbilitiesDictionary(organization.Id, false); + ArrangeOrganizationAbility(sutProvider, organization, false); sutProvider.GetDependency().UserId.Returns(actingUserId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId, Arg.Any()).Returns(collections); - sutProvider.GetDependency().GetOrganizationAbilitiesAsync() - .Returns(organizationAbilities); foreach (var c in collections) { @@ -849,7 +838,7 @@ public class BulkCollectionAuthorizationHandlerTests ManageUsers = false }; - var organizationAbilities = ArrangeOrganizationAbilitiesDictionary(organization.Id, true); + ArrangeOrganizationAbility(sutProvider, organization, true); var context = new AuthorizationHandlerContext( new[] { BulkCollectionOperations.Delete }, @@ -858,8 +847,6 @@ public class BulkCollectionAuthorizationHandlerTests sutProvider.GetDependency().UserId.Returns(actingUserId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - sutProvider.GetDependency().GetOrganizationAbilitiesAsync() - .Returns(organizationAbilities); sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); await sutProvider.Sut.HandleAsync(context); @@ -981,17 +968,16 @@ public class BulkCollectionAuthorizationHandlerTests } } - private static Dictionary ArrangeOrganizationAbilitiesDictionary(Guid orgId, - bool limitCollectionCreationDeletion) + private static void ArrangeOrganizationAbility( + SutProvider sutProvider, + CurrentContextOrganization organization, bool limitCollectionCreationDeletion) { - return new Dictionary - { - { orgId, - new OrganizationAbility - { - LimitCollectionCreationDeletion = limitCollectionCreationDeletion - } - } - }; + var organizationAbility = new OrganizationAbility(); + organizationAbility.Id = organization.Id; + organizationAbility.FlexibleCollections = true; + organizationAbility.LimitCollectionCreationDeletion = limitCollectionCreationDeletion; + + sutProvider.GetDependency().GetOrganizationAbilityAsync(organizationAbility.Id) + .Returns(organizationAbility); } } diff --git a/test/Api.Test/Vault/AuthorizationHandlers/CollectionAuthorizationHandlerTests.cs b/test/Api.Test/Vault/AuthorizationHandlers/CollectionAuthorizationHandlerTests.cs index 5bd7b6f849..ad06abd949 100644 --- a/test/Api.Test/Vault/AuthorizationHandlers/CollectionAuthorizationHandlerTests.cs +++ b/test/Api.Test/Vault/AuthorizationHandlers/CollectionAuthorizationHandlerTests.cs @@ -1,10 +1,8 @@ using System.Security.Claims; using Bit.Api.Vault.AuthorizationHandlers.Collections; -using Bit.Core; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Models.Data; -using Bit.Core.Test.AutoFixture; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Authorization; @@ -14,7 +12,6 @@ using Xunit; namespace Bit.Api.Test.Vault.AuthorizationHandlers; [SutProviderCustomize] -[FeatureServiceCustomize(FeatureFlagKeys.FlexibleCollections)] public class CollectionAuthorizationHandlerTests { [Theory] diff --git a/test/Api.Test/Vault/AuthorizationHandlers/GroupAuthorizationHandlerTests.cs b/test/Api.Test/Vault/AuthorizationHandlers/GroupAuthorizationHandlerTests.cs index 79d1ebada5..8ba03930ef 100644 --- a/test/Api.Test/Vault/AuthorizationHandlers/GroupAuthorizationHandlerTests.cs +++ b/test/Api.Test/Vault/AuthorizationHandlers/GroupAuthorizationHandlerTests.cs @@ -1,12 +1,10 @@ using System.Security.Claims; using Bit.Api.Vault.AuthorizationHandlers.Groups; -using Bit.Core; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations; using Bit.Core.Services; -using Bit.Core.Test.AutoFixture; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Authorization; @@ -16,7 +14,6 @@ using Xunit; namespace Bit.Api.Test.Vault.AuthorizationHandlers; [SutProviderCustomize] -[FeatureServiceCustomize(FeatureFlagKeys.FlexibleCollections)] public class GroupAuthorizationHandlerTests { [Theory] @@ -30,7 +27,7 @@ public class GroupAuthorizationHandlerTests organization.Type = userType; organization.Permissions = new Permissions(); - var organizationAbilities = ArrangeOrganizationAbilitiesDictionary(organization.Id, true); + ArrangeOrganizationAbility(sutProvider, organization, true); var context = new AuthorizationHandlerContext( new[] { GroupOperations.ReadAll(organization.Id) }, @@ -39,7 +36,6 @@ public class GroupAuthorizationHandlerTests sutProvider.GetDependency().UserId.Returns(userId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - sutProvider.GetDependency().GetOrganizationAbilitiesAsync().Returns(organizationAbilities); await sutProvider.Sut.HandleAsync(context); @@ -54,7 +50,7 @@ public class GroupAuthorizationHandlerTests organization.Type = OrganizationUserType.User; organization.Permissions = new Permissions(); - var organizationAbilities = ArrangeOrganizationAbilitiesDictionary(organization.Id, true); + ArrangeOrganizationAbility(sutProvider, organization, true); var context = new AuthorizationHandlerContext( new[] { GroupOperations.ReadAll(organization.Id) }, @@ -64,7 +60,6 @@ public class GroupAuthorizationHandlerTests sutProvider.GetDependency() .UserId .Returns(userId); - sutProvider.GetDependency().GetOrganizationAbilitiesAsync().Returns(organizationAbilities); sutProvider.GetDependency() .ProviderUserForOrgAsync(organization.Id) .Returns(true); @@ -97,7 +92,7 @@ public class GroupAuthorizationHandlerTests ManageUsers = manageUsers }; - var organizationAbilities = ArrangeOrganizationAbilitiesDictionary(organization.Id, limitCollectionCreationDeletion); + ArrangeOrganizationAbility(sutProvider, organization, limitCollectionCreationDeletion); var context = new AuthorizationHandlerContext( new[] { GroupOperations.ReadAll(organization.Id) }, @@ -106,7 +101,6 @@ public class GroupAuthorizationHandlerTests sutProvider.GetDependency().UserId.Returns(actingUserId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - sutProvider.GetDependency().GetOrganizationAbilitiesAsync().Returns(organizationAbilities); await sutProvider.Sut.HandleAsync(context); @@ -133,7 +127,7 @@ public class GroupAuthorizationHandlerTests AccessImportExport = false }; - var organizationAbilities = ArrangeOrganizationAbilitiesDictionary(organization.Id, true); + ArrangeOrganizationAbility(sutProvider, organization, true); var context = new AuthorizationHandlerContext( new[] { GroupOperations.ReadAll(organization.Id) }, @@ -142,7 +136,6 @@ public class GroupAuthorizationHandlerTests sutProvider.GetDependency().UserId.Returns(actingUserId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - sutProvider.GetDependency().GetOrganizationAbilitiesAsync().Returns(organizationAbilities); sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); await sutProvider.Sut.HandleAsync(context); @@ -153,20 +146,19 @@ public class GroupAuthorizationHandlerTests [Theory, BitAutoData] public async Task CanReadAllAsync_WhenMissingOrgAccess_NoSuccess( Guid userId, - Guid organizationId, + CurrentContextOrganization organization, SutProvider sutProvider) { - var organizationAbilities = ArrangeOrganizationAbilitiesDictionary(organizationId, true); + ArrangeOrganizationAbility(sutProvider, organization, true); var context = new AuthorizationHandlerContext( - new[] { GroupOperations.ReadAll(organizationId) }, + new[] { GroupOperations.ReadAll(organization.Id) }, new ClaimsPrincipal(), null ); sutProvider.GetDependency().UserId.Returns(userId); sutProvider.GetDependency().GetOrganization(Arg.Any()).Returns((CurrentContextOrganization)null); - sutProvider.GetDependency().GetOrganizationAbilitiesAsync().Returns(organizationAbilities); sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); await sutProvider.Sut.HandleAsync(context); @@ -210,17 +202,16 @@ public class GroupAuthorizationHandlerTests Assert.True(context.HasFailed); } - private static Dictionary ArrangeOrganizationAbilitiesDictionary(Guid orgId, - bool limitCollectionCreationDeletion) + private static void ArrangeOrganizationAbility( + SutProvider sutProvider, + CurrentContextOrganization organization, bool limitCollectionCreationDeletion) { - return new Dictionary - { - { orgId, - new OrganizationAbility - { - LimitCollectionCreationDeletion = limitCollectionCreationDeletion - } - } - }; + var organizationAbility = new OrganizationAbility(); + organizationAbility.Id = organization.Id; + organizationAbility.FlexibleCollections = true; + organizationAbility.LimitCollectionCreationDeletion = limitCollectionCreationDeletion; + + sutProvider.GetDependency().GetOrganizationAbilityAsync(organizationAbility.Id) + .Returns(organizationAbility); } } diff --git a/test/Api.Test/Vault/AuthorizationHandlers/OrganizationUserAuthorizationHandlerTests.cs b/test/Api.Test/Vault/AuthorizationHandlers/OrganizationUserAuthorizationHandlerTests.cs index c93d8a0f64..d6c22197fe 100644 --- a/test/Api.Test/Vault/AuthorizationHandlers/OrganizationUserAuthorizationHandlerTests.cs +++ b/test/Api.Test/Vault/AuthorizationHandlers/OrganizationUserAuthorizationHandlerTests.cs @@ -1,12 +1,10 @@ using System.Security.Claims; using Bit.Api.Vault.AuthorizationHandlers.OrganizationUsers; -using Bit.Core; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations; using Bit.Core.Services; -using Bit.Core.Test.AutoFixture; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Authorization; @@ -16,7 +14,6 @@ using Xunit; namespace Bit.Api.Test.Vault.AuthorizationHandlers; [SutProviderCustomize] -[FeatureServiceCustomize(FeatureFlagKeys.FlexibleCollections)] public class OrganizationUserAuthorizationHandlerTests { [Theory] @@ -30,7 +27,7 @@ public class OrganizationUserAuthorizationHandlerTests organization.Type = userType; organization.Permissions = new Permissions(); - var organizationAbilities = ArrangeOrganizationAbilitiesDictionary(organization.Id, true); + ArrangeOrganizationAbility(sutProvider, organization, true); var context = new AuthorizationHandlerContext( new[] { OrganizationUserOperations.ReadAll(organization.Id) }, @@ -39,7 +36,6 @@ public class OrganizationUserAuthorizationHandlerTests sutProvider.GetDependency().UserId.Returns(userId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - sutProvider.GetDependency().GetOrganizationAbilitiesAsync().Returns(organizationAbilities); await sutProvider.Sut.HandleAsync(context); @@ -54,7 +50,7 @@ public class OrganizationUserAuthorizationHandlerTests organization.Type = OrganizationUserType.User; organization.Permissions = new Permissions(); - var organizationAbilities = ArrangeOrganizationAbilitiesDictionary(organization.Id, true); + ArrangeOrganizationAbility(sutProvider, organization, true); var context = new AuthorizationHandlerContext( new[] { OrganizationUserOperations.ReadAll(organization.Id) }, @@ -64,7 +60,6 @@ public class OrganizationUserAuthorizationHandlerTests sutProvider.GetDependency() .UserId .Returns(userId); - sutProvider.GetDependency().GetOrganizationAbilitiesAsync().Returns(organizationAbilities); sutProvider.GetDependency() .ProviderUserForOrgAsync(organization.Id) .Returns(true); @@ -97,7 +92,7 @@ public class OrganizationUserAuthorizationHandlerTests ManageUsers = manageUsers }; - var organizationAbilities = ArrangeOrganizationAbilitiesDictionary(organization.Id, limitCollectionCreationDeletion); + ArrangeOrganizationAbility(sutProvider, organization, limitCollectionCreationDeletion); var context = new AuthorizationHandlerContext( new[] { OrganizationUserOperations.ReadAll(organization.Id) }, @@ -106,7 +101,6 @@ public class OrganizationUserAuthorizationHandlerTests sutProvider.GetDependency().UserId.Returns(actingUserId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - sutProvider.GetDependency().GetOrganizationAbilitiesAsync().Returns(organizationAbilities); await sutProvider.Sut.HandleAsync(context); @@ -132,7 +126,7 @@ public class OrganizationUserAuthorizationHandlerTests ManageUsers = false }; - var organizationAbilities = ArrangeOrganizationAbilitiesDictionary(organization.Id, true); + ArrangeOrganizationAbility(sutProvider, organization, true); var context = new AuthorizationHandlerContext( new[] { OrganizationUserOperations.ReadAll(organization.Id) }, @@ -141,7 +135,6 @@ public class OrganizationUserAuthorizationHandlerTests sutProvider.GetDependency().UserId.Returns(actingUserId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - sutProvider.GetDependency().GetOrganizationAbilitiesAsync().Returns(organizationAbilities); sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); await sutProvider.Sut.HandleAsync(context); @@ -152,20 +145,19 @@ public class OrganizationUserAuthorizationHandlerTests [Theory, BitAutoData] public async Task HandleRequirementAsync_WhenMissingOrgAccess_NoSuccess( Guid userId, - Guid organizationId, + CurrentContextOrganization organization, SutProvider sutProvider) { - var organizationAbilities = ArrangeOrganizationAbilitiesDictionary(organizationId, true); + ArrangeOrganizationAbility(sutProvider, organization, true); var context = new AuthorizationHandlerContext( - new[] { OrganizationUserOperations.ReadAll(organizationId) }, + new[] { OrganizationUserOperations.ReadAll(organization.Id) }, new ClaimsPrincipal(), null ); sutProvider.GetDependency().UserId.Returns(userId); sutProvider.GetDependency().GetOrganization(Arg.Any()).Returns((CurrentContextOrganization)null); - sutProvider.GetDependency().GetOrganizationAbilitiesAsync().Returns(organizationAbilities); sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); await sutProvider.Sut.HandleAsync(context); @@ -207,17 +199,16 @@ public class OrganizationUserAuthorizationHandlerTests Assert.True(context.HasFailed); } - private static Dictionary ArrangeOrganizationAbilitiesDictionary(Guid orgId, - bool limitCollectionCreationDeletion) + private static void ArrangeOrganizationAbility( + SutProvider sutProvider, + CurrentContextOrganization organization, bool limitCollectionCreationDeletion) { - return new Dictionary - { - { orgId, - new OrganizationAbility - { - LimitCollectionCreationDeletion = limitCollectionCreationDeletion - } - } - }; + var organizationAbility = new OrganizationAbility(); + organizationAbility.Id = organization.Id; + organizationAbility.FlexibleCollections = true; + organizationAbility.LimitCollectionCreationDeletion = limitCollectionCreationDeletion; + + sutProvider.GetDependency().GetOrganizationAbilityAsync(organizationAbility.Id) + .Returns(organizationAbility); } } diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index ae2ea4ae9a..d4888a63ed 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -15,6 +15,7 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.Mail; using Bit.Core.Models.StaticStore; @@ -972,21 +973,23 @@ OrganizationUserInvite invite, SutProvider sutProvider) } [Theory, BitAutoData] - public async Task InviteUser_WithFCEnabled_WhenInvitingManager_Throws(Organization organization, OrganizationUserInvite invite, - OrganizationUser invitor, SutProvider sutProvider) + public async Task InviteUser_WithFCEnabled_WhenInvitingManager_Throws(OrganizationAbility organizationAbility, + OrganizationUserInvite invite, OrganizationUser invitor, SutProvider sutProvider) { invite.Type = OrganizationUserType.Manager; + organizationAbility.FlexibleCollections = true; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.FlexibleCollections, Arg.Any()) - .Returns(true); + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(organizationAbility.Id) + .Returns(organizationAbility); sutProvider.GetDependency() - .ManageUsers(organization.Id) + .ManageUsers(organizationAbility.Id) .Returns(true); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) })); + () => sutProvider.Sut.InviteUsersAsync(organizationAbility.Id, invitor.UserId, + new (OrganizationUserInvite, string)[] { (invite, null) })); Assert.Contains("manager role is deprecated", exception.Message.ToLowerInvariant()); } @@ -1273,19 +1276,20 @@ OrganizationUserInvite invite, SutProvider sutProvider) [Theory, BitAutoData] public async Task SaveUser_WithFCEnabled_WhenUpgradingToManager_Throws( - Organization organization, + OrganizationAbility organizationAbility, [OrganizationUser(type: OrganizationUserType.User)] OrganizationUser oldUserData, [OrganizationUser(type: OrganizationUserType.Manager)] OrganizationUser newUserData, IEnumerable collections, IEnumerable groups, SutProvider sutProvider) { - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.FlexibleCollections, Arg.Any()) - .Returns(true); + organizationAbility.FlexibleCollections = true; + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(organizationAbility.Id) + .Returns(organizationAbility); sutProvider.GetDependency() - .ManageUsers(organization.Id) + .ManageUsers(organizationAbility.Id) .Returns(true); sutProvider.GetDependency() @@ -1294,7 +1298,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) newUserData.Id = oldUserData.Id; newUserData.UserId = oldUserData.UserId; - newUserData.OrganizationId = oldUserData.OrganizationId = organization.Id; + newUserData.OrganizationId = oldUserData.OrganizationId = organizationAbility.Id; newUserData.Permissions = CoreHelpers.ClassToJsonData(new Permissions()); var exception = await Assert.ThrowsAsync( diff --git a/test/Core.Test/AutoFixture/FeatureServiceFixtures.cs b/test/Core.Test/AutoFixture/FeatureServiceFixtures.cs deleted file mode 100644 index 69f771e321..0000000000 --- a/test/Core.Test/AutoFixture/FeatureServiceFixtures.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Reflection; -using AutoFixture; -using AutoFixture.Kernel; -using Bit.Core.Context; -using Bit.Core.Services; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; - -namespace Bit.Core.Test.AutoFixture; - -internal class FeatureServiceBuilder : ISpecimenBuilder -{ - private readonly string _enabledFeatureFlag; - - public FeatureServiceBuilder(string enabledFeatureFlag) - { - _enabledFeatureFlag = enabledFeatureFlag; - } - - public object Create(object request, ISpecimenContext context) - { - if (context == null) - { - throw new ArgumentNullException(nameof(context)); - } - - if (request is not ParameterInfo pi) - { - return new NoSpecimen(); - } - - if (pi.ParameterType == typeof(IFeatureService)) - { - var fixture = new Fixture(); - var featureService = fixture.WithAutoNSubstitutions().Create(); - featureService - .IsEnabled(_enabledFeatureFlag, Arg.Any(), Arg.Any()) - .Returns(true); - return featureService; - } - - return new NoSpecimen(); - } -} - -internal class FeatureServiceCustomization : ICustomization -{ - private readonly string _enabledFeatureFlag; - - public FeatureServiceCustomization(string enabledFeatureFlag) - { - _enabledFeatureFlag = enabledFeatureFlag; - } - - public void Customize(IFixture fixture) - { - fixture.Customizations.Add(new FeatureServiceBuilder(_enabledFeatureFlag)); - } -} - -/// -/// Arranges the IFeatureService mock to enable the specified feature flag -/// -public class FeatureServiceCustomizeAttribute : BitCustomizeAttribute -{ - private readonly string _enabledFeatureFlag; - - public FeatureServiceCustomizeAttribute(string enabledFeatureFlag) - { - _enabledFeatureFlag = enabledFeatureFlag; - } - - public override ICustomization GetCustomization() => new FeatureServiceCustomization(_enabledFeatureFlag); -} diff --git a/test/Core.Test/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommandTests.cs new file mode 100644 index 0000000000..565f2f32c4 --- /dev/null +++ b/test/Core.Test/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommandTests.cs @@ -0,0 +1,100 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Business; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.OrganizationFeatures.OrganizationLicenses; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.Helpers; +using NSubstitute; +using Xunit; +using JsonSerializer = System.Text.Json.JsonSerializer; + +namespace Bit.Core.Test.OrganizationFeatures.OrganizationLicenses; + +[SutProviderCustomize] +public class UpdateOrganizationLicenseCommandTests +{ + private static string LicenseDirectory => Path.GetDirectoryName(OrganizationLicenseDirectory.Value); + private static Lazy OrganizationLicenseDirectory => new(() => + { + // Create a temporary directory to write the license file to + var directory = Path.Combine(Path.GetTempPath(), "bitwarden/"); + if (!Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + return directory; + }); + + [Theory, BitAutoData] + public async Task UpdateLicenseAsync_UpdatesLicenseFileAndOrganization( + SelfHostedOrganizationDetails selfHostedOrg, + OrganizationLicense license, + SutProvider sutProvider) + { + var globalSettings = sutProvider.GetDependency(); + globalSettings.LicenseDirectory = LicenseDirectory; + globalSettings.SelfHosted = true; + + // Passing values for OrganizationLicense.CanUse + // NSubstitute cannot override non-virtual members so we have to ensure the real method passes + license.Enabled = true; + license.Issued = DateTime.Now.AddDays(-1); + license.Expires = DateTime.Now.AddDays(1); + license.Version = OrganizationLicense.CurrentLicenseFileVersion; + license.InstallationId = globalSettings.Installation.Id; + license.LicenseType = LicenseType.Organization; + sutProvider.GetDependency().VerifyLicense(license).Returns(true); + + // Passing values for SelfHostedOrganizationDetails.CanUseLicense + // NSubstitute cannot override non-virtual members so we have to ensure the real method passes + license.Seats = null; + license.MaxCollections = null; + license.UseGroups = true; + license.UsePolicies = true; + license.UseSso = true; + license.UseKeyConnector = true; + license.UseScim = true; + license.UseCustomPermissions = true; + license.UseResetPassword = true; + + try + { + await sutProvider.Sut.UpdateLicenseAsync(selfHostedOrg, license, null); + + // Assertion: should have saved the license file to disk + var filePath = Path.Combine(LicenseDirectory, "organization", $"{selfHostedOrg.Id}.json"); + await using var fs = File.OpenRead(filePath); + var licenseFromFile = await JsonSerializer.DeserializeAsync(fs); + + AssertHelper.AssertPropertyEqual(license, licenseFromFile, "SignatureBytes"); + + // Assertion: should have updated and saved the organization + // Properties excluded from the comparison below are exceptions to the rule that the Organization mirrors + // the OrganizationLicense + await sutProvider.GetDependency() + .Received(1) + .ReplaceAndUpdateCacheAsync(Arg.Is( + org => AssertPropertyEqual(license, org, + "Id", "MaxStorageGb", "Issued", "Refresh", "Version", "Trial", "LicenseType", + "Hash", "Signature", "SignatureBytes", "InstallationId", "Expires", "ExpirationWithoutGracePeriod") && + // Same property but different name, use explicit mapping + org.ExpirationDate == license.Expires)); + } + finally + { + // Clean up temporary directory + Directory.Delete(OrganizationLicenseDirectory.Value, true); + } + } + + // Wrapper to compare 2 objects that are different types + private bool AssertPropertyEqual(OrganizationLicense expected, Organization actual, params string[] excludedPropertyStrings) + { + AssertHelper.AssertPropertyEqual(expected, actual, excludedPropertyStrings); + return true; + } +} diff --git a/test/Core.Test/Vault/Services/CipherServiceTests.cs b/test/Core.Test/Vault/Services/CipherServiceTests.cs index fc64f80216..a4eb05ac4f 100644 --- a/test/Core.Test/Vault/Services/CipherServiceTests.cs +++ b/test/Core.Test/Vault/Services/CipherServiceTests.cs @@ -1,5 +1,4 @@ using Bit.Core.AdminConsole.Entities; -using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -36,6 +35,7 @@ public class CipherServiceTests SutProvider sutProvider) { organization.MaxCollections = null; + organization.FlexibleCollections = false; importingOrganizationUser.OrganizationId = organization.Id; foreach (var collection in collections) @@ -62,10 +62,6 @@ public class CipherServiceTests .GetByOrganizationAsync(organization.Id, importingUserId) .Returns(importingOrganizationUser); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.FlexibleCollections, Arg.Any(), Arg.Any()) - .Returns(false); - // Set up a collection that already exists in the organization sutProvider.GetDependency() .GetManyByOrganizationIdAsync(organization.Id) @@ -95,6 +91,7 @@ public class CipherServiceTests SutProvider sutProvider) { organization.MaxCollections = null; + organization.FlexibleCollections = true; importingOrganizationUser.OrganizationId = organization.Id; foreach (var collection in collections) @@ -121,10 +118,6 @@ public class CipherServiceTests .GetByOrganizationAsync(organization.Id, importingUserId) .Returns(importingOrganizationUser); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.FlexibleCollections, Arg.Any(), Arg.Any()) - .Returns(true); - // Set up a collection that already exists in the organization sutProvider.GetDependency() .GetManyByOrganizationIdAsync(organization.Id) From 880ceafe9f7df5bbab4c639e2c5505cc28e2b4ef Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Wed, 17 Jan 2024 10:42:43 -0600 Subject: [PATCH 013/117] [BEEEP] [SM-1059] Add missing auth table indexes to EF config (#3625) * Add missing indexes to EF auth tables * Add EF migrations --- .../GrantEntityTypeConfiguration.cs | 4 + .../SsoUserEntityTypeConfiguration.cs | 28 + .../Repositories/DatabaseContext.cs | 4 +- ...0112180622_AddAuthTableIndexes.Designer.cs | 2398 ++++++++++++++++ .../20240112180622_AddAuthTableIndexes.cs | 46 + .../DatabaseContextModelSnapshot.cs | 19 +- ...0112180915_AddAuthTableIndexes.Designer.cs | 2412 +++++++++++++++++ .../20240112180915_AddAuthTableIndexes.cs | 47 + .../DatabaseContextModelSnapshot.cs | 16 +- ...0112180610_AddAuthTableIndexes.Designer.cs | 2396 ++++++++++++++++ .../20240112180610_AddAuthTableIndexes.cs | 46 + .../DatabaseContextModelSnapshot.cs | 18 +- 12 files changed, 7425 insertions(+), 9 deletions(-) create mode 100644 src/Infrastructure.EntityFramework/Auth/Configurations/SsoUserEntityTypeConfiguration.cs create mode 100644 util/MySqlMigrations/Migrations/20240112180622_AddAuthTableIndexes.Designer.cs create mode 100644 util/MySqlMigrations/Migrations/20240112180622_AddAuthTableIndexes.cs create mode 100644 util/PostgresMigrations/Migrations/20240112180915_AddAuthTableIndexes.Designer.cs create mode 100644 util/PostgresMigrations/Migrations/20240112180915_AddAuthTableIndexes.cs create mode 100644 util/SqliteMigrations/Migrations/20240112180610_AddAuthTableIndexes.Designer.cs create mode 100644 util/SqliteMigrations/Migrations/20240112180610_AddAuthTableIndexes.cs diff --git a/src/Infrastructure.EntityFramework/Auth/Configurations/GrantEntityTypeConfiguration.cs b/src/Infrastructure.EntityFramework/Auth/Configurations/GrantEntityTypeConfiguration.cs index 77d8d1eb9f..34c9b0351f 100644 --- a/src/Infrastructure.EntityFramework/Auth/Configurations/GrantEntityTypeConfiguration.cs +++ b/src/Infrastructure.EntityFramework/Auth/Configurations/GrantEntityTypeConfiguration.cs @@ -21,6 +21,10 @@ public class GrantEntityTypeConfiguration : IEntityTypeConfiguration .HasIndex(s => s.Key) .IsUnique(true); + builder + .HasIndex(s => s.ExpirationDate) + .IsClustered(false); + builder.ToTable(nameof(Grant)); } } diff --git a/src/Infrastructure.EntityFramework/Auth/Configurations/SsoUserEntityTypeConfiguration.cs b/src/Infrastructure.EntityFramework/Auth/Configurations/SsoUserEntityTypeConfiguration.cs new file mode 100644 index 0000000000..f8b8a843bc --- /dev/null +++ b/src/Infrastructure.EntityFramework/Auth/Configurations/SsoUserEntityTypeConfiguration.cs @@ -0,0 +1,28 @@ +using Bit.Infrastructure.EntityFramework.Auth.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Bit.Infrastructure.EntityFramework.Auth.Configurations; + +public class SsoUserEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder + .HasIndex(su => su.OrganizationId) + .IsClustered(false); + + NpgsqlIndexBuilderExtensions.IncludeProperties( + builder.HasIndex(su => new { su.OrganizationId, su.ExternalId }) + .IsUnique() + .IsClustered(false), + su => su.UserId); + + builder + .HasIndex(su => new { su.OrganizationId, su.UserId }) + .IsUnique() + .IsClustered(false); + + builder.ToTable(nameof(SsoUser)); + } +} diff --git a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs index bd65d67364..e74a7a9f92 100644 --- a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs +++ b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs @@ -86,7 +86,6 @@ public class DatabaseContext : DbContext var eProviderUser = builder.Entity(); var eProviderOrganization = builder.Entity(); var eSsoConfig = builder.Entity(); - var eSsoUser = builder.Entity(); var eTaxRate = builder.Entity(); var eUser = builder.Entity(); var eOrganizationApiKey = builder.Entity(); @@ -125,8 +124,8 @@ public class DatabaseContext : DbContext // see https://www.npgsql.org/efcore/misc/collations-and-case-sensitivity.html#database-collation builder.HasCollation(postgresIndetermanisticCollation, locale: "en-u-ks-primary", provider: "icu", deterministic: false); eUser.Property(e => e.Email).UseCollation(postgresIndetermanisticCollation); - eSsoUser.Property(e => e.ExternalId).UseCollation(postgresIndetermanisticCollation); builder.Entity().Property(e => e.Identifier).UseCollation(postgresIndetermanisticCollation); + builder.Entity().Property(e => e.ExternalId).UseCollation(postgresIndetermanisticCollation); // } @@ -142,7 +141,6 @@ public class DatabaseContext : DbContext eProviderUser.ToTable(nameof(ProviderUser)); eProviderOrganization.ToTable(nameof(ProviderOrganization)); eSsoConfig.ToTable(nameof(SsoConfig)); - eSsoUser.ToTable(nameof(SsoUser)); eTaxRate.ToTable(nameof(TaxRate)); eOrganizationApiKey.ToTable(nameof(OrganizationApiKey)); eOrganizationConnection.ToTable(nameof(OrganizationConnection)); diff --git a/util/MySqlMigrations/Migrations/20240112180622_AddAuthTableIndexes.Designer.cs b/util/MySqlMigrations/Migrations/20240112180622_AddAuthTableIndexes.Designer.cs new file mode 100644 index 0000000000..712bdc6d86 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20240112180622_AddAuthTableIndexes.Designer.cs @@ -0,0 +1,2398 @@ +// +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.MySqlMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20240112180622_AddAuthTableIndexes")] + partial class AddAuthTableIndexes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.14") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + 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") + .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("FlexibleCollections") + .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("Identifier") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("LimitCollectionCreationDeletion") + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + 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") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("datetime(6)"); + + b.Property("Plan") + .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("SecretsManagerBeta") + .HasColumnType("tinyint(1)"); + + 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("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("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("Enabled") + .HasColumnType("tinyint(1)"); + + 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("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") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + 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") + .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"); + + 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"); + + 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.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") + .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("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("longtext"); + + b.Property("EncryptedPublicKey") + .HasColumnType("longtext"); + + b.Property("EncryptedUserKey") + .HasColumnType("longtext"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Name") + .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("AccessAll") + .HasColumnType("tinyint(1)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .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.Installation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApiKey") + .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") + .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") + .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("LastSyncDate") + .HasColumnType("datetime(6)"); + + 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("AccessAll") + .HasColumnType("tinyint(1)"); + + 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.HasIndex("UserId", "OrganizationId", "Status") + .HasAnnotation("Npgsql:IndexInclude", new[] { "AccessAll" }) + .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("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") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PostalCode") + .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("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("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") + .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.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.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Discriminator") + .IsRequired() + .HasColumnType("longtext"); + + 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("Discriminator").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") + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ExpireAt") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Scope") + .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.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("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.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") + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + 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.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.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.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .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.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("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.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() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + 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.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.ServiceAccount", b => + { + b.Navigation("GroupAccessPolicies"); + + 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/20240112180622_AddAuthTableIndexes.cs b/util/MySqlMigrations/Migrations/20240112180622_AddAuthTableIndexes.cs new file mode 100644 index 0000000000..c28d2bff62 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20240112180622_AddAuthTableIndexes.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations; + +/// +public partial class AddAuthTableIndexes : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_SsoUser_OrganizationId_ExternalId", + table: "SsoUser", + columns: new[] { "OrganizationId", "ExternalId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SsoUser_OrganizationId_UserId", + table: "SsoUser", + columns: new[] { "OrganizationId", "UserId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Grant_ExpirationDate", + table: "Grant", + column: "ExpirationDate"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_SsoUser_OrganizationId_ExternalId", + table: "SsoUser"); + + migrationBuilder.DropIndex( + name: "IX_SsoUser_OrganizationId_UserId", + table: "SsoUser"); + + migrationBuilder.DropIndex( + name: "IX_Grant_ExpirationDate", + table: "Grant"); + } +} diff --git a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs index 93b5f3653f..61268a40bd 100644 --- a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -3,8 +3,8 @@ using System; using Bit.Infrastructure.EntityFramework.Repositories; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable @@ -490,7 +490,7 @@ namespace Bit.MySqlMigrations.Migrations b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("int") - .HasAnnotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn); + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); b.Property("ClientId") .IsRequired() @@ -535,6 +535,9 @@ namespace Bit.MySqlMigrations.Migrations b.HasKey("Id") .HasAnnotation("SqlServer:Clustered", true); + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + b.HasIndex("Key") .IsUnique(); @@ -590,10 +593,20 @@ namespace Bit.MySqlMigrations.Migrations b.HasKey("Id"); - b.HasIndex("OrganizationId"); + 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); }); diff --git a/util/PostgresMigrations/Migrations/20240112180915_AddAuthTableIndexes.Designer.cs b/util/PostgresMigrations/Migrations/20240112180915_AddAuthTableIndexes.Designer.cs new file mode 100644 index 0000000000..abc5d26b76 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20240112180915_AddAuthTableIndexes.Designer.cs @@ -0,0 +1,2412 @@ +// +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("20240112180915_AddAuthTableIndexes")] + partial class AddAuthTableIndexes + { + /// + 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", "7.0.14") + .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") + .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("FlexibleCollections") + .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("Identifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LimitCollectionCreationDeletion") + .HasColumnType("boolean") + .HasDefaultValue(true); + + 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") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("timestamp with time zone"); + + b.Property("Plan") + .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("SecretsManagerBeta") + .HasColumnType("boolean"); + + 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("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("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("Enabled") + .HasColumnType("boolean"); + + 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("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") + .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.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") + .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("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("text"); + + b.Property("EncryptedPublicKey") + .HasColumnType("text"); + + b.Property("EncryptedUserKey") + .HasColumnType("text"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .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("AccessAll") + .HasColumnType("boolean"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .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.Installation", 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("Enabled") + .HasColumnType("boolean"); + + b.Property("Key") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApiKey") + .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") + .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") + .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("LastSyncDate") + .HasColumnType("timestamp with time zone"); + + 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("AccessAll") + .HasColumnType("boolean"); + + 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.HasIndex("UserId", "OrganizationId", "Status") + .HasAnnotation("SqlServer:Clustered", false); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("UserId", "OrganizationId", "Status"), new[] { "AccessAll" }); + + 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("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") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PostalCode") + .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("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("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") + .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.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.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Discriminator") + .IsRequired() + .HasColumnType("text"); + + 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("Discriminator").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") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .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.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("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.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") + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + 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.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.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.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .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.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("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.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() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + 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.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.ServiceAccount", b => + { + b.Navigation("GroupAccessPolicies"); + + 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/20240112180915_AddAuthTableIndexes.cs b/util/PostgresMigrations/Migrations/20240112180915_AddAuthTableIndexes.cs new file mode 100644 index 0000000000..132df68619 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20240112180915_AddAuthTableIndexes.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations; + +/// +public partial class AddAuthTableIndexes : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_SsoUser_OrganizationId_ExternalId", + table: "SsoUser", + columns: new[] { "OrganizationId", "ExternalId" }, + unique: true) + .Annotation("Npgsql:IndexInclude", new[] { "UserId" }); + + migrationBuilder.CreateIndex( + name: "IX_SsoUser_OrganizationId_UserId", + table: "SsoUser", + columns: new[] { "OrganizationId", "UserId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Grant_ExpirationDate", + table: "Grant", + column: "ExpirationDate"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_SsoUser_OrganizationId_ExternalId", + table: "SsoUser"); + + migrationBuilder.DropIndex( + name: "IX_SsoUser_OrganizationId_UserId", + table: "SsoUser"); + + migrationBuilder.DropIndex( + name: "IX_Grant_ExpirationDate", + table: "Grant"); + } +} diff --git a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs index 431c309a9a..78f2357e28 100644 --- a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -541,6 +541,9 @@ namespace Bit.PostgresMigrations.Migrations b.HasKey("Id") .HasAnnotation("SqlServer:Clustered", true); + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + b.HasIndex("Key") .IsUnique(); @@ -601,10 +604,21 @@ namespace Bit.PostgresMigrations.Migrations b.HasKey("Id"); - b.HasIndex("OrganizationId"); + 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); }); diff --git a/util/SqliteMigrations/Migrations/20240112180610_AddAuthTableIndexes.Designer.cs b/util/SqliteMigrations/Migrations/20240112180610_AddAuthTableIndexes.Designer.cs new file mode 100644 index 0000000000..702c862a45 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20240112180610_AddAuthTableIndexes.Designer.cs @@ -0,0 +1,2396 @@ +// +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.SqliteMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20240112180610_AddAuthTableIndexes")] + partial class AddAuthTableIndexes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.14"); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .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("FlexibleCollections") + .HasColumnType("INTEGER"); + + 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("LimitCollectionCreationDeletion") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("TEXT"); + + b.Property("Plan") + .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("SecretsManagerBeta") + .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("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("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("Enabled") + .HasColumnType("INTEGER"); + + 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("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") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + 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") + .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.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .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("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasColumnType("TEXT"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .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("AccessAll") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .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.Installation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .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") + .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") + .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("LastSyncDate") + .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("AccessAll") + .HasColumnType("INTEGER"); + + 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.HasIndex("UserId", "OrganizationId", "Status") + .HasAnnotation("Npgsql:IndexInclude", new[] { "AccessAll" }) + .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("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") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PostalCode") + .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("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("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") + .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.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.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Discriminator") + .IsRequired() + .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("Discriminator").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") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ExpireAt") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Scope") + .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.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("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.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") + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + 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.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.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.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .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.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("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.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() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + 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.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.ServiceAccount", b => + { + b.Navigation("GroupAccessPolicies"); + + 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/20240112180610_AddAuthTableIndexes.cs b/util/SqliteMigrations/Migrations/20240112180610_AddAuthTableIndexes.cs new file mode 100644 index 0000000000..56d5742477 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20240112180610_AddAuthTableIndexes.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations; + +/// +public partial class AddAuthTableIndexes : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_SsoUser_OrganizationId_ExternalId", + table: "SsoUser", + columns: new[] { "OrganizationId", "ExternalId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SsoUser_OrganizationId_UserId", + table: "SsoUser", + columns: new[] { "OrganizationId", "UserId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Grant_ExpirationDate", + table: "Grant", + column: "ExpirationDate"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_SsoUser_OrganizationId_ExternalId", + table: "SsoUser"); + + migrationBuilder.DropIndex( + name: "IX_SsoUser_OrganizationId_UserId", + table: "SsoUser"); + + migrationBuilder.DropIndex( + name: "IX_Grant_ExpirationDate", + table: "Grant"); + } +} diff --git a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs index 569f9809a3..f281a2353f 100644 --- a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -4,6 +4,7 @@ using Bit.Infrastructure.EntityFramework.Repositories; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable @@ -487,7 +488,7 @@ namespace Bit.SqliteMigrations.Migrations b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") - .HasAnnotation("Sqlite:Autoincrement", true); + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); b.Property("ClientId") .IsRequired() @@ -532,6 +533,9 @@ namespace Bit.SqliteMigrations.Migrations b.HasKey("Id") .HasAnnotation("SqlServer:Clustered", true); + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + b.HasIndex("Key") .IsUnique(); @@ -587,10 +591,20 @@ namespace Bit.SqliteMigrations.Migrations b.HasKey("Id"); - b.HasIndex("OrganizationId"); + 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); }); From cd006f3779c9ac326ea935405e6aa65fde79eaa8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 17 Jan 2024 15:43:40 -0500 Subject: [PATCH 014/117] [deps] Platform: Update Microsoft.Data.SqlClient to v5.1.4 (#3680) * [deps] Platform: Update Microsoft.Data.SqlClient to v5.1.4 * Remove Explicit Dep --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> --- src/Core/Core.csproj | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 6752cca078..ce2e3832bb 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -35,9 +35,7 @@ - - - + From 974d23efdd00fc54de52954c34c43e42e2a51e8a Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Thu, 18 Jan 2024 09:47:34 -0500 Subject: [PATCH 015/117] Establish IFeatureService as scoped (#3679) * Establish IFeatureService as scoped * Lint * Feedback around injection --- src/Admin/Controllers/UsersController.cs | 2 +- .../Controllers/GroupsController.cs | 1 - .../Controllers/OrganizationsController.cs | 2 +- .../Auth/Controllers/AccountsController.cs | 8 +-- src/Api/Controllers/ConfigController.cs | 6 +- .../Vault/Controllers/CiphersController.cs | 2 +- src/Api/Vault/Controllers/SyncController.cs | 6 +- .../Validators/CipherRotationValidator.cs | 9 +-- .../Implementations/OrganizationService.cs | 6 +- .../Implementations/EmergencyAccessService.cs | 6 +- src/Core/Context/CurrentContext.cs | 6 +- src/Core/Services/IFeatureService.cs | 16 ++--- .../Implementations/CollectionService.cs | 4 +- .../LaunchDarklyFeatureService.cs | 64 ++++++++++--------- src/Core/Utilities/RequireFeatureAttribute.cs | 6 +- .../Services/Implementations/CipherService.cs | 2 +- src/Events/Controllers/CollectController.cs | 2 +- src/Events/Startup.cs | 2 +- .../IdentityServer/WebAuthnGrantValidator.cs | 2 +- src/Notifications/NotificationsHub.cs | 4 +- .../Utilities/ServiceCollectionExtensions.cs | 19 +++++- .../Controllers/AccountsControllerTests.cs | 4 -- .../Controllers/ConfigControllerTests.cs | 6 +- .../Services/CollectionServiceTests.cs | 2 +- .../LaunchDarklyFeatureServiceTests.cs | 38 ++++------- .../Utilities/RequireFeatureAttributeTests.cs | 2 +- 26 files changed, 96 insertions(+), 131 deletions(-) diff --git a/src/Admin/Controllers/UsersController.cs b/src/Admin/Controllers/UsersController.cs index e4ff88a68e..ba9d04e3af 100644 --- a/src/Admin/Controllers/UsersController.cs +++ b/src/Admin/Controllers/UsersController.cs @@ -27,7 +27,7 @@ public class UsersController : Controller private readonly IFeatureService _featureService; private bool UseFlexibleCollections => - _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext); + _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections); public UsersController( IUserRepository userRepository, diff --git a/src/Api/AdminConsole/Controllers/GroupsController.cs b/src/Api/AdminConsole/Controllers/GroupsController.cs index 3a256043a0..9fa392af78 100644 --- a/src/Api/AdminConsole/Controllers/GroupsController.cs +++ b/src/Api/AdminConsole/Controllers/GroupsController.cs @@ -37,7 +37,6 @@ public class GroupsController : Controller ICreateGroupCommand createGroupCommand, IUpdateGroupCommand updateGroupCommand, IDeleteGroupCommand deleteGroupCommand, - IFeatureService featureService, IAuthorizationService authorizationService, IApplicationCacheService applicationCacheService) { diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index e4f0c3aa50..da0b8d4e2c 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -803,7 +803,7 @@ public class OrganizationsController : Controller throw new BadRequestException("Organization does not have collection enhancements enabled"); } - var v1Enabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1, _currentContext); + var v1Enabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1); if (!v1Enabled) { diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 0818bb13f6..a4b41310e8 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -21,7 +21,6 @@ using Bit.Core.Auth.Services; using Bit.Core.Auth.UserFeatures.UserKey; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Auth.Utilities; -using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -63,10 +62,9 @@ public class AccountsController : Controller private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand; private readonly IRotateUserKeyCommand _rotateUserKeyCommand; private readonly IFeatureService _featureService; - private readonly ICurrentContext _currentContext; private bool UseFlexibleCollections => - _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext); + _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections); private readonly IRotationValidator, IEnumerable> _cipherValidator; private readonly IRotationValidator, IEnumerable> _folderValidator; @@ -95,7 +93,6 @@ public class AccountsController : Controller ISetInitialMasterPasswordCommand setInitialMasterPasswordCommand, IRotateUserKeyCommand rotateUserKeyCommand, IFeatureService featureService, - ICurrentContext currentContext, IRotationValidator, IEnumerable> cipherValidator, IRotationValidator, IEnumerable> folderValidator, IRotationValidator, IReadOnlyList> sendValidator, @@ -121,7 +118,6 @@ public class AccountsController : Controller _setInitialMasterPasswordCommand = setInitialMasterPasswordCommand; _rotateUserKeyCommand = rotateUserKeyCommand; _featureService = featureService; - _currentContext = currentContext; _cipherValidator = cipherValidator; _folderValidator = folderValidator; _sendValidator = sendValidator; @@ -425,7 +421,7 @@ public class AccountsController : Controller } IdentityResult result; - if (_featureService.IsEnabled(FeatureFlagKeys.KeyRotationImprovements, _currentContext)) + if (_featureService.IsEnabled(FeatureFlagKeys.KeyRotationImprovements)) { var dataModel = new RotateUserKeyData { diff --git a/src/Api/Controllers/ConfigController.cs b/src/Api/Controllers/ConfigController.cs index 167de7c905..7699c6b115 100644 --- a/src/Api/Controllers/ConfigController.cs +++ b/src/Api/Controllers/ConfigController.cs @@ -1,5 +1,4 @@ using Bit.Api.Models.Response; -using Bit.Core.Context; using Bit.Core.Services; using Bit.Core.Settings; @@ -11,22 +10,19 @@ namespace Bit.Api.Controllers; public class ConfigController : Controller { private readonly IGlobalSettings _globalSettings; - private readonly ICurrentContext _currentContext; private readonly IFeatureService _featureService; public ConfigController( IGlobalSettings globalSettings, - ICurrentContext currentContext, IFeatureService featureService) { _globalSettings = globalSettings; - _currentContext = currentContext; _featureService = featureService; } [HttpGet("")] public ConfigResponseModel GetConfigs() { - return new ConfigResponseModel(_globalSettings, _featureService.GetAll(_currentContext)); + return new ConfigResponseModel(_globalSettings, _featureService.GetAll()); } } diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index a99d2b01c2..1bf20f4c57 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -43,7 +43,7 @@ public class CiphersController : Controller private readonly IFeatureService _featureService; private bool UseFlexibleCollections => - _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext); + _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections); public CiphersController( ICipherRepository cipherRepository, diff --git a/src/Api/Vault/Controllers/SyncController.cs b/src/Api/Vault/Controllers/SyncController.cs index 5835b9ebed..80e7d1a7e6 100644 --- a/src/Api/Vault/Controllers/SyncController.cs +++ b/src/Api/Vault/Controllers/SyncController.cs @@ -3,7 +3,6 @@ using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -32,11 +31,10 @@ public class SyncController : Controller private readonly IPolicyRepository _policyRepository; private readonly ISendRepository _sendRepository; private readonly GlobalSettings _globalSettings; - private readonly ICurrentContext _currentContext; private readonly IFeatureService _featureService; private bool UseFlexibleCollections => - _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext); + _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections); public SyncController( IUserService userService, @@ -49,7 +47,6 @@ public class SyncController : Controller IPolicyRepository policyRepository, ISendRepository sendRepository, GlobalSettings globalSettings, - ICurrentContext currentContext, IFeatureService featureService) { _userService = userService; @@ -62,7 +59,6 @@ public class SyncController : Controller _policyRepository = policyRepository; _sendRepository = sendRepository; _globalSettings = globalSettings; - _currentContext = currentContext; _featureService = featureService; } diff --git a/src/Api/Vault/Validators/CipherRotationValidator.cs b/src/Api/Vault/Validators/CipherRotationValidator.cs index 9259235d8c..2f5ae36ef4 100644 --- a/src/Api/Vault/Validators/CipherRotationValidator.cs +++ b/src/Api/Vault/Validators/CipherRotationValidator.cs @@ -1,7 +1,6 @@ using Bit.Api.Auth.Validators; using Bit.Api.Vault.Models.Request; using Bit.Core; -using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Services; @@ -13,19 +12,15 @@ namespace Bit.Api.Vault.Validators; public class CipherRotationValidator : IRotationValidator, IEnumerable> { private readonly ICipherRepository _cipherRepository; - private readonly ICurrentContext _currentContext; private readonly IFeatureService _featureService; private bool UseFlexibleCollections => - _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext); + _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections); - public CipherRotationValidator(ICipherRepository cipherRepository, ICurrentContext currentContext, - IFeatureService featureService) + public CipherRotationValidator(ICipherRepository cipherRepository, IFeatureService featureService) { _cipherRepository = cipherRepository; - _currentContext = currentContext; _featureService = featureService; - } public async Task> ValidateAsync(User user, IEnumerable ciphers) diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 1355a15120..7adf30f859 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -442,10 +442,10 @@ public class OrganizationService : IOrganizationService } var flexibleCollectionsSignupEnabled = - _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup, _currentContext); + _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup); var flexibleCollectionsV1IsEnabled = - _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1, _currentContext); + _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1); var organization = new Organization { @@ -572,7 +572,7 @@ public class OrganizationService : IOrganizationService await ValidateSignUpPoliciesAsync(owner.Id); var flexibleCollectionsSignupEnabled = - _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup, _currentContext); + _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup); var organization = new Organization { diff --git a/src/Core/Auth/Services/Implementations/EmergencyAccessService.cs b/src/Core/Auth/Services/Implementations/EmergencyAccessService.cs index 34c0e8e0e3..6936ce3036 100644 --- a/src/Core/Auth/Services/Implementations/EmergencyAccessService.cs +++ b/src/Core/Auth/Services/Implementations/EmergencyAccessService.cs @@ -5,7 +5,6 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Data; -using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -34,11 +33,10 @@ public class EmergencyAccessService : IEmergencyAccessService private readonly IPasswordHasher _passwordHasher; private readonly IOrganizationService _organizationService; private readonly IDataProtectorTokenFactory _dataProtectorTokenizer; - private readonly ICurrentContext _currentContext; private readonly IFeatureService _featureService; private bool UseFlexibleCollections => - _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext); + _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections); public EmergencyAccessService( IEmergencyAccessRepository emergencyAccessRepository, @@ -53,7 +51,6 @@ public class EmergencyAccessService : IEmergencyAccessService GlobalSettings globalSettings, IOrganizationService organizationService, IDataProtectorTokenFactory dataProtectorTokenizer, - ICurrentContext currentContext, IFeatureService featureService) { _emergencyAccessRepository = emergencyAccessRepository; @@ -68,7 +65,6 @@ public class EmergencyAccessService : IEmergencyAccessService _globalSettings = globalSettings; _organizationService = organizationService; _dataProtectorTokenizer = dataProtectorTokenizer; - _currentContext = currentContext; _featureService = featureService; } diff --git a/src/Core/Context/CurrentContext.cs b/src/Core/Context/CurrentContext.cs index 129e90e39d..cc74e60a8c 100644 --- a/src/Core/Context/CurrentContext.cs +++ b/src/Core/Context/CurrentContext.cs @@ -8,7 +8,6 @@ using Bit.Core.Enums; using Bit.Core.Identity; using Bit.Core.Models.Data; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; using Microsoft.AspNetCore.Http; @@ -19,7 +18,6 @@ public class CurrentContext : ICurrentContext { private readonly IProviderOrganizationRepository _providerOrganizationRepository; private readonly IProviderUserRepository _providerUserRepository; - private readonly IFeatureService _featureService; private bool _builtHttpContext; private bool _builtClaimsPrincipal; private IEnumerable _providerOrganizationProviderDetails; @@ -46,12 +44,10 @@ public class CurrentContext : ICurrentContext public CurrentContext( IProviderOrganizationRepository providerOrganizationRepository, - IProviderUserRepository providerUserRepository, - IFeatureService featureService) + IProviderUserRepository providerUserRepository) { _providerOrganizationRepository = providerOrganizationRepository; _providerUserRepository = providerUserRepository; - _featureService = featureService; ; } public async virtual Task BuildAsync(HttpContext httpContext, GlobalSettings globalSettings) diff --git a/src/Core/Services/IFeatureService.cs b/src/Core/Services/IFeatureService.cs index a85b16ec8e..0ac168a0cd 100644 --- a/src/Core/Services/IFeatureService.cs +++ b/src/Core/Services/IFeatureService.cs @@ -1,6 +1,4 @@ -using Bit.Core.Context; - -namespace Bit.Core.Services; +namespace Bit.Core.Services; public interface IFeatureService { @@ -14,33 +12,29 @@ public interface IFeatureService /// Checks whether a given feature is enabled. /// /// The key of the feature to check. - /// A context providing information that can be used to evaluate whether a feature should be on or off. /// The default value for the feature. /// True if the feature is enabled, otherwise false. - bool IsEnabled(string key, ICurrentContext currentContext, bool defaultValue = false); + bool IsEnabled(string key, bool defaultValue = false); /// /// Gets the integer variation of a feature. /// /// The key of the feature to check. - /// A context providing information that can be used to evaluate the feature value. /// The default value for the feature. /// The feature variation value. - int GetIntVariation(string key, ICurrentContext currentContext, int defaultValue = 0); + int GetIntVariation(string key, int defaultValue = 0); /// /// Gets the string variation of a feature. /// /// The key of the feature to check. - /// A context providing information that can be used to evaluate the feature value. /// The default value for the feature. /// The feature variation value. - string GetStringVariation(string key, ICurrentContext currentContext, string defaultValue = null); + string GetStringVariation(string key, string defaultValue = null); /// /// Gets all feature values. /// - /// A context providing information that can be used to evaluate the feature values. /// A dictionary of feature keys and their values. - Dictionary GetAll(ICurrentContext currentContext); + Dictionary GetAll(); } diff --git a/src/Core/Services/Implementations/CollectionService.cs b/src/Core/Services/Implementations/CollectionService.cs index 27c9338263..2941de2d56 100644 --- a/src/Core/Services/Implementations/CollectionService.cs +++ b/src/Core/Services/Implementations/CollectionService.cs @@ -56,7 +56,7 @@ public class CollectionService : ICollectionService var usersList = users?.ToList(); // If using Flexible Collections - a collection should always have someone with Can Manage permissions - if (_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1, _currentContext)) + if (_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1)) { var groupHasManageAccess = groupsList?.Any(g => g.Manage) ?? false; var userHasManageAccess = usersList?.Any(u => u.Manage) ?? false; @@ -124,7 +124,7 @@ public class CollectionService : ICollectionService { var collections = await _collectionRepository.GetManyByUserIdAsync( _currentContext.UserId.Value, - _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext) + _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections) ); orgCollections = collections.Where(c => c.OrganizationId == organizationId); } diff --git a/src/Core/Services/Implementations/LaunchDarklyFeatureService.cs b/src/Core/Services/Implementations/LaunchDarklyFeatureService.cs index ad3d6e82ed..b1fd9c5643 100644 --- a/src/Core/Services/Implementations/LaunchDarklyFeatureService.cs +++ b/src/Core/Services/Implementations/LaunchDarklyFeatureService.cs @@ -4,16 +4,25 @@ using Bit.Core.Utilities; using LaunchDarkly.Logging; using LaunchDarkly.Sdk.Server; using LaunchDarkly.Sdk.Server.Integrations; +using LaunchDarkly.Sdk.Server.Interfaces; namespace Bit.Core.Services; -public class LaunchDarklyFeatureService : IFeatureService, IDisposable +public class LaunchDarklyFeatureService : IFeatureService { - private readonly LdClient _client; + private readonly ILdClient _client; + private readonly ICurrentContext _currentContext; private const string _anonymousUser = "25a15cac-58cf-4ac0-ad0f-b17c4bd92294"; public LaunchDarklyFeatureService( - IGlobalSettings globalSettings) + ILdClient client, + ICurrentContext currentContext) + { + _client = client; + _currentContext = currentContext; + } + + public static Configuration GetConfiguredClient(GlobalSettings globalSettings) { var ldConfig = Configuration.Builder(globalSettings.LaunchDarkly?.SdkKey); ldConfig.Logging(Components.Logging().Level(LogLevel.Error)); @@ -64,7 +73,7 @@ public class LaunchDarklyFeatureService : IFeatureService, IDisposable ldConfig.Offline(true); } - _client = new LdClient(ldConfig.Build()); + return ldConfig.Build(); } public bool IsOnline() @@ -72,28 +81,28 @@ public class LaunchDarklyFeatureService : IFeatureService, IDisposable return _client.Initialized && !_client.IsOffline(); } - public bool IsEnabled(string key, ICurrentContext currentContext, bool defaultValue = false) + public bool IsEnabled(string key, bool defaultValue = false) { - return _client.BoolVariation(key, BuildContext(currentContext), defaultValue); + return _client.BoolVariation(key, BuildContext(), defaultValue); } - public int GetIntVariation(string key, ICurrentContext currentContext, int defaultValue = 0) + public int GetIntVariation(string key, int defaultValue = 0) { - return _client.IntVariation(key, BuildContext(currentContext), defaultValue); + return _client.IntVariation(key, BuildContext(), defaultValue); } - public string GetStringVariation(string key, ICurrentContext currentContext, string defaultValue = null) + public string GetStringVariation(string key, string defaultValue = null) { - return _client.StringVariation(key, BuildContext(currentContext), defaultValue); + return _client.StringVariation(key, BuildContext(), defaultValue); } - public Dictionary GetAll(ICurrentContext currentContext) + public Dictionary GetAll() { var results = new Dictionary(); var keys = FeatureFlagKeys.GetAllKeys(); - var values = _client.AllFlagsState(BuildContext(currentContext)); + var values = _client.AllFlagsState(BuildContext()); if (values.Valid) { foreach (var key in keys) @@ -119,23 +128,18 @@ public class LaunchDarklyFeatureService : IFeatureService, IDisposable return results; } - public void Dispose() - { - _client?.Dispose(); - } - - private LaunchDarkly.Sdk.Context BuildContext(ICurrentContext currentContext) + private LaunchDarkly.Sdk.Context BuildContext() { var builder = LaunchDarkly.Sdk.Context.MultiBuilder(); - switch (currentContext.ClientType) + switch (_currentContext.ClientType) { case Identity.ClientType.User: { LaunchDarkly.Sdk.ContextBuilder ldUser; - if (currentContext.UserId.HasValue) + if (_currentContext.UserId.HasValue) { - ldUser = LaunchDarkly.Sdk.Context.Builder(currentContext.UserId.Value.ToString()); + ldUser = LaunchDarkly.Sdk.Context.Builder(_currentContext.UserId.Value.ToString()); } else { @@ -146,9 +150,9 @@ public class LaunchDarklyFeatureService : IFeatureService, IDisposable ldUser.Kind(LaunchDarkly.Sdk.ContextKind.Default); - if (currentContext.Organizations?.Any() ?? false) + if (_currentContext.Organizations?.Any() ?? false) { - var ldOrgs = currentContext.Organizations.Select(o => LaunchDarkly.Sdk.LdValue.Of(o.Id.ToString())); + var ldOrgs = _currentContext.Organizations.Select(o => LaunchDarkly.Sdk.LdValue.Of(o.Id.ToString())); ldUser.Set("organizations", LaunchDarkly.Sdk.LdValue.ArrayFrom(ldOrgs)); } @@ -158,9 +162,9 @@ public class LaunchDarklyFeatureService : IFeatureService, IDisposable case Identity.ClientType.Organization: { - if (currentContext.OrganizationId.HasValue) + if (_currentContext.OrganizationId.HasValue) { - var ldOrg = LaunchDarkly.Sdk.Context.Builder(currentContext.OrganizationId.Value.ToString()); + var ldOrg = LaunchDarkly.Sdk.Context.Builder(_currentContext.OrganizationId.Value.ToString()); ldOrg.Kind("organization"); builder.Add(ldOrg.Build()); } @@ -169,16 +173,16 @@ public class LaunchDarklyFeatureService : IFeatureService, IDisposable case Identity.ClientType.ServiceAccount: { - if (currentContext.UserId.HasValue) + if (_currentContext.UserId.HasValue) { - var ldServiceAccount = LaunchDarkly.Sdk.Context.Builder(currentContext.UserId.Value.ToString()); + var ldServiceAccount = LaunchDarkly.Sdk.Context.Builder(_currentContext.UserId.Value.ToString()); ldServiceAccount.Kind("service-account"); builder.Add(ldServiceAccount.Build()); } - if (currentContext.OrganizationId.HasValue) + if (_currentContext.OrganizationId.HasValue) { - var ldOrg = LaunchDarkly.Sdk.Context.Builder(currentContext.OrganizationId.Value.ToString()); + var ldOrg = LaunchDarkly.Sdk.Context.Builder(_currentContext.OrganizationId.Value.ToString()); ldOrg.Kind("organization"); builder.Add(ldOrg.Build()); } @@ -189,7 +193,7 @@ public class LaunchDarklyFeatureService : IFeatureService, IDisposable return builder.Build(); } - private TestData BuildDataSource(Dictionary values) + private static TestData BuildDataSource(Dictionary values) { var source = TestData.DataSource(); foreach (var kvp in values) diff --git a/src/Core/Utilities/RequireFeatureAttribute.cs b/src/Core/Utilities/RequireFeatureAttribute.cs index f0a96bd0e5..62343e8918 100644 --- a/src/Core/Utilities/RequireFeatureAttribute.cs +++ b/src/Core/Utilities/RequireFeatureAttribute.cs @@ -1,5 +1,4 @@ -using Bit.Core.Context; -using Bit.Core.Exceptions; +using Bit.Core.Exceptions; using Bit.Core.Services; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.DependencyInjection; @@ -25,10 +24,9 @@ public class RequireFeatureAttribute : ActionFilterAttribute public override void OnActionExecuting(ActionExecutingContext context) { - var currentContext = context.HttpContext.RequestServices.GetRequiredService(); var featureService = context.HttpContext.RequestServices.GetRequiredService(); - if (!featureService.IsEnabled(_featureFlagKey, currentContext)) + if (!featureService.IsEnabled(_featureFlagKey)) { throw new FeatureUnavailableException(); } diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index 665f99aadf..9feaab9cbb 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -41,7 +41,7 @@ public class CipherService : ICipherService private readonly IFeatureService _featureService; private bool UseFlexibleCollections => - _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext); + _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections); public CipherService( ICipherRepository cipherRepository, diff --git a/src/Events/Controllers/CollectController.cs b/src/Events/Controllers/CollectController.cs index 144c248e46..38781c1854 100644 --- a/src/Events/Controllers/CollectController.cs +++ b/src/Events/Controllers/CollectController.cs @@ -73,7 +73,7 @@ public class CollectController : Controller } else { - var useFlexibleCollections = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext); + var useFlexibleCollections = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections); cipher = await _cipherRepository.GetByIdAsync(eventModel.CipherId.Value, _currentContext.UserId.Value, useFlexibleCollections); diff --git a/src/Events/Startup.cs b/src/Events/Startup.cs index 2bcb7a3ba3..bac39c68dd 100644 --- a/src/Events/Startup.cs +++ b/src/Events/Startup.cs @@ -70,7 +70,7 @@ public class Startup services.AddSingleton(); } - services.AddSingleton(); + services.AddOptionality(); // Mvc services.AddMvc(config => diff --git a/src/Identity/IdentityServer/WebAuthnGrantValidator.cs b/src/Identity/IdentityServer/WebAuthnGrantValidator.cs index 5928974d5c..dad2de354a 100644 --- a/src/Identity/IdentityServer/WebAuthnGrantValidator.cs +++ b/src/Identity/IdentityServer/WebAuthnGrantValidator.cs @@ -66,7 +66,7 @@ public class WebAuthnGrantValidator : BaseRequestValidator(); - services.AddSingleton(); + services.AddOptionality(); services.AddTokenizers(); if (CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ConnectionString) && @@ -426,8 +428,6 @@ public static class ServiceCollectionExtensions return identityBuilder; } - - public static void AddIdentityAuthenticationServices( this IServiceCollection services, GlobalSettings globalSettings, IWebHostEnvironment environment, Action addAuthorization) @@ -727,4 +727,17 @@ public static class ServiceCollectionExtensions }); }); } + + public static IServiceCollection AddOptionality(this IServiceCollection services) + { + services.AddSingleton(s => + { + return new LdClient(LaunchDarklyFeatureService.GetConfiguredClient( + s.GetRequiredService())); + }); + + services.AddScoped(); + + return services; + } } diff --git a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs index 51a04b65aa..b19b11f159 100644 --- a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs @@ -14,7 +14,6 @@ using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Services; using Bit.Core.Auth.UserFeatures.UserKey; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; -using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -54,7 +53,6 @@ public class AccountsControllerTests : IDisposable private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand; private readonly IRotateUserKeyCommand _rotateUserKeyCommand; private readonly IFeatureService _featureService; - private readonly ICurrentContext _currentContext; private readonly IRotationValidator, IEnumerable> _cipherValidator; private readonly IRotationValidator, IEnumerable> _folderValidator; @@ -84,7 +82,6 @@ public class AccountsControllerTests : IDisposable _setInitialMasterPasswordCommand = Substitute.For(); _rotateUserKeyCommand = Substitute.For(); _featureService = Substitute.For(); - _currentContext = Substitute.For(); _cipherValidator = Substitute.For, IEnumerable>>(); _folderValidator = @@ -113,7 +110,6 @@ public class AccountsControllerTests : IDisposable _setInitialMasterPasswordCommand, _rotateUserKeyCommand, _featureService, - _currentContext, _cipherValidator, _folderValidator, _sendValidator, diff --git a/test/Api.Test/Controllers/ConfigControllerTests.cs b/test/Api.Test/Controllers/ConfigControllerTests.cs index d9b857194c..4df3520947 100644 --- a/test/Api.Test/Controllers/ConfigControllerTests.cs +++ b/test/Api.Test/Controllers/ConfigControllerTests.cs @@ -1,6 +1,5 @@ using AutoFixture.Xunit2; using Bit.Api.Controllers; -using Bit.Core.Context; using Bit.Core.Services; using Bit.Core.Settings; using NSubstitute; @@ -13,17 +12,14 @@ public class ConfigControllerTests : IDisposable private readonly ConfigController _sut; private readonly GlobalSettings _globalSettings; private readonly IFeatureService _featureService; - private readonly ICurrentContext _currentContext; public ConfigControllerTests() { _globalSettings = new GlobalSettings(); - _currentContext = Substitute.For(); _featureService = Substitute.For(); _sut = new ConfigController( _globalSettings, - _currentContext, _featureService ); } @@ -36,7 +32,7 @@ public class ConfigControllerTests : IDisposable [Theory, AutoData] public void GetConfigs_WithFeatureStates(Dictionary featureStates) { - _featureService.GetAll(_currentContext).Returns(featureStates); + _featureService.GetAll().Returns(featureStates); var response = _sut.GetConfigs(); diff --git a/test/Core.Test/Services/CollectionServiceTests.cs b/test/Core.Test/Services/CollectionServiceTests.cs index e9c3acb487..d57acebe3a 100644 --- a/test/Core.Test/Services/CollectionServiceTests.cs +++ b/test/Core.Test/Services/CollectionServiceTests.cs @@ -114,7 +114,7 @@ public class CollectionServiceTest collection.Id = default; sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1, Arg.Any(), Arg.Any()) + .IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1, Arg.Any()) .Returns(true); organization.AllowAdminAccessToAllCollectionItems = false; diff --git a/test/Core.Test/Services/LaunchDarklyFeatureServiceTests.cs b/test/Core.Test/Services/LaunchDarklyFeatureServiceTests.cs index 3eb8c25479..6970b5f904 100644 --- a/test/Core.Test/Services/LaunchDarklyFeatureServiceTests.cs +++ b/test/Core.Test/Services/LaunchDarklyFeatureServiceTests.cs @@ -4,6 +4,7 @@ using Bit.Core.Services; using Bit.Core.Settings; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; +using LaunchDarkly.Sdk.Server.Interfaces; using NSubstitute; using Xunit; @@ -19,9 +20,16 @@ public class LaunchDarklyFeatureServiceTests { globalSettings.ProjectName = "LaunchDarkly Tests"; + var currentContext = Substitute.For(); + currentContext.UserId.Returns(Guid.NewGuid()); + + var client = Substitute.For(); + var fixture = new Fixture(); return new SutProvider(fixture) .SetDependency(globalSettings) + .SetDependency(currentContext) + .SetDependency(client) .Create(); } @@ -30,10 +38,7 @@ public class LaunchDarklyFeatureServiceTests { var sutProvider = GetSutProvider(new Settings.GlobalSettings { SelfHosted = true }); - var currentContext = Substitute.For(); - currentContext.UserId.Returns(Guid.NewGuid()); - - Assert.False(sutProvider.Sut.IsEnabled(key, currentContext)); + Assert.False(sutProvider.Sut.IsEnabled(key)); } [Fact] @@ -41,10 +46,7 @@ public class LaunchDarklyFeatureServiceTests { var sutProvider = GetSutProvider(new Settings.GlobalSettings()); - var currentContext = Substitute.For(); - currentContext.UserId.Returns(Guid.NewGuid()); - - Assert.False(sutProvider.Sut.IsEnabled(_fakeFeatureKey, currentContext)); + Assert.False(sutProvider.Sut.IsEnabled(_fakeFeatureKey)); } [Fact(Skip = "For local development")] @@ -54,10 +56,7 @@ public class LaunchDarklyFeatureServiceTests var sutProvider = GetSutProvider(settings); - var currentContext = Substitute.For(); - currentContext.UserId.Returns(Guid.NewGuid()); - - Assert.False(sutProvider.Sut.IsEnabled(_fakeFeatureKey, currentContext)); + Assert.False(sutProvider.Sut.IsEnabled(_fakeFeatureKey)); } [Fact(Skip = "For local development")] @@ -67,10 +66,7 @@ public class LaunchDarklyFeatureServiceTests var sutProvider = GetSutProvider(settings); - var currentContext = Substitute.For(); - currentContext.UserId.Returns(Guid.NewGuid()); - - Assert.Equal(0, sutProvider.Sut.GetIntVariation(_fakeFeatureKey, currentContext)); + Assert.Equal(0, sutProvider.Sut.GetIntVariation(_fakeFeatureKey)); } [Fact(Skip = "For local development")] @@ -80,10 +76,7 @@ public class LaunchDarklyFeatureServiceTests var sutProvider = GetSutProvider(settings); - var currentContext = Substitute.For(); - currentContext.UserId.Returns(Guid.NewGuid()); - - Assert.Null(sutProvider.Sut.GetStringVariation(_fakeFeatureKey, currentContext)); + Assert.Null(sutProvider.Sut.GetStringVariation(_fakeFeatureKey)); } [Fact(Skip = "For local development")] @@ -91,10 +84,7 @@ public class LaunchDarklyFeatureServiceTests { var sutProvider = GetSutProvider(new Settings.GlobalSettings()); - var currentContext = Substitute.For(); - currentContext.UserId.Returns(Guid.NewGuid()); - - var results = sutProvider.Sut.GetAll(currentContext); + var results = sutProvider.Sut.GetAll(); Assert.NotNull(results); Assert.NotEmpty(results); diff --git a/test/Core.Test/Utilities/RequireFeatureAttributeTests.cs b/test/Core.Test/Utilities/RequireFeatureAttributeTests.cs index 04917cc0c3..1734cc980d 100644 --- a/test/Core.Test/Utilities/RequireFeatureAttributeTests.cs +++ b/test/Core.Test/Utilities/RequireFeatureAttributeTests.cs @@ -64,7 +64,7 @@ public class RequireFeatureAttributeTests var featureService = Substitute.For(); var currentContext = Substitute.For(); - featureService.IsEnabled(_testFeature, Arg.Any()).Returns(enabled); + featureService.IsEnabled(_testFeature).Returns(enabled); services.AddSingleton(featureService); services.AddSingleton(currentContext); From ef359c3cf1925c07f87d608d81b0a70afacb769a Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Thu, 18 Jan 2024 17:54:57 +0100 Subject: [PATCH 016/117] [PM-5566] Remove U2F keys from TwoFactorProviders (#3645) * Remove U2F keys from TwoFactorProviders * Remove U2f from Premium check. --- src/Core/Auth/Models/TwoFactorProvider.cs | 1 - src/Core/Entities/User.cs | 7 +++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Core/Auth/Models/TwoFactorProvider.cs b/src/Core/Auth/Models/TwoFactorProvider.cs index 498a70cb09..04ef4d7cb2 100644 --- a/src/Core/Auth/Models/TwoFactorProvider.cs +++ b/src/Core/Auth/Models/TwoFactorProvider.cs @@ -56,7 +56,6 @@ public class TwoFactorProvider { case TwoFactorProviderType.Duo: case TwoFactorProviderType.YubiKey: - case TwoFactorProviderType.U2f: // Keep to ensure old U2f keys are considered premium return true; default: return false; diff --git a/src/Core/Entities/User.cs b/src/Core/Entities/User.cs index 16eac4cb75..d10ab25f18 100644 --- a/src/Core/Entities/User.cs +++ b/src/Core/Entities/User.cs @@ -137,6 +137,13 @@ public class User : ITableObject, ISubscriber, IStorable, IStorableSubscri TwoFactorProviders); } + // U2F is no longer supported, and all users keys should have been migrated to WebAuthn. + // To prevent issues with accounts being prompted for unsupported U2F we remove them + if (_twoFactorProviders.ContainsKey(TwoFactorProviderType.U2f)) + { + _twoFactorProviders.Remove(TwoFactorProviderType.U2f); + } + return _twoFactorProviders; } catch (JsonException) From 4b6299a0554f1e9e2a096033f0bb6bb60bb23e6a Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Thu, 18 Jan 2024 16:54:01 -0500 Subject: [PATCH 017/117] [PM-5149] unique SP entity id for organization sso configs (#3520) * org specific sp entity id * updates * dont default true --- .../src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs | 4 +++- src/Api/Auth/Models/Request/OrganizationSsoRequestModel.cs | 2 ++ src/Api/Auth/Models/Response/OrganizationSsoResponseModel.cs | 4 +++- src/Core/Auth/Models/Data/SsoConfigurationData.cs | 1 + 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs b/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs index 17a7b9e8c7..30c25f6757 100644 --- a/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs +++ b/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs @@ -349,7 +349,9 @@ public class DynamicAuthenticationSchemeProvider : AuthenticationSchemeProvider } var spEntityId = new Sustainsys.Saml2.Metadata.EntityId( - SsoConfigurationData.BuildSaml2ModulePath(_globalSettings.BaseServiceUri.Sso)); + SsoConfigurationData.BuildSaml2ModulePath( + _globalSettings.BaseServiceUri.Sso, + config.SpUniqueEntityId ? name : null)); bool? allowCreate = null; if (config.SpNameIdFormat != Saml2NameIdFormat.Transient) { diff --git a/src/Api/Auth/Models/Request/OrganizationSsoRequestModel.cs b/src/Api/Auth/Models/Request/OrganizationSsoRequestModel.cs index a1a50ed3f6..d82b26aa26 100644 --- a/src/Api/Auth/Models/Request/OrganizationSsoRequestModel.cs +++ b/src/Api/Auth/Models/Request/OrganizationSsoRequestModel.cs @@ -66,6 +66,7 @@ public class SsoConfigurationDataRequest : IValidatableObject public string ExpectedReturnAcrValue { get; set; } // SAML2 SP + public bool? SpUniqueEntityId { get; set; } public Saml2NameIdFormat SpNameIdFormat { get; set; } public string SpOutboundSigningAlgorithm { get; set; } public Saml2SigningBehavior SpSigningBehavior { get; set; } @@ -190,6 +191,7 @@ public class SsoConfigurationDataRequest : IValidatableObject IdpAllowUnsolicitedAuthnResponse = IdpAllowUnsolicitedAuthnResponse.GetValueOrDefault(), IdpDisableOutboundLogoutRequests = IdpDisableOutboundLogoutRequests.GetValueOrDefault(), IdpWantAuthnRequestsSigned = IdpWantAuthnRequestsSigned.GetValueOrDefault(), + SpUniqueEntityId = SpUniqueEntityId.GetValueOrDefault(), SpNameIdFormat = SpNameIdFormat, SpOutboundSigningAlgorithm = SpOutboundSigningAlgorithm ?? SamlSigningAlgorithms.Sha256, SpSigningBehavior = SpSigningBehavior, diff --git a/src/Api/Auth/Models/Response/OrganizationSsoResponseModel.cs b/src/Api/Auth/Models/Response/OrganizationSsoResponseModel.cs index bbf9d57c79..0d327e1009 100644 --- a/src/Api/Auth/Models/Response/OrganizationSsoResponseModel.cs +++ b/src/Api/Auth/Models/Response/OrganizationSsoResponseModel.cs @@ -33,7 +33,8 @@ public class SsoUrls { CallbackPath = SsoConfigurationData.BuildCallbackPath(globalSettings.BaseServiceUri.Sso); SignedOutCallbackPath = SsoConfigurationData.BuildSignedOutCallbackPath(globalSettings.BaseServiceUri.Sso); - SpEntityId = SsoConfigurationData.BuildSaml2ModulePath(globalSettings.BaseServiceUri.Sso); + SpEntityIdStatic = SsoConfigurationData.BuildSaml2ModulePath(globalSettings.BaseServiceUri.Sso); + SpEntityId = SsoConfigurationData.BuildSaml2ModulePath(globalSettings.BaseServiceUri.Sso, organizationId); SpMetadataUrl = SsoConfigurationData.BuildSaml2MetadataUrl(globalSettings.BaseServiceUri.Sso, organizationId); SpAcsUrl = SsoConfigurationData.BuildSaml2AcsUrl(globalSettings.BaseServiceUri.Sso, organizationId); } @@ -41,6 +42,7 @@ public class SsoUrls public string CallbackPath { get; set; } public string SignedOutCallbackPath { get; set; } public string SpEntityId { get; set; } + public string SpEntityIdStatic { get; set; } public string SpMetadataUrl { get; set; } public string SpAcsUrl { get; set; } } diff --git a/src/Core/Auth/Models/Data/SsoConfigurationData.cs b/src/Core/Auth/Models/Data/SsoConfigurationData.cs index d434661af6..fe39a5a054 100644 --- a/src/Core/Auth/Models/Data/SsoConfigurationData.cs +++ b/src/Core/Auth/Models/Data/SsoConfigurationData.cs @@ -70,6 +70,7 @@ public class SsoConfigurationData public bool IdpWantAuthnRequestsSigned { get; set; } // SAML2 SP + public bool SpUniqueEntityId { get; set; } public Saml2NameIdFormat SpNameIdFormat { get; set; } public string SpOutboundSigningAlgorithm { get; set; } public Saml2SigningBehavior SpSigningBehavior { get; set; } From 77698c3ee23156179989ef07999b68e8c8e45371 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Mon, 22 Jan 2024 08:56:20 +1000 Subject: [PATCH 018/117] [AC-2052] Block Manager role and AccessAll if using FlexibleCollections (#3671) * Also don't assign AccessAll to the first orgUser if using Flexible Collections --- .../AdminConsole/Services/ProviderService.cs | 5 +- .../Services/ProviderServiceTests.cs | 32 ++- .../Groups/CreateGroupCommand.cs | 11 +- .../Groups/UpdateGroupCommand.cs | 11 +- .../Implementations/OrganizationService.cs | 44 +++-- .../AutoFixture/OrganizationFixtures.cs | 13 +- .../Groups/CreateGroupCommandTests.cs | 26 ++- .../Groups/UpdateGroupCommandTests.cs | 25 ++- .../Services/OrganizationServiceTests.cs | 187 +++++++++++++++--- 9 files changed, 291 insertions(+), 63 deletions(-) diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index f9049de072..b6d53c0ada 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -515,7 +515,10 @@ public class ProviderService : IProviderService new OrganizationUserInvite { Emails = new[] { clientOwnerEmail }, - AccessAll = true, + + // If using Flexible Collections, AccessAll is deprecated and set to false. + // If not using Flexible Collections, set AccessAll to true (previous behavior) + AccessAll = !organization.FlexibleCollections, Type = OrganizationUserType.Owner, Permissions = null, Collections = Array.Empty(), diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs index eea0ac53f0..2d67c8c25e 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs @@ -12,6 +12,7 @@ using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Test.AutoFixture.OrganizationFixtures; using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -513,7 +514,7 @@ public class ProviderServiceTests await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().LogProviderOrganizationEventsAsync(default); } - [Theory, BitAutoData] + [Theory, OrganizationCustomize(FlexibleCollections = false), BitAutoData] public async Task CreateOrganizationAsync_Success(Provider provider, OrganizationSignup organizationSignup, Organization organization, string clientOwnerEmail, User user, SutProvider sutProvider) { @@ -541,6 +542,35 @@ public class ProviderServiceTests t.First().Item2 == null)); } + [Theory, OrganizationCustomize(FlexibleCollections = true), BitAutoData] + public async Task CreateOrganizationAsync_WithFlexibleCollections_SetsAccessAllToFalse + (Provider provider, OrganizationSignup organizationSignup, Organization organization, string clientOwnerEmail, + User user, SutProvider sutProvider) + { + organizationSignup.Plan = PlanType.EnterpriseAnnually; + + sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); + var providerOrganizationRepository = sutProvider.GetDependency(); + sutProvider.GetDependency().SignUpAsync(organizationSignup, true) + .Returns(Tuple.Create(organization, null as OrganizationUser)); + + var providerOrganization = + await sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user); + + await providerOrganizationRepository.ReceivedWithAnyArgs().CreateAsync(default); + await sutProvider.GetDependency() + .Received().LogProviderOrganizationEventAsync(providerOrganization, + EventType.ProviderOrganization_Created); + await sutProvider.GetDependency() + .Received().InviteUsersAsync(organization.Id, user.Id, Arg.Is>( + t => t.Count() == 1 && + t.First().Item1.Emails.Count() == 1 && + t.First().Item1.Emails.First() == clientOwnerEmail && + t.First().Item1.Type == OrganizationUserType.Owner && + t.First().Item1.AccessAll == false && + t.First().Item2 == null)); + } + [Theory, BitAutoData] public async Task AddOrganization_CreateAfterNov162023_PlanTypeDoesNotUpdated(Provider provider, Organization organization, string key, SutProvider sutProvider) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommand.cs index 6c2dfd6b58..61321b2df2 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommand.cs @@ -39,7 +39,7 @@ public class CreateGroupCommand : ICreateGroupCommand IEnumerable collections = null, IEnumerable users = null) { - Validate(organization); + Validate(organization, group); await GroupRepositoryCreateGroupAsync(group, organization, collections); if (users != null) @@ -54,7 +54,7 @@ public class CreateGroupCommand : ICreateGroupCommand IEnumerable collections = null, IEnumerable users = null) { - Validate(organization); + Validate(organization, group); await GroupRepositoryCreateGroupAsync(group, organization, collections); if (users != null) @@ -103,7 +103,7 @@ public class CreateGroupCommand : ICreateGroupCommand } } - private static void Validate(Organization organization) + private static void Validate(Organization organization, Group group) { if (organization == null) { @@ -114,5 +114,10 @@ public class CreateGroupCommand : ICreateGroupCommand { throw new BadRequestException("This organization cannot use groups."); } + + if (organization.FlexibleCollections && group.AccessAll) + { + throw new BadRequestException("The AccessAll property has been deprecated by collection enhancements. Assign the group to collections instead."); + } } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Groups/UpdateGroupCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Groups/UpdateGroupCommand.cs index 3bc241221c..fecc06be8a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Groups/UpdateGroupCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Groups/UpdateGroupCommand.cs @@ -29,7 +29,7 @@ public class UpdateGroupCommand : IUpdateGroupCommand IEnumerable collections = null, IEnumerable userIds = null) { - Validate(organization); + Validate(organization, group); await GroupRepositoryUpdateGroupAsync(group, collections); if (userIds != null) @@ -44,7 +44,7 @@ public class UpdateGroupCommand : IUpdateGroupCommand IEnumerable collections = null, IEnumerable userIds = null) { - Validate(organization); + Validate(organization, group); await GroupRepositoryUpdateGroupAsync(group, collections); if (userIds != null) @@ -97,7 +97,7 @@ public class UpdateGroupCommand : IUpdateGroupCommand } } - private static void Validate(Organization organization) + private static void Validate(Organization organization, Group group) { if (organization == null) { @@ -108,5 +108,10 @@ public class UpdateGroupCommand : IUpdateGroupCommand { throw new BadRequestException("This organization cannot use groups."); } + + if (organization.FlexibleCollections && group.AccessAll) + { + throw new BadRequestException("The AccessAll property has been deprecated by collection enhancements. Assign the group to collections instead."); + } } } diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 7adf30f859..c3a6a06e6e 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -673,7 +673,10 @@ public class OrganizationService : IOrganizationService AccessSecretsManager = organization.UseSecretsManager, Type = OrganizationUserType.Owner, Status = OrganizationUserStatusType.Confirmed, - AccessAll = true, + + // If using Flexible Collections, AccessAll is deprecated and set to false. + // If not using Flexible Collections, set AccessAll to true (previous behavior) + AccessAll = !organization.FlexibleCollections, CreationDate = organization.CreationDate, RevisionDate = organization.CreationDate }; @@ -885,6 +888,18 @@ public class OrganizationService : IOrganizationService throw new NotFoundException(); } + // If the organization is using Flexible Collections, prevent use of any deprecated permissions + if (organization.FlexibleCollections && invites.Any(i => i.invite.Type is OrganizationUserType.Manager)) + { + throw new BadRequestException("The Manager role has been deprecated by collection enhancements. Use the collection Can Manage permission instead."); + } + + if (organization.FlexibleCollections && invites.Any(i => i.invite.AccessAll)) + { + throw new BadRequestException("The AccessAll property has been deprecated by collection enhancements. Assign the user to collections instead."); + } + // End Flexible Collections + var existingEmails = new HashSet(await _organizationUserRepository.SelectKnownEmailsAsync( organizationId, invites.SelectMany(i => i.invite.Emails), false), StringComparer.InvariantCultureIgnoreCase); @@ -1377,6 +1392,19 @@ public class OrganizationService : IOrganizationService throw new BadRequestException("Organization must have at least one confirmed owner."); } + // If the organization is using Flexible Collections, prevent use of any deprecated permissions + var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(user.OrganizationId); + if (organizationAbility?.FlexibleCollections == true && user.Type == OrganizationUserType.Manager) + { + throw new BadRequestException("The Manager role has been deprecated by collection enhancements. Use the collection Can Manage permission instead."); + } + + if (organizationAbility?.FlexibleCollections == true && user.AccessAll) + { + throw new BadRequestException("The AccessAll property has been deprecated by collection enhancements. Assign the user to collections instead."); + } + // End Flexible Collections + // Only autoscale (if required) after all validation has passed so that we know it's a valid request before // updating Stripe if (!originalUser.AccessSecretsManager && user.AccessSecretsManager) @@ -2027,15 +2055,6 @@ public class OrganizationService : IOrganizationService { throw new BadRequestException("Custom users can only grant the same custom permissions that they have."); } - - // TODO: pass in the whole organization object when this is refactored into a command/query - // See AC-2036 - var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId); - var flexibleCollectionsEnabled = organizationAbility?.FlexibleCollections ?? false; - if (flexibleCollectionsEnabled && newType == OrganizationUserType.Manager && oldType is not OrganizationUserType.Manager) - { - throw new BadRequestException("Manager role is deprecated after Flexible Collections."); - } } private async Task ValidateOrganizationCustomPermissionsEnabledAsync(Guid organizationId, OrganizationUserType newType) @@ -2451,7 +2470,10 @@ public class OrganizationService : IOrganizationService Key = null, Type = OrganizationUserType.Owner, Status = OrganizationUserStatusType.Invited, - AccessAll = true + + // If using Flexible Collections, AccessAll is deprecated and set to false. + // If not using Flexible Collections, set AccessAll to true (previous behavior) + AccessAll = !organization.FlexibleCollections, }; await _organizationUserRepository.CreateAsync(ownerOrganizationUser); diff --git a/test/Core.Test/AdminConsole/AutoFixture/OrganizationFixtures.cs b/test/Core.Test/AdminConsole/AutoFixture/OrganizationFixtures.cs index ef3889c6d2..f549dd2539 100644 --- a/test/Core.Test/AdminConsole/AutoFixture/OrganizationFixtures.cs +++ b/test/Core.Test/AdminConsole/AutoFixture/OrganizationFixtures.cs @@ -18,6 +18,7 @@ namespace Bit.Core.Test.AutoFixture.OrganizationFixtures; public class OrganizationCustomization : ICustomization { public bool UseGroups { get; set; } + public bool FlexibleCollections { get; set; } public void Customize(IFixture fixture) { @@ -27,7 +28,8 @@ public class OrganizationCustomization : ICustomization fixture.Customize(composer => composer .With(o => o.Id, organizationId) .With(o => o.MaxCollections, maxCollections) - .With(o => o.UseGroups, UseGroups)); + .With(o => o.UseGroups, UseGroups) + .With(o => o.FlexibleCollections, FlexibleCollections)); fixture.Customize(composer => composer @@ -181,10 +183,15 @@ internal class TeamsMonthlyWithAddOnsOrganizationCustomization : ICustomization } } -internal class OrganizationCustomizeAttribute : BitCustomizeAttribute +public class OrganizationCustomizeAttribute : BitCustomizeAttribute { public bool UseGroups { get; set; } - public override ICustomization GetCustomization() => new OrganizationCustomization() { UseGroups = UseGroups }; + public bool FlexibleCollections { get; set; } + public override ICustomization GetCustomization() => new OrganizationCustomization() + { + UseGroups = UseGroups, + FlexibleCollections = FlexibleCollections + }; } internal class PaidOrganizationCustomizeAttribute : BitCustomizeAttribute diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommandTests.cs index 9d28705e10..bac2630ed5 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommandTests.cs @@ -20,7 +20,7 @@ namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Groups; [SutProviderCustomize] public class CreateGroupCommandTests { - [Theory, OrganizationCustomize(UseGroups = true), BitAutoData] + [Theory, OrganizationCustomize(UseGroups = true, FlexibleCollections = false), BitAutoData] public async Task CreateGroup_Success(SutProvider sutProvider, Organization organization, Group group) { await sutProvider.Sut.CreateGroupAsync(group, organization); @@ -32,7 +32,7 @@ public class CreateGroupCommandTests AssertHelper.AssertRecent(group.RevisionDate); } - [Theory, OrganizationCustomize(UseGroups = true), BitAutoData] + [Theory, OrganizationCustomize(UseGroups = true, FlexibleCollections = false), BitAutoData] public async Task CreateGroup_WithCollections_Success(SutProvider sutProvider, Organization organization, Group group, List collections) { await sutProvider.Sut.CreateGroupAsync(group, organization, collections); @@ -44,7 +44,7 @@ public class CreateGroupCommandTests AssertHelper.AssertRecent(group.RevisionDate); } - [Theory, OrganizationCustomize(UseGroups = true), BitAutoData] + [Theory, OrganizationCustomize(UseGroups = true, FlexibleCollections = false), BitAutoData] public async Task CreateGroup_WithEventSystemUser_Success(SutProvider sutProvider, Organization organization, Group group, EventSystemUser eventSystemUser) { await sutProvider.Sut.CreateGroupAsync(group, organization, eventSystemUser); @@ -56,7 +56,7 @@ public class CreateGroupCommandTests AssertHelper.AssertRecent(group.RevisionDate); } - [Theory, OrganizationCustomize(UseGroups = true), BitAutoData] + [Theory, OrganizationCustomize(UseGroups = true, FlexibleCollections = false), BitAutoData] public async Task CreateGroup_WithNullOrganization_Throws(SutProvider sutProvider, Group group, EventSystemUser eventSystemUser) { var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateGroupAsync(group, null, eventSystemUser)); @@ -68,7 +68,7 @@ public class CreateGroupCommandTests await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().RaiseEventAsync(default); } - [Theory, OrganizationCustomize(UseGroups = false), BitAutoData] + [Theory, OrganizationCustomize(UseGroups = false, FlexibleCollections = false), BitAutoData] public async Task CreateGroup_WithUseGroupsAsFalse_Throws(SutProvider sutProvider, Organization organization, Group group, EventSystemUser eventSystemUser) { var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateGroupAsync(group, organization, eventSystemUser)); @@ -79,4 +79,20 @@ public class CreateGroupCommandTests await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().LogGroupEventAsync(default, default, default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().RaiseEventAsync(default); } + + [Theory, OrganizationCustomize(UseGroups = true, FlexibleCollections = true), BitAutoData] + public async Task CreateGroup_WithFlexibleCollections_WithAccessAll_Throws( + SutProvider sutProvider, Organization organization, Group group) + { + group.AccessAll = true; + organization.FlexibleCollections = true; + + var exception = + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateGroupAsync(group, organization)); + Assert.Contains("AccessAll property has been deprecated", exception.Message); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().LogGroupEventAsync(default, default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().RaiseEventAsync(default); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Groups/UpdateGroupCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Groups/UpdateGroupCommandTests.cs index 82ac484e72..1b21574fdb 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Groups/UpdateGroupCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Groups/UpdateGroupCommandTests.cs @@ -17,7 +17,7 @@ namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Groups; [SutProviderCustomize] public class UpdateGroupCommandTests { - [Theory, OrganizationCustomize(UseGroups = true), BitAutoData] + [Theory, OrganizationCustomize(UseGroups = true, FlexibleCollections = false), BitAutoData] public async Task UpdateGroup_Success(SutProvider sutProvider, Group group, Organization organization) { await sutProvider.Sut.UpdateGroupAsync(group, organization); @@ -27,7 +27,7 @@ public class UpdateGroupCommandTests AssertHelper.AssertRecent(group.RevisionDate); } - [Theory, OrganizationCustomize(UseGroups = true), BitAutoData] + [Theory, OrganizationCustomize(UseGroups = true, FlexibleCollections = false), BitAutoData] public async Task UpdateGroup_WithCollections_Success(SutProvider sutProvider, Group group, Organization organization, List collections) { await sutProvider.Sut.UpdateGroupAsync(group, organization, collections); @@ -37,7 +37,7 @@ public class UpdateGroupCommandTests AssertHelper.AssertRecent(group.RevisionDate); } - [Theory, OrganizationCustomize(UseGroups = true), BitAutoData] + [Theory, OrganizationCustomize(UseGroups = true, FlexibleCollections = false), BitAutoData] public async Task UpdateGroup_WithEventSystemUser_Success(SutProvider sutProvider, Group group, Organization organization, EventSystemUser eventSystemUser) { await sutProvider.Sut.UpdateGroupAsync(group, organization, eventSystemUser); @@ -47,7 +47,7 @@ public class UpdateGroupCommandTests AssertHelper.AssertRecent(group.RevisionDate); } - [Theory, OrganizationCustomize(UseGroups = true), BitAutoData] + [Theory, OrganizationCustomize(UseGroups = true, FlexibleCollections = false), BitAutoData] public async Task UpdateGroup_WithNullOrganization_Throws(SutProvider sutProvider, Group group, EventSystemUser eventSystemUser) { var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.UpdateGroupAsync(group, null, eventSystemUser)); @@ -58,7 +58,7 @@ public class UpdateGroupCommandTests await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().LogGroupEventAsync(default, default, default); } - [Theory, OrganizationCustomize(UseGroups = false), BitAutoData] + [Theory, OrganizationCustomize(UseGroups = false, FlexibleCollections = false), BitAutoData] public async Task UpdateGroup_WithUseGroupsAsFalse_Throws(SutProvider sutProvider, Organization organization, Group group, EventSystemUser eventSystemUser) { var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.UpdateGroupAsync(group, organization, eventSystemUser)); @@ -68,4 +68,19 @@ public class UpdateGroupCommandTests await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().LogGroupEventAsync(default, default, default); } + + [Theory, OrganizationCustomize(UseGroups = true, FlexibleCollections = true), BitAutoData] + public async Task UpdateGroup_WithFlexibleCollections_WithAccessAll_Throws( + SutProvider sutProvider, Organization organization, Group group) + { + group.AccessAll = true; + organization.FlexibleCollections = true; + + var exception = + await Assert.ThrowsAsync(async () => await sutProvider.Sut.UpdateGroupAsync(group, organization)); + Assert.Contains("AccessAll property has been deprecated", exception.Message); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().LogGroupEventAsync(default, default, default); + } } diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index d4888a63ed..52dce5802c 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -251,6 +251,64 @@ public class OrganizationServiceTests ); } + [Theory] + [BitAutoData(PlanType.FamiliesAnnually)] + public async Task SignUp_WithFlexibleCollections_SetsAccessAllToFalse + (PlanType planType, OrganizationSignup signup, SutProvider sutProvider) + { + signup.Plan = planType; + var plan = StaticStore.GetPlan(signup.Plan); + signup.AdditionalSeats = 0; + signup.PaymentMethodType = PaymentMethodType.Card; + signup.PremiumAccessAddon = false; + signup.UseSecretsManager = false; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup) + .Returns(true); + + var result = await sutProvider.Sut.SignUpAsync(signup); + + await sutProvider.GetDependency().Received(1).CreateAsync( + Arg.Is(o => + o.UserId == signup.Owner.Id && + o.AccessAll == false)); + + Assert.NotNull(result); + Assert.NotNull(result.Item1); + Assert.NotNull(result.Item2); + Assert.IsType>(result); + } + + [Theory] + [BitAutoData(PlanType.FamiliesAnnually)] + public async Task SignUp_WithoutFlexibleCollections_SetsAccessAllToTrue + (PlanType planType, OrganizationSignup signup, SutProvider sutProvider) + { + signup.Plan = planType; + var plan = StaticStore.GetPlan(signup.Plan); + signup.AdditionalSeats = 0; + signup.PaymentMethodType = PaymentMethodType.Card; + signup.PremiumAccessAddon = false; + signup.UseSecretsManager = false; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup) + .Returns(false); + + var result = await sutProvider.Sut.SignUpAsync(signup); + + await sutProvider.GetDependency().Received(1).CreateAsync( + Arg.Is(o => + o.UserId == signup.Owner.Id && + o.AccessAll == true)); + + Assert.NotNull(result); + Assert.NotNull(result.Item1); + Assert.NotNull(result.Item2); + Assert.IsType>(result); + } + [Theory] [BitAutoData(PlanType.EnterpriseAnnually)] [BitAutoData(PlanType.EnterpriseMonthly)] @@ -378,7 +436,7 @@ public class OrganizationServiceTests [Theory] [OrganizationInviteCustomize(InviteeUserType = OrganizationUserType.User, - InvitorUserType = OrganizationUserType.Owner), BitAutoData] + InvitorUserType = OrganizationUserType.Owner), OrganizationCustomize(FlexibleCollections = false), BitAutoData] public async Task InviteUser_NoEmails_Throws(Organization organization, OrganizationUser invitor, OrganizationUserInvite invite, SutProvider sutProvider) { @@ -391,7 +449,7 @@ public class OrganizationServiceTests } [Theory] - [OrganizationInviteCustomize, BitAutoData] + [OrganizationInviteCustomize, OrganizationCustomize(FlexibleCollections = false), BitAutoData] public async Task InviteUser_DuplicateEmails_PassesWithoutDuplicates(Organization organization, OrganizationUser invitor, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, OrganizationUserInvite invite, SutProvider sutProvider) @@ -434,7 +492,7 @@ public class OrganizationServiceTests } [Theory] - [OrganizationInviteCustomize, BitAutoData] + [OrganizationInviteCustomize, OrganizationCustomize(FlexibleCollections = false), BitAutoData] public async Task InviteUser_SsoOrgWithNullSsoConfig_Passes(Organization organization, OrganizationUser invitor, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, OrganizationUserInvite invite, SutProvider sutProvider) @@ -483,7 +541,7 @@ public class OrganizationServiceTests } [Theory] - [OrganizationInviteCustomize, BitAutoData] + [OrganizationInviteCustomize, OrganizationCustomize(FlexibleCollections = false), BitAutoData] public async Task InviteUser_SsoOrgWithNeverEnabledRequireSsoPolicy_Passes(Organization organization, SsoConfig ssoConfig, OrganizationUser invitor, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, OrganizationUserInvite invite, SutProvider sutProvider) @@ -537,7 +595,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) [OrganizationInviteCustomize( InviteeUserType = OrganizationUserType.Admin, InvitorUserType = OrganizationUserType.Owner - ), BitAutoData] + ), OrganizationCustomize(FlexibleCollections = false), BitAutoData] public async Task InviteUser_NoOwner_Throws(Organization organization, OrganizationUser invitor, OrganizationUserInvite invite, SutProvider sutProvider) { @@ -553,7 +611,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) [OrganizationInviteCustomize( InviteeUserType = OrganizationUserType.Owner, InvitorUserType = OrganizationUserType.Admin - ), BitAutoData] + ), OrganizationCustomize(FlexibleCollections = false), BitAutoData] public async Task InviteUser_NonOwnerConfiguringOwner_Throws(Organization organization, OrganizationUserInvite invite, OrganizationUser invitor, SutProvider sutProvider) { @@ -572,7 +630,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) [OrganizationInviteCustomize( InviteeUserType = OrganizationUserType.Custom, InvitorUserType = OrganizationUserType.User - ), BitAutoData] + ), OrganizationCustomize(FlexibleCollections = false), BitAutoData] public async Task InviteUser_NonAdminConfiguringAdmin_Throws(Organization organization, OrganizationUserInvite invite, OrganizationUser invitor, SutProvider sutProvider) { @@ -593,7 +651,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) [OrganizationInviteCustomize( InviteeUserType = OrganizationUserType.Custom, InvitorUserType = OrganizationUserType.Admin - ), BitAutoData] + ), OrganizationCustomize(FlexibleCollections = false), BitAutoData] public async Task InviteUser_WithCustomType_WhenUseCustomPermissionsIsFalse_Throws(Organization organization, OrganizationUserInvite invite, OrganizationUser invitor, SutProvider sutProvider) { @@ -620,7 +678,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) [OrganizationInviteCustomize( InviteeUserType = OrganizationUserType.Custom, InvitorUserType = OrganizationUserType.Admin - ), BitAutoData] + ), OrganizationCustomize(FlexibleCollections = false), BitAutoData] public async Task InviteUser_WithCustomType_WhenUseCustomPermissionsIsTrue_Passes(Organization organization, OrganizationUserInvite invite, OrganizationUser invitor, SutProvider sutProvider) { @@ -646,6 +704,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) } [Theory] + [OrganizationCustomize(FlexibleCollections = false)] [BitAutoData(OrganizationUserType.Admin)] [BitAutoData(OrganizationUserType.Manager)] [BitAutoData(OrganizationUserType.Owner)] @@ -679,7 +738,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) [OrganizationInviteCustomize( InviteeUserType = OrganizationUserType.Manager, InvitorUserType = OrganizationUserType.Custom - ), BitAutoData] + ), OrganizationCustomize(FlexibleCollections = false), BitAutoData] public async Task InviteUser_CustomUserWithoutManageUsersConfiguringUser_Throws(Organization organization, OrganizationUserInvite invite, OrganizationUser invitor, SutProvider sutProvider) { @@ -707,7 +766,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) [OrganizationInviteCustomize( InviteeUserType = OrganizationUserType.Admin, InvitorUserType = OrganizationUserType.Custom - ), BitAutoData] + ), OrganizationCustomize(FlexibleCollections = false), BitAutoData] public async Task InviteUser_CustomUserConfiguringAdmin_Throws(Organization organization, OrganizationUserInvite invite, OrganizationUser invitor, SutProvider sutProvider) { @@ -733,7 +792,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) [OrganizationInviteCustomize( InviteeUserType = OrganizationUserType.User, InvitorUserType = OrganizationUserType.Owner - ), BitAutoData] + ), OrganizationCustomize(FlexibleCollections = false), BitAutoData] public async Task InviteUser_NoPermissionsObject_Passes(Organization organization, OrganizationUserInvite invite, OrganizationUser invitor, SutProvider sutProvider) { @@ -759,7 +818,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) [OrganizationInviteCustomize( InviteeUserType = OrganizationUserType.User, InvitorUserType = OrganizationUserType.Custom - ), BitAutoData] + ), OrganizationCustomize(FlexibleCollections = false), BitAutoData] public async Task InviteUser_Passes(Organization organization, IEnumerable<(OrganizationUserInvite invite, string externalId)> invites, OrganizationUser invitor, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, @@ -832,7 +891,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) [OrganizationInviteCustomize( InviteeUserType = OrganizationUserType.User, InvitorUserType = OrganizationUserType.Custom - ), BitAutoData] + ), OrganizationCustomize(FlexibleCollections = false), BitAutoData] public async Task InviteUser_WithEventSystemUser_Passes(Organization organization, EventSystemUser eventSystemUser, IEnumerable<(OrganizationUserInvite invite, string externalId)> invites, OrganizationUser invitor, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, @@ -882,7 +941,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) await sutProvider.GetDependency().Received(1).LogOrganizationUserEventsAsync(Arg.Any>()); } - [Theory, BitAutoData, OrganizationInviteCustomize] + [Theory, BitAutoData, OrganizationCustomize(FlexibleCollections = false), OrganizationInviteCustomize] public async Task InviteUser_WithSecretsManager_Passes(Organization organization, IEnumerable<(OrganizationUserInvite invite, string externalId)> invites, OrganizationUser savingUser, SutProvider sutProvider) @@ -916,7 +975,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) !update.MaxAutoscaleSmSeatsChanged)); } - [Theory, BitAutoData, OrganizationInviteCustomize] + [Theory, BitAutoData, OrganizationCustomize(FlexibleCollections = false), OrganizationInviteCustomize] public async Task InviteUser_WithSecretsManager_WhenErrorIsThrown_RevertsAutoscaling(Organization organization, IEnumerable<(OrganizationUserInvite invite, string externalId)> invites, OrganizationUser savingUser, SutProvider sutProvider) @@ -972,26 +1031,48 @@ OrganizationUserInvite invite, SutProvider sutProvider) }); } - [Theory, BitAutoData] - public async Task InviteUser_WithFCEnabled_WhenInvitingManager_Throws(OrganizationAbility organizationAbility, + [Theory, OrganizationCustomize(FlexibleCollections = true), BitAutoData] + public async Task InviteUser_WithFlexibleCollections_WhenInvitingManager_Throws(Organization organization, OrganizationUserInvite invite, OrganizationUser invitor, SutProvider sutProvider) { invite.Type = OrganizationUserType.Manager; - organizationAbility.FlexibleCollections = true; + organization.FlexibleCollections = true; - sutProvider.GetDependency() - .GetOrganizationAbilityAsync(organizationAbility.Id) - .Returns(organizationAbility); + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); sutProvider.GetDependency() - .ManageUsers(organizationAbility.Id) + .ManageUsers(organization.Id) .Returns(true); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.InviteUsersAsync(organizationAbility.Id, invitor.UserId, + () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) })); - Assert.Contains("manager role is deprecated", exception.Message.ToLowerInvariant()); + Assert.Contains("manager role has been deprecated", exception.Message.ToLowerInvariant()); + } + + [Theory, OrganizationCustomize(FlexibleCollections = true), BitAutoData] + public async Task InviteUser_WithFlexibleCollections_WithAccessAll_Throws(Organization organization, + OrganizationUserInvite invite, OrganizationUser invitor, SutProvider sutProvider) + { + invite.Type = OrganizationUserType.User; + invite.AccessAll = true; + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .ManageUsers(organization.Id) + .Returns(true); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, + new (OrganizationUserInvite, string)[] { (invite, null) })); + + Assert.Contains("accessall property has been deprecated", exception.Message.ToLowerInvariant()); } private void InviteUserHelper_ArrangeValidPermissions(Organization organization, OrganizationUser savingUser, @@ -1275,15 +1356,21 @@ OrganizationUserInvite invite, SutProvider sutProvider) } [Theory, BitAutoData] - public async Task SaveUser_WithFCEnabled_WhenUpgradingToManager_Throws( + public async Task SaveUser_WithFlexibleCollections_WhenUpgradingToManager_Throws( OrganizationAbility organizationAbility, [OrganizationUser(type: OrganizationUserType.User)] OrganizationUser oldUserData, [OrganizationUser(type: OrganizationUserType.Manager)] OrganizationUser newUserData, + [OrganizationUser(type: OrganizationUserType.Owner, status: OrganizationUserStatusType.Confirmed)] OrganizationUser savingUser, IEnumerable collections, IEnumerable groups, SutProvider sutProvider) { organizationAbility.FlexibleCollections = true; + newUserData.Id = oldUserData.Id; + newUserData.UserId = oldUserData.UserId; + newUserData.OrganizationId = oldUserData.OrganizationId = savingUser.OrganizationId = organizationAbility.Id; + newUserData.Permissions = CoreHelpers.ClassToJsonData(new Permissions()); + sutProvider.GetDependency() .GetOrganizationAbilityAsync(organizationAbility.Id) .Returns(organizationAbility); @@ -1296,15 +1383,53 @@ OrganizationUserInvite invite, SutProvider sutProvider) .GetByIdAsync(oldUserData.Id) .Returns(oldUserData); - newUserData.Id = oldUserData.Id; - newUserData.UserId = oldUserData.UserId; - newUserData.OrganizationId = oldUserData.OrganizationId = organizationAbility.Id; - newUserData.Permissions = CoreHelpers.ClassToJsonData(new Permissions()); + sutProvider.GetDependency() + .GetManyByOrganizationAsync(organizationAbility.Id, OrganizationUserType.Owner) + .Returns(new List { savingUser }); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.SaveUserAsync(newUserData, oldUserData.UserId, collections, groups)); - Assert.Contains("manager role is deprecated", exception.Message.ToLowerInvariant()); + Assert.Contains("manager role has been deprecated", exception.Message.ToLowerInvariant()); + } + + [Theory, BitAutoData] + public async Task SaveUser_WithFlexibleCollections_WithAccessAll_Throws( + OrganizationAbility organizationAbility, + [OrganizationUser(type: OrganizationUserType.User)] OrganizationUser oldUserData, + [OrganizationUser(type: OrganizationUserType.User)] OrganizationUser newUserData, + [OrganizationUser(type: OrganizationUserType.Owner, status: OrganizationUserStatusType.Confirmed)] OrganizationUser savingUser, + IEnumerable collections, + IEnumerable groups, + SutProvider sutProvider) + { + organizationAbility.FlexibleCollections = true; + newUserData.Id = oldUserData.Id; + newUserData.UserId = oldUserData.UserId; + newUserData.OrganizationId = oldUserData.OrganizationId = savingUser.OrganizationId = organizationAbility.Id; + newUserData.Permissions = CoreHelpers.ClassToJsonData(new Permissions()); + newUserData.AccessAll = true; + + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(organizationAbility.Id) + .Returns(organizationAbility); + + sutProvider.GetDependency() + .ManageUsers(organizationAbility.Id) + .Returns(true); + + sutProvider.GetDependency() + .GetByIdAsync(oldUserData.Id) + .Returns(oldUserData); + + sutProvider.GetDependency() + .GetManyByOrganizationAsync(organizationAbility.Id, OrganizationUserType.Owner) + .Returns(new List { savingUser }); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveUserAsync(newUserData, oldUserData.UserId, collections, groups)); + + Assert.Contains("the accessall property has been deprecated", exception.Message.ToLowerInvariant()); } [Theory, BitAutoData] From e6bb6e1114b2defe7620c8054b5b5329d01fe747 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Mon, 22 Jan 2024 08:05:42 -0800 Subject: [PATCH 019/117] [PM-5788] Ensure Collection Service respects Flexible Collections falg (#3686) * [PM-5788] Ensure the organization has FC enabled before enforcing a user/group with Manage permissions * [PM-5788] Fix unit test --- src/Core/Services/Implementations/CollectionService.cs | 2 +- test/Core.Test/Services/CollectionServiceTests.cs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Core/Services/Implementations/CollectionService.cs b/src/Core/Services/Implementations/CollectionService.cs index 2941de2d56..26e59fd4d6 100644 --- a/src/Core/Services/Implementations/CollectionService.cs +++ b/src/Core/Services/Implementations/CollectionService.cs @@ -56,7 +56,7 @@ public class CollectionService : ICollectionService var usersList = users?.ToList(); // If using Flexible Collections - a collection should always have someone with Can Manage permissions - if (_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1)) + if (org.FlexibleCollections && _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1)) { var groupHasManageAccess = groupsList?.Any(g => g.Manage) ?? false; var userHasManageAccess = usersList?.Any(u => u.Manage) ?? false; diff --git a/test/Core.Test/Services/CollectionServiceTests.cs b/test/Core.Test/Services/CollectionServiceTests.cs index d57acebe3a..1981d681f3 100644 --- a/test/Core.Test/Services/CollectionServiceTests.cs +++ b/test/Core.Test/Services/CollectionServiceTests.cs @@ -112,6 +112,7 @@ public class CollectionServiceTest [CollectionAccessSelectionCustomize] IEnumerable users, SutProvider sutProvider) { collection.Id = default; + organization.FlexibleCollections = true; sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency() .IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1, Arg.Any()) From aeca1722fc61680715e5b439aea8bf6cc4e7b300 Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Mon, 22 Jan 2024 10:44:33 -0600 Subject: [PATCH 020/117] [AC-1880] - Public API - Update collection permission associations with Manage property (#3656) * Add missing hide-passwords permission to api models * Update src/Api/Auth/Models/Public/AssociationWithPermissionsBaseModel.cs Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> * Rename ToSelectionReadOnly to ToCollectionAccessSelection * Remove Required attribute which would break backwards compatability * Update src/Api/Auth/Models/Public/Request/AssociationWithPermissionsRequestModel.cs Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> * feat: add Manage property to collection permissions associations, refs AC-1880 * feat: throw if not allowed to send manage property, refs AC-1880 * fix: format, refs AC-1880 * feat: replace ambiguous call for all organizations in cache with specific orgId, refs AC-1880 * feat: move all property assignements back into CollectionAccessSelection init, refs AC-1880 * feat: align bad request messaging, refs AC-1880 --------- Co-authored-by: Daniel James Smith Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Co-authored-by: Daniel James Smith Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> --- .../Public/Controllers/GroupsController.cs | 4 ++-- .../Public/Controllers/MembersController.cs | 11 ++++++++--- .../AssociationWithPermissionsBaseModel.cs | 5 +++++ .../AssociationWithPermissionsRequestModel.cs | 19 +++++++++++++++---- ...AssociationWithPermissionsResponseModel.cs | 1 + .../Controllers/CollectionsController.cs | 8 ++++++-- 6 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/Api/AdminConsole/Public/Controllers/GroupsController.cs b/src/Api/AdminConsole/Public/Controllers/GroupsController.cs index 4113014ac3..6ced361771 100644 --- a/src/Api/AdminConsole/Public/Controllers/GroupsController.cs +++ b/src/Api/AdminConsole/Public/Controllers/GroupsController.cs @@ -110,8 +110,8 @@ public class GroupsController : Controller public async Task Post([FromBody] GroupCreateUpdateRequestModel model) { var group = model.ToGroup(_currentContext.OrganizationId.Value); - var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection()); var organization = await _organizationRepository.GetByIdAsync(_currentContext.OrganizationId.Value); + var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection(organization.FlexibleCollections)); await _createGroupCommand.CreateGroupAsync(group, organization, associations); var response = new GroupResponseModel(group, associations); return new JsonResult(response); @@ -139,8 +139,8 @@ public class GroupsController : Controller } var updatedGroup = model.ToGroup(existingGroup); - var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection()); var organization = await _organizationRepository.GetByIdAsync(_currentContext.OrganizationId.Value); + var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection(organization.FlexibleCollections)); await _updateGroupCommand.UpdateGroupAsync(updatedGroup, organization, associations); var response = new GroupResponseModel(updatedGroup, associations); return new JsonResult(response); diff --git a/src/Api/AdminConsole/Public/Controllers/MembersController.cs b/src/Api/AdminConsole/Public/Controllers/MembersController.cs index 3fde19faa7..754c3130d0 100644 --- a/src/Api/AdminConsole/Public/Controllers/MembersController.cs +++ b/src/Api/AdminConsole/Public/Controllers/MembersController.cs @@ -23,6 +23,7 @@ public class MembersController : Controller private readonly IUserService _userService; private readonly ICurrentContext _currentContext; private readonly IUpdateOrganizationUserGroupsCommand _updateOrganizationUserGroupsCommand; + private readonly IApplicationCacheService _applicationCacheService; public MembersController( IOrganizationUserRepository organizationUserRepository, @@ -30,7 +31,8 @@ public class MembersController : Controller IOrganizationService organizationService, IUserService userService, ICurrentContext currentContext, - IUpdateOrganizationUserGroupsCommand updateOrganizationUserGroupsCommand) + IUpdateOrganizationUserGroupsCommand updateOrganizationUserGroupsCommand, + IApplicationCacheService applicationCacheService) { _organizationUserRepository = organizationUserRepository; _groupRepository = groupRepository; @@ -38,6 +40,7 @@ public class MembersController : Controller _userService = userService; _currentContext = currentContext; _updateOrganizationUserGroupsCommand = updateOrganizationUserGroupsCommand; + _applicationCacheService = applicationCacheService; } /// @@ -119,7 +122,8 @@ public class MembersController : Controller [ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)] public async Task Post([FromBody] MemberCreateRequestModel model) { - var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection()); + var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(_currentContext.OrganizationId.Value); + var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection(organizationAbility?.FlexibleCollections ?? false)); var invite = new OrganizationUserInvite { Emails = new List { model.Email }, @@ -154,7 +158,8 @@ public class MembersController : Controller return new NotFoundResult(); } var updatedUser = model.ToOrganizationUser(existingUser); - var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection()); + var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(_currentContext.OrganizationId.Value); + var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection(organizationAbility?.FlexibleCollections ?? false)); await _organizationService.SaveUserAsync(updatedUser, null, associations, model.Groups); MemberResponseModel response = null; if (existingUser.UserId.HasValue) diff --git a/src/Api/AdminConsole/Public/Models/AssociationWithPermissionsBaseModel.cs b/src/Api/AdminConsole/Public/Models/AssociationWithPermissionsBaseModel.cs index 4e24d2462f..72bbe87b92 100644 --- a/src/Api/AdminConsole/Public/Models/AssociationWithPermissionsBaseModel.cs +++ b/src/Api/AdminConsole/Public/Models/AssociationWithPermissionsBaseModel.cs @@ -20,4 +20,9 @@ public abstract class AssociationWithPermissionsBaseModel /// This prevents easy copy-and-paste of hidden items, however it may not completely prevent user access. /// public bool? HidePasswords { get; set; } + /// + /// When true, the manage permission allows a user to both edit the ciphers within a collection and edit the users/groups that are assigned to the collection. + /// This field will not affect behavior until the Flexible Collections functionality is released in Q1, 2024. + /// + public bool? Manage { get; set; } } diff --git a/src/Api/AdminConsole/Public/Models/Request/AssociationWithPermissionsRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/AssociationWithPermissionsRequestModel.cs index fcf5a68ba2..b54fe60b27 100644 --- a/src/Api/AdminConsole/Public/Models/Request/AssociationWithPermissionsRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/AssociationWithPermissionsRequestModel.cs @@ -1,16 +1,27 @@ -using Bit.Core.Models.Data; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data; namespace Bit.Api.AdminConsole.Public.Models.Request; public class AssociationWithPermissionsRequestModel : AssociationWithPermissionsBaseModel { - public CollectionAccessSelection ToCollectionAccessSelection() + public CollectionAccessSelection ToCollectionAccessSelection(bool migratedToFlexibleCollections) { - return new CollectionAccessSelection + var collectionAccessSelection = new CollectionAccessSelection { Id = Id.Value, ReadOnly = ReadOnly.Value, - HidePasswords = HidePasswords.GetValueOrDefault() + HidePasswords = HidePasswords.GetValueOrDefault(), + Manage = Manage.GetValueOrDefault() }; + + // Throws if the org has not migrated to use FC but has passed in a Manage value in the request + if (!migratedToFlexibleCollections && Manage.HasValue) + { + throw new BadRequestException( + "Your organization must be using the latest collection enhancements to use the Manage property."); + } + + return collectionAccessSelection; } } diff --git a/src/Api/AdminConsole/Public/Models/Response/AssociationWithPermissionsResponseModel.cs b/src/Api/AdminConsole/Public/Models/Response/AssociationWithPermissionsResponseModel.cs index 798234e7a6..e319ead8a4 100644 --- a/src/Api/AdminConsole/Public/Models/Response/AssociationWithPermissionsResponseModel.cs +++ b/src/Api/AdminConsole/Public/Models/Response/AssociationWithPermissionsResponseModel.cs @@ -13,5 +13,6 @@ public class AssociationWithPermissionsResponseModel : AssociationWithPermission Id = selection.Id; ReadOnly = selection.ReadOnly; HidePasswords = selection.HidePasswords; + Manage = selection.Manage; } } diff --git a/src/Api/Public/Controllers/CollectionsController.cs b/src/Api/Public/Controllers/CollectionsController.cs index 97f082cb8a..ecd84aa728 100644 --- a/src/Api/Public/Controllers/CollectionsController.cs +++ b/src/Api/Public/Controllers/CollectionsController.cs @@ -16,15 +16,18 @@ public class CollectionsController : Controller private readonly ICollectionRepository _collectionRepository; private readonly ICollectionService _collectionService; private readonly ICurrentContext _currentContext; + private readonly IApplicationCacheService _applicationCacheService; public CollectionsController( ICollectionRepository collectionRepository, ICollectionService collectionService, - ICurrentContext currentContext) + ICurrentContext currentContext, + IApplicationCacheService applicationCacheService) { _collectionRepository = collectionRepository; _collectionService = collectionService; _currentContext = currentContext; + _applicationCacheService = applicationCacheService; } /// @@ -89,7 +92,8 @@ public class CollectionsController : Controller return new NotFoundResult(); } var updatedCollection = model.ToCollection(existingCollection); - var associations = model.Groups?.Select(c => c.ToCollectionAccessSelection()); + var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(_currentContext.OrganizationId.Value); + var associations = model.Groups?.Select(c => c.ToCollectionAccessSelection(organizationAbility?.FlexibleCollections ?? false)); await _collectionService.SaveAsync(updatedCollection, associations); var response = new CollectionResponseModel(updatedCollection, associations); return new JsonResult(response); From c63db733e05609ff04c037bf013d628b78e2925f Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Tue, 23 Jan 2024 13:24:52 -0500 Subject: [PATCH 021/117] Workflow linting and test separation (#3684) * Workflow linting and test separation * Name linting step * Few more renames * Database testing consolidation * Few more renames and tweaks --- .../_move_finalization_db_scripts.yml | 18 +- .../workflows/automatic-issue-responses.yml | 6 +- .github/workflows/build.yml | 100 +++------- .github/workflows/cleanup-after-pr.yml | 18 +- .../workflows/container-registry-purge.yml | 12 +- .github/workflows/database.yml | 95 --------- .github/workflows/enforce-labels.yml | 19 +- .github/workflows/infrastructure-tests.yml | 117 ----------- .github/workflows/protect-files.yml | 7 +- .github/workflows/release.yml | 34 ++-- .github/workflows/stale-bot.yml | 22 +-- .github/workflows/stop-staging-slots.yml | 10 +- .github/workflows/test-database.yml | 185 ++++++++++++++++++ .github/workflows/test.yml | 57 ++++++ .github/workflows/version-bump.yml | 23 ++- .github/workflows/workflow-linter.yml | 3 +- 16 files changed, 356 insertions(+), 370 deletions(-) delete mode 100644 .github/workflows/database.yml delete mode 100644 .github/workflows/infrastructure-tests.yml create mode 100644 .github/workflows/test-database.yml create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/_move_finalization_db_scripts.yml b/.github/workflows/_move_finalization_db_scripts.yml index 5e3ea0d250..0b1d18797e 100644 --- a/.github/workflows/_move_finalization_db_scripts.yml +++ b/.github/workflows/_move_finalization_db_scripts.yml @@ -1,7 +1,6 @@ --- - name: _move_finalization_db_scripts -run-name: Move finalization db scripts +run-name: Move finalization database scripts on: workflow_call: @@ -11,7 +10,6 @@ permissions: contents: write jobs: - setup: name: Setup runs-on: ubuntu-22.04 @@ -19,7 +17,7 @@ jobs: migration_filename_prefix: ${{ steps.prefix.outputs.prefix }} copy_finalization_scripts: ${{ steps.check-finalization-scripts-existence.outputs.copy_finalization_scripts }} steps: - - name: Login to Azure + - name: Log in to Azure uses: Azure/login@de95379fe4dadc2defb305917eaa7e5dde727294 # v1.5.1 with: creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} @@ -31,7 +29,7 @@ jobs: keyvault: "bitwarden-ci" secrets: "github-pat-bitwarden-devops-bot-repo-scope" - - name: Checkout Branch + - name: Check out branch uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} @@ -40,7 +38,7 @@ jobs: id: prefix run: echo "prefix=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT - - name: Check if any files in db finalization + - name: Check if any files in DB finalization directory id: check-finalization-scripts-existence run: | if [ -f util/Migrator/DbScripts_finalization/* ]; then @@ -50,7 +48,7 @@ jobs: fi move-finalization-db-scripts: - name: Move finalization db scripts + name: Move finalization database scripts runs-on: ubuntu-22.04 needs: setup if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }} @@ -95,12 +93,12 @@ jobs: done echo "moved_files=$moved_files" >> $GITHUB_OUTPUT - - name: Login to Azure - Prod Subscription + - name: Log in to Azure - production subscription uses: Azure/login@de95379fe4dadc2defb305917eaa7e5dde727294 # v1.5.1 with: creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} - - name: Retrieve Secrets + - name: Retrieve secrets id: retrieve-secrets uses: bitwarden/gh-actions/get-keyvault-secrets@main with: @@ -140,7 +138,7 @@ jobs: BRANCH: ${{ steps.branch_name.outputs.branch_name }} GH_TOKEN: ${{ github.token }} MOVED_FILES: ${{ steps.move-files.outputs.moved_files }} - TITLE: "Move finalization db scripts" + TITLE: "Move finalization database scripts" run: | PR_URL=$(gh pr create --title "$TITLE" \ --base "main" \ diff --git a/.github/workflows/automatic-issue-responses.yml b/.github/workflows/automatic-issue-responses.yml index cfe999c80b..21c65e1938 100644 --- a/.github/workflows/automatic-issue-responses.yml +++ b/.github/workflows/automatic-issue-responses.yml @@ -6,8 +6,8 @@ on: - labeled jobs: close-issue: - name: 'Close issue with automatic response' - runs-on: ubuntu-20.04 + name: Close issue with automatic response + runs-on: ubuntu-22.04 permissions: issues: write steps: @@ -24,7 +24,7 @@ jobs: This issue will now be closed. Thanks! # Intended behavior - if: github.event.label.name == 'intended-behavior' - name: Intended behaviour + name: Intended behavior uses: peter-evans/close-issue@1373cadf1f0c96c1420bc000cfba2273ea307fd1 # v2.2.0 with: comment: | diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 96061b128e..5ecb3915ce 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,23 +2,23 @@ name: Build on: - push: - branches-ignore: - - "l10n_master" - - "gh-pages" - paths-ignore: - - ".github/workflows/**" workflow_dispatch: + push: + branches: + - "main" + - "rc" + - "hotfix-rc" + pull_request: env: _AZ_REGISTRY: "bitwardenprod.azurecr.io" jobs: cloc: - name: CLOC + name: Count lines of code runs-on: ubuntu-22.04 steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 - name: Install cloc @@ -33,62 +33,19 @@ jobs: name: Lint runs-on: ubuntu-22.04 steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 - - name: Set up dotnet + - name: Set up .NET uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0 - - name: Verify Format + - name: Verify format run: dotnet format --verify-no-changes - testing: - name: Testing - runs-on: ubuntu-22.04 - env: - NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages - steps: - - name: Checkout repo - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 - - - name: Set up dotnet - uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0 - - - name: Print environment - run: | - dotnet --info - nuget help | grep Version - echo "GitHub ref: $GITHUB_REF" - echo "GitHub event: $GITHUB_EVENT" - - - name: Remove SQL proj - run: dotnet sln bitwarden-server.sln remove src/Sql/Sql.sqlproj - - - name: Test OSS solution - run: dotnet test ./test --configuration Release --logger "trx;LogFileName=oss-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage" - - - name: Test Bitwarden solution - run: dotnet test ./bitwarden_license/test --configuration Release --logger "trx;LogFileName=bw-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage" - - - name: Report test results - uses: dorny/test-reporter@c9b3d0e2bd2a4e96aaf424dbaa31c46b42318226 # v1.6.0 - if: always() - with: - name: Test Results - path: "**/*-test-results.trx" - reporter: dotnet-trx - fail-on-error: true - - - name: Upload to codecov.io - uses: codecov/codecov-action@eaaf4bedf32dbdc6b720b63067d99c4d77d6047d # v3.1.4 - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - build-artifacts: name: Build artifacts runs-on: ubuntu-22.04 needs: - - testing - lint strategy: fail-fast: false @@ -125,10 +82,10 @@ jobs: base_path: ./bitwarden_license/src node: true steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 - - name: Set up dotnet + - name: Set up .NET uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0 - name: Set up Node @@ -228,7 +185,7 @@ jobs: base_path: ./bitwarden_license/src dotnet: true steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 - name: Check Branch to Publish @@ -245,7 +202,7 @@ jobs: fi ########## ACRs ########## - - name: Login to Azure - PROD Subscription + - name: Log in to Azure - production subscription uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 with: creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} @@ -253,7 +210,7 @@ jobs: - name: Login to PROD ACR run: az acr login -n bitwardenprod - - name: Login to Azure - CI Subscription + - name: Log in to Azure - CI subscription uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 with: creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} @@ -275,7 +232,7 @@ jobs: fi echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT - - name: Setup project name + - name: Set up project name id: setup run: | PROJECT_NAME=$(echo "${{ matrix.project_name }}" | awk '{print tolower($0)}') @@ -303,7 +260,7 @@ jobs: with: name: ${{ matrix.project_name }}.zip - - name: Setup build artifact + - name: Set up build artifact if: ${{ matrix.dotnet }} run: | mkdir -p ${{ matrix.base_path}}/${{ matrix.project_name }}/obj/build-output/publish @@ -326,13 +283,13 @@ jobs: runs-on: ubuntu-22.04 needs: build-docker steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 - - name: Set up dotnet + - name: Set up .NET uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0 - - name: Login to Azure - PROD Subscription + - name: Log in to Azure - production subscription uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 with: creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} @@ -445,7 +402,7 @@ jobs: if-no-files-found: error build-mssqlmigratorutility: - name: Build MsSqlMigratorUtility + name: Build MSSQL migrator utility runs-on: ubuntu-22.04 needs: lint defaults: @@ -460,10 +417,10 @@ jobs: - linux-x64 - win-x64 steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 - - name: Set up dotnet + - name: Set up .NET uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0 - name: Print environment @@ -478,7 +435,7 @@ jobs: dotnet publish -c "Release" -o obj/build-output/publish -r ${{ matrix.target }} -p:PublishSingleFile=true \ -p:IncludeNativeLibrariesForSelfExtract=true --self-contained true - - name: Upload project artifact Windows + - name: Upload project artifact for Windows if: ${{ contains(matrix.target, 'win') == true }} uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 with: @@ -499,7 +456,7 @@ jobs: runs-on: ubuntu-22.04 needs: build-docker steps: - - name: Login to Azure - CI Subscription + - name: Log in to Azure - CI subscription uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 with: creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} @@ -532,7 +489,7 @@ jobs: runs-on: ubuntu-22.04 needs: build-docker steps: - - name: Login to Azure - CI Subscription + - name: Log in to Azure - CI subscription uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 with: creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} @@ -567,7 +524,6 @@ jobs: needs: - cloc - lint - - testing - build-artifacts - build-docker - upload @@ -611,7 +567,7 @@ jobs: exit 1 fi - - name: Login to Azure - CI subscription + - name: Log in to Azure - CI subscription uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 if: failure() with: diff --git a/.github/workflows/cleanup-after-pr.yml b/.github/workflows/cleanup-after-pr.yml index ac8a1b624e..c61d509b1c 100644 --- a/.github/workflows/cleanup-after-pr.yml +++ b/.github/workflows/cleanup-after-pr.yml @@ -1,5 +1,5 @@ --- -name: Clean After PR +name: Container registry cleanup on: pull_request: @@ -7,31 +7,31 @@ on: jobs: build-docker: - name: Remove feature branch docker images - runs-on: ubuntu-20.04 + name: Remove branch-specific Docker images + runs-on: ubuntu-22.04 steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 ########## ACR ########## - - name: Login to Azure - QA Subscription + - name: Log in to Azure - QA Subscription uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 with: creds: ${{ secrets.AZURE_QA_KV_CREDENTIALS }} - - name: Login to Azure ACR + - name: Log in to Azure ACR run: az acr login -n bitwardenqa - - name: Login to Azure - PROD Subscription + - name: Log in to Azure - production subscription uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 with: creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} - - name: Login to Azure ACR + - name: Log in to Azure ACR run: az acr login -n bitwardenprod ########## Remove Docker images ########## - - name: Remove the docker image from ACR + - name: Remove the Docker image from ACR env: REGISTRIES: | registries: diff --git a/.github/workflows/container-registry-purge.yml b/.github/workflows/container-registry-purge.yml index f9999e8dc8..4b61e59125 100644 --- a/.github/workflows/container-registry-purge.yml +++ b/.github/workflows/container-registry-purge.yml @@ -1,18 +1,18 @@ --- -name: Container Registry Purge +name: Container registry purge on: schedule: - - cron: '0 0 * * SUN' + - cron: "0 0 * * SUN" workflow_dispatch: inputs: {} jobs: purge: name: Purge old images - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - - name: Login to Azure + - name: Log in to Azure uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 with: creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} @@ -68,7 +68,7 @@ jobs: check-failures: name: Check for failures if: always() - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: - purge steps: @@ -84,7 +84,7 @@ jobs: exit 1 fi - - name: Login to Azure - CI subscription + - name: Log in to Azure - CI subscription uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 if: failure() with: diff --git a/.github/workflows/database.yml b/.github/workflows/database.yml deleted file mode 100644 index 2527440abd..0000000000 --- a/.github/workflows/database.yml +++ /dev/null @@ -1,95 +0,0 @@ ---- -name: Validate Database - -on: - pull_request: - branches-ignore: - - 'l10n_master' - - 'gh-pages' - paths: - - 'src/Sql/**' - - 'util/Migrator/**' - push: - branches: - - 'main' - - 'rc' - paths: - - 'src/Sql/**' - - 'util/Migrator/**' - workflow_dispatch: - inputs: {} - -jobs: - validate: - name: Validate - runs-on: ubuntu-22.04 - steps: - - name: Checkout repo - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 - - - name: Set up dotnet - uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0 - with: - dotnet-version: '6.0.x' - - - name: Print environment - run: | - dotnet --info - nuget help | grep Version - echo "GitHub ref: $GITHUB_REF" - echo "GitHub event: $GITHUB_EVENT" - - - name: Build DACPAC - run: dotnet build src/Sql --configuration Release --verbosity minimal --output . - shell: pwsh - - - name: Upload DACPAC - uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 - with: - name: sql.dacpac - path: Sql.dacpac - - - name: Docker Compose up - working-directory: "dev" - run: | - cp .env.example .env - docker compose --profile mssql up -d - shell: pwsh - - - name: Migrate - working-directory: "dev" - run: "pwsh ./migrate.ps1" - shell: pwsh - - - name: Diff sqlproj to migrations - run: /usr/local/sqlpackage/sqlpackage /action:DeployReport /SourceFile:"Sql.dacpac" /TargetConnectionString:"Server=localhost;Database=vault_dev;User Id=SA;Password=SET_A_PASSWORD_HERE_123;Encrypt=True;TrustServerCertificate=True;" /OutputPath:"report.xml" /p:IgnoreColumnOrder=True /p:IgnoreComments=True - shell: pwsh - - - name: Generate SQL file - run: /usr/local/sqlpackage/sqlpackage /action:Script /SourceFile:"Sql.dacpac" /TargetConnectionString:"Server=localhost;Database=vault_dev;User Id=SA;Password=SET_A_PASSWORD_HERE_123;Encrypt=True;TrustServerCertificate=True;" /OutputPath:"diff.sql" /p:IgnoreColumnOrder=True /p:IgnoreComments=True - shell: pwsh - - - name: Upload Report - uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 - with: - name: report.xml - path: | - report.xml - diff.sql - - - name: Validate XML - run: | - if grep -q "" "report.xml"; then - echo - echo "Migrations are out of sync with sqlproj!" - exit 1 - else - echo "Report looks good" - fi - shell: bash - - - name: Docker compose down - if: ${{ always() }} - working-directory: "dev" - run: docker compose down - shell: pwsh diff --git a/.github/workflows/enforce-labels.yml b/.github/workflows/enforce-labels.yml index eff371fbb3..160ee15b96 100644 --- a/.github/workflows/enforce-labels.yml +++ b/.github/workflows/enforce-labels.yml @@ -2,15 +2,18 @@ name: Enforce PR labels on: + workflow_call: pull_request: - types: [labeled, unlabeled, opened, edited, synchronize] - + types: [labeled, unlabeled, opened, reopened, synchronize] jobs: enforce-label: - name: EnforceLabel - runs-on: ubuntu-20.04 + if: ${{ contains(github.event.*.labels.*.name, 'hold') || contains(github.event.*.labels.*.name, 'needs-qa') || contains(github.event.*.labels.*.name, 'DB-migrations-changed') }} + name: Enforce label + runs-on: ubuntu-22.04 + steps: - - name: Enforce Label - uses: yogevbd/enforce-label-action@a3c219da6b8fa73f6ba62b68ff09c469b3a1c024 # 2.2.2 - with: - BANNED_LABELS: "hold,DB-migrations-changed,needs-qa" + - name: Check for label + run: | + echo "PRs with the hold or needs-qa labels cannot be merged" + echo "### :x: PRs with the hold or needs-qa labels cannot be merged" >> $GITHUB_STEP_SUMMARY + exit 1 diff --git a/.github/workflows/infrastructure-tests.yml b/.github/workflows/infrastructure-tests.yml deleted file mode 100644 index 1e17203bf9..0000000000 --- a/.github/workflows/infrastructure-tests.yml +++ /dev/null @@ -1,117 +0,0 @@ ---- -name: Run Database Infrastructure Tests -on: - pull_request: - branches-ignore: - - 'l10n_master' - - 'gh-pages' - paths: - - '.github/workflows/infrastructure-tests.yml' # This file - - 'src/Sql/**' # SQL Server Database Changes - - 'util/Migrator/**' # New SQL Server Migrations - - 'util/MySqlMigrations/**' # Changes to MySQL - - 'util/PostgresMigrations/**' # Changes to Postgres - - 'util/SqliteMigrations/**' # Changes to Sqlite - - 'src/Infrastructure.Dapper/**' # Changes to SQL Server Dapper Repository Layer - - 'src/Infrastructure.EntityFramework/**' # Changes to Entity Framework Repository Layer - - 'test/Infrastructure.IntegrationTest/**' # Any changes to the tests - push: - branches: - - 'main' - - 'rc' - paths: - - '.github/workflows/infrastructure-tests.yml' # This file - - 'src/Sql/**' # SQL Server Database Changes - - 'util/Migrator/**' # New SQL Server Migrations - - 'util/MySqlMigrations/**' # Changes to MySQL - - 'util/PostgresMigrations/**' # Changes to Postgres - - 'util/SqliteMigrations/**' # Changes to Sqlite - - 'src/Infrastructure.Dapper/**' # Changes to SQL Server Dapper Repository Layer - - 'src/Infrastructure.EntityFramework/**' # Changes to Entity Framework Repository Layer - - 'test/Infrastructure.IntegrationTest/**' # Any changes to the tests - workflow_dispatch: - inputs: {} - -jobs: - test: - name: 'Run Infrastructure.IntegrationTest' - runs-on: ubuntu-22.04 - steps: - - name: Checkout repo - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 - - - name: Set up dotnet - uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0 - with: - dotnet-version: '6.0.x' - - - name: Restore Tools - run: dotnet tool restore - - - name: Compose Databases - working-directory: 'dev' - # We could think about not using profiles and pulling images directly to cover multiple versions - run: | - cp .env.example .env - docker compose --profile mssql --profile postgres --profile mysql up -d - shell: pwsh - - # I've seen the SQL Server container not be ready for commands right after starting up and just needing a bit longer to be ready - - name: Sleep - run: sleep 15s - - - name: Migrate SQL Server - working-directory: 'dev' - run: "pwsh ./migrate.ps1" - shell: pwsh - - - name: Migrate MySQL - working-directory: 'util/MySqlMigrations' - run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:MySql:ConnectionString="$CONN_STR"' - env: - CONN_STR: "server=localhost;uid=root;pwd=SET_A_PASSWORD_HERE_123;database=vault_dev;Allow User Variables=true" - - - name: Migrate Postgres - working-directory: 'util/PostgresMigrations' - run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:PostgreSql:ConnectionString="$CONN_STR"' - env: - CONN_STR: "Host=localhost;Username=postgres;Password=SET_A_PASSWORD_HERE_123;Database=vault_dev" - - - name: Migrate Sqlite - working-directory: 'util/SqliteMigrations' - run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:Sqlite:ConnectionString="$CONN_STR"' - env: - CONN_STR: "Data Source=${{ runner.temp }}/test.db" - - - name: Run Tests - working-directory: 'test/Infrastructure.IntegrationTest' - env: - # Default Postgres: - BW_TEST_DATABASES__0__TYPE: "Postgres" - BW_TEST_DATABASES__0__CONNECTIONSTRING: "Host=localhost;Username=postgres;Password=SET_A_PASSWORD_HERE_123;Database=vault_dev" - # Default MySql - BW_TEST_DATABASES__1__TYPE: "MySql" - BW_TEST_DATABASES__1__CONNECTIONSTRING: "server=localhost;uid=root;pwd=SET_A_PASSWORD_HERE_123;database=vault_dev" - # Default Dapper SqlServer - BW_TEST_DATABASES__2__TYPE: "SqlServer" - BW_TEST_DATABASES__2__CONNECTIONSTRING: "Server=localhost;Database=vault_dev;User Id=SA;Password=SET_A_PASSWORD_HERE_123;Encrypt=True;TrustServerCertificate=True;" - # Default Sqlite - BW_TEST_DATABASES__3__TYPE: "Sqlite" - BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db" - run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx" - shell: pwsh - - - name: Report test results - uses: dorny/test-reporter@c9b3d0e2bd2a4e96aaf424dbaa31c46b42318226 # v1.6.0 - if: always() - with: - name: Test Results - path: "**/*-test-results.trx" - reporter: dotnet-trx - fail-on-error: true - - - name: Docker compose down - if: always() - working-directory: "dev" - run: docker compose down - shell: pwsh diff --git a/.github/workflows/protect-files.yml b/.github/workflows/protect-files.yml index df595e900c..dea02dd917 100644 --- a/.github/workflows/protect-files.yml +++ b/.github/workflows/protect-files.yml @@ -2,8 +2,7 @@ # Starts a matrix job to check for modified files, then sets output based on the results. # The input decides if the label job is ran, adding a label to the PR. --- - -name: Protect Files +name: Protect files on: pull_request: @@ -17,7 +16,7 @@ on: jobs: changed-files: name: Check for file changes - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 outputs: changes: ${{steps.check-changes.outputs.changes_detected}} @@ -29,7 +28,7 @@ jobs: path: util/Migrator/DbScripts label: "DB-migrations-changed" steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 with: fetch-depth: 2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9839641d26..e4c238755a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ on: - Dry Run env: - _AZ_REGISTRY: 'bitwardenprod.azurecr.io' + _AZ_REGISTRY: "bitwardenprod.azurecr.io" jobs: setup: @@ -36,10 +36,10 @@ jobs: exit 1 fi - - name: Checkout repo + - name: Check out repo uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 - - name: Check Release Version + - name: Check release version id: version uses: bitwarden/gh-actions/release-version-check@main with: @@ -87,7 +87,7 @@ jobs: task: "deploy" description: "Deploy from ${{ needs.setup.outputs.branch-name }} branch" - - name: Download latest Release ${{ matrix.name }} asset + - name: Download latest release ${{ matrix.name }} asset if: ${{ github.event.inputs.release_type != 'Dry Run' }} uses: bitwarden/gh-actions/download-artifacts@main with: @@ -96,7 +96,7 @@ jobs: branch: ${{ needs.setup.outputs.branch-name }} artifacts: ${{ matrix.name }}.zip - - name: Dry Run - Download latest Release ${{ matrix.name }} asset + - name: Dry run - Download latest release ${{ matrix.name }} asset if: ${{ github.event.inputs.release_type == 'Dry Run' }} uses: bitwarden/gh-actions/download-artifacts@main with: @@ -105,7 +105,7 @@ jobs: branch: main artifacts: ${{ matrix.name }}.zip - - name: Login to Azure - CI subscription + - name: Log in to Azure - CI subscription uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 with: creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} @@ -130,12 +130,12 @@ jobs: echo "::add-mask::$publish_profile" echo "publish-profile=$publish_profile" >> $GITHUB_OUTPUT - - name: Login to Azure + - name: Log in to Azure uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 with: creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} - - name: Deploy App + - name: Deploy app uses: azure/webapps-deploy@4bca689e4c7129e55923ea9c45401b22dc6aa96f # v2.2.11 with: app-name: ${{ steps.retrieve-secrets.outputs.webapp-name }} @@ -156,7 +156,7 @@ jobs: fi az webapp start -n $WEBAPP_NAME -g $RESOURCE_GROUP -s staging - - name: Update ${{ matrix.name }} deployment status to Success + - name: Update ${{ matrix.name }} deployment status to success if: ${{ github.event.inputs.release_type != 'Dry Run' && success() }} uses: chrnorm/deployment-status@2afb7d27101260f4a764219439564d954d10b5b0 # v2.0.1 with: @@ -164,7 +164,7 @@ jobs: state: "success" deployment-id: ${{ steps.deployment.outputs.deployment_id }} - - name: Update ${{ matrix.name }} deployment status to Failure + - name: Update ${{ matrix.name }} deployment status to failure if: ${{ github.event.inputs.release_type != 'Dry Run' && failure() }} uses: chrnorm/deployment-status@2afb7d27101260f4a764219439564d954d10b5b0 # v2.0.1 with: @@ -210,10 +210,10 @@ jobs: echo "GitHub event: $GITHUB_EVENT" echo "Github Release Option: $RELEASE_OPTION" - - name: Checkout repo + - name: Check out repo uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 - - name: Setup project name + - name: Set up project name id: setup run: | PROJECT_NAME=$(echo "${{ matrix.project_name }}" | awk '{print tolower($0)}') @@ -222,12 +222,12 @@ jobs: echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT ########## ACR PROD ########## - - name: Login to Azure - PROD Subscription + - name: Log in to Azure - production subscription uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 with: creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} - - name: Login to Azure ACR + - name: Log in to Azure ACR run: az acr login -n $_AZ_REGISTRY --only-show-errors - name: Pull latest project image @@ -266,13 +266,13 @@ jobs: run: docker logout release: - name: Create GitHub Release + name: Create GitHub release runs-on: ubuntu-22.04 needs: - setup - deploy steps: - - name: Download latest Release Docker Stubs + - name: Download latest release Docker stubs if: ${{ github.event.inputs.release_type != 'Dry Run' }} uses: bitwarden/gh-actions/download-artifacts@main with: @@ -285,7 +285,7 @@ jobs: docker-stub-EU-sha256.txt, swagger.json" - - name: Dry Run - Download latest Release Docker Stubs + - name: Dry Run - Download latest release Docker stubs if: ${{ github.event.inputs.release_type == 'Dry Run' }} uses: bitwarden/gh-actions/download-artifacts@main with: diff --git a/.github/workflows/stale-bot.yml b/.github/workflows/stale-bot.yml index 1bd058b94b..721fee4ae7 100644 --- a/.github/workflows/stale-bot.yml +++ b/.github/workflows/stale-bot.yml @@ -1,23 +1,23 @@ --- -name: 'Close stale issues and PRs' +name: Staleness on: workflow_dispatch: - schedule: # Run once a day at 5.23am (arbitrary but should avoid peak loads on the hour) - - cron: '23 5 * * *' + schedule: # Run once a day at 5.23am (arbitrary but should avoid peak loads on the hour) + - cron: "23 5 * * *" jobs: stale: - name: 'Check for stale issues and PRs' - runs-on: ubuntu-20.04 + name: Check for stale issues and PRs + runs-on: ubuntu-22.04 steps: - - name: 'Run stale action' + - name: Check uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8.0.0 with: - stale-issue-label: 'needs-reply' - stale-pr-label: 'needs-changes' - days-before-stale: -1 # Do not apply the stale labels automatically, this is a manual process - days-before-issue-close: 14 # Close issue if no further activity after X days - days-before-pr-close: 21 # Close PR if no further activity after X days + stale-issue-label: "needs-reply" + stale-pr-label: "needs-changes" + days-before-stale: -1 # Do not apply the stale labels automatically, this is a manual process + days-before-issue-close: 14 # Close issue if no further activity after X days + days-before-pr-close: 21 # Close PR if no further activity after X days close-issue-message: | We need more information before we can help you with your problem. As we haven’t heard from you recently, this issue will be closed. diff --git a/.github/workflows/stop-staging-slots.yml b/.github/workflows/stop-staging-slots.yml index ca28a4db6b..0ffe94ecdf 100644 --- a/.github/workflows/stop-staging-slots.yml +++ b/.github/workflows/stop-staging-slots.yml @@ -1,5 +1,5 @@ --- -name: Stop Staging Slots +name: Stop staging slots on: workflow_dispatch: @@ -7,8 +7,8 @@ on: jobs: stop-slots: - name: Stop Slots - runs-on: ubuntu-20.04 + name: Stop slots + runs-on: ubuntu-22.04 strategy: fail-fast: false matrix: @@ -28,7 +28,7 @@ jobs: echo "NAME_LOWER: $NAME_LOWER" echo "name_lower=$NAME_LOWER" >> $GITHUB_OUTPUT - - name: Login to Azure - CI Subscription + - name: Log in to Azure - CI subscription uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 with: creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} @@ -46,7 +46,7 @@ jobs: echo "::add-mask::$webapp_name" echo "webapp-name=$webapp_name" >> $GITHUB_OUTPUT - - name: Login to Azure + - name: Log in to Azure uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 with: creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} diff --git a/.github/workflows/test-database.yml b/.github/workflows/test-database.yml new file mode 100644 index 0000000000..cf62a1f431 --- /dev/null +++ b/.github/workflows/test-database.yml @@ -0,0 +1,185 @@ +--- +name: Database testing + +on: + workflow_dispatch: + push: + branches: + - "main" + - "rc" + - "hotfix-rc" + paths: + - ".github/workflows/infrastructure-tests.yml" # This file + - "src/Sql/**" # SQL Server Database Changes + - "util/Migrator/**" # New SQL Server Migrations + - "util/MySqlMigrations/**" # Changes to MySQL + - "util/PostgresMigrations/**" # Changes to Postgres + - "util/SqliteMigrations/**" # Changes to Sqlite + - "src/Infrastructure.Dapper/**" # Changes to SQL Server Dapper Repository Layer + - "src/Infrastructure.EntityFramework/**" # Changes to Entity Framework Repository Layer + - "test/Infrastructure.IntegrationTest/**" # Any changes to the tests + pull_request: + paths: + - ".github/workflows/infrastructure-tests.yml" # This file + - "src/Sql/**" # SQL Server Database Changes + - "util/Migrator/**" # New SQL Server Migrations + - "util/MySqlMigrations/**" # Changes to MySQL + - "util/PostgresMigrations/**" # Changes to Postgres + - "util/SqliteMigrations/**" # Changes to Sqlite + - "src/Infrastructure.Dapper/**" # Changes to SQL Server Dapper Repository Layer + - "src/Infrastructure.EntityFramework/**" # Changes to Entity Framework Repository Layer + - "test/Infrastructure.IntegrationTest/**" # Any changes to the tests + +jobs: + test: + name: Run tests + runs-on: ubuntu-22.04 + steps: + - name: Check out repo + uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + + - name: Set up .NET + uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0 + + - name: Restore tools + run: dotnet tool restore + + - name: Docker Compose databases + working-directory: "dev" + # We could think about not using profiles and pulling images directly to cover multiple versions + run: | + cp .env.example .env + docker compose --profile mssql --profile postgres --profile mysql up -d + shell: pwsh + + # I've seen the SQL Server container not be ready for commands right after starting up and just needing a bit longer to be ready + - name: Sleep + run: sleep 15s + + - name: Migrate SQL Server + working-directory: "dev" + run: "./migrate.ps1" + shell: pwsh + + - name: Migrate MySQL + working-directory: "util/MySqlMigrations" + run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:MySql:ConnectionString="$CONN_STR"' + env: + CONN_STR: "server=localhost;uid=root;pwd=SET_A_PASSWORD_HERE_123;database=vault_dev;Allow User Variables=true" + + - name: Migrate Postgres + working-directory: "util/PostgresMigrations" + run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:PostgreSql:ConnectionString="$CONN_STR"' + env: + CONN_STR: "Host=localhost;Username=postgres;Password=SET_A_PASSWORD_HERE_123;Database=vault_dev" + + - name: Migrate SQLite + working-directory: "util/SqliteMigrations" + run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:Sqlite:ConnectionString="$CONN_STR"' + env: + CONN_STR: "Data Source=${{ runner.temp }}/test.db" + + - name: Run tests + working-directory: "test/Infrastructure.IntegrationTest" + env: + # Default Postgres: + BW_TEST_DATABASES__0__TYPE: "Postgres" + BW_TEST_DATABASES__0__CONNECTIONSTRING: "Host=localhost;Username=postgres;Password=SET_A_PASSWORD_HERE_123;Database=vault_dev" + # Default MySql + BW_TEST_DATABASES__1__TYPE: "MySql" + BW_TEST_DATABASES__1__CONNECTIONSTRING: "server=localhost;uid=root;pwd=SET_A_PASSWORD_HERE_123;database=vault_dev" + # Default Dapper SqlServer + BW_TEST_DATABASES__2__TYPE: "SqlServer" + BW_TEST_DATABASES__2__CONNECTIONSTRING: "Server=localhost;Database=vault_dev;User Id=SA;Password=SET_A_PASSWORD_HERE_123;Encrypt=True;TrustServerCertificate=True;" + # Default Sqlite + BW_TEST_DATABASES__3__TYPE: "Sqlite" + BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db" + run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx" + shell: pwsh + + - name: Report test results + uses: dorny/test-reporter@c9b3d0e2bd2a4e96aaf424dbaa31c46b42318226 # v1.6.0 + if: always() + with: + name: Test Results + path: "**/*-test-results.trx" + reporter: dotnet-trx + fail-on-error: true + + - name: Docker Compose down + if: always() + working-directory: "dev" + run: docker compose down + shell: pwsh + + validate: + name: Run validation + runs-on: ubuntu-22.04 + steps: + - name: Check out repo + uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + + - name: Set up .NET + uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0 + + - name: Print environment + run: | + dotnet --info + nuget help | grep Version + echo "GitHub ref: $GITHUB_REF" + echo "GitHub event: $GITHUB_EVENT" + + - name: Build DACPAC + run: dotnet build src/Sql --configuration Release --verbosity minimal --output . + shell: pwsh + + - name: Upload DACPAC + uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 + with: + name: sql.dacpac + path: Sql.dacpac + + - name: Docker Compose up + working-directory: "dev" + run: | + cp .env.example .env + docker compose --profile mssql up -d + shell: pwsh + + - name: Migrate + working-directory: "dev" + run: "./migrate.ps1" + shell: pwsh + + - name: Diff .sqlproj to migrations + run: /usr/local/sqlpackage/sqlpackage /action:DeployReport /SourceFile:"Sql.dacpac" /TargetConnectionString:"Server=localhost;Database=vault_dev;User Id=SA;Password=SET_A_PASSWORD_HERE_123;Encrypt=True;TrustServerCertificate=True;" /OutputPath:"report.xml" /p:IgnoreColumnOrder=True /p:IgnoreComments=True + shell: pwsh + + - name: Generate SQL file + run: /usr/local/sqlpackage/sqlpackage /action:Script /SourceFile:"Sql.dacpac" /TargetConnectionString:"Server=localhost;Database=vault_dev;User Id=SA;Password=SET_A_PASSWORD_HERE_123;Encrypt=True;TrustServerCertificate=True;" /OutputPath:"diff.sql" /p:IgnoreColumnOrder=True /p:IgnoreComments=True + shell: pwsh + + - name: Report validation results + uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 + with: + name: report.xml + path: | + report.xml + diff.sql + + - name: Validate XML + run: | + if grep -q "" "report.xml"; then + echo + echo "Migrations are out of sync with sqlproj!" + exit 1 + else + echo "Report looks good" + fi + shell: bash + + - name: Docker Compose down + if: ${{ always() }} + working-directory: "dev" + run: docker compose down + shell: pwsh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000000..8712b0cdf9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,57 @@ +--- +name: Testing + +on: + workflow_dispatch: + push: + branches: + - "main" + - "rc" + - "hotfix-rc" + pull_request: + +env: + _AZ_REGISTRY: "bitwardenprod.azurecr.io" + +jobs: + testing: + name: Run tests + runs-on: ubuntu-22.04 + env: + NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages + steps: + - name: Check out repo + uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + + - name: Set up .NET + uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0 + + - name: Print environment + run: | + dotnet --info + nuget help | grep Version + echo "GitHub ref: $GITHUB_REF" + echo "GitHub event: $GITHUB_EVENT" + + - name: Remove SQL project + run: dotnet sln bitwarden-server.sln remove src/Sql/Sql.sqlproj + + - name: Test OSS solution + run: dotnet test ./test --configuration Debug --logger "trx;LogFileName=oss-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage" + + - name: Test Bitwarden solution + run: dotnet test ./bitwarden_license/test --configuration Debug --logger "trx;LogFileName=bw-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage" + + - name: Report test results + uses: dorny/test-reporter@c9b3d0e2bd2a4e96aaf424dbaa31c46b42318226 # v1.6.0 + if: always() + with: + name: Test Results + path: "**/*-test-results.trx" + reporter: dotnet-trx + fail-on-error: true + + - name: Upload to codecov.io + uses: codecov/codecov-action@eaaf4bedf32dbdc6b720b63067d99c4d77d6047d # v3.1.4 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index ea6af8136e..4eacb4b38e 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -1,6 +1,6 @@ --- -name: Version Bump -run-name: Version Bump - v${{ inputs.version_number }} +name: Bump version +run-name: Bump version to ${{ inputs.version_number }} on: workflow_dispatch: @@ -16,10 +16,10 @@ on: jobs: bump_version: - name: "Bump Version to v${{ inputs.version_number }}" + name: Bump runs-on: ubuntu-22.04 steps: - - name: Login to Azure - CI Subscription + - name: Log in to Azure - CI subscription uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 with: creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} @@ -33,7 +33,7 @@ jobs: github-gpg-private-key-passphrase, github-pat-bitwarden-devops-bot-repo-scope" - - name: Checkout Branch + - name: Check out branch uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 with: ref: main @@ -47,7 +47,7 @@ jobs: git_user_signingkey: true git_commit_gpgsign: true - - name: Create Version Branch + - name: Create version branch id: create-branch run: | NAME=version_bump_${{ github.ref_name }}_${{ inputs.version_number }} @@ -78,13 +78,13 @@ jobs: exit 1 fi - - name: Bump Version - Props + - name: Bump version props uses: bitwarden/gh-actions/version-bump@main with: version: ${{ inputs.version_number }} file_path: "Directory.Build.props" - - name: Setup git + - name: Set up Git run: | git config --local user.email "106330231+bitwarden-devops-bot@users.noreply.github.com" git config --local user.name "bitwarden-devops-bot" @@ -109,7 +109,7 @@ jobs: PR_BRANCH: ${{ steps.create-branch.outputs.name }} run: git push -u origin $PR_BRANCH - - name: Create Version PR + - name: Create version PR if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} id: create-pr env: @@ -152,7 +152,7 @@ jobs: if: ${{ inputs.cut_rc_branch == true }} runs-on: ubuntu-22.04 steps: - - name: Checkout Branch + - name: Check out branch uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: ref: main @@ -171,9 +171,8 @@ jobs: git switch --quiet --create rc git push --quiet --set-upstream origin rc - move-future-db-scripts: - name: Move future DB scripts + name: Move finalization database scripts needs: cut_rc uses: ./.github/workflows/_move_finalization_db_scripts.yml secrets: inherit diff --git a/.github/workflows/workflow-linter.yml b/.github/workflows/workflow-linter.yml index fc1db4d390..24f10f1e46 100644 --- a/.github/workflows/workflow-linter.yml +++ b/.github/workflows/workflow-linter.yml @@ -1,5 +1,5 @@ --- -name: Workflow Linter +name: Workflow linter on: pull_request: @@ -8,4 +8,5 @@ on: jobs: call-workflow: + name: Lint uses: bitwarden/gh-actions/.github/workflows/workflow-linter.yml@main From 26ee43b7703a0108b2cdc1741ae0362b2d5a9522 Mon Sep 17 00:00:00 2001 From: Vince Grassia <593223+vgrassia@users.noreply.github.com> Date: Tue, 23 Jan 2024 16:03:11 -0500 Subject: [PATCH 022/117] Update logic for Docker image tag (#3695) --- .github/workflows/build.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5ecb3915ce..421ee309fd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -226,11 +226,18 @@ jobs: - name: Generate Docker image tag id: tag run: | - IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g") # slash safe branch name + if [[ $(grep "pull" <<< "${GITHUB_REF}") ]]; then + IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g") + else + IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g") + fi + if [[ "$IMAGE_TAG" == "main" ]]; then IMAGE_TAG=dev fi + echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT + echo "### :mega: Docker Image Tag: $IMAGE_TAG" >> $GITHUB_STEP_SUMMARY - name: Set up project name id: setup From 17ebbe9d9f3923986f862067d2fb14f19db50a6e Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Wed, 24 Jan 2024 12:18:20 +0100 Subject: [PATCH 023/117] [AC-2021] Bump import limits (#3698) * Increase individual import limits * Increase organizational import limits --------- Co-authored-by: Daniel James Smith --- src/Api/Tools/Controllers/ImportCiphersController.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Api/Tools/Controllers/ImportCiphersController.cs b/src/Api/Tools/Controllers/ImportCiphersController.cs index b480e9c5cb..7c9752076f 100644 --- a/src/Api/Tools/Controllers/ImportCiphersController.cs +++ b/src/Api/Tools/Controllers/ImportCiphersController.cs @@ -40,8 +40,8 @@ public class ImportCiphersController : Controller public async Task PostImport([FromBody] ImportCiphersRequestModel model) { if (!_globalSettings.SelfHosted && - (model.Ciphers.Count() > 6000 || model.FolderRelationships.Count() > 6000 || - model.Folders.Count() > 1000)) + (model.Ciphers.Count() > 7000 || model.FolderRelationships.Count() > 7000 || + model.Folders.Count() > 2000)) { throw new BadRequestException("You cannot import this much data at once."); } @@ -57,8 +57,8 @@ public class ImportCiphersController : Controller [FromBody] ImportOrganizationCiphersRequestModel model) { if (!_globalSettings.SelfHosted && - (model.Ciphers.Count() > 6000 || model.CollectionRelationships.Count() > 12000 || - model.Collections.Count() > 1000)) + (model.Ciphers.Count() > 7000 || model.CollectionRelationships.Count() > 14000 || + model.Collections.Count() > 2000)) { throw new BadRequestException("You cannot import this much data at once."); } From 0389c1d0dd53b6913ef12cec56af0fa677356eda Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Wed, 24 Jan 2024 15:48:03 +0100 Subject: [PATCH 024/117] Update paths to point to main instead of master (#3699) Co-authored-by: Daniel James Smith --- LICENSE.txt | 6 +++--- LICENSE_BITWARDEN.txt | 2 +- LICENSE_FAQ.md | 8 ++++---- README.md | 4 ++-- bitwarden_license/README.md | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/LICENSE.txt b/LICENSE.txt index 371850301a..d47755561e 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -5,13 +5,13 @@ specifies another license. Bitwarden Licensed code is found only in the /bitwarden_license directory. AGPL v3.0: -https://github.com/bitwarden/server/blob/master/LICENSE_AGPL.txt +https://github.com/bitwarden/server/blob/main/LICENSE_AGPL.txt Bitwarden License v1.0: -https://github.com/bitwarden/server/blob/master/LICENSE_BITWARDEN.txt +https://github.com/bitwarden/server/blob/main/LICENSE_BITWARDEN.txt No grant of any rights in the trademarks, service marks, or logos of Bitwarden is made (except as may be necessary to comply with the notice requirements as applicable), and use of any Bitwarden trademarks must comply with Bitwarden Trademark Guidelines -. +. diff --git a/LICENSE_BITWARDEN.txt b/LICENSE_BITWARDEN.txt index fd037c0679..8855600f8b 100644 --- a/LICENSE_BITWARDEN.txt +++ b/LICENSE_BITWARDEN.txt @@ -56,7 +56,7 @@ such Open Source Software only. logos of any Contributor (except as may be necessary to comply with the notice requirements in Section 2.3), and use of any Bitwarden trademarks must comply with Bitwarden Trademark Guidelines -. +. 3. TERMINATION diff --git a/LICENSE_FAQ.md b/LICENSE_FAQ.md index 84a46fe93a..ddb18240c0 100644 --- a/LICENSE_FAQ.md +++ b/LICENSE_FAQ.md @@ -8,7 +8,7 @@ As an open solution, Bitwarden publishes the source code for various modules und # Bitwarden Software Licensing -We have two tiers of licensing for our software. The core products are offered under one of the GPL open source licenses: GPL 3 and A-GPL 3. A select number of features, primarily those designed for use by larger organizations rather than individuals and families, are licensed under a "Source Available" commercial license [here](https://github.com/bitwarden/server/blob/master/LICENSE_BITWARDEN.txt). +We have two tiers of licensing for our software. The core products are offered under one of the GPL open source licenses: GPL 3 and A-GPL 3. A select number of features, primarily those designed for use by larger organizations rather than individuals and families, are licensed under a "Source Available" commercial license [here](https://github.com/bitwarden/server/blob/main/LICENSE_BITWARDEN.txt). Our current software products have the following licenses: @@ -49,7 +49,7 @@ As detailed above, the Bitwarden password management clients for individual use, ***If I redistribute or provide services related to Bitwarden open source software can I use the "Bitwarden" name?*** -Our licenses do not grant any rights in the trademarks, service marks, or logos of Bitwarden (except as may be necessary to comply with the notice requirements as applicable). The Bitwarden trademark is a trusted mark applied to products distributed by Bitwarden, Inc., owner of the Bitwarden trademarks and products. We have adopted and enforce strict rules governing use of our trademarks. Use of any Bitwarden trademarks must comply with Bitwarden [Trademark Guidelines](https://github.com/bitwarden/server/blob/master/TRADEMARK_GUIDELINES.md). +Our licenses do not grant any rights in the trademarks, service marks, or logos of Bitwarden (except as may be necessary to comply with the notice requirements as applicable). The Bitwarden trademark is a trusted mark applied to products distributed by Bitwarden, Inc., owner of the Bitwarden trademarks and products. We have adopted and enforce strict rules governing use of our trademarks. Use of any Bitwarden trademarks must comply with Bitwarden [Trademark Guidelines](https://github.com/bitwarden/server/blob/main/TRADEMARK_GUIDELINES.md). ***Bitwarden Trademark Usage*** @@ -61,10 +61,10 @@ You don't need permission to use our marks when truthfully referring to our prod ***How should I use the Bitwarden Trademarks when allowed?*** -Use the Bitwarden Trademarks exactly as [shown](https://github.com/bitwarden/server/blob/master/TRADEMARK_GUIDELINES.md) and without modification. For example, do not abbreviate, hyphenate, or remove elements and separate them from surrounding text, images and other features. Always use the Bitwarden Trademarks as adjectives followed by a generic term, never as a noun or verb. +Use the Bitwarden Trademarks exactly as [shown](https://github.com/bitwarden/server/blob/main/TRADEMARK_GUIDELINES.md) and without modification. For example, do not abbreviate, hyphenate, or remove elements and separate them from surrounding text, images and other features. Always use the Bitwarden Trademarks as adjectives followed by a generic term, never as a noun or verb. Use the Bitwarden Trademarks only to reference one of our products or services, but never in a way that implies sponsorship or affiliation by Bitwarden. For example, do not use any part of the Bitwarden Trademarks as the name of your business, product or service name, application, domain name, publication or other offering – this can be confusing to others. ***Where can I find more information?*** -For more information on how to use the Bitwarden Trademarks, including in connection with self-hosted options and open-source code, see our [Trademark Guidelines](https://github.com/bitwarden/server/blob/master/TRADEMARK_GUIDELINES.md) or [contacts us](https://bitwarden.com/contact/). +For more information on how to use the Bitwarden Trademarks, including in connection with self-hosted options and open-source code, see our [Trademark Guidelines](https://github.com/bitwarden/server/blob/main/TRADEMARK_GUIDELINES.md) or [contacts us](https://bitwarden.com/contact/). diff --git a/README.md b/README.md index dbbb1ddba5..eb0521e0b4 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ Bitwarden

- - Github Workflow build on master + + Github Workflow build on main DockerHub diff --git a/bitwarden_license/README.md b/bitwarden_license/README.md index 847e6e9e6e..39515e7d58 100644 --- a/bitwarden_license/README.md +++ b/bitwarden_license/README.md @@ -1,3 +1,3 @@ # Bitwarden Licensed Code -All source code under this directory is licensed under the [Bitwarden License Agreement](https://github.com/bitwarden/server/blob/master/LICENSE_BITWARDEN.txt). +All source code under this directory is licensed under the [Bitwarden License Agreement](https://github.com/bitwarden/server/blob/main/LICENSE_BITWARDEN.txt). From 243e1de4ee585b81f314f21b7561ea16d51516a1 Mon Sep 17 00:00:00 2001 From: Vince Grassia <593223+vgrassia@users.noreply.github.com> Date: Wed, 24 Jan 2024 10:26:05 -0500 Subject: [PATCH 025/117] Update Renovate config (#3700) --- .github/renovate.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/renovate.json b/.github/renovate.json index 8fb079a7de..a0a51f91c7 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -2,6 +2,7 @@ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "config:base", + "github>bitwarden/renovate-config:pin-actions", ":combinePatchMinorReleases", ":dependencyDashboard", ":maintainLockFilesWeekly", From 99762667e97204c03b7ebc4b69b09643586387c1 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Wed, 24 Jan 2024 08:26:37 -0800 Subject: [PATCH 026/117] [AC-1890] Include collection permission details in PUT/POST response (#3658) * [Ac-1890] Return CollectionDetailsResponseModel for collection PUT/POST endpoints when a userId is available in the current context * [AC-1890] Fix broken tests * [AC-1890] Update to use Organization FC column --- src/Api/Controllers/CollectionsController.cs | 23 +++++++++++++++++-- .../LegacyCollectionsControllerTests.cs | 8 +++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/Api/Controllers/CollectionsController.cs b/src/Api/Controllers/CollectionsController.cs index 4072518017..6c10805035 100644 --- a/src/Api/Controllers/CollectionsController.cs +++ b/src/Api/Controllers/CollectionsController.cs @@ -250,7 +250,17 @@ public class CollectionsController : Controller } await _collectionService.SaveAsync(collection, groups, users); - return new CollectionResponseModel(collection); + + if (!_currentContext.UserId.HasValue) + { + return new CollectionResponseModel(collection); + } + + // If we have a user, fetch the collection to get the latest permission details + var userCollectionDetails = await _collectionRepository.GetByIdAsync(collection.Id, + _currentContext.UserId.Value, await FlexibleCollectionsIsEnabledAsync(collection.OrganizationId)); + + return new CollectionDetailsResponseModel(userCollectionDetails); } [HttpPut("{id}")] @@ -618,7 +628,16 @@ public class CollectionsController : Controller var groups = model.Groups?.Select(g => g.ToSelectionReadOnly()); var users = model.Users?.Select(g => g.ToSelectionReadOnly()); await _collectionService.SaveAsync(model.ToCollection(collection), groups, users); - return new CollectionResponseModel(collection); + + if (!_currentContext.UserId.HasValue) + { + return new CollectionResponseModel(collection); + } + + // If we have a user, fetch the collection details to get the latest permission details for the user + var updatedCollectionDetails = await _collectionRepository.GetByIdAsync(id, _currentContext.UserId.Value, await FlexibleCollectionsIsEnabledAsync(collection.OrganizationId)); + + return new CollectionDetailsResponseModel(updatedCollectionDetails); } private async Task PutUsers_vNext(Guid id, IEnumerable model) diff --git a/test/Api.Test/Controllers/LegacyCollectionsControllerTests.cs b/test/Api.Test/Controllers/LegacyCollectionsControllerTests.cs index 7b3bad6767..0d2ec824f4 100644 --- a/test/Api.Test/Controllers/LegacyCollectionsControllerTests.cs +++ b/test/Api.Test/Controllers/LegacyCollectionsControllerTests.cs @@ -49,6 +49,10 @@ public class LegacyCollectionsControllerTests sutProvider.GetDependency().GetByOrganizationAsync(orgId, orgUser.UserId.Value) .Returns(orgUser); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any(), orgUser.UserId.Value, Arg.Any()) + .Returns(new CollectionDetails()); + var collectionRequest = new CollectionRequestModel { Name = "encrypted_string", @@ -87,6 +91,10 @@ public class LegacyCollectionsControllerTests sutProvider.GetDependency().GetByOrganizationAsync(orgId, orgUser.UserId.Value) .Returns(orgUser); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any(), orgUser.UserId.Value, Arg.Any()) + .Returns(new CollectionDetails()); + var collectionRequest = new CollectionRequestModel { Name = "encrypted_string", From 8dc8b681bb946954ae023d61fac4b9dab73d1a38 Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Wed, 24 Jan 2024 12:23:09 -0500 Subject: [PATCH 027/117] Vault/pm 4185/checksum uris (#3418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add checksum to Login Uri models * Revert "Revert "Add checksum to Login Uri models (#3318)" (#3417)" This reverts commit b44887d125f8100410a987447a7dc342d22eaf83. * PM-4810 Bumped up minimum version --------- Co-authored-by: Carlos Gonçalves Co-authored-by: bnagawiecki <107435978+bnagawiecki@users.noreply.github.com> Co-authored-by: Carlos Gonçalves --- src/Api/Vault/Models/CipherLoginModel.cs | 6 +++++- src/Core/Constants.cs | 2 +- src/Core/Vault/Models/Data/CipherLoginData.cs | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Api/Vault/Models/CipherLoginModel.cs b/src/Api/Vault/Models/CipherLoginModel.cs index d1ea167513..9580ebfed4 100644 --- a/src/Api/Vault/Models/CipherLoginModel.cs +++ b/src/Api/Vault/Models/CipherLoginModel.cs @@ -74,17 +74,21 @@ public class CipherLoginModel public CipherLoginUriModel(CipherLoginData.CipherLoginUriData uri) { Uri = uri.Uri; + UriChecksum = uri.UriChecksum; Match = uri.Match; } [EncryptedString] [EncryptedStringLength(10000)] public string Uri { get; set; } + [EncryptedString] + [EncryptedStringLength(10000)] + public string UriChecksum { get; set; } public UriMatchType? Match { get; set; } = null; public CipherLoginData.CipherLoginUriData ToCipherLoginUriData() { - return new CipherLoginData.CipherLoginUriData { Uri = Uri, Match = Match, }; + return new CipherLoginData.CipherLoginUriData { Uri = Uri, UriChecksum = UriChecksum, Match = Match, }; } } } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 3f5d618e6c..7b1524aa5b 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -23,7 +23,7 @@ public static class Constants public const string Fido2KeyCipherMinimumVersion = "2023.10.0"; - public const string CipherKeyEncryptionMinimumVersion = "2023.9.2"; + public const string CipherKeyEncryptionMinimumVersion = "2023.12.0"; ///

/// Used by IdentityServer to identify our own provider. diff --git a/src/Core/Vault/Models/Data/CipherLoginData.cs b/src/Core/Vault/Models/Data/CipherLoginData.cs index e952b39cf2..e2d1776abd 100644 --- a/src/Core/Vault/Models/Data/CipherLoginData.cs +++ b/src/Core/Vault/Models/Data/CipherLoginData.cs @@ -26,6 +26,7 @@ public class CipherLoginData : CipherData public CipherLoginUriData() { } public string Uri { get; set; } + public string UriChecksum { get; set; } public UriMatchType? Match { get; set; } = null; } } From 7577da083cd98d7bbc3c17a4a850426c8a860d87 Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Wed, 24 Jan 2024 13:08:57 -0500 Subject: [PATCH 028/117] Remove unused ACT test (#3701) --- .github/test/on-master-event.json | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 .github/test/on-master-event.json diff --git a/.github/test/on-master-event.json b/.github/test/on-master-event.json deleted file mode 100644 index c497522e6d..0000000000 --- a/.github/test/on-master-event.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "release": { - "head": { - "ref": "master" - } - } -} From 0deb13791a308b05fb3ea1c3d77c70a5aa3829d2 Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Wed, 24 Jan 2024 10:13:00 -0800 Subject: [PATCH 029/117] [PM-4614] Updating Duo to SDK v4 for Universal Prompt (#3664) * added v4 updates * Fixed packages. * Null checks and OrganizationDuo * enable backwards compatibility support * updated validation * Update DuoUniversalPromptService.cs add JIRA ticket for cleanup * Update BaseRequestValidator.cs * updates to names and comments * fixed tests * fixed validation errros and authURL * updated naming * Filename change * Update BaseRequestValidator.cs --- .../Identity/TemporaryDuoWebV4SDKService.cs | 121 ++++++++++++++++++ src/Core/Constants.cs | 1 + src/Core/Core.csproj | 1 + .../IdentityServer/BaseRequestValidator.cs | 54 +++++++- .../CustomTokenRequestValidator.cs | 3 +- .../ResourceOwnerPasswordValidator.cs | 3 +- .../IdentityServer/WebAuthnGrantValidator.cs | 3 +- .../Utilities/ServiceCollectionExtensions.cs | 3 +- 8 files changed, 180 insertions(+), 9 deletions(-) create mode 100644 src/Core/Auth/Identity/TemporaryDuoWebV4SDKService.cs diff --git a/src/Core/Auth/Identity/TemporaryDuoWebV4SDKService.cs b/src/Core/Auth/Identity/TemporaryDuoWebV4SDKService.cs new file mode 100644 index 0000000000..316abbe9a2 --- /dev/null +++ b/src/Core/Auth/Identity/TemporaryDuoWebV4SDKService.cs @@ -0,0 +1,121 @@ +using Bit.Core.Auth.Models; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Settings; +using Duo = DuoUniversal; + +namespace Bit.Core.Auth.Identity; + +/* + PM-5156 addresses tech debt + Interface to allow for DI, will end up being removed as part of the removal of the old Duo SDK v2 flows. + This service is to support SDK v4 flows for Duo. At some time in the future we will need + to combine this service with the DuoWebTokenProvider and OrganizationDuoWebTokenProvider to support SDK v4. +*/ +public interface ITemporaryDuoWebV4SDKService +{ + Task GenerateAsync(TwoFactorProvider provider, User user); + Task ValidateAsync(string token, TwoFactorProvider provider, User user); +} + +public class TemporaryDuoWebV4SDKService : ITemporaryDuoWebV4SDKService +{ + private readonly ICurrentContext _currentContext; + private readonly GlobalSettings _globalSettings; + + /// + /// Constructor for the DuoUniversalPromptService. Used to supplement v2 implementation of Duo with v4 SDK + /// + /// used to fetch initiating Client + /// used to fetch vault URL for Redirect URL + public TemporaryDuoWebV4SDKService( + ICurrentContext currentContext, + GlobalSettings globalSettings) + { + _currentContext = currentContext; + _globalSettings = globalSettings; + } + + /// + /// Provider agnostic (either Duo or OrganizationDuo) method to generate a Duo Auth URL + /// + /// Either Duo or OrganizationDuo + /// self + /// AuthUrl for DUO SDK v4 + public async Task GenerateAsync(TwoFactorProvider provider, User user) + { + if (!HasProperMetaData(provider)) + { + return null; + } + + + var duoClient = await BuildDuoClientAsync(provider); + if (duoClient == null) + { + return null; + } + + var state = Duo.Client.GenerateState(); //? Not sure on this yet. But required for GenerateAuthUrl + var authUrl = duoClient.GenerateAuthUri(user.Email, state); + + return authUrl; + } + + /// + /// Validates Duo SDK v4 response + /// + /// response form Duo + /// TwoFactorProviderType Duo or OrganizationDuo + /// self + /// true or false depending on result of verification + public async Task ValidateAsync(string token, TwoFactorProvider provider, User user) + { + if (!HasProperMetaData(provider)) + { + return false; + } + + var duoClient = await BuildDuoClientAsync(provider); + if (duoClient == null) + { + return false; + } + + // If the result of the exchange doesn't throw an exception and it's not null, then it's valid + var res = await duoClient.ExchangeAuthorizationCodeFor2faResult(token, user.Email); + return res.AuthResult.Result == "allow"; + } + + private bool HasProperMetaData(TwoFactorProvider provider) + { + return provider?.MetaData != null && provider.MetaData.ContainsKey("IKey") && + provider.MetaData.ContainsKey("SKey") && provider.MetaData.ContainsKey("Host"); + } + + /// + /// Generates a Duo.Client object for use with Duo SDK v4. This combines the health check and the client generation + /// + /// TwoFactorProvider Duo or OrganizationDuo + /// Duo.Client object or null + private async Task BuildDuoClientAsync(TwoFactorProvider provider) + { + // Fetch Client name from header value since duo auth can be initiated from multiple clients and we want + // to redirect back to the correct client + _currentContext.HttpContext.Request.Headers.TryGetValue("Bitwarden-Client-Name", out var bitwardenClientName); + var redirectUri = string.Format("{0}/duo-redirect-connector.html?client={1}", + _globalSettings.BaseServiceUri.Vault, bitwardenClientName.FirstOrDefault() ?? "web"); + + var client = new Duo.ClientBuilder( + (string)provider.MetaData["IKey"], + (string)provider.MetaData["SKey"], + (string)provider.MetaData["Host"], + redirectUri).Build(); + + if (!await client.DoHealthCheck()) + { + return null; + } + return client; + } +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 7b1524aa5b..07fe176381 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -105,6 +105,7 @@ public static class FeatureFlagKeys public const string AutofillOverlay = "autofill-overlay"; public const string ItemShare = "item-share"; public const string KeyRotationImprovements = "key-rotation-improvements"; + public const string DuoRedirect = "duo-redirect"; public const string FlexibleCollectionsMigration = "flexible-collections-migration"; public const string FlexibleCollectionsSignup = "flexible-collections-signup"; diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index ce2e3832bb..f50412495b 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -28,6 +28,7 @@ + diff --git a/src/Identity/IdentityServer/BaseRequestValidator.cs b/src/Identity/IdentityServer/BaseRequestValidator.cs index f638783a56..f6d8b3b23a 100644 --- a/src/Identity/IdentityServer/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/BaseRequestValidator.cs @@ -38,6 +38,7 @@ public abstract class BaseRequestValidator where T : class private readonly IDeviceService _deviceService; private readonly IEventService _eventService; private readonly IOrganizationDuoWebTokenProvider _organizationDuoWebTokenProvider; + private readonly ITemporaryDuoWebV4SDKService _duoWebV4SDKService; private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IApplicationCacheService _applicationCacheService; @@ -63,6 +64,7 @@ public abstract class BaseRequestValidator where T : class IUserService userService, IEventService eventService, IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider, + ITemporaryDuoWebV4SDKService duoWebV4SDKService, IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, IApplicationCacheService applicationCacheService, @@ -84,6 +86,7 @@ public abstract class BaseRequestValidator where T : class _userService = userService; _eventService = eventService; _organizationDuoWebTokenProvider = organizationDuoWebTokenProvider; + _duoWebV4SDKService = duoWebV4SDKService; _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; _applicationCacheService = applicationCacheService; @@ -167,7 +170,7 @@ public abstract class BaseRequestValidator where T : class } return; } - // We only want to track TOTPs in the chache to enforce one time use. + // We only want to track TOTPs in the cache to enforce one time use. if (twoFactorProviderType == TwoFactorProviderType.Authenticator || twoFactorProviderType == TwoFactorProviderType.Email) { await Core.Utilities.DistributedCacheExtensions.SetAsync(_distributedCache, cacheKey, twoFactorToken, _cacheEntryOptions); @@ -428,10 +431,23 @@ public abstract class BaseRequestValidator where T : class case TwoFactorProviderType.WebAuthn: case TwoFactorProviderType.Remember: if (type != TwoFactorProviderType.Remember && - !(await _userService.TwoFactorProviderIsEnabledAsync(type, user))) + !await _userService.TwoFactorProviderIsEnabledAsync(type, user)) { return false; } + // DUO SDK v4 Update: try to validate the token - PM-5156 addresses tech debt + if (FeatureService.IsEnabled(FeatureFlagKeys.DuoRedirect)) + { + if (type == TwoFactorProviderType.Duo) + { + if (!token.Contains(':')) + { + // We have to send the provider to the DuoWebV4SDKService to create the DuoClient + var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo); + return await _duoWebV4SDKService.ValidateAsync(token, provider, user); + } + } + } return await _userManager.VerifyTwoFactorTokenAsync(user, CoreHelpers.CustomProviderName(type), token); @@ -441,6 +457,20 @@ public abstract class BaseRequestValidator where T : class return false; } + // DUO SDK v4 Update: try to validate the token - PM-5156 addresses tech debt + if (FeatureService.IsEnabled(FeatureFlagKeys.DuoRedirect)) + { + if (type == TwoFactorProviderType.OrganizationDuo) + { + if (!token.Contains(':')) + { + // We have to send the provider to the DuoWebV4SDKService to create the DuoClient + var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo); + return await _duoWebV4SDKService.ValidateAsync(token, provider, user); + } + } + } + return await _organizationDuoWebTokenProvider.ValidateAsync(token, organization, user); default: return false; @@ -465,11 +495,19 @@ public abstract class BaseRequestValidator where T : class CoreHelpers.CustomProviderName(type)); if (type == TwoFactorProviderType.Duo) { - return new Dictionary + var duoResponse = new Dictionary { ["Host"] = provider.MetaData["Host"], ["Signature"] = token }; + + // DUO SDK v4 Update: Duo-Redirect + if (FeatureService.IsEnabled(FeatureFlagKeys.DuoRedirect)) + { + // Generate AuthUrl from DUO SDK v4 token provider + duoResponse.Add("AuthUrl", await _duoWebV4SDKService.GenerateAsync(provider, user)); + } + return duoResponse; } else if (type == TwoFactorProviderType.WebAuthn) { @@ -493,13 +531,19 @@ public abstract class BaseRequestValidator where T : class case TwoFactorProviderType.OrganizationDuo: if (await _organizationDuoWebTokenProvider.CanGenerateTwoFactorTokenAsync(organization)) { - return new Dictionary + var duoResponse = new Dictionary { ["Host"] = provider.MetaData["Host"], ["Signature"] = await _organizationDuoWebTokenProvider.GenerateAsync(organization, user) }; + // DUO SDK v4 Update: DUO-Redirect + if (FeatureService.IsEnabled(FeatureFlagKeys.DuoRedirect)) + { + // Generate AuthUrl from DUO SDK v4 token provider + duoResponse.Add("AuthUrl", await _duoWebV4SDKService.GenerateAsync(provider, user)); + } + return duoResponse; } - return null; default: return null; diff --git a/src/Identity/IdentityServer/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/CustomTokenRequestValidator.cs index 4710184f78..96243533ed 100644 --- a/src/Identity/IdentityServer/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/CustomTokenRequestValidator.cs @@ -33,6 +33,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator(); + services.AddScoped(); + services.AddScoped(); services.Configure(options => options.IterationCount = 100000); services.Configure(options => { From 10f590b4e75404388b4a01f317a6446c55e8c669 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Thu, 25 Jan 2024 16:57:57 +1000 Subject: [PATCH 030/117] [AC-2026] Add flexible collections opt-in endpoint (#3643) Stored procedure to be added in AC-1682 --- .../Controllers/OrganizationsController.cs | 33 ++++++++++++++ .../Repositories/IOrganizationRepository.cs | 1 + src/Core/Constants.cs | 9 +++- .../Repositories/OrganizationRepository.cs | 12 +++++ .../Repositories/OrganizationRepository.cs | 5 +++ .../OrganizationsControllerTests.cs | 45 ++++++++++++++++++- 6 files changed, 103 insertions(+), 2 deletions(-) diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index da0b8d4e2c..6acdace5e3 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -815,6 +815,39 @@ public class OrganizationsController : Controller return new OrganizationResponseModel(organization); } + /// + /// Migrates user, collection, and group data to the new Flexible Collections permissions scheme, + /// then sets organization.FlexibleCollections to true to enable these new features for the organization. + /// This is irreversible. + /// + /// + /// + [HttpPost("{id}/enable-collection-enhancements")] + [RequireFeature(FeatureFlagKeys.FlexibleCollectionsMigration)] + public async Task EnableCollectionEnhancements(Guid id) + { + if (!await _currentContext.OrganizationOwner(id)) + { + throw new NotFoundException(); + } + + var organization = await _organizationRepository.GetByIdAsync(id); + if (organization == null) + { + throw new NotFoundException(); + } + + if (organization.FlexibleCollections) + { + throw new BadRequestException("Organization has already been migrated to the new collection enhancements"); + } + + await _organizationRepository.EnableCollectionEnhancements(id); + + organization.FlexibleCollections = true; + await _organizationService.ReplaceAndUpdateCacheAsync(organization); + } + private async Task TryGrantOwnerAccessToSecretsManagerAsync(Guid organizationId, Guid userId) { var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(organizationId, userId); diff --git a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs index 4598a11fb9..36a445442b 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs @@ -15,4 +15,5 @@ public interface IOrganizationRepository : IRepository Task GetSelfHostedOrganizationDetailsById(Guid id); Task> SearchUnassignedToProviderAsync(string name, string ownerEmail, int skip, int take); Task> GetOwnerEmailAddressesById(Guid organizationId); + Task EnableCollectionEnhancements(Guid organizationId); } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 07fe176381..d3e9b3f749 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -106,8 +106,15 @@ public static class FeatureFlagKeys public const string ItemShare = "item-share"; public const string KeyRotationImprovements = "key-rotation-improvements"; public const string DuoRedirect = "duo-redirect"; - public const string FlexibleCollectionsMigration = "flexible-collections-migration"; + /// + /// Enables flexible collections improvements for new organizations on creation + /// public const string FlexibleCollectionsSignup = "flexible-collections-signup"; + /// + /// Exposes a migration button in the web vault which allows users to migrate an existing organization to + /// flexible collections + /// + public const string FlexibleCollectionsMigration = "flexible-collections-migration"; public static List GetAllKeys() { diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs index f4c771adec..9080e17c3e 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs @@ -169,4 +169,16 @@ public class OrganizationRepository : Repository, IOrganizat new { OrganizationId = organizationId }, commandType: CommandType.StoredProcedure); } + + public async Task EnableCollectionEnhancements(Guid organizationId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + await connection.ExecuteAsync( + "[dbo].[Organization_EnableCollectionEnhancements]", + new { OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure, + commandTimeout: 180); + } + } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index acc36c9449..7610f8dd15 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -267,4 +267,9 @@ public class OrganizationRepository : Repository()); } + + [Theory, AutoData] + public async Task EnableCollectionEnhancements_Success(Organization organization) + { + organization.FlexibleCollections = false; + _currentContext.OrganizationOwner(organization.Id).Returns(true); + _organizationRepository.GetByIdAsync(organization.Id).Returns(organization); + + await _sut.EnableCollectionEnhancements(organization.Id); + + await _organizationRepository.Received(1).EnableCollectionEnhancements(organization.Id); + await _organizationService.Received(1).ReplaceAndUpdateCacheAsync( + Arg.Is(o => + o.Id == organization.Id && + o.FlexibleCollections)); + } + + [Theory, AutoData] + public async Task EnableCollectionEnhancements_WhenNotOwner_Throws(Organization organization) + { + organization.FlexibleCollections = false; + _currentContext.OrganizationOwner(organization.Id).Returns(false); + _organizationRepository.GetByIdAsync(organization.Id).Returns(organization); + + await Assert.ThrowsAsync(async () => await _sut.EnableCollectionEnhancements(organization.Id)); + + await _organizationRepository.DidNotReceiveWithAnyArgs().EnableCollectionEnhancements(Arg.Any()); + await _organizationService.DidNotReceiveWithAnyArgs().ReplaceAndUpdateCacheAsync(Arg.Any()); + } + + [Theory, AutoData] + public async Task EnableCollectionEnhancements_WhenAlreadyMigrated_Throws(Organization organization) + { + organization.FlexibleCollections = true; + _currentContext.OrganizationOwner(organization.Id).Returns(true); + _organizationRepository.GetByIdAsync(organization.Id).Returns(organization); + + var exception = await Assert.ThrowsAsync(async () => await _sut.EnableCollectionEnhancements(organization.Id)); + Assert.Contains("has already been migrated", exception.Message); + + await _organizationRepository.DidNotReceiveWithAnyArgs().EnableCollectionEnhancements(Arg.Any()); + await _organizationService.DidNotReceiveWithAnyArgs().ReplaceAndUpdateCacheAsync(Arg.Any()); + } } From c4625c6c94bd5216c9ba0e4c969bbcebc9260f59 Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Thu, 25 Jan 2024 14:50:13 +0100 Subject: [PATCH 031/117] [PM-5819] fix: return empty string if name is null (#3691) --- .../GetWebAuthnLoginCredentialCreateOptionsCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/GetWebAuthnLoginCredentialCreateOptionsCommand.cs b/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/GetWebAuthnLoginCredentialCreateOptionsCommand.cs index 1d47afb03c..16737d85a0 100644 --- a/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/GetWebAuthnLoginCredentialCreateOptionsCommand.cs +++ b/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/GetWebAuthnLoginCredentialCreateOptionsCommand.cs @@ -21,7 +21,7 @@ internal class GetWebAuthnLoginCredentialCreateOptionsCommand : IGetWebAuthnLogi { var fidoUser = new Fido2User { - DisplayName = user.Name, + DisplayName = user.Name ?? "", Name = user.Email, Id = user.Id.ToByteArray(), }; From bac06763f530aa94520dfc27d2bbda539d88c008 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Thu, 25 Jan 2024 14:08:09 +0000 Subject: [PATCH 032/117] [AC-1682] Flexible collections: data migrations for deprecated permissions (#3437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [AC-1682] Data migrations for OrgUsers or Groups with AccessAll enabled * [AC-1682] Added script to update [dbo].[CollectionUser] with [Manage] = 1 for all users with Manager role or 'EditAssignedCollections' permission * [AC-1682] Updated sql data migration procedures with performance recommendations * [AC-1682] Moved data migration scripts to DbScripts_transition folder * Apply suggestions from code review: Remove Manage permission from Collection assignments Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> * [AC-1682] Removed unnecessary Collection table join on ManagersEditAssignedCollectionUsers sql script * [AC-1682] Change JOIN to INNER JOIN in SQL scripts * [AC-1682] Renamed sql script to recent date and added correct order to file name * [AC-1682] Add new rows to CollectionUser for Managers and users with EditAssignedCollections permission assigned to groups with collection access * [AC-1682] Update FC data migration scripts to clear AccessAll flags and set all Managers to Users * [AC-1682] Updated data migration scripts to bump the account revision date * [AC-1682] Created Organization_EnableCollectionEnhancements to migrate organization data for flexible collections * [AC-1682] Added script to migrate all organization data for flexible collections * [AC-1682] Deleted old data migration scripts * Revert "[AC-1682] Deleted old data migration scripts" This reverts commit 54cc6fab8f162448446eeb06822e44e97a2b6534. * [AC-1682] Modified AccessAllCollectionUsers script to bump revision date by each OrgUser * [AC-1682] Update data migration script to only enable collection enhancements for organizations that have not yet migrated * [AC-1682] Updated AccessAllCollectionGroups migration script to use User_BumpAccountRevisionDateByCollectionId * [AC-1682] Bumped up the date on data migration scripts * [AC-1682] Added back batching system to AccessAllCollectionUsers data migration script * [AC-1682] Added data migration script to set FlexibleCollections = 1 for all orgs * [AC-1682] Modified data migration script to contain multiple transactions * [AC-1682] Deleted old data migration scripts * [AC-1682] Placed temp tables outside transactions * [AC-1682] Removed batching from AllOrgsEnableCollectionEnhancements script * [AC-1682] Removed bulk data migration script * [AC-1682] Refactor stored procedure to enable collection enhancements * [AC-1682] Added missing where clause * [AC-1682] Modified data migration script to have just one big transaction * [AC-1682] Combining all updated OrganizationUserIds to bump all revision dates at the same time * Update src/Sql/dbo/Stored Procedures/Organization_EnableCollectionEnhancements.sql Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> * [AC-1682] Renamed aliases * [AC-1682] Simplified inner queries * [AC-1682] Bumping each modified groups RevisionDate * [AC-1682] Removed updating CollectionUser existing records with [ReadOnly] = 0 and [HidePasswords] = 0 * [AC-1682] Updating OrganizationUser RevisionDate * [AC-1682] Updated the stored procedure file * [AC-1682] Selecting distinct values to insert into CollectionUser table * Revert "[AC-1682] Removed updating CollectionUser existing records with [ReadOnly] = 0 and [HidePasswords] = 0" This reverts commit 086c88f3c62573a2ff0db149423440bf664a40c0. * [AC-1682] Bumped up the date on the migration script * [AC-1682] Updating OrganizationUser RevisionDate --------- Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> --- ...anization_EnableCollectionEnhancements.sql | 155 +++++++++++++++++ ...anization_EnableCollectionEnhancements.sql | 156 ++++++++++++++++++ 2 files changed, 311 insertions(+) create mode 100644 src/Sql/dbo/Stored Procedures/Organization_EnableCollectionEnhancements.sql create mode 100644 util/Migrator/DbScripts/2024-01-25_00_Organization_EnableCollectionEnhancements.sql diff --git a/src/Sql/dbo/Stored Procedures/Organization_EnableCollectionEnhancements.sql b/src/Sql/dbo/Stored Procedures/Organization_EnableCollectionEnhancements.sql new file mode 100644 index 0000000000..c69fa39772 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Organization_EnableCollectionEnhancements.sql @@ -0,0 +1,155 @@ +CREATE PROCEDURE [dbo].[Organization_EnableCollectionEnhancements] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- Step 1: AccessAll migration for Groups + -- Create a temporary table to store the groups with AccessAll = 1 + SELECT [Id] AS [GroupId], [OrganizationId] + INTO #TempGroupsAccessAll + FROM [dbo].[Group] + WHERE [OrganizationId] = @OrganizationId + AND [AccessAll] = 1; + + -- Step 2: AccessAll migration for OrganizationUsers + -- Create a temporary table to store the OrganizationUsers with AccessAll = 1 + SELECT [Id] AS [OrganizationUserId], [OrganizationId] + INTO #TempUsersAccessAll + FROM [dbo].[OrganizationUser] + WHERE [OrganizationId] = @OrganizationId + AND [AccessAll] = 1; + + -- Step 3: For all OrganizationUsers with Manager role or 'EditAssignedCollections' permission update their existing CollectionUser rows and insert new rows with [Manage] = 1 + -- and finally update all OrganizationUsers with Manager role to User role + -- Create a temporary table to store the OrganizationUsers with Manager role or 'EditAssignedCollections' permission + SELECT ou.[Id] AS [OrganizationUserId], + CASE WHEN ou.[Type] = 3 THEN 1 ELSE 0 END AS [IsManager] + INTO #TempUserManagers + FROM [dbo].[OrganizationUser] ou + WHERE ou.[OrganizationId] = @OrganizationId + AND (ou.[Type] = 3 OR (ou.[Permissions] IS NOT NULL + AND ISJSON(ou.[Permissions]) > 0 AND JSON_VALUE(ou.[Permissions], '$.editAssignedCollections') = 'true')); + + -- Step 4: Bump AccountRevisionDate for all OrganizationUsers updated in the previous steps + -- Combine and union the distinct OrganizationUserIds from all steps into a single variable + DECLARE @OrgUsersToBump [dbo].[GuidIdArray] + INSERT INTO @OrgUsersToBump + SELECT DISTINCT [OrganizationUserId] AS Id + FROM ( + -- Step 1 + SELECT GU.[OrganizationUserId] + FROM [dbo].[GroupUser] GU + INNER JOIN #TempGroupsAccessAll TG ON GU.[GroupId] = TG.[GroupId] + + UNION + + -- Step 2 + SELECT [OrganizationUserId] + FROM #TempUsersAccessAll + + UNION + + -- Step 3 + SELECT [OrganizationUserId] + FROM #TempUserManagers + ) AS CombinedOrgUsers; + + BEGIN TRY + BEGIN TRANSACTION; + -- Step 1 + -- Update existing rows in [dbo].[CollectionGroup] + UPDATE CG + SET + CG.[ReadOnly] = 0, + CG.[HidePasswords] = 0, + CG.[Manage] = 0 + FROM [dbo].[CollectionGroup] CG + INNER JOIN [dbo].[Collection] C ON CG.[CollectionId] = C.[Id] + INNER JOIN #TempGroupsAccessAll TG ON CG.[GroupId] = TG.[GroupId] + WHERE C.[OrganizationId] = TG.[OrganizationId]; + + -- Insert new rows into [dbo].[CollectionGroup] + INSERT INTO [dbo].[CollectionGroup] ([CollectionId], [GroupId], [ReadOnly], [HidePasswords], [Manage]) + SELECT C.[Id], TG.[GroupId], 0, 0, 0 + FROM [dbo].[Collection] C + INNER JOIN #TempGroupsAccessAll TG ON C.[OrganizationId] = TG.[OrganizationId] + LEFT JOIN [dbo].[CollectionGroup] CG ON CG.[CollectionId] = C.[Id] AND CG.[GroupId] = TG.[GroupId] + WHERE CG.[CollectionId] IS NULL; + + -- Update Group to clear AccessAll flag and update RevisionDate + UPDATE G + SET [AccessAll] = 0, [RevisionDate] = GETUTCDATE() + FROM [dbo].[Group] G + INNER JOIN #TempGroupsAccessAll TG ON G.[Id] = TG.[GroupId]; + + -- Step 2 + -- Update existing rows in [dbo].[CollectionUser] + UPDATE target + SET + target.[ReadOnly] = 0, + target.[HidePasswords] = 0, + target.[Manage] = 0 + FROM [dbo].[CollectionUser] AS target + INNER JOIN [dbo].[Collection] AS C ON target.[CollectionId] = C.[Id] + INNER JOIN #TempUsersAccessAll AS TU ON C.[OrganizationId] = TU.[OrganizationId] AND target.[OrganizationUserId] = TU.[OrganizationUserId]; + + -- Insert new rows into [dbo].[CollectionUser] + INSERT INTO [dbo].[CollectionUser] ([CollectionId], [OrganizationUserId], [ReadOnly], [HidePasswords], [Manage]) + SELECT C.[Id] AS [CollectionId], TU.[OrganizationUserId], 0, 0, 0 + FROM [dbo].[Collection] C + INNER JOIN #TempUsersAccessAll TU ON C.[OrganizationId] = TU.[OrganizationId] + LEFT JOIN [dbo].[CollectionUser] target + ON target.[CollectionId] = C.[Id] AND target.[OrganizationUserId] = TU.[OrganizationUserId] + WHERE target.[CollectionId] IS NULL; + + -- Update OrganizationUser to clear AccessAll flag + UPDATE OU + SET [AccessAll] = 0, [RevisionDate] = GETUTCDATE() + FROM [dbo].[OrganizationUser] OU + INNER JOIN #TempUsersAccessAll TU ON OU.[Id] = TU.[OrganizationUserId]; + + -- Step 3 + -- Update [dbo].[CollectionUser] with [Manage] = 1 using the temporary table + UPDATE CU + SET CU.[ReadOnly] = 0, + CU.[HidePasswords] = 0, + CU.[Manage] = 1 + FROM [dbo].[CollectionUser] CU + INNER JOIN #TempUserManagers TUM ON CU.[OrganizationUserId] = TUM.[OrganizationUserId]; + + -- Insert rows to [dbo].[CollectionUser] with [Manage] = 1 using the temporary table + -- This is for orgUsers who are Managers / EditAssignedCollections but have access via a group + -- We cannot give the whole group Manage permissions so we have to give them a direct assignment + INSERT INTO [dbo].[CollectionUser] ([CollectionId], [OrganizationUserId], [ReadOnly], [HidePasswords], [Manage]) + SELECT DISTINCT CG.[CollectionId], TUM.[OrganizationUserId], 0, 0, 1 + FROM [dbo].[CollectionGroup] CG + INNER JOIN [dbo].[GroupUser] GU ON CG.[GroupId] = GU.[GroupId] + INNER JOIN #TempUserManagers TUM ON GU.[OrganizationUserId] = TUM.[OrganizationUserId] + WHERE NOT EXISTS ( + SELECT 1 FROM [dbo].[CollectionUser] CU + WHERE CU.[CollectionId] = CG.[CollectionId] AND CU.[OrganizationUserId] = TUM.[OrganizationUserId] + ); + + -- Update [dbo].[OrganizationUser] to migrate all OrganizationUsers with Manager role to User role + UPDATE OU + SET OU.[Type] = 2, OU.[RevisionDate] = GETUTCDATE() -- User + FROM [dbo].[OrganizationUser] OU + INNER JOIN #TempUserManagers TUM ON ou.[Id] = TUM.[OrganizationUserId] + WHERE TUM.[IsManager] = 1; -- Filter for Managers + + -- Step 4 + -- Execute User_BumpAccountRevisionDateByOrganizationUserIds for the distinct OrganizationUserIds + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationUserIds] @OrgUsersToBump; + COMMIT TRANSACTION; + END TRY + BEGIN CATCH + ROLLBACK TRANSACTION; + THROW; + END CATCH; + + -- Drop the temporary table + DROP TABLE #TempGroupsAccessAll; + DROP TABLE #TempUsersAccessAll; + DROP TABLE #TempUserManagers; +END diff --git a/util/Migrator/DbScripts/2024-01-25_00_Organization_EnableCollectionEnhancements.sql b/util/Migrator/DbScripts/2024-01-25_00_Organization_EnableCollectionEnhancements.sql new file mode 100644 index 0000000000..41346f46af --- /dev/null +++ b/util/Migrator/DbScripts/2024-01-25_00_Organization_EnableCollectionEnhancements.sql @@ -0,0 +1,156 @@ +CREATE OR ALTER PROCEDURE [dbo].[Organization_EnableCollectionEnhancements] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- Step 1: AccessAll migration for Groups + -- Create a temporary table to store the groups with AccessAll = 1 + SELECT [Id] AS [GroupId], [OrganizationId] + INTO #TempGroupsAccessAll + FROM [dbo].[Group] + WHERE [OrganizationId] = @OrganizationId + AND [AccessAll] = 1; + + -- Step 2: AccessAll migration for OrganizationUsers + -- Create a temporary table to store the OrganizationUsers with AccessAll = 1 + SELECT [Id] AS [OrganizationUserId], [OrganizationId] + INTO #TempUsersAccessAll + FROM [dbo].[OrganizationUser] + WHERE [OrganizationId] = @OrganizationId + AND [AccessAll] = 1; + + -- Step 3: For all OrganizationUsers with Manager role or 'EditAssignedCollections' permission update their existing CollectionUser rows and insert new rows with [Manage] = 1 + -- and finally update all OrganizationUsers with Manager role to User role + -- Create a temporary table to store the OrganizationUsers with Manager role or 'EditAssignedCollections' permission + SELECT ou.[Id] AS [OrganizationUserId], + CASE WHEN ou.[Type] = 3 THEN 1 ELSE 0 END AS [IsManager] + INTO #TempUserManagers + FROM [dbo].[OrganizationUser] ou + WHERE ou.[OrganizationId] = @OrganizationId + AND (ou.[Type] = 3 OR (ou.[Permissions] IS NOT NULL + AND ISJSON(ou.[Permissions]) > 0 AND JSON_VALUE(ou.[Permissions], '$.editAssignedCollections') = 'true')); + + -- Step 4: Bump AccountRevisionDate for all OrganizationUsers updated in the previous steps + -- Combine and union the distinct OrganizationUserIds from all steps into a single variable + DECLARE @OrgUsersToBump [dbo].[GuidIdArray] + INSERT INTO @OrgUsersToBump + SELECT DISTINCT [OrganizationUserId] AS Id + FROM ( + -- Step 1 + SELECT GU.[OrganizationUserId] + FROM [dbo].[GroupUser] GU + INNER JOIN #TempGroupsAccessAll TG ON GU.[GroupId] = TG.[GroupId] + + UNION + + -- Step 2 + SELECT [OrganizationUserId] + FROM #TempUsersAccessAll + + UNION + + -- Step 3 + SELECT [OrganizationUserId] + FROM #TempUserManagers + ) AS CombinedOrgUsers; + + BEGIN TRY + BEGIN TRANSACTION; + -- Step 1 + -- Update existing rows in [dbo].[CollectionGroup] + UPDATE CG + SET + CG.[ReadOnly] = 0, + CG.[HidePasswords] = 0, + CG.[Manage] = 0 + FROM [dbo].[CollectionGroup] CG + INNER JOIN [dbo].[Collection] C ON CG.[CollectionId] = C.[Id] + INNER JOIN #TempGroupsAccessAll TG ON CG.[GroupId] = TG.[GroupId] + WHERE C.[OrganizationId] = TG.[OrganizationId]; + + -- Insert new rows into [dbo].[CollectionGroup] + INSERT INTO [dbo].[CollectionGroup] ([CollectionId], [GroupId], [ReadOnly], [HidePasswords], [Manage]) + SELECT C.[Id], TG.[GroupId], 0, 0, 0 + FROM [dbo].[Collection] C + INNER JOIN #TempGroupsAccessAll TG ON C.[OrganizationId] = TG.[OrganizationId] + LEFT JOIN [dbo].[CollectionGroup] CG ON CG.[CollectionId] = C.[Id] AND CG.[GroupId] = TG.[GroupId] + WHERE CG.[CollectionId] IS NULL; + + -- Update Group to clear AccessAll flag and update RevisionDate + UPDATE G + SET [AccessAll] = 0, [RevisionDate] = GETUTCDATE() + FROM [dbo].[Group] G + INNER JOIN #TempGroupsAccessAll TG ON G.[Id] = TG.[GroupId]; + + -- Step 2 + -- Update existing rows in [dbo].[CollectionUser] + UPDATE target + SET + target.[ReadOnly] = 0, + target.[HidePasswords] = 0, + target.[Manage] = 0 + FROM [dbo].[CollectionUser] AS target + INNER JOIN [dbo].[Collection] AS C ON target.[CollectionId] = C.[Id] + INNER JOIN #TempUsersAccessAll AS TU ON C.[OrganizationId] = TU.[OrganizationId] AND target.[OrganizationUserId] = TU.[OrganizationUserId]; + + -- Insert new rows into [dbo].[CollectionUser] + INSERT INTO [dbo].[CollectionUser] ([CollectionId], [OrganizationUserId], [ReadOnly], [HidePasswords], [Manage]) + SELECT C.[Id] AS [CollectionId], TU.[OrganizationUserId], 0, 0, 0 + FROM [dbo].[Collection] C + INNER JOIN #TempUsersAccessAll TU ON C.[OrganizationId] = TU.[OrganizationId] + LEFT JOIN [dbo].[CollectionUser] target + ON target.[CollectionId] = C.[Id] AND target.[OrganizationUserId] = TU.[OrganizationUserId] + WHERE target.[CollectionId] IS NULL; + + -- Update OrganizationUser to clear AccessAll flag + UPDATE OU + SET [AccessAll] = 0, [RevisionDate] = GETUTCDATE() + FROM [dbo].[OrganizationUser] OU + INNER JOIN #TempUsersAccessAll TU ON OU.[Id] = TU.[OrganizationUserId]; + + -- Step 3 + -- Update [dbo].[CollectionUser] with [Manage] = 1 using the temporary table + UPDATE CU + SET CU.[ReadOnly] = 0, + CU.[HidePasswords] = 0, + CU.[Manage] = 1 + FROM [dbo].[CollectionUser] CU + INNER JOIN #TempUserManagers TUM ON CU.[OrganizationUserId] = TUM.[OrganizationUserId]; + + -- Insert rows to [dbo].[CollectionUser] with [Manage] = 1 using the temporary table + -- This is for orgUsers who are Managers / EditAssignedCollections but have access via a group + -- We cannot give the whole group Manage permissions so we have to give them a direct assignment + INSERT INTO [dbo].[CollectionUser] ([CollectionId], [OrganizationUserId], [ReadOnly], [HidePasswords], [Manage]) + SELECT DISTINCT CG.[CollectionId], TUM.[OrganizationUserId], 0, 0, 1 + FROM [dbo].[CollectionGroup] CG + INNER JOIN [dbo].[GroupUser] GU ON CG.[GroupId] = GU.[GroupId] + INNER JOIN #TempUserManagers TUM ON GU.[OrganizationUserId] = TUM.[OrganizationUserId] + WHERE NOT EXISTS ( + SELECT 1 FROM [dbo].[CollectionUser] CU + WHERE CU.[CollectionId] = CG.[CollectionId] AND CU.[OrganizationUserId] = TUM.[OrganizationUserId] + ); + + -- Update [dbo].[OrganizationUser] to migrate all OrganizationUsers with Manager role to User role + UPDATE OU + SET OU.[Type] = 2, OU.[RevisionDate] = GETUTCDATE() -- User + FROM [dbo].[OrganizationUser] OU + INNER JOIN #TempUserManagers TUM ON ou.[Id] = TUM.[OrganizationUserId] + WHERE TUM.[IsManager] = 1; -- Filter for Managers + + -- Step 4 + -- Execute User_BumpAccountRevisionDateByOrganizationUserIds for the distinct OrganizationUserIds + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationUserIds] @OrgUsersToBump; + COMMIT TRANSACTION; + END TRY + BEGIN CATCH + ROLLBACK TRANSACTION; + THROW; + END CATCH; + + -- Drop the temporary table + DROP TABLE #TempGroupsAccessAll; + DROP TABLE #TempUsersAccessAll; + DROP TABLE #TempUserManagers; +END +GO From 2763345e9eb5f2062c4342b53211db58f7751560 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Thu, 25 Jan 2024 10:59:53 -0500 Subject: [PATCH 033/117] [PM-3777[PM-3633] Update minimum KDF iterations when creating new User record (#3687) * Updated minimum iterations on new Users to the default. * Fixed test I missed. --- .../Auth/Models/Api/Request/Accounts/RegisterRequestModel.cs | 2 +- src/Core/Entities/User.cs | 2 +- test/Api.Test/Auth/Controllers/AccountsControllerTests.cs | 2 +- test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs | 3 ++- test/Identity.Test/Controllers/AccountsControllerTests.cs | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Core/Auth/Models/Api/Request/Accounts/RegisterRequestModel.cs b/src/Core/Auth/Models/Api/Request/Accounts/RegisterRequestModel.cs index f023b488ce..6fa00f4679 100644 --- a/src/Core/Auth/Models/Api/Request/Accounts/RegisterRequestModel.cs +++ b/src/Core/Auth/Models/Api/Request/Accounts/RegisterRequestModel.cs @@ -38,7 +38,7 @@ public class RegisterRequestModel : IValidatableObject, ICaptchaProtectedModel Email = Email, MasterPasswordHint = MasterPasswordHint, Kdf = Kdf.GetValueOrDefault(KdfType.PBKDF2_SHA256), - KdfIterations = KdfIterations.GetValueOrDefault(5000), + KdfIterations = KdfIterations.GetValueOrDefault(AuthConstants.PBKDF2_ITERATIONS.Default), KdfMemory = KdfMemory, KdfParallelism = KdfParallelism }; diff --git a/src/Core/Entities/User.cs b/src/Core/Entities/User.cs index d10ab25f18..b0db21eb14 100644 --- a/src/Core/Entities/User.cs +++ b/src/Core/Entities/User.cs @@ -55,7 +55,7 @@ public class User : ITableObject, ISubscriber, IStorable, IStorableSubscri [MaxLength(30)] public string ApiKey { get; set; } public KdfType Kdf { get; set; } = KdfType.PBKDF2_SHA256; - public int KdfIterations { get; set; } = 5000; + public int KdfIterations { get; set; } = AuthConstants.PBKDF2_ITERATIONS.Default; public int? KdfMemory { get; set; } public int? KdfParallelism { get; set; } public DateTime CreationDate { get; set; } = DateTime.UtcNow; diff --git a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs index b19b11f159..0321b4f138 100644 --- a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs @@ -129,7 +129,7 @@ public class AccountsControllerTests : IDisposable var userKdfInfo = new UserKdfInformation { Kdf = KdfType.PBKDF2_SHA256, - KdfIterations = 5000 + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default }; _userRepository.GetKdfInformationByEmailAsync(Arg.Any()).Returns(Task.FromResult(userKdfInfo)); diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs index e742a5d27b..2599559f38 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Repositories; @@ -67,7 +68,7 @@ public class IdentityServerTests : IClassFixture var kdf = AssertHelper.AssertJsonProperty(root, "Kdf", JsonValueKind.Number).GetInt32(); Assert.Equal(0, kdf); var kdfIterations = AssertHelper.AssertJsonProperty(root, "KdfIterations", JsonValueKind.Number).GetInt32(); - Assert.Equal(5000, kdfIterations); + Assert.Equal(AuthConstants.PBKDF2_ITERATIONS.Default, kdfIterations); AssertUserDecryptionOptions(root); } diff --git a/test/Identity.Test/Controllers/AccountsControllerTests.cs b/test/Identity.Test/Controllers/AccountsControllerTests.cs index a46bf38679..3775d8c635 100644 --- a/test/Identity.Test/Controllers/AccountsControllerTests.cs +++ b/test/Identity.Test/Controllers/AccountsControllerTests.cs @@ -58,7 +58,7 @@ public class AccountsControllerTests : IDisposable var userKdfInfo = new UserKdfInformation { Kdf = KdfType.PBKDF2_SHA256, - KdfIterations = 5000 + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default }; _userRepository.GetKdfInformationByEmailAsync(Arg.Any()).Returns(Task.FromResult(userKdfInfo)); From 59b40f36d98295c7ca78dc897650b8ae1fc3378d Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Fri, 26 Jan 2024 14:20:12 -0500 Subject: [PATCH 034/117] Feature flag code reference collection (#3444) * Feature flag code reference collection * Provide project * Try another key * Use different workflow * Touch a feature flag to test detection * Adjust permissions * Remove another flag * Bump workflow * Add label * Undo changes to constants * One more test * Fix logic * Identify step * Try modified * Adjust a flag * Remove test * Try with Boolean * Changed * Undo flag change again * Ignore Renovate Co-authored-by: MtnBurrit0 <77340197+mimartin12@users.noreply.github.com> * Line break --------- Co-authored-by: MtnBurrit0 <77340197+mimartin12@users.noreply.github.com> --- .github/workflows/code-references.yml | 42 +++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/code-references.yml diff --git a/.github/workflows/code-references.yml b/.github/workflows/code-references.yml new file mode 100644 index 0000000000..ca584a1d3a --- /dev/null +++ b/.github/workflows/code-references.yml @@ -0,0 +1,42 @@ +--- +name: Collect code references + +on: + pull_request: + branches-ignore: + - "renovate/**" + +permissions: + contents: read + pull-requests: write + +jobs: + refs: + name: Code reference collection + runs-on: ubuntu-22.04 + steps: + - name: Check out repository + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Collect + id: collect + uses: launchdarkly/find-code-references-in-pull-request@2e9333c88539377cfbe818c265ba8b9ebced3c91 # v1.1.0 + with: + project-key: default + environment-key: dev + access-token: ${{ secrets.LD_ACCESS_TOKEN }} + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Add label + if: steps.collect.outputs.any-changed == 'true' + run: gh pr edit $PR_NUMBER --add-label feature-flag + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + + - name: Remove label + if: steps.collect.outputs.any-changed == 'false' + run: gh pr edit $PR_NUMBER --remove-label feature-flag + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} From 114b72d7386908c57082629f8f7216a0179a0e50 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Fri, 26 Jan 2024 17:00:37 -0500 Subject: [PATCH 035/117] [PM-5638] Bump minimum client version for vault item encryption (#3711) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Carlos Gonçalves --- 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 d3e9b3f749..57c75da842 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -23,7 +23,7 @@ public static class Constants public const string Fido2KeyCipherMinimumVersion = "2023.10.0"; - public const string CipherKeyEncryptionMinimumVersion = "2023.12.0"; + public const string CipherKeyEncryptionMinimumVersion = "2024.1.3"; /// /// Used by IdentityServer to identify our own provider. From c2b4ee7eacafb3cd55ef3f4fa548e666cd2f0752 Mon Sep 17 00:00:00 2001 From: aj-rosado <109146700+aj-rosado@users.noreply.github.com> Date: Mon, 29 Jan 2024 14:46:34 +0000 Subject: [PATCH 036/117] [AC-1782] Import can manage (#3453) * Changed Import permissions validation to check if the user CanCreate a Collection * Corrected authorized to import validation allowing import without collections when the user is admin * Added validation to check if user can import ciphers into existing collections * swapped feature flag flexible collections with org property * Removed unused feature service from ImportCiphersController * Improved code readability * added null protection against empty org when checking for FlexibleCollections flag --- .../Controllers/ImportCiphersController.cs | 63 +++++++++++++++++-- .../BulkCollectionAuthorizationHandler.cs | 1 + .../Collections/BulkCollectionOperations.cs | 1 + 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/Api/Tools/Controllers/ImportCiphersController.cs b/src/Api/Tools/Controllers/ImportCiphersController.cs index 7c9752076f..fab5037040 100644 --- a/src/Api/Tools/Controllers/ImportCiphersController.cs +++ b/src/Api/Tools/Controllers/ImportCiphersController.cs @@ -1,6 +1,8 @@ using Bit.Api.Tools.Models.Request.Accounts; using Bit.Api.Tools.Models.Request.Organizations; +using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Core.Context; +using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; @@ -20,20 +22,28 @@ public class ImportCiphersController : Controller private readonly ICurrentContext _currentContext; private readonly ILogger _logger; private readonly GlobalSettings _globalSettings; + private readonly ICollectionRepository _collectionRepository; + private readonly IAuthorizationService _authorizationService; + private readonly IOrganizationRepository _organizationRepository; public ImportCiphersController( - ICollectionCipherRepository collectionCipherRepository, ICipherService cipherService, IUserService userService, ICurrentContext currentContext, ILogger logger, - GlobalSettings globalSettings) + GlobalSettings globalSettings, + ICollectionRepository collectionRepository, + IAuthorizationService authorizationService, + IOrganizationRepository organizationRepository) { _cipherService = cipherService; _userService = userService; _currentContext = currentContext; _logger = logger; _globalSettings = globalSettings; + _collectionRepository = collectionRepository; + _authorizationService = authorizationService; + _organizationRepository = organizationRepository; } [HttpPost("import")] @@ -64,14 +74,59 @@ public class ImportCiphersController : Controller } var orgId = new Guid(organizationId); - if (!await _currentContext.AccessImportExport(orgId)) + var collections = model.Collections.Select(c => c.ToCollection(orgId)).ToList(); + + + //An User is allowed to import if CanCreate Collections or has AccessToImportExport + var authorized = await CheckOrgImportPermission(collections, orgId); + + if (!authorized) { throw new NotFoundException(); } var userId = _userService.GetProperUserId(User).Value; - var collections = model.Collections.Select(c => c.ToCollection(orgId)).ToList(); var ciphers = model.Ciphers.Select(l => l.ToOrganizationCipherDetails(orgId)).ToList(); await _cipherService.ImportCiphersAsync(collections, ciphers, model.CollectionRelationships, userId); } + + private async Task CheckOrgImportPermission(List collections, Guid orgId) + { + //Users are allowed to import if they have the AccessToImportExport permission + if (await _currentContext.AccessImportExport(orgId)) + { + return true; + } + + //If flexible collections is disabled the user cannot continue with the import + var orgFlexibleCollections = await _organizationRepository.GetByIdAsync(orgId); + if (!orgFlexibleCollections?.FlexibleCollections ?? false) + { + return false; + } + + //Users allowed to import if they CanCreate Collections + if (!(await _authorizationService.AuthorizeAsync(User, collections, BulkCollectionOperations.Create)).Succeeded) + { + return false; + } + + //Calling Repository instead of Service as we want to get all the collections, regardless of permission + //Permissions check will be done later on AuthorizationService + var orgCollectionIds = + (await _collectionRepository.GetManyByOrganizationIdAsync(orgId)) + .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) + { + return false; + }; + + return true; + } } diff --git a/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs b/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs index fb47602bc9..afdd7c012a 100644 --- a/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs +++ b/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs @@ -76,6 +76,7 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler public static readonly BulkCollectionOperationRequirement ModifyAccess = new() { Name = nameof(ModifyAccess) }; public static readonly BulkCollectionOperationRequirement Delete = new() { Name = nameof(Delete) }; + public static readonly BulkCollectionOperationRequirement ImportCiphers = new() { Name = nameof(ImportCiphers) }; } From a2e6550b61436a0db572ec70c7b84019c5824695 Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Mon, 29 Jan 2024 09:48:59 -0500 Subject: [PATCH 037/117] [PM-5766] Enabled Automatic Tax for all customers (#3685) * Removed TaxRate logic when creating or updating a Stripe subscription and replaced it with AutomaticTax enabled flag * Updated Stripe webhook to update subscription to automatically calculate tax * Removed TaxRate unit tests since Stripe now handles tax * Removed test proration logic * Including taxInfo when updating payment method * Adding the address to the upgrade free org flow if it doesn't exist * Fixed failing tests and added a new test to validate that the customer is updated --- src/Billing/Controllers/StripeController.cs | 41 ++---- src/Billing/Services/IStripeFacade.cs | 6 + .../Services/Implementations/StripeFacade.cs | 7 + .../Implementations/OrganizationService.cs | 2 +- .../Implementations/StripePaymentService.cs | 107 +++++--------- .../Services/StripePaymentServiceTests.cs | 131 ++++++++++-------- 6 files changed, 127 insertions(+), 167 deletions(-) diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index a0e6206a91..37378184d7 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -21,7 +21,6 @@ using Customer = Stripe.Customer; using Event = Stripe.Event; using PaymentMethod = Stripe.PaymentMethod; using Subscription = Stripe.Subscription; -using TaxRate = Bit.Core.Entities.TaxRate; using Transaction = Bit.Core.Entities.Transaction; using TransactionType = Bit.Core.Enums.TransactionType; @@ -223,9 +222,17 @@ public class StripeController : Controller $"Received null Subscription from Stripe for ID '{invoice.SubscriptionId}' while processing Event with ID '{parsedEvent.Id}'"); } - var updatedSubscription = await VerifyCorrectTaxRateForCharge(invoice, subscription); + if (!subscription.AutomaticTax.Enabled) + { + subscription = await _stripeFacade.UpdateSubscription(subscription.Id, + new SubscriptionUpdateOptions + { + DefaultTaxRates = new List(), + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } + }); + } - var (organizationId, userId) = GetIdsFromMetaData(updatedSubscription.Metadata); + var (organizationId, userId) = GetIdsFromMetaData(subscription.Metadata); var invoiceLineItemDescriptions = invoice.Lines.Select(i => i.Description).ToList(); @@ -246,7 +253,7 @@ public class StripeController : Controller if (organizationId.HasValue) { - if (IsSponsoredSubscription(updatedSubscription)) + if (IsSponsoredSubscription(subscription)) { await _validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value); } @@ -828,32 +835,6 @@ public class StripeController : Controller invoice.BillingReason == "subscription_cycle" && invoice.SubscriptionId != null; } - private async Task VerifyCorrectTaxRateForCharge(Invoice invoice, Subscription subscription) - { - if (!string.IsNullOrWhiteSpace(invoice?.CustomerAddress?.Country) && !string.IsNullOrWhiteSpace(invoice?.CustomerAddress?.PostalCode)) - { - var localBitwardenTaxRates = await _taxRateRepository.GetByLocationAsync( - new TaxRate() - { - Country = invoice.CustomerAddress.Country, - PostalCode = invoice.CustomerAddress.PostalCode - } - ); - - if (localBitwardenTaxRates.Any()) - { - var stripeTaxRate = await new TaxRateService().GetAsync(localBitwardenTaxRates.First().Id); - if (stripeTaxRate != null && !subscription.DefaultTaxRates.Any(x => x == stripeTaxRate)) - { - subscription.DefaultTaxRates = new List { stripeTaxRate }; - var subscriptionOptions = new SubscriptionUpdateOptions() { DefaultTaxRates = new List() { stripeTaxRate.Id } }; - subscription = await new SubscriptionService().UpdateAsync(subscription.Id, subscriptionOptions); - } - } - } - return subscription; - } - private static bool IsSponsoredSubscription(Subscription subscription) => StaticStore.SponsoredPlans.Any(p => p.StripePlanId == subscription.Id); diff --git a/src/Billing/Services/IStripeFacade.cs b/src/Billing/Services/IStripeFacade.cs index cbe36b6a46..4a49c75ea2 100644 --- a/src/Billing/Services/IStripeFacade.cs +++ b/src/Billing/Services/IStripeFacade.cs @@ -33,4 +33,10 @@ public interface IStripeFacade SubscriptionGetOptions subscriptionGetOptions = null, RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + + Task UpdateSubscription( + string subscriptionId, + SubscriptionUpdateOptions subscriptionGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); } diff --git a/src/Billing/Services/Implementations/StripeFacade.cs b/src/Billing/Services/Implementations/StripeFacade.cs index 2ea4d0b93f..db60621029 100644 --- a/src/Billing/Services/Implementations/StripeFacade.cs +++ b/src/Billing/Services/Implementations/StripeFacade.cs @@ -44,4 +44,11 @@ public class StripeFacade : IStripeFacade RequestOptions requestOptions = null, CancellationToken cancellationToken = default) => await _subscriptionService.GetAsync(subscriptionId, subscriptionGetOptions, requestOptions, cancellationToken); + + public async Task UpdateSubscription( + string subscriptionId, + SubscriptionUpdateOptions subscriptionUpdateOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + await _subscriptionService.UpdateAsync(subscriptionId, subscriptionUpdateOptions, requestOptions, cancellationToken); } diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index c3a6a06e6e..f97bd7c072 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -140,7 +140,7 @@ public class OrganizationService : IOrganizationService await _paymentService.SaveTaxInfoAsync(organization, taxInfo); var updated = await _paymentService.UpdatePaymentMethodAsync(organization, - paymentMethodType, paymentToken); + paymentMethodType, paymentToken, taxInfo); if (updated) { await ReplaceAndUpdateCacheAsync(organization); diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 1aeda88076..cc960083a1 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -98,23 +98,6 @@ public class StripePaymentService : IPaymentService throw new GatewayException("Payment method is not supported at this time."); } - if (taxInfo != null && !string.IsNullOrWhiteSpace(taxInfo.BillingAddressCountry) && !string.IsNullOrWhiteSpace(taxInfo.BillingAddressPostalCode)) - { - var taxRateSearch = new TaxRate - { - Country = taxInfo.BillingAddressCountry, - PostalCode = taxInfo.BillingAddressPostalCode - }; - var taxRates = await _taxRateRepository.GetByLocationAsync(taxRateSearch); - - // should only be one tax rate per country/zip combo - var taxRate = taxRates.FirstOrDefault(); - if (taxRate != null) - { - taxInfo.StripeTaxRateId = taxRate.Id; - } - } - var subCreateOptions = new OrganizationPurchaseSubscriptionOptions(org, plan, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon , additionalSmSeats, additionalServiceAccount); @@ -163,6 +146,9 @@ public class StripePaymentService : IPaymentService }); subCreateOptions.AddExpand("latest_invoice.payment_intent"); subCreateOptions.Customer = customer.Id; + + subCreateOptions.AutomaticTax = new Stripe.SubscriptionAutomaticTaxOptions { Enabled = true }; + subscription = await _stripeAdapter.SubscriptionCreateAsync(subCreateOptions); if (subscription.Status == "incomplete" && subscription.LatestInvoice?.PaymentIntent != null) { @@ -244,25 +230,31 @@ public class StripePaymentService : IPaymentService throw new GatewayException("Could not find customer payment profile."); } - var taxInfo = upgrade.TaxInfo; - if (taxInfo != null && !string.IsNullOrWhiteSpace(taxInfo.BillingAddressCountry) && !string.IsNullOrWhiteSpace(taxInfo.BillingAddressPostalCode)) + if (customer.Address is null && + !string.IsNullOrEmpty(upgrade.TaxInfo?.BillingAddressCountry) && + !string.IsNullOrEmpty(upgrade.TaxInfo?.BillingAddressPostalCode)) { - var taxRateSearch = new TaxRate + var addressOptions = new Stripe.AddressOptions { - Country = taxInfo.BillingAddressCountry, - PostalCode = taxInfo.BillingAddressPostalCode + Country = upgrade.TaxInfo.BillingAddressCountry, + PostalCode = upgrade.TaxInfo.BillingAddressPostalCode, + // Line1 is required in Stripe's API, suggestion in Docs is to use Business Name instead. + Line1 = upgrade.TaxInfo.BillingAddressLine1 ?? string.Empty, + Line2 = upgrade.TaxInfo.BillingAddressLine2, + City = upgrade.TaxInfo.BillingAddressCity, + State = upgrade.TaxInfo.BillingAddressState, }; - var taxRates = await _taxRateRepository.GetByLocationAsync(taxRateSearch); - - // should only be one tax rate per country/zip combo - var taxRate = taxRates.FirstOrDefault(); - if (taxRate != null) - { - taxInfo.StripeTaxRateId = taxRate.Id; - } + var customerUpdateOptions = new Stripe.CustomerUpdateOptions { Address = addressOptions }; + customerUpdateOptions.AddExpand("default_source"); + customerUpdateOptions.AddExpand("invoice_settings.default_payment_method"); + customer = await _stripeAdapter.CustomerUpdateAsync(org.GatewayCustomerId, customerUpdateOptions); } - var subCreateOptions = new OrganizationUpgradeSubscriptionOptions(customer.Id, org, plan, upgrade); + var subCreateOptions = new OrganizationUpgradeSubscriptionOptions(customer.Id, org, plan, upgrade) + { + DefaultTaxRates = new List(), + AutomaticTax = new Stripe.SubscriptionAutomaticTaxOptions { Enabled = true } + }; var (stripePaymentMethod, paymentMethodType) = IdentifyPaymentMethod(customer, subCreateOptions); var subscription = await ChargeForNewSubscriptionAsync(org, customer, false, @@ -459,26 +451,6 @@ public class StripePaymentService : IPaymentService Quantity = 1 }); - if (!string.IsNullOrWhiteSpace(taxInfo?.BillingAddressCountry) - && !string.IsNullOrWhiteSpace(taxInfo?.BillingAddressPostalCode)) - { - var taxRates = await _taxRateRepository.GetByLocationAsync( - new TaxRate() - { - Country = taxInfo.BillingAddressCountry, - PostalCode = taxInfo.BillingAddressPostalCode - } - ); - var taxRate = taxRates.FirstOrDefault(); - if (taxRate != null) - { - subCreateOptions.DefaultTaxRates = new List(1) - { - taxRate.Id - }; - } - } - if (additionalStorageGb > 0) { subCreateOptions.Items.Add(new Stripe.SubscriptionItemOptions @@ -488,6 +460,8 @@ public class StripePaymentService : IPaymentService }); } + subCreateOptions.AutomaticTax = new Stripe.SubscriptionAutomaticTaxOptions { Enabled = true }; + var subscription = await ChargeForNewSubscriptionAsync(user, customer, createdStripeCustomer, stripePaymentMethod, paymentMethodType, subCreateOptions, braintreeCustomer); @@ -525,7 +499,8 @@ public class StripePaymentService : IPaymentService { Customer = customer.Id, SubscriptionItems = ToInvoiceSubscriptionItemOptions(subCreateOptions.Items), - SubscriptionDefaultTaxRates = subCreateOptions.DefaultTaxRates, + AutomaticTax = + new Stripe.InvoiceAutomaticTaxOptions { Enabled = subCreateOptions.AutomaticTax.Enabled } }); if (previewInvoice.AmountDue > 0) @@ -583,7 +558,8 @@ public class StripePaymentService : IPaymentService { Customer = customer.Id, SubscriptionItems = ToInvoiceSubscriptionItemOptions(subCreateOptions.Items), - SubscriptionDefaultTaxRates = subCreateOptions.DefaultTaxRates, + AutomaticTax = + new Stripe.InvoiceAutomaticTaxOptions { Enabled = subCreateOptions.AutomaticTax.Enabled } }); if (previewInvoice.AmountDue > 0) { @@ -593,6 +569,7 @@ public class StripePaymentService : IPaymentService subCreateOptions.OffSession = true; subCreateOptions.AddExpand("latest_invoice.payment_intent"); + subCreateOptions.AutomaticTax = new Stripe.SubscriptionAutomaticTaxOptions { Enabled = true }; subscription = await _stripeAdapter.SubscriptionCreateAsync(subCreateOptions); if (subscription.Status == "incomplete" && subscription.LatestInvoice?.PaymentIntent != null) { @@ -692,6 +669,8 @@ public class StripePaymentService : IPaymentService DaysUntilDue = daysUntilDue ?? 1, CollectionMethod = "send_invoice", ProrationDate = prorationDate, + DefaultTaxRates = new List(), + AutomaticTax = new Stripe.SubscriptionAutomaticTaxOptions { Enabled = true } }; if (!subscriptionUpdate.UpdateNeeded(sub)) @@ -700,28 +679,6 @@ public class StripePaymentService : IPaymentService return null; } - var customer = await _stripeAdapter.CustomerGetAsync(sub.CustomerId); - - if (!string.IsNullOrWhiteSpace(customer?.Address?.Country) - && !string.IsNullOrWhiteSpace(customer?.Address?.PostalCode)) - { - var taxRates = await _taxRateRepository.GetByLocationAsync( - new TaxRate() - { - Country = customer.Address.Country, - PostalCode = customer.Address.PostalCode - } - ); - var taxRate = taxRates.FirstOrDefault(); - if (taxRate != null && !sub.DefaultTaxRates.Any(x => x.Equals(taxRate.Id))) - { - subUpdateOptions.DefaultTaxRates = new List(1) - { - taxRate.Id - }; - } - } - string paymentIntentClientSecret = null; try { diff --git a/test/Core.Test/Services/StripePaymentServiceTests.cs b/test/Core.Test/Services/StripePaymentServiceTests.cs index ea40f0d000..171fab0fb5 100644 --- a/test/Core.Test/Services/StripePaymentServiceTests.cs +++ b/test/Core.Test/Services/StripePaymentServiceTests.cs @@ -2,7 +2,6 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; -using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; @@ -14,7 +13,6 @@ using Xunit; using Customer = Braintree.Customer; using PaymentMethod = Braintree.PaymentMethod; using PaymentMethodType = Bit.Core.Enums.PaymentMethodType; -using TaxRate = Bit.Core.Entities.TaxRate; namespace Bit.Core.Test.Services; @@ -259,65 +257,6 @@ public class StripePaymentServiceTests )); } - [Theory, BitAutoData] - public async void PurchaseOrganizationAsync_Stripe_TaxRate(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) - { - var plan = StaticStore.GetPlan(PlanType.EnterpriseAnnually); - - var stripeAdapter = sutProvider.GetDependency(); - stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer - { - Id = "C-1", - }); - stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription - { - Id = "S-1", - CurrentPeriodEnd = DateTime.Today.AddDays(10), - }); - sutProvider.GetDependency().GetByLocationAsync(Arg.Is(t => - t.Country == taxInfo.BillingAddressCountry && t.PostalCode == taxInfo.BillingAddressPostalCode)) - .Returns(new List { new() { Id = "T-1" } }); - - var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan, 0, 0, false, taxInfo); - - Assert.Null(result); - - await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is(s => - s.DefaultTaxRates.Count == 1 && - s.DefaultTaxRates[0] == "T-1" - )); - } - - [Theory, BitAutoData] - public async void PurchaseOrganizationAsync_Stripe_TaxRate_SM(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) - { - var plan = StaticStore.GetPlan(PlanType.EnterpriseAnnually); - - var stripeAdapter = sutProvider.GetDependency(); - stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer - { - Id = "C-1", - }); - stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription - { - Id = "S-1", - CurrentPeriodEnd = DateTime.Today.AddDays(10), - }); - sutProvider.GetDependency().GetByLocationAsync(Arg.Is(t => - t.Country == taxInfo.BillingAddressCountry && t.PostalCode == taxInfo.BillingAddressPostalCode)) - .Returns(new List { new() { Id = "T-1" } }); - - var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan, 2, 2, - false, taxInfo, false, 2, 2); - - Assert.Null(result); - - await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is(s => - s.DefaultTaxRates.Count == 1 && - s.DefaultTaxRates[0] == "T-1" - )); - } - [Theory, BitAutoData] public async void PurchaseOrganizationAsync_Stripe_Declined(SutProvider sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo) { @@ -678,6 +617,14 @@ public class StripePaymentServiceTests { "btCustomerId", "B-123" }, } }); + stripeAdapter.CustomerUpdateAsync(default).ReturnsForAnyArgs(new Stripe.Customer + { + Id = "C-1", + Metadata = new Dictionary + { + { "btCustomerId", "B-123" }, + } + }); stripeAdapter.InvoiceUpcomingAsync(default).ReturnsForAnyArgs(new Stripe.Invoice { PaymentIntent = new Stripe.PaymentIntent { Status = "requires_payment_method", }, @@ -715,6 +662,14 @@ public class StripePaymentServiceTests { "btCustomerId", "B-123" }, } }); + stripeAdapter.CustomerUpdateAsync(default).ReturnsForAnyArgs(new Stripe.Customer + { + Id = "C-1", + Metadata = new Dictionary + { + { "btCustomerId", "B-123" }, + } + }); stripeAdapter.InvoiceUpcomingAsync(default).ReturnsForAnyArgs(new Stripe.Invoice { PaymentIntent = new Stripe.PaymentIntent { Status = "requires_payment_method", }, @@ -737,4 +692,58 @@ public class StripePaymentServiceTests Assert.Null(result); } + + [Theory, BitAutoData] + public async void UpgradeFreeOrganizationAsync_WhenCustomerHasNoAddress_UpdatesCustomerAddressWithTaxInfo( + SutProvider sutProvider, + Organization organization, + TaxInfo taxInfo) + { + organization.GatewaySubscriptionId = null; + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.CustomerGetAsync(default).ReturnsForAnyArgs(new Stripe.Customer + { + Id = "C-1", + Metadata = new Dictionary + { + { "btCustomerId", "B-123" }, + } + }); + stripeAdapter.CustomerUpdateAsync(default).ReturnsForAnyArgs(new Stripe.Customer + { + Id = "C-1", + Metadata = new Dictionary + { + { "btCustomerId", "B-123" }, + } + }); + stripeAdapter.InvoiceUpcomingAsync(default).ReturnsForAnyArgs(new Stripe.Invoice + { + PaymentIntent = new Stripe.PaymentIntent { Status = "requires_payment_method", }, + AmountDue = 0 + }); + stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription { }); + + var upgrade = new OrganizationUpgrade() + { + AdditionalStorageGb = 1, + AdditionalSeats = 10, + PremiumAccessAddon = false, + TaxInfo = taxInfo, + AdditionalSmSeats = 5, + AdditionalServiceAccounts = 50 + }; + + var plan = StaticStore.GetPlan(PlanType.EnterpriseAnnually); + _ = await sutProvider.Sut.UpgradeFreeOrganizationAsync(organization, plan, upgrade); + + await stripeAdapter.Received() + .CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is(c => + c.Address.Country == taxInfo.BillingAddressCountry && + c.Address.PostalCode == taxInfo.BillingAddressPostalCode && + c.Address.Line1 == taxInfo.BillingAddressLine1 && + c.Address.Line2 == taxInfo.BillingAddressLine2 && + c.Address.City == taxInfo.BillingAddressCity && + c.Address.State == taxInfo.BillingAddressState)); + } } From 693f0566a6bf27e728f6f0079dea90e03c6f931b Mon Sep 17 00:00:00 2001 From: Bitwarden DevOps <106330231+bitwarden-devops-bot@users.noreply.github.com> Date: Mon, 29 Jan 2024 10:49:12 -0500 Subject: [PATCH 038/117] Bumped version to 2024.2.0 (#3714) --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 5c11359b54..376ab13177 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,7 +2,7 @@ net6.0 - 2024.1.2 + 2024.2.0 Bit.$(MSBuildProjectName) enable false From d7de5cbf289880d08eb8f1a804567f7bec7cae5c Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Mon, 29 Jan 2024 11:10:27 -0500 Subject: [PATCH 039/117] [AC-1843] Automate PM discount for SM Trial (#3661) * Added appliesTo to customer discount. Added productId to subscription item * Added IsFromSecretsManagerTrial flag to add discount for SM trials * Fixed broken tests --------- Co-authored-by: Alex Morask --- .../OrganizationCreateRequestModel.cs | 2 + .../Response/SubscriptionResponseModel.cs | 4 ++ .../Implementations/OrganizationService.cs | 2 +- .../Models/Business/OrganizationUpgrade.cs | 1 + src/Core/Models/Business/SubscriptionInfo.cs | 4 ++ src/Core/Services/IPaymentService.cs | 2 +- .../Implementations/StripePaymentService.cs | 57 ++++++++++++------- .../Services/OrganizationServiceTests.cs | 8 ++- 8 files changed, 55 insertions(+), 25 deletions(-) diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs index 2ea4e8b84e..304978eb13 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs @@ -46,6 +46,7 @@ public class OrganizationCreateRequestModel : IValidatableObject public int? AdditionalServiceAccounts { get; set; } [Required] public bool UseSecretsManager { get; set; } + public bool IsFromSecretsManagerTrial { get; set; } public virtual OrganizationSignup ToOrganizationSignup(User user) { @@ -67,6 +68,7 @@ public class OrganizationCreateRequestModel : IValidatableObject AdditionalSmSeats = AdditionalSmSeats.GetValueOrDefault(), AdditionalServiceAccounts = AdditionalServiceAccounts.GetValueOrDefault(), UseSecretsManager = UseSecretsManager, + IsFromSecretsManagerTrial = IsFromSecretsManagerTrial, TaxInfo = new TaxInfo { TaxIdNumber = TaxIdNumber, diff --git a/src/Api/Models/Response/SubscriptionResponseModel.cs b/src/Api/Models/Response/SubscriptionResponseModel.cs index bac1cf3f91..7ba2b857eb 100644 --- a/src/Api/Models/Response/SubscriptionResponseModel.cs +++ b/src/Api/Models/Response/SubscriptionResponseModel.cs @@ -50,11 +50,13 @@ public class BillingCustomerDiscount Id = discount.Id; Active = discount.Active; PercentOff = discount.PercentOff; + AppliesTo = discount.AppliesTo; } public string Id { get; } public bool Active { get; } public decimal? PercentOff { get; } + public List AppliesTo { get; } } public class BillingSubscription @@ -89,6 +91,7 @@ public class BillingSubscription { public BillingSubscriptionItem(SubscriptionInfo.BillingSubscription.BillingSubscriptionItem item) { + ProductId = item.ProductId; Name = item.Name; Amount = item.Amount; Interval = item.Interval; @@ -97,6 +100,7 @@ public class BillingSubscription AddonSubscriptionItem = item.AddonSubscriptionItem; } + public string ProductId { get; set; } public string Name { get; set; } public decimal Amount { get; set; } public int Quantity { get; set; } diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index f97bd7c072..8ab5293e95 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -517,7 +517,7 @@ public class OrganizationService : IOrganizationService await _paymentService.PurchaseOrganizationAsync(organization, signup.PaymentMethodType.Value, signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats, signup.PremiumAccessAddon, signup.TaxInfo, provider, signup.AdditionalSmSeats.GetValueOrDefault(), - signup.AdditionalServiceAccounts.GetValueOrDefault()); + signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial); } var ownerId = provider ? default : signup.Owner.Id; diff --git a/src/Core/Models/Business/OrganizationUpgrade.cs b/src/Core/Models/Business/OrganizationUpgrade.cs index 173502a24f..6992f492a6 100644 --- a/src/Core/Models/Business/OrganizationUpgrade.cs +++ b/src/Core/Models/Business/OrganizationUpgrade.cs @@ -15,4 +15,5 @@ public class OrganizationUpgrade public int? AdditionalSmSeats { get; set; } public int? AdditionalServiceAccounts { get; set; } public bool UseSecretsManager { get; set; } + public bool IsFromSecretsManagerTrial { get; set; } } diff --git a/src/Core/Models/Business/SubscriptionInfo.cs b/src/Core/Models/Business/SubscriptionInfo.cs index e231081230..e2a689f613 100644 --- a/src/Core/Models/Business/SubscriptionInfo.cs +++ b/src/Core/Models/Business/SubscriptionInfo.cs @@ -17,11 +17,13 @@ public class SubscriptionInfo Id = discount.Id; Active = discount.Start != null && discount.End == null; PercentOff = discount.Coupon?.PercentOff; + AppliesTo = discount.Coupon?.AppliesTo?.Products ?? new List(); } public string Id { get; } public bool Active { get; } public decimal? PercentOff { get; } + public List AppliesTo { get; } } public class BillingSubscription @@ -59,6 +61,7 @@ public class SubscriptionInfo { if (item.Plan != null) { + ProductId = item.Plan.ProductId; Name = item.Plan.Nickname; Amount = item.Plan.Amount.GetValueOrDefault() / 100M; Interval = item.Plan.Interval; @@ -72,6 +75,7 @@ public class SubscriptionInfo public bool AddonSubscriptionItem { get; set; } + public string ProductId { get; set; } public string Name { get; set; } public decimal Amount { get; set; } public int Quantity { get; set; } diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index 70cc88c206..f8f24cfbdb 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -12,7 +12,7 @@ public interface IPaymentService Task PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType, string paymentToken, Plan plan, short additionalStorageGb, int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo, bool provider = false, int additionalSmSeats = 0, - int additionalServiceAccount = 0); + int additionalServiceAccount = 0, bool signupIsFromSecretsManagerTrial = false); Task SponsorOrganizationAsync(Organization org, OrganizationSponsorship sponsorship); Task RemoveOrganizationSponsorshipAsync(Organization org, OrganizationSponsorship sponsorship); Task UpgradeFreeOrganizationAsync(Organization org, Plan plan, OrganizationUpgrade upgrade); diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index cc960083a1..f3a939650a 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -7,6 +7,7 @@ using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.Settings; using Microsoft.Extensions.Logging; +using Stripe; using StaticStore = Bit.Core.Models.StaticStore; using TaxRate = Bit.Core.Entities.TaxRate; @@ -17,6 +18,7 @@ public class StripePaymentService : IPaymentService private const string PremiumPlanId = "premium-annually"; private const string StoragePlanId = "storage-gb-annually"; private const string ProviderDiscountId = "msp-discount-35"; + private const string SecretsManagerStandaloneDiscountId = "sm-standalone"; private readonly ITransactionRepository _transactionRepository; private readonly IUserRepository _userRepository; @@ -47,7 +49,7 @@ public class StripePaymentService : IPaymentService public async Task PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType, string paymentToken, StaticStore.Plan plan, short additionalStorageGb, int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo, bool provider = false, - int additionalSmSeats = 0, int additionalServiceAccount = 0) + int additionalSmSeats = 0, int additionalServiceAccount = 0, bool signupIsFromSecretsManagerTrial = false) { Braintree.Customer braintreeCustomer = null; string stipeCustomerSourceToken = null; @@ -124,7 +126,11 @@ public class StripePaymentService : IPaymentService }, }, }, - Coupon = provider ? ProviderDiscountId : null, + Coupon = signupIsFromSecretsManagerTrial + ? SecretsManagerStandaloneDiscountId + : provider + ? ProviderDiscountId + : null, Address = new Stripe.AddressOptions { Country = taxInfo.BillingAddressCountry, @@ -1410,7 +1416,9 @@ public class StripePaymentService : IPaymentService if (!string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) { - var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId); + var customerGetOptions = new CustomerGetOptions(); + customerGetOptions.AddExpand("discount.coupon.applies_to"); + var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerGetOptions); if (customer.Discount != null) { @@ -1418,29 +1426,36 @@ public class StripePaymentService : IPaymentService } } - if (!string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId)) + if (string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId)) { - var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId); - if (sub != null) - { - subscriptionInfo.Subscription = new SubscriptionInfo.BillingSubscription(sub); - } + return subscriptionInfo; + } - if (!sub.CanceledAt.HasValue && !string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) + var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId); + if (sub != null) + { + subscriptionInfo.Subscription = new SubscriptionInfo.BillingSubscription(sub); + } + + if (sub is { CanceledAt: not null } || string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) + { + return subscriptionInfo; + } + + try + { + var upcomingInvoiceOptions = new UpcomingInvoiceOptions { Customer = subscriber.GatewayCustomerId }; + var upcomingInvoice = await _stripeAdapter.InvoiceUpcomingAsync(upcomingInvoiceOptions); + + if (upcomingInvoice != null) { - try - { - var upcomingInvoice = await _stripeAdapter.InvoiceUpcomingAsync( - new Stripe.UpcomingInvoiceOptions { Customer = subscriber.GatewayCustomerId }); - if (upcomingInvoice != null) - { - subscriptionInfo.UpcomingInvoice = - new SubscriptionInfo.BillingUpcomingInvoice(upcomingInvoice); - } - } - catch (Stripe.StripeException) { } + subscriptionInfo.UpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice(upcomingInvoice); } } + catch (StripeException ex) + { + _logger.LogWarning(ex, "Encountered an unexpected Stripe error"); + } return subscriptionInfo; } diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index 52dce5802c..5f6aa08663 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -209,6 +209,7 @@ public class OrganizationServiceTests signup.PaymentMethodType = PaymentMethodType.Card; signup.PremiumAccessAddon = false; signup.UseSecretsManager = false; + signup.IsFromSecretsManagerTrial = false; var purchaseOrganizationPlan = StaticStore.GetPlan(signup.Plan); @@ -247,7 +248,8 @@ public class OrganizationServiceTests signup.TaxInfo, false, signup.AdditionalSmSeats.GetValueOrDefault(), - signup.AdditionalServiceAccounts.GetValueOrDefault() + signup.AdditionalServiceAccounts.GetValueOrDefault(), + signup.UseSecretsManager ); } @@ -326,6 +328,7 @@ public class OrganizationServiceTests signup.AdditionalServiceAccounts = 20; signup.PaymentMethodType = PaymentMethodType.Card; signup.PremiumAccessAddon = false; + signup.IsFromSecretsManagerTrial = false; var result = await sutProvider.Sut.SignUpAsync(signup); @@ -362,7 +365,8 @@ public class OrganizationServiceTests signup.TaxInfo, false, signup.AdditionalSmSeats.GetValueOrDefault(), - signup.AdditionalServiceAccounts.GetValueOrDefault() + signup.AdditionalServiceAccounts.GetValueOrDefault(), + signup.IsFromSecretsManagerTrial ); } From a3a51c614b261ea60a63e6c79d4146b4668f6b09 Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Mon, 29 Jan 2024 11:25:47 -0500 Subject: [PATCH 040/117] Configure Codecov to ignore tests (#3712) --- .github/codecov.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .github/codecov.yml diff --git a/.github/codecov.yml b/.github/codecov.yml new file mode 100644 index 0000000000..3a606f3b5a --- /dev/null +++ b/.github/codecov.yml @@ -0,0 +1,2 @@ +ignore: + - "test" # Tests From b1f21269a8071e75a530d94306b939f8ac6786ee Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Mon, 29 Jan 2024 11:26:54 -0500 Subject: [PATCH 041/117] Move some packages to DbOps (#3710) --- .github/renovate.json | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/.github/renovate.json b/.github/renovate.json index a0a51f91c7..4ec012b326 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -113,27 +113,13 @@ "groupName": "Microsoft.Extensions.Logging", "description": "Group Microsoft.Extensions.Logging to exclude them from the dotnet monorepo preset" }, - { - "matchPackageNames": ["CommandDotNet", "dbup-sqlserver", "YamlDotNet"], - "description": "DevOps owned dependencies", - "commitMessagePrefix": "[deps] DevOps:", - "reviewers": ["team:team-devops"] - }, - { - "matchPackageNames": [ - "Microsoft.AspNetCore.Authentication.JwtBearer", - "Microsoft.AspNetCore.Http", - "Microsoft.Data.SqlClient" - ], - "description": "Platform owned dependencies", - "commitMessagePrefix": "[deps] Platform:", - "reviewers": ["team:team-platform-dev"] - }, { "matchPackageNames": [ "Dapper", + "dbup-sqlserver", "dotnet-ef", "linq2db.EntityFrameworkCore", + "Microsoft.Data.SqlClient", "Microsoft.EntityFrameworkCore.Design", "Microsoft.EntityFrameworkCore.InMemory", "Microsoft.EntityFrameworkCore.Relational", @@ -142,9 +128,24 @@ "Npgsql.EntityFrameworkCore.PostgreSQL", "Pomelo.EntityFrameworkCore.MySql" ], - "description": "Secrets Manager owned dependencies", - "commitMessagePrefix": "[deps] SM:", - "reviewers": ["team:team-secrets-manager-dev"] + "description": "DbOps owned dependencies", + "commitMessagePrefix": "[deps] DbOps:", + "reviewers": ["team:dept-dbops"] + }, + { + "matchPackageNames": ["CommandDotNet", "YamlDotNet"], + "description": "DevOps owned dependencies", + "commitMessagePrefix": "[deps] DevOps:", + "reviewers": ["team:dept-devops"] + }, + { + "matchPackageNames": [ + "Microsoft.AspNetCore.Authentication.JwtBearer", + "Microsoft.AspNetCore.Http" + ], + "description": "Platform owned dependencies", + "commitMessagePrefix": "[deps] Platform:", + "reviewers": ["team:team-platform-dev"] }, { "matchPackagePatterns": ["EntityFrameworkCore", "^dotnet-ef"], From 31e09e415d91fd49236d09ad270623aebd46b8fd Mon Sep 17 00:00:00 2001 From: Vince Grassia <593223+vgrassia@users.noreply.github.com> Date: Mon, 29 Jan 2024 13:25:34 -0500 Subject: [PATCH 042/117] Add logic to prevent running on Version Bump PRs (#3716) --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8712b0cdf9..78890f1d1b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,6 +16,7 @@ env: jobs: testing: name: Run tests + if: ${{ startsWith(github.head_ref, 'version_bump_') == false }} runs-on: ubuntu-22.04 env: NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages From 7bf17a20f4655e92c01a8ab7d35498314a64776b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Mon, 29 Jan 2024 20:04:45 +0000 Subject: [PATCH 043/117] [AC-2104] Add flexible collections properties to provider organizations sync response (#3717) --- ...rofileProviderOrganizationResponseModel.cs | 3 ++ .../ProviderUserOrganizationDetails.cs | 3 ++ ...roviderUserOrganizationDetailsViewQuery.cs | 5 +- ...derUserProviderOrganizationDetailsView.sql | 5 +- ...OrganizationsFlexibleCollectionColumns.sql | 54 +++++++++++++++++++ 5 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 util/Migrator/DbScripts/2024-01-29_00_ProviderOrganizationsFlexibleCollectionColumns.sql diff --git a/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs index d6b7656e4b..9c3951c291 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs @@ -43,5 +43,8 @@ public class ProfileProviderOrganizationResponseModel : ProfileOrganizationRespo ProviderId = organization.ProviderId; ProviderName = organization.ProviderName; PlanProductType = StaticStore.GetPlan(organization.PlanType).Product; + LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion; + AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; + FlexibleCollections = organization.FlexibleCollections; } } diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs index 92f420a5b5..e692179498 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs @@ -35,4 +35,7 @@ public class ProviderUserOrganizationDetails public Guid? ProviderUserId { get; set; } public string ProviderName { get; set; } public Core.Enums.PlanType PlanType { get; set; } + public bool LimitCollectionCreationDeletion { get; set; } + public bool AllowAdminAccessToAllCollectionItems { get; set; } + public bool FlexibleCollections { get; set; } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserOrganizationDetailsViewQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserOrganizationDetailsViewQuery.cs index 95a83968bc..2b0ddf752b 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserOrganizationDetailsViewQuery.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserOrganizationDetailsViewQuery.cs @@ -43,7 +43,10 @@ public class ProviderUserOrganizationDetailsViewQuery : IQuery Date: Mon, 29 Jan 2024 16:16:54 -0500 Subject: [PATCH 044/117] [PM-5638] Update minimum version for vault item encryption to 2024.2.0 (#3718) --- 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 57c75da842..3235e1db3c 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -23,7 +23,7 @@ public static class Constants public const string Fido2KeyCipherMinimumVersion = "2023.10.0"; - public const string CipherKeyEncryptionMinimumVersion = "2024.1.3"; + public const string CipherKeyEncryptionMinimumVersion = "2024.2.0"; /// /// Used by IdentityServer to identify our own provider. From cc2a81ae3f734bd2e42f7925004b1bc2cd7b1b52 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 30 Jan 2024 09:03:50 -0500 Subject: [PATCH 045/117] [AC-1800] PayPal IPN Refactor (#3619) * Add more logging to PayPal IPN webhook * Add PayPalIPNClient tests * Add PayPalControllerTests --------- Co-authored-by: aelinton <95626935+aelinton@users.noreply.github.com> --- src/Billing/Controllers/PayPalController.cs | 331 +++++---- .../Models/PayPalIPNTransactionModel.cs | 110 +++ src/Billing/Services/IPayPalIPNClient.cs | 6 + .../Implementations/PayPalIPNClient.cs | 86 +++ src/Billing/Startup.cs | 6 +- src/Billing/Utilities/PayPalIpnClient.cs | 176 ----- test/Billing.Test/Billing.Test.csproj | 29 + .../Controllers/PayPalControllerTests.cs | 644 ++++++++++++++++++ .../Resources/IPN/echeck-payment.txt | 40 ++ .../Resources/IPN/non-usd-payment.txt | 40 ++ .../IPN/refund-missing-parent-transaction.txt | 40 ++ .../IPN/successful-payment-org-credit.txt | 40 ++ .../IPN/successful-payment-user-credit.txt | 40 ++ .../Resources/IPN/successful-payment.txt | 40 ++ .../Resources/IPN/successful-refund.txt | 41 ++ .../IPN/transaction-missing-entity-ids.txt | 40 ++ .../IPN/unsupported-transaction-type.txt | 40 ++ .../Services/PayPalIPNClientTests.cs | 86 +++ test/Billing.Test/Utilities/PayPalTestIPN.cs | 37 + 19 files changed, 1546 insertions(+), 326 deletions(-) create mode 100644 src/Billing/Models/PayPalIPNTransactionModel.cs create mode 100644 src/Billing/Services/IPayPalIPNClient.cs create mode 100644 src/Billing/Services/Implementations/PayPalIPNClient.cs delete mode 100644 src/Billing/Utilities/PayPalIpnClient.cs create mode 100644 test/Billing.Test/Controllers/PayPalControllerTests.cs create mode 100644 test/Billing.Test/Resources/IPN/echeck-payment.txt create mode 100644 test/Billing.Test/Resources/IPN/non-usd-payment.txt create mode 100644 test/Billing.Test/Resources/IPN/refund-missing-parent-transaction.txt create mode 100644 test/Billing.Test/Resources/IPN/successful-payment-org-credit.txt create mode 100644 test/Billing.Test/Resources/IPN/successful-payment-user-credit.txt create mode 100644 test/Billing.Test/Resources/IPN/successful-payment.txt create mode 100644 test/Billing.Test/Resources/IPN/successful-refund.txt create mode 100644 test/Billing.Test/Resources/IPN/transaction-missing-entity-ids.txt create mode 100644 test/Billing.Test/Resources/IPN/unsupported-transaction-type.txt create mode 100644 test/Billing.Test/Services/PayPalIPNClientTests.cs create mode 100644 test/Billing.Test/Utilities/PayPalTestIPN.cs diff --git a/src/Billing/Controllers/PayPalController.cs b/src/Billing/Controllers/PayPalController.cs index c0d3a2700a..1621ee2392 100644 --- a/src/Billing/Controllers/PayPalController.cs +++ b/src/Billing/Controllers/PayPalController.cs @@ -1,5 +1,6 @@ using System.Text; -using Bit.Billing.Utilities; +using Bit.Billing.Models; +using Bit.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; @@ -15,220 +16,256 @@ namespace Bit.Billing.Controllers; public class PayPalController : Controller { private readonly BillingSettings _billingSettings; - private readonly PayPalIpnClient _paypalIpnClient; - private readonly ITransactionRepository _transactionRepository; - private readonly IOrganizationRepository _organizationRepository; - private readonly IUserRepository _userRepository; - private readonly IMailService _mailService; - private readonly IPaymentService _paymentService; private readonly ILogger _logger; + private readonly IMailService _mailService; + private readonly IOrganizationRepository _organizationRepository; + private readonly IPaymentService _paymentService; + private readonly IPayPalIPNClient _payPalIPNClient; + private readonly ITransactionRepository _transactionRepository; + private readonly IUserRepository _userRepository; public PayPalController( IOptions billingSettings, - PayPalIpnClient paypalIpnClient, - ITransactionRepository transactionRepository, - IOrganizationRepository organizationRepository, - IUserRepository userRepository, + ILogger logger, IMailService mailService, + IOrganizationRepository organizationRepository, IPaymentService paymentService, - ILogger logger) + IPayPalIPNClient payPalIPNClient, + ITransactionRepository transactionRepository, + IUserRepository userRepository) { _billingSettings = billingSettings?.Value; - _paypalIpnClient = paypalIpnClient; - _transactionRepository = transactionRepository; - _organizationRepository = organizationRepository; - _userRepository = userRepository; - _mailService = mailService; - _paymentService = paymentService; _logger = logger; + _mailService = mailService; + _organizationRepository = organizationRepository; + _paymentService = paymentService; + _payPalIPNClient = payPalIPNClient; + _transactionRepository = transactionRepository; + _userRepository = userRepository; } [HttpPost("ipn")] public async Task PostIpn() { - _logger.LogDebug("PayPal webhook has been hit."); - if (HttpContext?.Request?.Query == null) + var key = HttpContext.Request.Query.ContainsKey("key") + ? HttpContext.Request.Query["key"].ToString() + : null; + + if (string.IsNullOrEmpty(key)) { - return new BadRequestResult(); + _logger.LogError("PayPal IPN: Key is missing"); + return BadRequest(); } - var key = HttpContext.Request.Query.ContainsKey("key") ? - HttpContext.Request.Query["key"].ToString() : null; if (!CoreHelpers.FixedTimeEquals(key, _billingSettings.PayPal.WebhookKey)) { - _logger.LogWarning("PayPal webhook key is incorrect or does not exist."); - return new BadRequestResult(); + _logger.LogError("PayPal IPN: Key is incorrect"); + return BadRequest(); } - string body = null; - using (var reader = new StreamReader(HttpContext.Request.Body, Encoding.UTF8)) + using var streamReader = new StreamReader(HttpContext.Request.Body, Encoding.UTF8); + + var requestContent = await streamReader.ReadToEndAsync(); + + if (string.IsNullOrEmpty(requestContent)) { - body = await reader.ReadToEndAsync(); + _logger.LogError("PayPal IPN: Request body is null or empty"); + return BadRequest(); } - if (string.IsNullOrWhiteSpace(body)) + var transactionModel = new PayPalIPNTransactionModel(requestContent); + + var entityId = transactionModel.UserId ?? transactionModel.OrganizationId; + + if (!entityId.HasValue) { - return new BadRequestResult(); + _logger.LogError("PayPal IPN ({Id}): 'custom' did not contain a User ID or Organization ID", transactionModel.TransactionId); + return BadRequest(); } - var verified = await _paypalIpnClient.VerifyIpnAsync(body); + var verified = await _payPalIPNClient.VerifyIPN(entityId.Value, requestContent); + if (!verified) { - _logger.LogWarning("Unverified IPN received."); - return new BadRequestResult(); + _logger.LogError("PayPal IPN ({Id}): Verification failed", transactionModel.TransactionId); + return BadRequest(); } - var ipnTransaction = new PayPalIpnClient.IpnTransaction(body); - if (ipnTransaction.TxnType != "web_accept" && ipnTransaction.TxnType != "merch_pmt" && - ipnTransaction.PaymentStatus != "Refunded") + if (transactionModel.TransactionType != "web_accept" && + transactionModel.TransactionType != "merch_pmt" && + transactionModel.PaymentStatus != "Refunded") { - // Only processing billing agreement payments, buy now button payments, and refunds for now. - return new OkResult(); + _logger.LogWarning("PayPal IPN ({Id}): Transaction type ({Type}) not supported for payments", + transactionModel.TransactionId, + transactionModel.TransactionType); + + return Ok(); } - if (ipnTransaction.ReceiverId != _billingSettings.PayPal.BusinessId) + if (transactionModel.ReceiverId != _billingSettings.PayPal.BusinessId) { - _logger.LogWarning("Receiver was not proper business id. " + ipnTransaction.ReceiverId); - return new BadRequestResult(); + _logger.LogWarning( + "PayPal IPN ({Id}): Receiver ID ({ReceiverId}) does not match Bitwarden business ID ({BusinessId})", + transactionModel.TransactionId, + transactionModel.ReceiverId, + _billingSettings.PayPal.BusinessId); + + return Ok(); } - if (ipnTransaction.PaymentStatus == "Refunded" && ipnTransaction.ParentTxnId == null) + if (transactionModel.PaymentStatus == "Refunded" && string.IsNullOrEmpty(transactionModel.ParentTransactionId)) { - // Refunds require parent transaction - return new OkResult(); + _logger.LogWarning("PayPal IPN ({Id}): Parent transaction ID is required for refund", transactionModel.TransactionId); + return Ok(); } - if (ipnTransaction.PaymentType == "echeck" && ipnTransaction.PaymentStatus != "Refunded") + if (transactionModel.PaymentType == "echeck" && transactionModel.PaymentStatus != "Refunded") { - // Not accepting eChecks, unless it is a refund - _logger.LogWarning("Got an eCheck payment. " + ipnTransaction.TxnId); - return new OkResult(); + _logger.LogWarning("PayPal IPN ({Id}): Transaction was an eCheck payment", transactionModel.TransactionId); + return Ok(); } - if (ipnTransaction.McCurrency != "USD") + if (transactionModel.MerchantCurrency != "USD") { - // Only process USD payments - _logger.LogWarning("Received a payment not in USD. " + ipnTransaction.TxnId); - return new OkResult(); + _logger.LogWarning("PayPal IPN ({Id}): Transaction was not in USD ({Currency})", + transactionModel.TransactionId, + transactionModel.MerchantCurrency); + + return Ok(); } - var ids = ipnTransaction.GetIdsFromCustom(); - if (!ids.Item1.HasValue && !ids.Item2.HasValue) + switch (transactionModel.PaymentStatus) { - return new OkResult(); - } - - if (ipnTransaction.PaymentStatus == "Completed") - { - var transaction = await _transactionRepository.GetByGatewayIdAsync( - GatewayType.PayPal, ipnTransaction.TxnId); - if (transaction != null) - { - _logger.LogWarning("Already processed this completed transaction. #" + ipnTransaction.TxnId); - return new OkResult(); - } - - var isAccountCredit = ipnTransaction.IsAccountCredit(); - try - { - var tx = new Transaction + case "Completed": { - Amount = ipnTransaction.McGross, - CreationDate = ipnTransaction.PaymentDate, - OrganizationId = ids.Item1, - UserId = ids.Item2, - Type = isAccountCredit ? TransactionType.Credit : TransactionType.Charge, - Gateway = GatewayType.PayPal, - GatewayId = ipnTransaction.TxnId, - PaymentMethodType = PaymentMethodType.PayPal, - Details = ipnTransaction.TxnId - }; - await _transactionRepository.CreateAsync(tx); + var existingTransaction = await _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, + transactionModel.TransactionId); - if (isAccountCredit) - { - string billingEmail = null; - if (tx.OrganizationId.HasValue) + if (existingTransaction != null) { - var org = await _organizationRepository.GetByIdAsync(tx.OrganizationId.Value); - if (org != null) + _logger.LogWarning("PayPal IPN ({Id}): Already processed this completed transaction", transactionModel.TransactionId); + return Ok(); + } + + try + { + var transaction = new Transaction { - billingEmail = org.BillingEmailAddress(); - if (await _paymentService.CreditAccountAsync(org, tx.Amount)) - { - await _organizationRepository.ReplaceAsync(org); - } + Amount = transactionModel.MerchantGross, + CreationDate = transactionModel.PaymentDate, + OrganizationId = transactionModel.OrganizationId, + UserId = transactionModel.UserId, + Type = transactionModel.IsAccountCredit ? TransactionType.Credit : TransactionType.Charge, + Gateway = GatewayType.PayPal, + GatewayId = transactionModel.TransactionId, + PaymentMethodType = PaymentMethodType.PayPal, + Details = transactionModel.TransactionId + }; + + await _transactionRepository.CreateAsync(transaction); + + if (transactionModel.IsAccountCredit) + { + await ApplyCreditAsync(transaction); } } - else + // Catch foreign key violations because user/org could have been deleted. + catch (SqlException sqlException) when (sqlException.Number == 547) { - var user = await _userRepository.GetByIdAsync(tx.UserId.Value); - if (user != null) + _logger.LogError("PayPal IPN ({Id}): SQL Exception | {Message}", transactionModel.TransactionId, sqlException.Message); + } + + break; + } + case "Refunded" or "Reversed": + { + var existingTransaction = await _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, + transactionModel.TransactionId); + + if (existingTransaction != null) + { + _logger.LogWarning("PayPal IPN ({Id}): Already processed this refunded transaction", transactionModel.TransactionId); + return Ok(); + } + + var parentTransaction = await _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, + transactionModel.ParentTransactionId); + + if (parentTransaction == null) + { + _logger.LogError("PayPal IPN ({Id}): Could not find parent transaction", transactionModel.TransactionId); + return BadRequest(); + } + + var refundAmount = Math.Abs(transactionModel.MerchantGross); + + var remainingAmount = parentTransaction.Amount - parentTransaction.RefundedAmount.GetValueOrDefault(); + + if (refundAmount > 0 && !parentTransaction.Refunded.GetValueOrDefault() && remainingAmount >= refundAmount) + { + parentTransaction.RefundedAmount = parentTransaction.RefundedAmount.GetValueOrDefault() + refundAmount; + + if (parentTransaction.RefundedAmount == parentTransaction.Amount) { - billingEmail = user.BillingEmailAddress(); - if (await _paymentService.CreditAccountAsync(user, tx.Amount)) - { - await _userRepository.ReplaceAsync(user); - } + parentTransaction.Refunded = true; } + + await _transactionRepository.ReplaceAsync(parentTransaction); + + await _transactionRepository.CreateAsync(new Transaction + { + Amount = refundAmount, + CreationDate = transactionModel.PaymentDate, + OrganizationId = transactionModel.OrganizationId, + UserId = transactionModel.UserId, + Type = TransactionType.Refund, + Gateway = GatewayType.PayPal, + GatewayId = transactionModel.TransactionId, + PaymentMethodType = PaymentMethodType.PayPal, + Details = transactionModel.TransactionId + }); } - if (!string.IsNullOrWhiteSpace(billingEmail)) - { - await _mailService.SendAddedCreditAsync(billingEmail, tx.Amount); - } + break; } - } - // Catch foreign key violations because user/org could have been deleted. - catch (SqlException e) when (e.Number == 547) { } } - else if (ipnTransaction.PaymentStatus == "Refunded" || ipnTransaction.PaymentStatus == "Reversed") + + return Ok(); + } + + private async Task ApplyCreditAsync(Transaction transaction) + { + string billingEmail = null; + + if (transaction.OrganizationId.HasValue) { - var refundTransaction = await _transactionRepository.GetByGatewayIdAsync( - GatewayType.PayPal, ipnTransaction.TxnId); - if (refundTransaction != null) + var organization = await _organizationRepository.GetByIdAsync(transaction.OrganizationId.Value); + + if (await _paymentService.CreditAccountAsync(organization, transaction.Amount)) { - _logger.LogWarning("Already processed this refunded transaction. #" + ipnTransaction.TxnId); - return new OkResult(); + await _organizationRepository.ReplaceAsync(organization); + + billingEmail = organization.BillingEmailAddress(); } + } + else if (transaction.UserId.HasValue) + { + var user = await _userRepository.GetByIdAsync(transaction.UserId.Value); - var parentTransaction = await _transactionRepository.GetByGatewayIdAsync( - GatewayType.PayPal, ipnTransaction.ParentTxnId); - if (parentTransaction == null) + if (await _paymentService.CreditAccountAsync(user, transaction.Amount)) { - _logger.LogWarning("Parent transaction was not found. " + ipnTransaction.TxnId); - return new BadRequestResult(); - } + await _userRepository.ReplaceAsync(user); - var refundAmount = System.Math.Abs(ipnTransaction.McGross); - var remainingAmount = parentTransaction.Amount - - parentTransaction.RefundedAmount.GetValueOrDefault(); - if (refundAmount > 0 && !parentTransaction.Refunded.GetValueOrDefault() && - remainingAmount >= refundAmount) - { - parentTransaction.RefundedAmount = - parentTransaction.RefundedAmount.GetValueOrDefault() + refundAmount; - if (parentTransaction.RefundedAmount == parentTransaction.Amount) - { - parentTransaction.Refunded = true; - } - - await _transactionRepository.ReplaceAsync(parentTransaction); - await _transactionRepository.CreateAsync(new Transaction - { - Amount = refundAmount, - CreationDate = ipnTransaction.PaymentDate, - OrganizationId = ids.Item1, - UserId = ids.Item2, - Type = TransactionType.Refund, - Gateway = GatewayType.PayPal, - GatewayId = ipnTransaction.TxnId, - PaymentMethodType = PaymentMethodType.PayPal, - Details = ipnTransaction.TxnId - }); + billingEmail = user.BillingEmailAddress(); } } - return new OkResult(); + if (!string.IsNullOrEmpty(billingEmail)) + { + await _mailService.SendAddedCreditAsync(billingEmail, transaction.Amount); + } } } diff --git a/src/Billing/Models/PayPalIPNTransactionModel.cs b/src/Billing/Models/PayPalIPNTransactionModel.cs new file mode 100644 index 0000000000..c2d9f46579 --- /dev/null +++ b/src/Billing/Models/PayPalIPNTransactionModel.cs @@ -0,0 +1,110 @@ +using System.Globalization; +using System.Runtime.InteropServices; +using System.Web; + +namespace Bit.Billing.Models; + +public class PayPalIPNTransactionModel +{ + public string TransactionId { get; } + public string TransactionType { get; } + public string ParentTransactionId { get; } + public string PaymentStatus { get; } + public string PaymentType { get; } + public decimal MerchantGross { get; } + public string MerchantCurrency { get; } + public string ReceiverId { get; } + public DateTime PaymentDate { get; } + public Guid? UserId { get; } + public Guid? OrganizationId { get; } + public bool IsAccountCredit { get; } + + public PayPalIPNTransactionModel(string formData) + { + var queryString = HttpUtility.ParseQueryString(formData); + + var data = queryString + .AllKeys + .ToDictionary(key => key, key => queryString[key]); + + TransactionId = Extract(data, "txn_id"); + TransactionType = Extract(data, "txn_type"); + ParentTransactionId = Extract(data, "parent_txn_id"); + PaymentStatus = Extract(data, "payment_status"); + PaymentType = Extract(data, "payment_type"); + + var merchantGross = Extract(data, "mc_gross"); + if (!string.IsNullOrEmpty(merchantGross)) + { + MerchantGross = decimal.Parse(merchantGross); + } + + MerchantCurrency = Extract(data, "mc_currency"); + ReceiverId = Extract(data, "receiver_id"); + + var paymentDate = Extract(data, "payment_date"); + PaymentDate = ToUTCDateTime(paymentDate); + + var custom = Extract(data, "custom"); + + if (string.IsNullOrEmpty(custom)) + { + return; + } + + var metadata = custom.Split(',') + .Where(field => !string.IsNullOrEmpty(field) && field.Contains(':')) + .Select(field => field.Split(':')) + .ToDictionary(parts => parts[0], parts => parts[1]); + + if (metadata.TryGetValue("user_id", out var userIdStr) && + Guid.TryParse(userIdStr, out var userId)) + { + UserId = userId; + } + + if (metadata.TryGetValue("organization_id", out var organizationIdStr) && + Guid.TryParse(organizationIdStr, out var organizationId)) + { + OrganizationId = organizationId; + } + + IsAccountCredit = custom.Contains("account_credit:1"); + } + + private static string Extract(IReadOnlyDictionary data, string key) + { + var success = data.TryGetValue(key, out var value); + return success ? value : null; + } + + private static DateTime ToUTCDateTime(string input) + { + if (string.IsNullOrEmpty(input)) + { + return default; + } + + var success = DateTime.TryParseExact(input, + new[] + { + "HH:mm:ss dd MMM yyyy PDT", + "HH:mm:ss dd MMM yyyy PST", + "HH:mm:ss dd MMM, yyyy PST", + "HH:mm:ss dd MMM, yyyy PDT", + "HH:mm:ss MMM dd, yyyy PST", + "HH:mm:ss MMM dd, yyyy PDT" + }, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateTime); + + if (!success) + { + return default; + } + + var pacificTime = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time") + : TimeZoneInfo.FindSystemTimeZoneById("America/Los_Angeles"); + + return TimeZoneInfo.ConvertTimeToUtc(dateTime, pacificTime); + } +} diff --git a/src/Billing/Services/IPayPalIPNClient.cs b/src/Billing/Services/IPayPalIPNClient.cs new file mode 100644 index 0000000000..3b3d4cede3 --- /dev/null +++ b/src/Billing/Services/IPayPalIPNClient.cs @@ -0,0 +1,6 @@ +namespace Bit.Billing.Services; + +public interface IPayPalIPNClient +{ + Task VerifyIPN(Guid entityId, string formData); +} diff --git a/src/Billing/Services/Implementations/PayPalIPNClient.cs b/src/Billing/Services/Implementations/PayPalIPNClient.cs new file mode 100644 index 0000000000..f0f20499b6 --- /dev/null +++ b/src/Billing/Services/Implementations/PayPalIPNClient.cs @@ -0,0 +1,86 @@ +using System.Text; +using Microsoft.Extensions.Options; + +namespace Bit.Billing.Services.Implementations; + +public class PayPalIPNClient : IPayPalIPNClient +{ + private readonly HttpClient _httpClient; + private readonly Uri _ipnEndpoint; + private readonly ILogger _logger; + + public PayPalIPNClient( + IOptions billingSettings, + HttpClient httpClient, + ILogger logger) + { + _httpClient = httpClient; + _ipnEndpoint = new Uri(billingSettings.Value.PayPal.Production + ? "https://www.paypal.com/cgi-bin/webscr" + : "https://www.sandbox.paypal.com/cgi-bin/webscr"); + _logger = logger; + } + + public async Task VerifyIPN(Guid entityId, string formData) + { + LogInfo(entityId, $"Verifying IPN against {_ipnEndpoint}"); + + if (string.IsNullOrEmpty(formData)) + { + throw new ArgumentNullException(nameof(formData)); + } + + var requestMessage = new HttpRequestMessage { Method = HttpMethod.Post, RequestUri = _ipnEndpoint }; + + var requestContent = string.Concat("cmd=_notify-validate&", formData); + + LogInfo(entityId, $"Request Content: {requestContent}"); + + requestMessage.Content = new StringContent(requestContent, Encoding.UTF8, "application/x-www-form-urlencoded"); + + var response = await _httpClient.SendAsync(requestMessage); + + var responseContent = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode) + { + return responseContent switch + { + "VERIFIED" => Verified(), + "INVALID" => Invalid(), + _ => Unhandled(responseContent) + }; + } + + LogError(entityId, $"Unsuccessful Response | Status Code: {response.StatusCode} | Content: {responseContent}"); + + return false; + + bool Verified() + { + LogInfo(entityId, "Verified"); + return true; + } + + bool Invalid() + { + LogError(entityId, "Verification Invalid"); + return false; + } + + bool Unhandled(string content) + { + LogWarning(entityId, $"Unhandled Response Content: {content}"); + return false; + } + } + + private void LogInfo(Guid entityId, string message) + => _logger.LogInformation("Verify PayPal IPN ({RequestId}) | {Message}", entityId, message); + + private void LogWarning(Guid entityId, string message) + => _logger.LogWarning("Verify PayPal IPN ({RequestId}) | {Message}", entityId, message); + + private void LogError(Guid entityId, string message) + => _logger.LogError("Verify PayPal IPN ({RequestId}) | {Message}", entityId, message); +} diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index cbde05ce06..f4436f6c52 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -43,12 +43,12 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); - // PayPal Client - services.AddSingleton(); - // BitPay Client services.AddSingleton(); + // PayPal IPN Client + services.AddHttpClient(); + // Context services.AddScoped(); diff --git a/src/Billing/Utilities/PayPalIpnClient.cs b/src/Billing/Utilities/PayPalIpnClient.cs deleted file mode 100644 index 0534faf76c..0000000000 --- a/src/Billing/Utilities/PayPalIpnClient.cs +++ /dev/null @@ -1,176 +0,0 @@ -using System.Globalization; -using System.Runtime.InteropServices; -using System.Text; -using System.Web; -using Microsoft.Extensions.Options; - -namespace Bit.Billing.Utilities; - -public class PayPalIpnClient -{ - private readonly HttpClient _httpClient = new HttpClient(); - private readonly Uri _ipnUri; - private readonly ILogger _logger; - - public PayPalIpnClient(IOptions billingSettings, ILogger logger) - { - var bSettings = billingSettings?.Value; - _logger = logger; - _ipnUri = new Uri(bSettings.PayPal.Production ? "https://www.paypal.com/cgi-bin/webscr" : - "https://www.sandbox.paypal.com/cgi-bin/webscr"); - } - - public async Task VerifyIpnAsync(string ipnBody) - { - _logger.LogInformation("Verifying IPN with PayPal at {Timestamp}: {VerificationUri}", DateTime.UtcNow, _ipnUri); - if (ipnBody == null) - { - _logger.LogError("No IPN body."); - throw new ArgumentException("No IPN body."); - } - - var request = new HttpRequestMessage { Method = HttpMethod.Post, RequestUri = _ipnUri }; - var cmdIpnBody = string.Concat("cmd=_notify-validate&", ipnBody); - request.Content = new StringContent(cmdIpnBody, Encoding.UTF8, "application/x-www-form-urlencoded"); - var response = await _httpClient.SendAsync(request); - if (!response.IsSuccessStatusCode) - { - _logger.LogError("Failed to receive a successful response from PayPal IPN verification service. Response: {Response}", response); - throw new Exception("Failed to verify IPN, status: " + response.StatusCode); - } - - var responseContent = await response.Content.ReadAsStringAsync(); - if (responseContent.Equals("VERIFIED")) - { - return true; - } - - if (responseContent.Equals("INVALID")) - { - _logger.LogWarning("Received an INVALID response from PayPal: {ResponseContent}", responseContent); - return false; - } - - _logger.LogError("Failed to verify IPN: {ResponseContent}", responseContent); - throw new Exception("Failed to verify IPN."); - } - - public class IpnTransaction - { - private string[] _dateFormats = new string[] - { - "HH:mm:ss dd MMM yyyy PDT", "HH:mm:ss dd MMM yyyy PST", "HH:mm:ss dd MMM, yyyy PST", - "HH:mm:ss dd MMM, yyyy PDT","HH:mm:ss MMM dd, yyyy PST", "HH:mm:ss MMM dd, yyyy PDT" - }; - - public IpnTransaction(string ipnFormData) - { - if (string.IsNullOrWhiteSpace(ipnFormData)) - { - return; - } - - var qsData = HttpUtility.ParseQueryString(ipnFormData); - var dataDict = qsData.Keys.Cast().ToDictionary(k => k, v => qsData[v].ToString()); - - TxnId = GetDictValue(dataDict, "txn_id"); - TxnType = GetDictValue(dataDict, "txn_type"); - ParentTxnId = GetDictValue(dataDict, "parent_txn_id"); - PaymentStatus = GetDictValue(dataDict, "payment_status"); - PaymentType = GetDictValue(dataDict, "payment_type"); - McCurrency = GetDictValue(dataDict, "mc_currency"); - Custom = GetDictValue(dataDict, "custom"); - ItemName = GetDictValue(dataDict, "item_name"); - ItemNumber = GetDictValue(dataDict, "item_number"); - PayerId = GetDictValue(dataDict, "payer_id"); - PayerEmail = GetDictValue(dataDict, "payer_email"); - ReceiverId = GetDictValue(dataDict, "receiver_id"); - ReceiverEmail = GetDictValue(dataDict, "receiver_email"); - - PaymentDate = ConvertDate(GetDictValue(dataDict, "payment_date")); - - var mcGrossString = GetDictValue(dataDict, "mc_gross"); - if (!string.IsNullOrWhiteSpace(mcGrossString) && decimal.TryParse(mcGrossString, out var mcGross)) - { - McGross = mcGross; - } - var mcFeeString = GetDictValue(dataDict, "mc_fee"); - if (!string.IsNullOrWhiteSpace(mcFeeString) && decimal.TryParse(mcFeeString, out var mcFee)) - { - McFee = mcFee; - } - } - - public string TxnId { get; set; } - public string TxnType { get; set; } - public string ParentTxnId { get; set; } - public string PaymentStatus { get; set; } - public string PaymentType { get; set; } - public decimal McGross { get; set; } - public decimal McFee { get; set; } - public string McCurrency { get; set; } - public string Custom { get; set; } - public string ItemName { get; set; } - public string ItemNumber { get; set; } - public string PayerId { get; set; } - public string PayerEmail { get; set; } - public string ReceiverId { get; set; } - public string ReceiverEmail { get; set; } - public DateTime PaymentDate { get; set; } - - public Tuple GetIdsFromCustom() - { - Guid? orgId = null; - Guid? userId = null; - - if (!string.IsNullOrWhiteSpace(Custom) && Custom.Contains(":")) - { - var mainParts = Custom.Split(','); - foreach (var mainPart in mainParts) - { - var parts = mainPart.Split(':'); - if (parts.Length > 1 && Guid.TryParse(parts[1], out var id)) - { - if (parts[0] == "user_id") - { - userId = id; - } - else if (parts[0] == "organization_id") - { - orgId = id; - } - } - } - } - - return new Tuple(orgId, userId); - } - - public bool IsAccountCredit() - { - return !string.IsNullOrWhiteSpace(Custom) && Custom.Contains("account_credit:1"); - } - - private string GetDictValue(IDictionary dict, string key) - { - return dict.ContainsKey(key) ? dict[key] : null; - } - - private DateTime ConvertDate(string dateString) - { - if (!string.IsNullOrWhiteSpace(dateString)) - { - var parsed = DateTime.TryParseExact(dateString, _dateFormats, - CultureInfo.InvariantCulture, DateTimeStyles.None, out var paymentDate); - if (parsed) - { - var pacificTime = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? - TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time") : - TimeZoneInfo.FindSystemTimeZoneById("America/Los_Angeles"); - return TimeZoneInfo.ConvertTimeToUtc(paymentDate, pacificTime); - } - } - return default(DateTime); - } - } -} diff --git a/test/Billing.Test/Billing.Test.csproj b/test/Billing.Test/Billing.Test.csproj index 302f590ad1..0bd8368f4f 100644 --- a/test/Billing.Test/Billing.Test.csproj +++ b/test/Billing.Test/Billing.Test.csproj @@ -5,8 +5,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -44,6 +46,33 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + diff --git a/test/Billing.Test/Controllers/PayPalControllerTests.cs b/test/Billing.Test/Controllers/PayPalControllerTests.cs new file mode 100644 index 0000000000..7d4bfc36bd --- /dev/null +++ b/test/Billing.Test/Controllers/PayPalControllerTests.cs @@ -0,0 +1,644 @@ +using System.Text; +using Bit.Billing.Controllers; +using Bit.Billing.Services; +using Bit.Billing.Test.Utilities; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Divergic.Logging.Xunit; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; +using Xunit.Abstractions; +using Transaction = Bit.Core.Entities.Transaction; + +namespace Bit.Billing.Test.Controllers; + +public class PayPalControllerTests +{ + private readonly ITestOutputHelper _testOutputHelper; + + private readonly IOptions _billingSettings = Substitute.For>(); + private readonly IMailService _mailService = Substitute.For(); + private readonly IOrganizationRepository _organizationRepository = Substitute.For(); + private readonly IPaymentService _paymentService = Substitute.For(); + private readonly IPayPalIPNClient _payPalIPNClient = Substitute.For(); + private readonly ITransactionRepository _transactionRepository = Substitute.For(); + private readonly IUserRepository _userRepository = Substitute.For(); + + private const string _defaultWebhookKey = "webhook-key"; + + public PayPalControllerTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + + [Fact] + public async Task PostIpn_NullKey_BadRequest() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + var controller = ConfigureControllerContextWith(logger, null, null); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 400); + + LoggedError(logger, "PayPal IPN: Key is missing"); + } + + [Fact] + public async Task PostIpn_IncorrectKey_BadRequest() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = { WebhookKey = "INCORRECT" } + }); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, null); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 400); + + LoggedError(logger, "PayPal IPN: Key is incorrect"); + } + + [Fact] + public async Task PostIpn_EmptyIPNBody_BadRequest() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = { WebhookKey = _defaultWebhookKey } + }); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, null); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 400); + + LoggedError(logger, "PayPal IPN: Request body is null or empty"); + } + + [Fact] + public async Task PostIpn_IPNHasNoEntityId_BadRequest() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = { WebhookKey = _defaultWebhookKey } + }); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.TransactionMissingEntityIds); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 400); + + LoggedError(logger, "PayPal IPN (2PK15573S8089712Y): 'custom' did not contain a User ID or Organization ID"); + } + + [Fact] + public async Task PostIpn_Unverified_BadRequest() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = { WebhookKey = _defaultWebhookKey } + }); + + var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulPayment); + + _payPalIPNClient.VerifyIPN(organizationId, ipnBody).Returns(false); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 400); + + LoggedError(logger, "PayPal IPN (2PK15573S8089712Y): Verification failed"); + } + + [Fact] + public async Task PostIpn_OtherTransactionType_Unprocessed_Ok() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = { WebhookKey = _defaultWebhookKey } + }); + + var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.UnsupportedTransactionType); + + _payPalIPNClient.VerifyIPN(organizationId, ipnBody).Returns(true); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 200); + + LoggedWarning(logger, "PayPal IPN (2PK15573S8089712Y): Transaction type (other) not supported for payments"); + } + + [Fact] + public async Task PostIpn_MismatchedReceiverID_Unprocessed_Ok() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = + { + WebhookKey = _defaultWebhookKey, + BusinessId = "INCORRECT" + } + }); + + var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulPayment); + + _payPalIPNClient.VerifyIPN(organizationId, ipnBody).Returns(true); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 200); + + LoggedWarning(logger, "PayPal IPN (2PK15573S8089712Y): Receiver ID (NHDYKLQ3L4LWL) does not match Bitwarden business ID (INCORRECT)"); + } + + [Fact] + public async Task PostIpn_RefundMissingParent_Unprocessed_Ok() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = + { + WebhookKey = _defaultWebhookKey, + BusinessId = "NHDYKLQ3L4LWL" + } + }); + + var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.RefundMissingParentTransaction); + + _payPalIPNClient.VerifyIPN(organizationId, ipnBody).Returns(true); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 200); + + LoggedWarning(logger, "PayPal IPN (2PK15573S8089712Y): Parent transaction ID is required for refund"); + } + + [Fact] + public async Task PostIpn_eCheckPayment_Unprocessed_Ok() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = + { + WebhookKey = _defaultWebhookKey, + BusinessId = "NHDYKLQ3L4LWL" + } + }); + + var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.ECheckPayment); + + _payPalIPNClient.VerifyIPN(organizationId, ipnBody).Returns(true); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 200); + + LoggedWarning(logger, "PayPal IPN (2PK15573S8089712Y): Transaction was an eCheck payment"); + } + + [Fact] + public async Task PostIpn_NonUSD_Unprocessed_Ok() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = + { + WebhookKey = _defaultWebhookKey, + BusinessId = "NHDYKLQ3L4LWL" + } + }); + + var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.NonUSDPayment); + + _payPalIPNClient.VerifyIPN(organizationId, ipnBody).Returns(true); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 200); + + LoggedWarning(logger, "PayPal IPN (2PK15573S8089712Y): Transaction was not in USD (CAD)"); + } + + [Fact] + public async Task PostIpn_Completed_ExistingTransaction_Unprocessed_Ok() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = + { + WebhookKey = _defaultWebhookKey, + BusinessId = "NHDYKLQ3L4LWL" + } + }); + + var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulPayment); + + _payPalIPNClient.VerifyIPN(organizationId, ipnBody).Returns(true); + + _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, + "2PK15573S8089712Y").Returns(new Transaction()); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 200); + + LoggedWarning(logger, "PayPal IPN (2PK15573S8089712Y): Already processed this completed transaction"); + } + + [Fact] + public async Task PostIpn_Completed_CreatesTransaction_Ok() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = + { + WebhookKey = _defaultWebhookKey, + BusinessId = "NHDYKLQ3L4LWL" + } + }); + + var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulPayment); + + _payPalIPNClient.VerifyIPN(organizationId, ipnBody).Returns(true); + + _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, + "2PK15573S8089712Y").ReturnsNull(); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 200); + + await _transactionRepository.Received().CreateAsync(Arg.Any()); + + await _paymentService.DidNotReceiveWithAnyArgs().CreditAccountAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task PostIpn_Completed_CreatesTransaction_CreditsOrganizationAccount_Ok() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = + { + WebhookKey = _defaultWebhookKey, + BusinessId = "NHDYKLQ3L4LWL" + } + }); + + var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulPaymentForOrganizationCredit); + + _payPalIPNClient.VerifyIPN(organizationId, ipnBody).Returns(true); + + _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, + "2PK15573S8089712Y").ReturnsNull(); + + const string billingEmail = "billing@organization.com"; + + var organization = new Organization { BillingEmail = billingEmail }; + + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + _paymentService.CreditAccountAsync(organization, 48M).Returns(true); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 200); + + await _transactionRepository.Received(1).CreateAsync(Arg.Is(transaction => + transaction.GatewayId == "2PK15573S8089712Y" && + transaction.OrganizationId == organizationId && + transaction.Amount == 48M)); + + await _paymentService.Received(1).CreditAccountAsync(organization, 48M); + + await _organizationRepository.Received(1).ReplaceAsync(organization); + + await _mailService.Received(1).SendAddedCreditAsync(billingEmail, 48M); + } + + [Fact] + public async Task PostIpn_Completed_CreatesTransaction_CreditsUserAccount_Ok() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = + { + WebhookKey = _defaultWebhookKey, + BusinessId = "NHDYKLQ3L4LWL" + } + }); + + var userId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulPaymentForUserCredit); + + _payPalIPNClient.VerifyIPN(userId, ipnBody).Returns(true); + + _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, + "2PK15573S8089712Y").ReturnsNull(); + + const string billingEmail = "billing@user.com"; + + var user = new User { Email = billingEmail }; + + _userRepository.GetByIdAsync(userId).Returns(user); + + _paymentService.CreditAccountAsync(user, 48M).Returns(true); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 200); + + await _transactionRepository.Received(1).CreateAsync(Arg.Is(transaction => + transaction.GatewayId == "2PK15573S8089712Y" && + transaction.UserId == userId && + transaction.Amount == 48M)); + + await _paymentService.Received(1).CreditAccountAsync(user, 48M); + + await _userRepository.Received(1).ReplaceAsync(user); + + await _mailService.Received(1).SendAddedCreditAsync(billingEmail, 48M); + } + + [Fact] + public async Task PostIpn_Refunded_ExistingTransaction_Unprocessed_Ok() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = + { + WebhookKey = _defaultWebhookKey, + BusinessId = "NHDYKLQ3L4LWL" + } + }); + + var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulRefund); + + _payPalIPNClient.VerifyIPN(organizationId, ipnBody).Returns(true); + + _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, + "2PK15573S8089712Y").Returns(new Transaction()); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 200); + + LoggedWarning(logger, "PayPal IPN (2PK15573S8089712Y): Already processed this refunded transaction"); + + await _transactionRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(Arg.Any()); + + await _transactionRepository.DidNotReceiveWithAnyArgs().CreateAsync(Arg.Any()); + } + + [Fact] + public async Task PostIpn_Refunded_MissingParentTransaction_BadRequest() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = + { + WebhookKey = _defaultWebhookKey, + BusinessId = "NHDYKLQ3L4LWL" + } + }); + + var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulRefund); + + _payPalIPNClient.VerifyIPN(organizationId, ipnBody).Returns(true); + + _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, + "2PK15573S8089712Y").ReturnsNull(); + + _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, + "PARENT").ReturnsNull(); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 400); + + LoggedError(logger, "PayPal IPN (2PK15573S8089712Y): Could not find parent transaction"); + + await _transactionRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(Arg.Any()); + + await _transactionRepository.DidNotReceiveWithAnyArgs().CreateAsync(Arg.Any()); + } + + [Fact] + public async Task PostIpn_Refunded_ReplacesParent_CreatesTransaction_Ok() + { + var logger = _testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = + { + WebhookKey = _defaultWebhookKey, + BusinessId = "NHDYKLQ3L4LWL" + } + }); + + var organizationId = new Guid("ca8c6f2b-2d7b-4639-809f-b0e5013a304e"); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulRefund); + + _payPalIPNClient.VerifyIPN(organizationId, ipnBody).Returns(true); + + _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, + "2PK15573S8089712Y").ReturnsNull(); + + var parentTransaction = new Transaction + { + GatewayId = "PARENT", + Amount = 48M, + RefundedAmount = 0, + Refunded = false + }; + + _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, + "PARENT").Returns(parentTransaction); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 200); + + await _transactionRepository.Received(1).ReplaceAsync(Arg.Is(transaction => + transaction.GatewayId == "PARENT" && + transaction.RefundedAmount == 48M && + transaction.Refunded == true)); + + await _transactionRepository.Received(1).CreateAsync(Arg.Is(transaction => + transaction.GatewayId == "2PK15573S8089712Y" && + transaction.Amount == 48M && + transaction.OrganizationId == organizationId && + transaction.Type == TransactionType.Refund)); + } + + private PayPalController ConfigureControllerContextWith( + ILogger logger, + string webhookKey, + string ipnBody) + { + var controller = new PayPalController( + _billingSettings, + logger, + _mailService, + _organizationRepository, + _paymentService, + _payPalIPNClient, + _transactionRepository, + _userRepository); + + var httpContext = new DefaultHttpContext(); + + if (!string.IsNullOrEmpty(webhookKey)) + { + httpContext.Request.Query = new QueryCollection(new Dictionary + { + { "key", new StringValues(webhookKey) } + }); + } + + if (!string.IsNullOrEmpty(ipnBody)) + { + var memoryStream = new MemoryStream(Encoding.UTF8.GetBytes(ipnBody)); + + httpContext.Request.Body = memoryStream; + httpContext.Request.ContentLength = memoryStream.Length; + } + + controller.ControllerContext = new ControllerContext + { + HttpContext = httpContext + }; + + return controller; + } + + private static void HasStatusCode(IActionResult result, int statusCode) + { + var statusCodeActionResult = (IStatusCodeActionResult)result; + + statusCodeActionResult.StatusCode.Should().Be(statusCode); + } + + private static void Logged(ICacheLogger logger, LogLevel logLevel, string message) + { + logger.Last.Should().NotBeNull(); + logger.Last!.LogLevel.Should().Be(logLevel); + logger.Last!.Message.Should().Be(message); + } + + private static void LoggedError(ICacheLogger logger, string message) + => Logged(logger, LogLevel.Error, message); + + private static void LoggedWarning(ICacheLogger logger, string message) + => Logged(logger, LogLevel.Warning, message); +} diff --git a/test/Billing.Test/Resources/IPN/echeck-payment.txt b/test/Billing.Test/Resources/IPN/echeck-payment.txt new file mode 100644 index 0000000000..40294f014a --- /dev/null +++ b/test/Billing.Test/Resources/IPN/echeck-payment.txt @@ -0,0 +1,40 @@ +mc_gross=48.00& +mp_custom=& +mp_currency=USD& +protection_eligibility=Eligible& +payer_id=SVELHYY6G7TJ4& +payment_date=11%3A07%3A43+Dec+27%2C+2023+PST& +mp_id=B-4DP02332FD689211K& +payment_status=Completed& +charset=UTF-8& +first_name=John& +mp_status=0& +mc_fee=2.17& +notify_version=3.9& +custom=organization_id%3Aca8c6f2b-2d7b-4639-809f-b0e5013a304e%2Cregion%3AUS& +payer_status=verified& +business=sb-edwkp27927299%40business.example.com& +quantity=1& +verify_sign=ADVh..MARsZyzWEIrCQ5ouOAs1ILA3B0hB.p9fXf41nrdYnQQO5Xid7G& +payer_email=sb-xuhf727950096%40personal.example.com& +txn_id=2PK15573S8089712Y& +payment_type=echeck& +last_name=Doe& +mp_desc=& +receiver_email=sb-edwkp27927299%40business.example.com& +payment_fee=2.17& +mp_cycle_start=30& +shipping_discount=0.00& +insurance_amount=0.00& +receiver_id=NHDYKLQ3L4LWL& +txn_type=merch_pmt& +item_name=& +discount=0.00& +mc_currency=USD& +item_number=& +residence_country=US& +test_ipn=1& +shipping_method=Default& +transaction_subject=& +payment_gross=48.00& +ipn_track_id=769757969c134 diff --git a/test/Billing.Test/Resources/IPN/non-usd-payment.txt b/test/Billing.Test/Resources/IPN/non-usd-payment.txt new file mode 100644 index 0000000000..593308f97e --- /dev/null +++ b/test/Billing.Test/Resources/IPN/non-usd-payment.txt @@ -0,0 +1,40 @@ +mc_gross=48.00& +mp_custom=& +mp_currency=CAD& +protection_eligibility=Eligible& +payer_id=SVELHYY6G7TJ4& +payment_date=11%3A07%3A43+Dec+27%2C+2023+PST& +mp_id=B-4DP02332FD689211K& +payment_status=Completed& +charset=UTF-8& +first_name=John& +mp_status=0& +mc_fee=2.17& +notify_version=3.9& +custom=organization_id%3Aca8c6f2b-2d7b-4639-809f-b0e5013a304e%2Cregion%3AUS& +payer_status=verified& +business=sb-edwkp27927299%40business.example.com& +quantity=1& +verify_sign=ADVh..MARsZyzWEIrCQ5ouOAs1ILA3B0hB.p9fXf41nrdYnQQO5Xid7G& +payer_email=sb-xuhf727950096%40personal.example.com& +txn_id=2PK15573S8089712Y& +payment_type=instant& +last_name=Doe& +mp_desc=& +receiver_email=sb-edwkp27927299%40business.example.com& +payment_fee=2.17& +mp_cycle_start=30& +shipping_discount=0.00& +insurance_amount=0.00& +receiver_id=NHDYKLQ3L4LWL& +txn_type=merch_pmt& +item_name=& +discount=0.00& +mc_currency=CAD& +item_number=& +residence_country=US& +test_ipn=1& +shipping_method=Default& +transaction_subject=& +payment_gross=48.00& +ipn_track_id=769757969c134 diff --git a/test/Billing.Test/Resources/IPN/refund-missing-parent-transaction.txt b/test/Billing.Test/Resources/IPN/refund-missing-parent-transaction.txt new file mode 100644 index 0000000000..f3228c24d3 --- /dev/null +++ b/test/Billing.Test/Resources/IPN/refund-missing-parent-transaction.txt @@ -0,0 +1,40 @@ +mc_gross=48.00& +mp_custom=& +mp_currency=USD& +protection_eligibility=Eligible& +payer_id=SVELHYY6G7TJ4& +payment_date=11%3A07%3A43+Dec+27%2C+2023+PST& +mp_id=B-4DP02332FD689211K& +payment_status=Refunded& +charset=UTF-8& +first_name=John& +mp_status=0& +mc_fee=2.17& +notify_version=3.9& +custom=organization_id%3Aca8c6f2b-2d7b-4639-809f-b0e5013a304e%2Cregion%3AUS& +payer_status=verified& +business=sb-edwkp27927299%40business.example.com& +quantity=1& +verify_sign=ADVh..MARsZyzWEIrCQ5ouOAs1ILA3B0hB.p9fXf41nrdYnQQO5Xid7G& +payer_email=sb-xuhf727950096%40personal.example.com& +txn_id=2PK15573S8089712Y& +payment_type=instant& +last_name=Doe& +mp_desc=& +receiver_email=sb-edwkp27927299%40business.example.com& +payment_fee=2.17& +mp_cycle_start=30& +shipping_discount=0.00& +insurance_amount=0.00& +receiver_id=NHDYKLQ3L4LWL& +txn_type=merch_pmt& +item_name=& +discount=0.00& +mc_currency=USD& +item_number=& +residence_country=US& +test_ipn=1& +shipping_method=Default& +transaction_subject=& +payment_gross=48.00& +ipn_track_id=769757969c134 diff --git a/test/Billing.Test/Resources/IPN/successful-payment-org-credit.txt b/test/Billing.Test/Resources/IPN/successful-payment-org-credit.txt new file mode 100644 index 0000000000..7ea976adc1 --- /dev/null +++ b/test/Billing.Test/Resources/IPN/successful-payment-org-credit.txt @@ -0,0 +1,40 @@ +mc_gross=48.00& +mp_custom=& +mp_currency=USD& +protection_eligibility=Eligible& +payer_id=SVELHYY6G7TJ4& +payment_date=11%3A07%3A43+Dec+27%2C+2023+PST& +mp_id=B-4DP02332FD689211K& +payment_status=Completed& +charset=UTF-8& +first_name=John& +mp_status=0& +mc_fee=2.17& +notify_version=3.9& +custom=organization_id%3Aca8c6f2b-2d7b-4639-809f-b0e5013a304e%2Cregion%3AUS%2Caccount_credit%3A1& +payer_status=verified& +business=sb-edwkp27927299%40business.example.com& +quantity=1& +verify_sign=ADVh..MARsZyzWEIrCQ5ouOAs1ILA3B0hB.p9fXf41nrdYnQQO5Xid7G& +payer_email=sb-xuhf727950096%40personal.example.com& +txn_id=2PK15573S8089712Y& +payment_type=instant& +last_name=Doe& +mp_desc=& +receiver_email=sb-edwkp27927299%40business.example.com& +payment_fee=2.17& +mp_cycle_start=30& +shipping_discount=0.00& +insurance_amount=0.00& +receiver_id=NHDYKLQ3L4LWL& +txn_type=merch_pmt& +item_name=& +discount=0.00& +mc_currency=USD& +item_number=& +residence_country=US& +test_ipn=1& +shipping_method=Default& +transaction_subject=& +payment_gross=48.00& +ipn_track_id=769757969c134 diff --git a/test/Billing.Test/Resources/IPN/successful-payment-user-credit.txt b/test/Billing.Test/Resources/IPN/successful-payment-user-credit.txt new file mode 100644 index 0000000000..714d143ba5 --- /dev/null +++ b/test/Billing.Test/Resources/IPN/successful-payment-user-credit.txt @@ -0,0 +1,40 @@ +mc_gross=48.00& +mp_custom=& +mp_currency=USD& +protection_eligibility=Eligible& +payer_id=SVELHYY6G7TJ4& +payment_date=11%3A07%3A43+Dec+27%2C+2023+PST& +mp_id=B-4DP02332FD689211K& +payment_status=Completed& +charset=UTF-8& +first_name=John& +mp_status=0& +mc_fee=2.17& +notify_version=3.9& +custom=user_id%3Aca8c6f2b-2d7b-4639-809f-b0e5013a304e%2Cregion%3AUS%2Caccount_credit%3A1& +payer_status=verified& +business=sb-edwkp27927299%40business.example.com& +quantity=1& +verify_sign=ADVh..MARsZyzWEIrCQ5ouOAs1ILA3B0hB.p9fXf41nrdYnQQO5Xid7G& +payer_email=sb-xuhf727950096%40personal.example.com& +txn_id=2PK15573S8089712Y& +payment_type=instant& +last_name=Doe& +mp_desc=& +receiver_email=sb-edwkp27927299%40business.example.com& +payment_fee=2.17& +mp_cycle_start=30& +shipping_discount=0.00& +insurance_amount=0.00& +receiver_id=NHDYKLQ3L4LWL& +txn_type=merch_pmt& +item_name=& +discount=0.00& +mc_currency=USD& +item_number=& +residence_country=US& +test_ipn=1& +shipping_method=Default& +transaction_subject=& +payment_gross=48.00& +ipn_track_id=769757969c134 diff --git a/test/Billing.Test/Resources/IPN/successful-payment.txt b/test/Billing.Test/Resources/IPN/successful-payment.txt new file mode 100644 index 0000000000..3192fedd69 --- /dev/null +++ b/test/Billing.Test/Resources/IPN/successful-payment.txt @@ -0,0 +1,40 @@ +mc_gross=48.00& +mp_custom=& +mp_currency=USD& +protection_eligibility=Eligible& +payer_id=SVELHYY6G7TJ4& +payment_date=11%3A07%3A43+Dec+27%2C+2023+PST& +mp_id=B-4DP02332FD689211K& +payment_status=Completed& +charset=UTF-8& +first_name=John& +mp_status=0& +mc_fee=2.17& +notify_version=3.9& +custom=organization_id%3Aca8c6f2b-2d7b-4639-809f-b0e5013a304e%2Cregion%3AUS& +payer_status=verified& +business=sb-edwkp27927299%40business.example.com& +quantity=1& +verify_sign=ADVh..MARsZyzWEIrCQ5ouOAs1ILA3B0hB.p9fXf41nrdYnQQO5Xid7G& +payer_email=sb-xuhf727950096%40personal.example.com& +txn_id=2PK15573S8089712Y& +payment_type=instant& +last_name=Doe& +mp_desc=& +receiver_email=sb-edwkp27927299%40business.example.com& +payment_fee=2.17& +mp_cycle_start=30& +shipping_discount=0.00& +insurance_amount=0.00& +receiver_id=NHDYKLQ3L4LWL& +txn_type=merch_pmt& +item_name=& +discount=0.00& +mc_currency=USD& +item_number=& +residence_country=US& +test_ipn=1& +shipping_method=Default& +transaction_subject=& +payment_gross=48.00& +ipn_track_id=769757969c134 diff --git a/test/Billing.Test/Resources/IPN/successful-refund.txt b/test/Billing.Test/Resources/IPN/successful-refund.txt new file mode 100644 index 0000000000..d77093d330 --- /dev/null +++ b/test/Billing.Test/Resources/IPN/successful-refund.txt @@ -0,0 +1,41 @@ +mc_gross=48.00& +mp_custom=& +mp_currency=USD& +protection_eligibility=Eligible& +payer_id=SVELHYY6G7TJ4& +payment_date=11%3A07%3A43+Dec+27%2C+2023+PST& +mp_id=B-4DP02332FD689211K& +payment_status=Refunded& +charset=UTF-8& +first_name=John& +mp_status=0& +mc_fee=2.17& +notify_version=3.9& +custom=organization_id%3Aca8c6f2b-2d7b-4639-809f-b0e5013a304e%2Cregion%3AUS& +payer_status=verified& +business=sb-edwkp27927299%40business.example.com& +quantity=1& +verify_sign=ADVh..MARsZyzWEIrCQ5ouOAs1ILA3B0hB.p9fXf41nrdYnQQO5Xid7G& +payer_email=sb-xuhf727950096%40personal.example.com& +txn_id=2PK15573S8089712Y& +payment_type=instant& +last_name=Doe& +mp_desc=& +receiver_email=sb-edwkp27927299%40business.example.com& +payment_fee=2.17& +mp_cycle_start=30& +shipping_discount=0.00& +insurance_amount=0.00& +receiver_id=NHDYKLQ3L4LWL& +txn_type=merch_pmt& +item_name=& +discount=0.00& +mc_currency=USD& +item_number=& +residence_country=US& +test_ipn=1& +shipping_method=Default& +transaction_subject=& +payment_gross=48.00& +ipn_track_id=769757969c134& +parent_txn_id=PARENT diff --git a/test/Billing.Test/Resources/IPN/transaction-missing-entity-ids.txt b/test/Billing.Test/Resources/IPN/transaction-missing-entity-ids.txt new file mode 100644 index 0000000000..5156ff4480 --- /dev/null +++ b/test/Billing.Test/Resources/IPN/transaction-missing-entity-ids.txt @@ -0,0 +1,40 @@ +mc_gross=48.00& +mp_custom=& +mp_currency=USD& +protection_eligibility=Eligible& +payer_id=SVELHYY6G7TJ4& +payment_date=11%3A07%3A43+Dec+27%2C+2023+PST& +mp_id=B-4DP02332FD689211K& +payment_status=Completed& +charset=UTF-8& +first_name=John& +mp_status=0& +mc_fee=2.17& +notify_version=3.9& +custom=region%3AUS& +payer_status=verified& +business=sb-edwkp27927299%40business.example.com& +quantity=1& +verify_sign=ADVh..MARsZyzWEIrCQ5ouOAs1ILA3B0hB.p9fXf41nrdYnQQO5Xid7G& +payer_email=sb-xuhf727950096%40personal.example.com& +txn_id=2PK15573S8089712Y& +payment_type=instant& +last_name=Doe& +mp_desc=& +receiver_email=sb-edwkp27927299%40business.example.com& +payment_fee=2.17& +mp_cycle_start=30& +shipping_discount=0.00& +insurance_amount=0.00& +receiver_id=NHDYKLQ3L4LWL& +txn_type=merch_pmt& +item_name=& +discount=0.00& +mc_currency=USD& +item_number=& +residence_country=US& +test_ipn=1& +shipping_method=Default& +transaction_subject=& +payment_gross=48.00& +ipn_track_id=769757969c134 diff --git a/test/Billing.Test/Resources/IPN/unsupported-transaction-type.txt b/test/Billing.Test/Resources/IPN/unsupported-transaction-type.txt new file mode 100644 index 0000000000..8d261d9820 --- /dev/null +++ b/test/Billing.Test/Resources/IPN/unsupported-transaction-type.txt @@ -0,0 +1,40 @@ +mc_gross=48.00& +mp_custom=& +mp_currency=USD& +protection_eligibility=Eligible& +payer_id=SVELHYY6G7TJ4& +payment_date=11%3A07%3A43+Dec+27%2C+2023+PST& +mp_id=B-4DP02332FD689211K& +payment_status=Completed& +charset=UTF-8& +first_name=John& +mp_status=0& +mc_fee=2.17& +notify_version=3.9& +custom=organization_id%3Aca8c6f2b-2d7b-4639-809f-b0e5013a304e%2Cregion%3AUS& +payer_status=verified& +business=sb-edwkp27927299%40business.example.com& +quantity=1& +verify_sign=ADVh..MARsZyzWEIrCQ5ouOAs1ILA3B0hB.p9fXf41nrdYnQQO5Xid7G& +payer_email=sb-xuhf727950096%40personal.example.com& +txn_id=2PK15573S8089712Y& +payment_type=instant& +last_name=Doe& +mp_desc=& +receiver_email=sb-edwkp27927299%40business.example.com& +payment_fee=2.17& +mp_cycle_start=30& +shipping_discount=0.00& +insurance_amount=0.00& +receiver_id=NHDYKLQ3L4LWL& +txn_type=other& +item_name=& +discount=0.00& +mc_currency=USD& +item_number=& +residence_country=US& +test_ipn=1& +shipping_method=Default& +transaction_subject=& +payment_gross=48.00& +ipn_track_id=769757969c134 diff --git a/test/Billing.Test/Services/PayPalIPNClientTests.cs b/test/Billing.Test/Services/PayPalIPNClientTests.cs new file mode 100644 index 0000000000..cbf2997c06 --- /dev/null +++ b/test/Billing.Test/Services/PayPalIPNClientTests.cs @@ -0,0 +1,86 @@ +using System.Net; +using Bit.Billing.Services; +using Bit.Billing.Services.Implementations; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using RichardSzalay.MockHttp; +using Xunit; + +namespace Bit.Billing.Test.Services; + +public class PayPalIPNClientTests +{ + private readonly Uri _endpoint = new("https://www.sandbox.paypal.com/cgi-bin/webscr"); + private readonly MockHttpMessageHandler _mockHttpMessageHandler = new(); + + private readonly IOptions _billingSettings = Substitute.For>(); + private readonly ILogger _logger = Substitute.For>(); + + private readonly IPayPalIPNClient _payPalIPNClient; + + public PayPalIPNClientTests() + { + var httpClient = new HttpClient(_mockHttpMessageHandler) + { + BaseAddress = _endpoint + }; + + _payPalIPNClient = new PayPalIPNClient( + _billingSettings, + httpClient, + _logger); + } + + [Fact] + public async Task VerifyIPN_FormDataNull_ThrowsArgumentNullException() + => await Assert.ThrowsAsync(() => _payPalIPNClient.VerifyIPN(Guid.NewGuid(), null)); + + [Fact] + public async Task VerifyIPN_Unauthorized_ReturnsFalse() + { + const string formData = "form=data"; + + var request = _mockHttpMessageHandler + .Expect(HttpMethod.Post, _endpoint.ToString()) + .WithFormData(new Dictionary { { "cmd", "_notify-validate" }, { "form", "data" } }) + .Respond(HttpStatusCode.Unauthorized); + + var verified = await _payPalIPNClient.VerifyIPN(Guid.NewGuid(), formData); + + Assert.False(verified); + Assert.Equal(1, _mockHttpMessageHandler.GetMatchCount(request)); + } + + [Fact] + public async Task VerifyIPN_OK_Invalid_ReturnsFalse() + { + const string formData = "form=data"; + + var request = _mockHttpMessageHandler + .Expect(HttpMethod.Post, _endpoint.ToString()) + .WithFormData(new Dictionary { { "cmd", "_notify-validate" }, { "form", "data" } }) + .Respond("application/text", "INVALID"); + + var verified = await _payPalIPNClient.VerifyIPN(Guid.NewGuid(), formData); + + Assert.False(verified); + Assert.Equal(1, _mockHttpMessageHandler.GetMatchCount(request)); + } + + [Fact] + public async Task VerifyIPN_OK_Verified_ReturnsTrue() + { + const string formData = "form=data"; + + var request = _mockHttpMessageHandler + .Expect(HttpMethod.Post, _endpoint.ToString()) + .WithFormData(new Dictionary { { "cmd", "_notify-validate" }, { "form", "data" } }) + .Respond("application/text", "VERIFIED"); + + var verified = await _payPalIPNClient.VerifyIPN(Guid.NewGuid(), formData); + + Assert.True(verified); + Assert.Equal(1, _mockHttpMessageHandler.GetMatchCount(request)); + } +} diff --git a/test/Billing.Test/Utilities/PayPalTestIPN.cs b/test/Billing.Test/Utilities/PayPalTestIPN.cs new file mode 100644 index 0000000000..2697851a87 --- /dev/null +++ b/test/Billing.Test/Utilities/PayPalTestIPN.cs @@ -0,0 +1,37 @@ +namespace Bit.Billing.Test.Utilities; + +public enum IPNBody +{ + SuccessfulPayment, + ECheckPayment, + TransactionMissingEntityIds, + NonUSDPayment, + SuccessfulPaymentForOrganizationCredit, + UnsupportedTransactionType, + SuccessfulRefund, + RefundMissingParentTransaction, + SuccessfulPaymentForUserCredit +} + +public static class PayPalTestIPN +{ + public static async Task GetAsync(IPNBody ipnBody) + { + var fileName = ipnBody switch + { + IPNBody.ECheckPayment => "echeck-payment.txt", + IPNBody.NonUSDPayment => "non-usd-payment.txt", + IPNBody.RefundMissingParentTransaction => "refund-missing-parent-transaction.txt", + IPNBody.SuccessfulPayment => "successful-payment.txt", + IPNBody.SuccessfulPaymentForOrganizationCredit => "successful-payment-org-credit.txt", + IPNBody.SuccessfulRefund => "successful-refund.txt", + IPNBody.SuccessfulPaymentForUserCredit => "successful-payment-user-credit.txt", + IPNBody.TransactionMissingEntityIds => "transaction-missing-entity-ids.txt", + IPNBody.UnsupportedTransactionType => "unsupported-transaction-type.txt" + }; + + var content = await EmbeddedResourceReader.ReadAsync("IPN", fileName); + + return content.Replace("\n", string.Empty); + } +} From 7180a6618ea2e047f4c3790e4866e130ee80e007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Tue, 30 Jan 2024 16:18:18 +0000 Subject: [PATCH 046/117] [PM-5873 / PM-5932] Fix collection creation by users other than the Organization owner (#3721) * [AC-2106] Add check for providers and additional check for null response * [PM-5873] Separated CollectionsController.Post flexible collections logic from non-migrated orgs --------- Co-authored-by: Shane Melton --- src/Api/Controllers/CollectionsController.cs | 58 ++++++++++++++------ 1 file changed, 41 insertions(+), 17 deletions(-) diff --git a/src/Api/Controllers/CollectionsController.cs b/src/Api/Controllers/CollectionsController.cs index 6c10805035..ba3647b25e 100644 --- a/src/Api/Controllers/CollectionsController.cs +++ b/src/Api/Controllers/CollectionsController.cs @@ -213,12 +213,15 @@ public class CollectionsController : Controller [HttpPost("")] public async Task Post(Guid orgId, [FromBody] CollectionRequestModel model) { + if (await FlexibleCollectionsIsEnabledAsync(orgId)) + { + // New flexible collections logic + return await Post_vNext(orgId, model); + } + var collection = model.ToCollection(orgId); - var flexibleCollectionsIsEnabled = await FlexibleCollectionsIsEnabledAsync(orgId); - var authorized = flexibleCollectionsIsEnabled - ? (await _authorizationService.AuthorizeAsync(User, collection, BulkCollectionOperations.Create)).Succeeded - : await CanCreateCollection(orgId, collection.Id) || await CanEditCollectionAsync(orgId, collection.Id); + var authorized = await CanCreateCollection(orgId, collection.Id) || await CanEditCollectionAsync(orgId, collection.Id); if (!authorized) { throw new NotFoundException(); @@ -229,7 +232,6 @@ public class CollectionsController : Controller // Pre-flexible collections logic assigned Managers to collections they create var assignUserToCollection = - !flexibleCollectionsIsEnabled && !await _currentContext.EditAnyCollection(orgId) && await _currentContext.EditAssignedCollections(orgId); var isNewCollection = collection.Id == default; @@ -251,16 +253,7 @@ public class CollectionsController : Controller await _collectionService.SaveAsync(collection, groups, users); - if (!_currentContext.UserId.HasValue) - { - return new CollectionResponseModel(collection); - } - - // If we have a user, fetch the collection to get the latest permission details - var userCollectionDetails = await _collectionRepository.GetByIdAsync(collection.Id, - _currentContext.UserId.Value, await FlexibleCollectionsIsEnabledAsync(collection.OrganizationId)); - - return new CollectionDetailsResponseModel(userCollectionDetails); + return new CollectionResponseModel(collection); } [HttpPut("{id}")] @@ -616,6 +609,35 @@ public class CollectionsController : Controller return responses; } + private async Task Post_vNext(Guid orgId, [FromBody] CollectionRequestModel model) + { + var collection = model.ToCollection(orgId); + + var authorized = (await _authorizationService.AuthorizeAsync(User, collection, BulkCollectionOperations.Create)).Succeeded; + if (!authorized) + { + throw new NotFoundException(); + } + + var groups = model.Groups?.Select(g => g.ToSelectionReadOnly()); + var users = model.Users?.Select(g => g.ToSelectionReadOnly()).ToList() ?? new List(); + + await _collectionService.SaveAsync(collection, groups, users); + + if (!_currentContext.UserId.HasValue || await _currentContext.ProviderUserForOrgAsync(orgId)) + { + return new CollectionResponseModel(collection); + } + + // If we have a user, fetch the collection to get the latest permission details + var userCollectionDetails = await _collectionRepository.GetByIdAsync(collection.Id, + _currentContext.UserId.Value, await FlexibleCollectionsIsEnabledAsync(collection.OrganizationId)); + + return userCollectionDetails == null + ? new CollectionResponseModel(collection) + : new CollectionDetailsResponseModel(userCollectionDetails); + } + private async Task Put_vNext(Guid id, CollectionRequestModel model) { var collection = await _collectionRepository.GetByIdAsync(id); @@ -629,7 +651,7 @@ public class CollectionsController : Controller var users = model.Users?.Select(g => g.ToSelectionReadOnly()); await _collectionService.SaveAsync(model.ToCollection(collection), groups, users); - if (!_currentContext.UserId.HasValue) + if (!_currentContext.UserId.HasValue || await _currentContext.ProviderUserForOrgAsync(collection.OrganizationId)) { return new CollectionResponseModel(collection); } @@ -637,7 +659,9 @@ public class CollectionsController : Controller // If we have a user, fetch the collection details to get the latest permission details for the user var updatedCollectionDetails = await _collectionRepository.GetByIdAsync(id, _currentContext.UserId.Value, await FlexibleCollectionsIsEnabledAsync(collection.OrganizationId)); - return new CollectionDetailsResponseModel(updatedCollectionDetails); + return updatedCollectionDetails == null + ? new CollectionResponseModel(collection) + : new CollectionDetailsResponseModel(updatedCollectionDetails); } private async Task PutUsers_vNext(Guid id, IEnumerable model) From ca2915494d506c983c2a277b52f6133d3f26d627 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Tue, 30 Jan 2024 09:53:56 -0800 Subject: [PATCH 047/117] [AC-2068] Allows Users to read all users/groups when Flexible Collections is enabled (#3720) * [AC-2068] Allow any member of an org to read all users for that organization with flexible collections * [AC-2068] Allow any member of an org to read all groups for that organization with flexible collections * [AC-2068] Formatting --- .../Groups/GroupAuthorizationHandler.cs | 42 +------- .../OrganizationUserAuthorizationHandler.cs | 44 +-------- .../GroupAuthorizationHandlerTests.cs | 98 +------------------ ...ganizationUserAuthorizationHandlerTests.cs | 98 +------------------ 4 files changed, 12 insertions(+), 270 deletions(-) diff --git a/src/Api/Vault/AuthorizationHandlers/Groups/GroupAuthorizationHandler.cs b/src/Api/Vault/AuthorizationHandlers/Groups/GroupAuthorizationHandler.cs index 7a74c35dbd..666cd725e4 100644 --- a/src/Api/Vault/AuthorizationHandlers/Groups/GroupAuthorizationHandler.cs +++ b/src/Api/Vault/AuthorizationHandlers/Groups/GroupAuthorizationHandler.cs @@ -1,8 +1,5 @@ #nullable enable using Bit.Core.Context; -using Bit.Core.Enums; -using Bit.Core.Models.Data.Organizations; -using Bit.Core.Services; using Microsoft.AspNetCore.Authorization; namespace Bit.Api.Vault.AuthorizationHandlers.Groups; @@ -14,17 +11,10 @@ namespace Bit.Api.Vault.AuthorizationHandlers.Groups; public class GroupAuthorizationHandler : AuthorizationHandler { private readonly ICurrentContext _currentContext; - private readonly IFeatureService _featureService; - private readonly IApplicationCacheService _applicationCacheService; - public GroupAuthorizationHandler( - ICurrentContext currentContext, - IFeatureService featureService, - IApplicationCacheService applicationCacheService) + public GroupAuthorizationHandler(ICurrentContext currentContext) { _currentContext = currentContext; - _featureService = featureService; - _applicationCacheService = applicationCacheService; } protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, @@ -56,22 +46,8 @@ public class GroupAuthorizationHandler : AuthorizationHandler GetOrganizationAbilityAsync(CurrentContextOrganization? organization) - { - // If the CurrentContextOrganization is null, then the user isn't a member of the org so the setting is - // irrelevant - if (organization == null) - { - return null; - } - - return await _applicationCacheService.GetOrganizationAbilityAsync(organization.Id); - } } diff --git a/src/Api/Vault/AuthorizationHandlers/OrganizationUsers/OrganizationUserAuthorizationHandler.cs b/src/Api/Vault/AuthorizationHandlers/OrganizationUsers/OrganizationUserAuthorizationHandler.cs index 28b60cb0c0..4b267242a3 100644 --- a/src/Api/Vault/AuthorizationHandlers/OrganizationUsers/OrganizationUserAuthorizationHandler.cs +++ b/src/Api/Vault/AuthorizationHandlers/OrganizationUsers/OrganizationUserAuthorizationHandler.cs @@ -1,8 +1,5 @@ #nullable enable using Bit.Core.Context; -using Bit.Core.Enums; -using Bit.Core.Models.Data.Organizations; -using Bit.Core.Services; using Microsoft.AspNetCore.Authorization; namespace Bit.Api.Vault.AuthorizationHandlers.OrganizationUsers; @@ -14,17 +11,10 @@ namespace Bit.Api.Vault.AuthorizationHandlers.OrganizationUsers; public class OrganizationUserAuthorizationHandler : AuthorizationHandler { private readonly ICurrentContext _currentContext; - private readonly IFeatureService _featureService; - private readonly IApplicationCacheService _applicationCacheService; - public OrganizationUserAuthorizationHandler( - ICurrentContext currentContext, - IFeatureService featureService, - IApplicationCacheService applicationCacheService) + public OrganizationUserAuthorizationHandler(ICurrentContext currentContext) { _currentContext = currentContext; - _featureService = featureService; - _applicationCacheService = applicationCacheService; } protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, @@ -55,26 +45,10 @@ public class OrganizationUserAuthorizationHandler : AuthorizationHandler GetOrganizationAbilityAsync(CurrentContextOrganization? organization) - { - // If the CurrentContextOrganization is null, then the user isn't a member of the org so the setting is - // irrelevant - if (organization == null) - { - return null; - } - - return await _applicationCacheService.GetOrganizationAbilityAsync(organization.Id); - } } diff --git a/test/Api.Test/Vault/AuthorizationHandlers/GroupAuthorizationHandlerTests.cs b/test/Api.Test/Vault/AuthorizationHandlers/GroupAuthorizationHandlerTests.cs index 8ba03930ef..608e201c50 100644 --- a/test/Api.Test/Vault/AuthorizationHandlers/GroupAuthorizationHandlerTests.cs +++ b/test/Api.Test/Vault/AuthorizationHandlers/GroupAuthorizationHandlerTests.cs @@ -3,8 +3,6 @@ using Bit.Api.Vault.AuthorizationHandlers.Groups; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Models.Data; -using Bit.Core.Models.Data.Organizations; -using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Authorization; @@ -19,7 +17,9 @@ public class GroupAuthorizationHandlerTests [Theory] [BitAutoData(OrganizationUserType.Admin)] [BitAutoData(OrganizationUserType.Owner)] - public async Task CanReadAllAsync_WhenAdminOrOwner_Success( + [BitAutoData(OrganizationUserType.User)] + [BitAutoData(OrganizationUserType.Custom)] + public async Task CanReadAllAsync_WhenMemberOfOrg_Success( OrganizationUserType userType, Guid userId, SutProvider sutProvider, CurrentContextOrganization organization) @@ -27,8 +27,6 @@ public class GroupAuthorizationHandlerTests organization.Type = userType; organization.Permissions = new Permissions(); - ArrangeOrganizationAbility(sutProvider, organization, true); - var context = new AuthorizationHandlerContext( new[] { GroupOperations.ReadAll(organization.Id) }, new ClaimsPrincipal(), @@ -50,8 +48,6 @@ public class GroupAuthorizationHandlerTests organization.Type = OrganizationUserType.User; organization.Permissions = new Permissions(); - ArrangeOrganizationAbility(sutProvider, organization, true); - var context = new AuthorizationHandlerContext( new[] { GroupOperations.ReadAll(organization.Id) }, new ClaimsPrincipal(), @@ -69,87 +65,12 @@ public class GroupAuthorizationHandlerTests Assert.True(context.HasSucceeded); } - [Theory] - [BitAutoData(true, false, false, false, true)] - [BitAutoData(false, true, false, false, true)] - [BitAutoData(false, false, true, false, true)] - [BitAutoData(false, false, false, true, true)] - [BitAutoData(false, false, false, false, false)] - public async Task CanReadAllAsync_WhenCustomUserWithRequiredPermissions_Success( - bool editAnyCollection, bool deleteAnyCollection, bool manageGroups, - bool manageUsers, bool limitCollectionCreationDeletion, - SutProvider sutProvider, - CurrentContextOrganization organization) - { - var actingUserId = Guid.NewGuid(); - - organization.Type = OrganizationUserType.Custom; - organization.Permissions = new Permissions - { - EditAnyCollection = editAnyCollection, - DeleteAnyCollection = deleteAnyCollection, - ManageGroups = manageGroups, - ManageUsers = manageUsers - }; - - ArrangeOrganizationAbility(sutProvider, organization, limitCollectionCreationDeletion); - - var context = new AuthorizationHandlerContext( - new[] { GroupOperations.ReadAll(organization.Id) }, - new ClaimsPrincipal(), - null); - - sutProvider.GetDependency().UserId.Returns(actingUserId); - sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - - await sutProvider.Sut.HandleAsync(context); - - Assert.True(context.HasSucceeded); - } - - [Theory] - [BitAutoData(OrganizationUserType.User)] - [BitAutoData(OrganizationUserType.Custom)] - public async Task CanReadAllAsync_WhenMissingPermissions_NoSuccess( - OrganizationUserType userType, - SutProvider sutProvider, - CurrentContextOrganization organization) - { - var actingUserId = Guid.NewGuid(); - - organization.Type = userType; - organization.Permissions = new Permissions - { - EditAnyCollection = false, - DeleteAnyCollection = false, - ManageGroups = false, - ManageUsers = false, - AccessImportExport = false - }; - - ArrangeOrganizationAbility(sutProvider, organization, true); - - var context = new AuthorizationHandlerContext( - new[] { GroupOperations.ReadAll(organization.Id) }, - new ClaimsPrincipal(), - null); - - sutProvider.GetDependency().UserId.Returns(actingUserId); - sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); - - await sutProvider.Sut.HandleAsync(context); - - Assert.False(context.HasSucceeded); - } - [Theory, BitAutoData] public async Task CanReadAllAsync_WhenMissingOrgAccess_NoSuccess( Guid userId, CurrentContextOrganization organization, SutProvider sutProvider) { - ArrangeOrganizationAbility(sutProvider, organization, true); var context = new AuthorizationHandlerContext( new[] { GroupOperations.ReadAll(organization.Id) }, @@ -201,17 +122,4 @@ public class GroupAuthorizationHandlerTests Assert.False(context.HasSucceeded); Assert.True(context.HasFailed); } - - private static void ArrangeOrganizationAbility( - SutProvider sutProvider, - CurrentContextOrganization organization, bool limitCollectionCreationDeletion) - { - var organizationAbility = new OrganizationAbility(); - organizationAbility.Id = organization.Id; - organizationAbility.FlexibleCollections = true; - organizationAbility.LimitCollectionCreationDeletion = limitCollectionCreationDeletion; - - sutProvider.GetDependency().GetOrganizationAbilityAsync(organizationAbility.Id) - .Returns(organizationAbility); - } } diff --git a/test/Api.Test/Vault/AuthorizationHandlers/OrganizationUserAuthorizationHandlerTests.cs b/test/Api.Test/Vault/AuthorizationHandlers/OrganizationUserAuthorizationHandlerTests.cs index d6c22197fe..0d7090e688 100644 --- a/test/Api.Test/Vault/AuthorizationHandlers/OrganizationUserAuthorizationHandlerTests.cs +++ b/test/Api.Test/Vault/AuthorizationHandlers/OrganizationUserAuthorizationHandlerTests.cs @@ -3,8 +3,6 @@ using Bit.Api.Vault.AuthorizationHandlers.OrganizationUsers; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Models.Data; -using Bit.Core.Models.Data.Organizations; -using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Authorization; @@ -19,7 +17,9 @@ public class OrganizationUserAuthorizationHandlerTests [Theory] [BitAutoData(OrganizationUserType.Admin)] [BitAutoData(OrganizationUserType.Owner)] - public async Task CanReadAllAsync_WhenAdminOrOwner_Success( + [BitAutoData(OrganizationUserType.User)] + [BitAutoData(OrganizationUserType.Custom)] + public async Task CanReadAllAsync_WhenMemberOfOrg_Success( OrganizationUserType userType, Guid userId, SutProvider sutProvider, CurrentContextOrganization organization) @@ -27,8 +27,6 @@ public class OrganizationUserAuthorizationHandlerTests organization.Type = userType; organization.Permissions = new Permissions(); - ArrangeOrganizationAbility(sutProvider, organization, true); - var context = new AuthorizationHandlerContext( new[] { OrganizationUserOperations.ReadAll(organization.Id) }, new ClaimsPrincipal(), @@ -50,8 +48,6 @@ public class OrganizationUserAuthorizationHandlerTests organization.Type = OrganizationUserType.User; organization.Permissions = new Permissions(); - ArrangeOrganizationAbility(sutProvider, organization, true); - var context = new AuthorizationHandlerContext( new[] { OrganizationUserOperations.ReadAll(organization.Id) }, new ClaimsPrincipal(), @@ -69,87 +65,12 @@ public class OrganizationUserAuthorizationHandlerTests Assert.True(context.HasSucceeded); } - [Theory] - [BitAutoData(true, false, false, false, true)] - [BitAutoData(false, true, false, false, true)] - [BitAutoData(false, false, true, false, true)] - [BitAutoData(false, false, false, true, true)] - [BitAutoData(false, false, false, false, false)] - public async Task CanReadAllAsync_WhenCustomUserWithRequiredPermissions_Success( - bool editAnyCollection, bool deleteAnyCollection, bool manageGroups, - bool manageUsers, bool limitCollectionCreationDeletion, - SutProvider sutProvider, - CurrentContextOrganization organization) - { - var actingUserId = Guid.NewGuid(); - - organization.Type = OrganizationUserType.Custom; - organization.Permissions = new Permissions - { - EditAnyCollection = editAnyCollection, - DeleteAnyCollection = deleteAnyCollection, - ManageGroups = manageGroups, - ManageUsers = manageUsers - }; - - ArrangeOrganizationAbility(sutProvider, organization, limitCollectionCreationDeletion); - - var context = new AuthorizationHandlerContext( - new[] { OrganizationUserOperations.ReadAll(organization.Id) }, - new ClaimsPrincipal(), - null); - - sutProvider.GetDependency().UserId.Returns(actingUserId); - sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - - await sutProvider.Sut.HandleAsync(context); - - Assert.True(context.HasSucceeded); - } - - [Theory] - [BitAutoData(OrganizationUserType.User)] - [BitAutoData(OrganizationUserType.Custom)] - public async Task CanReadAllAsync_WhenMissingPermissions_NoSuccess( - OrganizationUserType userType, - SutProvider sutProvider, - CurrentContextOrganization organization) - { - var actingUserId = Guid.NewGuid(); - - organization.Type = userType; - organization.Permissions = new Permissions - { - EditAnyCollection = false, - DeleteAnyCollection = false, - ManageGroups = false, - ManageUsers = false - }; - - ArrangeOrganizationAbility(sutProvider, organization, true); - - var context = new AuthorizationHandlerContext( - new[] { OrganizationUserOperations.ReadAll(organization.Id) }, - new ClaimsPrincipal(), - null); - - sutProvider.GetDependency().UserId.Returns(actingUserId); - sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); - - await sutProvider.Sut.HandleAsync(context); - - Assert.False(context.HasSucceeded); - } - [Theory, BitAutoData] public async Task HandleRequirementAsync_WhenMissingOrgAccess_NoSuccess( Guid userId, CurrentContextOrganization organization, SutProvider sutProvider) { - ArrangeOrganizationAbility(sutProvider, organization, true); - var context = new AuthorizationHandlerContext( new[] { OrganizationUserOperations.ReadAll(organization.Id) }, new ClaimsPrincipal(), @@ -198,17 +119,4 @@ public class OrganizationUserAuthorizationHandlerTests Assert.True(context.HasFailed); } - - private static void ArrangeOrganizationAbility( - SutProvider sutProvider, - CurrentContextOrganization organization, bool limitCollectionCreationDeletion) - { - var organizationAbility = new OrganizationAbility(); - organizationAbility.Id = organization.Id; - organizationAbility.FlexibleCollections = true; - organizationAbility.LimitCollectionCreationDeletion = limitCollectionCreationDeletion; - - sutProvider.GetDependency().GetOrganizationAbilityAsync(organizationAbility.Id) - .Returns(organizationAbility); - } } From 02b10abaf8f828fba92393aa857e36281ef57986 Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Tue, 30 Jan 2024 15:30:56 -0500 Subject: [PATCH 048/117] Tweak load test thresholds again (#3724) --- perf/load/config.js | 2 +- perf/load/groups.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/perf/load/config.js b/perf/load/config.js index 54413cba01..f4e1b33bc0 100644 --- a/perf/load/config.js +++ b/perf/load/config.js @@ -43,7 +43,7 @@ export const options = { }, thresholds: { http_req_failed: ["rate<0.01"], - http_req_duration: ["p(95)<200"], + http_req_duration: ["p(95)<350"], }, }; diff --git a/perf/load/groups.js b/perf/load/groups.js index a668f7f023..aee3b3e94d 100644 --- a/perf/load/groups.js +++ b/perf/load/groups.js @@ -44,7 +44,7 @@ export const options = { }, thresholds: { http_req_failed: ["rate<0.01"], - http_req_duration: ["p(95)<300"], + http_req_duration: ["p(95)<400"], }, }; From 2ad4bb8a79c5ec67aa83ff0b885a0278eddc83f4 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 31 Jan 2024 08:19:29 -0500 Subject: [PATCH 049/117] [AC-1980] Upgrade Stripe.net (#3596) * Upgrade Stripe.net * Don't process mismatched version webhooks * Manually handle API mismatch in Stripe webhook * Pivot webhook secret off webhook version --- src/Billing/BillingSettings.cs | 2 +- src/Billing/Controllers/StripeController.cs | 45 ++++++++++++++++++- .../Models/StripeWebhookVersionContainer.cs | 9 ++++ src/Billing/appsettings.json | 2 +- src/Core/Core.csproj | 2 +- .../Resources/Events/charge.succeeded.json | 2 +- .../Events/customer.subscription.updated.json | 2 +- .../Resources/Events/customer.updated.json | 2 +- .../Resources/Events/invoice.created.json | 2 +- .../Resources/Events/invoice.upcoming.json | 2 +- .../Events/payment_method.attached.json | 2 +- 11 files changed, 61 insertions(+), 11 deletions(-) create mode 100644 src/Billing/Models/StripeWebhookVersionContainer.cs diff --git a/src/Billing/BillingSettings.cs b/src/Billing/BillingSettings.cs index fe829dc3dd..06027952cf 100644 --- a/src/Billing/BillingSettings.cs +++ b/src/Billing/BillingSettings.cs @@ -5,7 +5,7 @@ public class BillingSettings public virtual string JobsKey { get; set; } public virtual string StripeWebhookKey { get; set; } public virtual string StripeWebhookSecret { get; set; } - public virtual bool StripeEventParseThrowMismatch { get; set; } = true; + public virtual string StripeWebhookSecret20231016 { get; set; } public virtual string BitPayWebhookKey { get; set; } public virtual string AppleWebhookKey { get; set; } public virtual FreshDeskSettings FreshDesk { get; set; } = new FreshDeskSettings(); diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index 37378184d7..2187f98a80 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -1,4 +1,5 @@ using Bit.Billing.Constants; +using Bit.Billing.Models; using Bit.Billing.Services; using Bit.Core.AdminConsole.Entities; using Bit.Core.Context; @@ -19,6 +20,7 @@ using Microsoft.Extensions.Options; using Stripe; using Customer = Stripe.Customer; using Event = Stripe.Event; +using JsonSerializer = System.Text.Json.JsonSerializer; using PaymentMethod = Stripe.PaymentMethod; using Subscription = Stripe.Subscription; using Transaction = Bit.Core.Entities.Transaction; @@ -109,9 +111,27 @@ public class StripeController : Controller using (var sr = new StreamReader(HttpContext.Request.Body)) { var json = await sr.ReadToEndAsync(); + var webhookSecret = PickStripeWebhookSecret(json); + + if (string.IsNullOrEmpty(webhookSecret)) + { + return new OkResult(); + } + parsedEvent = EventUtility.ConstructEvent(json, Request.Headers["Stripe-Signature"], - _billingSettings.StripeWebhookSecret, - throwOnApiVersionMismatch: _billingSettings.StripeEventParseThrowMismatch); + webhookSecret, + throwOnApiVersionMismatch: false); + } + + if (StripeConfiguration.ApiVersion != parsedEvent.ApiVersion) + { + _logger.LogWarning( + "Stripe {WebhookType} webhook's API version ({WebhookAPIVersion}) does not match SDK API Version ({SDKAPIVersion})", + parsedEvent.Type, + parsedEvent.ApiVersion, + StripeConfiguration.ApiVersion); + + return new OkResult(); } if (string.IsNullOrWhiteSpace(parsedEvent?.Id)) @@ -872,4 +892,25 @@ public class StripeController : Controller await invoiceService.VoidInvoiceAsync(invoice.Id); } } + + private string PickStripeWebhookSecret(string webhookBody) + { + var versionContainer = JsonSerializer.Deserialize(webhookBody); + + return versionContainer.ApiVersion switch + { + "2023-10-16" => _billingSettings.StripeWebhookSecret20231016, + "2022-08-01" => _billingSettings.StripeWebhookSecret, + _ => HandleDefault(versionContainer.ApiVersion) + }; + + string HandleDefault(string version) + { + _logger.LogWarning( + "Stripe webhook contained an recognized 'api_version': {ApiVersion}", + version); + + return null; + } + } } diff --git a/src/Billing/Models/StripeWebhookVersionContainer.cs b/src/Billing/Models/StripeWebhookVersionContainer.cs new file mode 100644 index 0000000000..594a0f0ed5 --- /dev/null +++ b/src/Billing/Models/StripeWebhookVersionContainer.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Bit.Billing.Models; + +public class StripeWebhookVersionContainer +{ + [JsonPropertyName("api_version")] + public string ApiVersion { get; set; } +} diff --git a/src/Billing/appsettings.json b/src/Billing/appsettings.json index 27a35cf1f6..93d103aa80 100644 --- a/src/Billing/appsettings.json +++ b/src/Billing/appsettings.json @@ -62,7 +62,7 @@ "jobsKey": "SECRET", "stripeWebhookKey": "SECRET", "stripeWebhookSecret": "SECRET", - "stripeEventParseThrowMismatch": true, + "stripeWebhookSecret20231016": "SECRET", "bitPayWebhookKey": "SECRET", "appleWebhookKey": "SECRET", "payPal": { diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index f50412495b..0edcd05821 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -52,7 +52,7 @@ - + diff --git a/test/Billing.Test/Resources/Events/charge.succeeded.json b/test/Billing.Test/Resources/Events/charge.succeeded.json index e88efa4079..a5446a11d1 100644 --- a/test/Billing.Test/Resources/Events/charge.succeeded.json +++ b/test/Billing.Test/Resources/Events/charge.succeeded.json @@ -1,7 +1,7 @@ { "id": "evt_3NvKgBIGBnsLynRr0pJJqudS", "object": "event", - "api_version": "2022-08-01", + "api_version": "2023-10-16", "created": 1695909300, "data": { "object": { diff --git a/test/Billing.Test/Resources/Events/customer.subscription.updated.json b/test/Billing.Test/Resources/Events/customer.subscription.updated.json index 1a128c1508..dbd30f1c4c 100644 --- a/test/Billing.Test/Resources/Events/customer.subscription.updated.json +++ b/test/Billing.Test/Resources/Events/customer.subscription.updated.json @@ -1,7 +1,7 @@ { "id": "evt_1NvLMDIGBnsLynRr6oBxebrE", "object": "event", - "api_version": "2022-08-01", + "api_version": "2023-10-16", "created": 1695911902, "data": { "object": { diff --git a/test/Billing.Test/Resources/Events/customer.updated.json b/test/Billing.Test/Resources/Events/customer.updated.json index 323a9b9ba5..c2445c24e2 100644 --- a/test/Billing.Test/Resources/Events/customer.updated.json +++ b/test/Billing.Test/Resources/Events/customer.updated.json @@ -2,7 +2,7 @@ "id": "evt_1NvKjSIGBnsLynRrS3MTK4DZ", "object": "event", "account": "acct_19smIXIGBnsLynRr", - "api_version": "2022-08-01", + "api_version": "2023-10-16", "created": 1695909502, "data": { "object": { diff --git a/test/Billing.Test/Resources/Events/invoice.created.json b/test/Billing.Test/Resources/Events/invoice.created.json index b70442ed36..4c3c85233d 100644 --- a/test/Billing.Test/Resources/Events/invoice.created.json +++ b/test/Billing.Test/Resources/Events/invoice.created.json @@ -1,7 +1,7 @@ { "id": "evt_1NvKzfIGBnsLynRr0SkwrlkE", "object": "event", - "api_version": "2022-08-01", + "api_version": "2023-10-16", "created": 1695910506, "data": { "object": { diff --git a/test/Billing.Test/Resources/Events/invoice.upcoming.json b/test/Billing.Test/Resources/Events/invoice.upcoming.json index 7b9055d497..056f3a2d1c 100644 --- a/test/Billing.Test/Resources/Events/invoice.upcoming.json +++ b/test/Billing.Test/Resources/Events/invoice.upcoming.json @@ -1,7 +1,7 @@ { "id": "evt_1Nv0w8IGBnsLynRrZoDVI44u", "object": "event", - "api_version": "2022-08-01", + "api_version": "2023-10-16", "created": 1695833408, "data": { "object": { diff --git a/test/Billing.Test/Resources/Events/payment_method.attached.json b/test/Billing.Test/Resources/Events/payment_method.attached.json index 40e6972bdd..9b65630646 100644 --- a/test/Billing.Test/Resources/Events/payment_method.attached.json +++ b/test/Billing.Test/Resources/Events/payment_method.attached.json @@ -1,7 +1,7 @@ { "id": "evt_1NvKzcIGBnsLynRrPJ3hybkd", "object": "event", - "api_version": "2022-08-01", + "api_version": "2023-10-16", "created": 1695910504, "data": { "object": { From f7cf989b24f3e849e808a224b5286e29c15b3093 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 31 Jan 2024 18:11:31 +0100 Subject: [PATCH 050/117] [deps] Tools: Update aws-sdk-net monorepo to v3.7.300.43 (#3726) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Core/Core.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 0edcd05821..47ed170b9f 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -21,8 +21,8 @@ - - + + From 34c4a5df5de652c7fc55c2529efcdf37b9c98e9c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 1 Feb 2024 10:27:25 +0000 Subject: [PATCH 051/117] [deps] Tools: Update SendGrid to v9.29.1 (#3727) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Core/Core.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 47ed170b9f..499b81fa02 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -41,7 +41,7 @@ - + From 9a1519f131041ebd9db588c9d53567e5f5ae577a Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Thu, 1 Feb 2024 13:21:17 -0500 Subject: [PATCH 052/117] [PM-5766] Automatic Tax Feature Flag (#3729) * Added feature flag constant * Wrapped Automatic Tax logic behind feature flag * Only getting customer if feature is anabled. * Enabled feature flag in unit tests * Made IPaymentService scoped * Added missing StripeFacade calls --- src/Billing/Controllers/StripeController.cs | 116 ++++++++---- src/Billing/Services/IStripeFacade.cs | 40 +++++ .../Services/Implementations/StripeFacade.cs | 45 +++++ .../Implementations/OrganizationService.cs | 9 +- src/Core/Constants.cs | 2 + .../Implementations/StripePaymentService.cs | 166 +++++++++++++++--- .../Utilities/ServiceCollectionExtensions.cs | 2 +- .../Services/StripePaymentServiceTests.cs | 2 + 8 files changed, 318 insertions(+), 64 deletions(-) diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index 2187f98a80..a0a517b798 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -1,6 +1,7 @@ using Bit.Billing.Constants; using Bit.Billing.Models; using Bit.Billing.Services; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.Context; using Bit.Core.Enums; @@ -23,6 +24,7 @@ using Event = Stripe.Event; using JsonSerializer = System.Text.Json.JsonSerializer; using PaymentMethod = Stripe.PaymentMethod; using Subscription = Stripe.Subscription; +using TaxRate = Bit.Core.Entities.TaxRate; using Transaction = Bit.Core.Entities.Transaction; using TransactionType = Bit.Core.Enums.TransactionType; @@ -52,6 +54,7 @@ public class StripeController : Controller private readonly GlobalSettings _globalSettings; private readonly IStripeEventService _stripeEventService; private readonly IStripeFacade _stripeFacade; + private readonly IFeatureService _featureService; public StripeController( GlobalSettings globalSettings, @@ -70,7 +73,8 @@ public class StripeController : Controller IUserRepository userRepository, ICurrentContext currentContext, IStripeEventService stripeEventService, - IStripeFacade stripeFacade) + IStripeFacade stripeFacade, + IFeatureService featureService) { _billingSettings = billingSettings?.Value; _hostingEnvironment = hostingEnvironment; @@ -97,6 +101,7 @@ public class StripeController : Controller _globalSettings = globalSettings; _stripeEventService = stripeEventService; _stripeFacade = stripeFacade; + _featureService = featureService; } [HttpPost("webhook")] @@ -242,17 +247,29 @@ public class StripeController : Controller $"Received null Subscription from Stripe for ID '{invoice.SubscriptionId}' while processing Event with ID '{parsedEvent.Id}'"); } - if (!subscription.AutomaticTax.Enabled) + var pm5766AutomaticTaxIsEnabled = _featureService.IsEnabled(FeatureFlagKeys.PM5766AutomaticTax); + if (pm5766AutomaticTaxIsEnabled) { - subscription = await _stripeFacade.UpdateSubscription(subscription.Id, - new SubscriptionUpdateOptions - { - DefaultTaxRates = new List(), - AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } - }); + var customer = await _stripeFacade.GetCustomer(subscription.CustomerId); + if (!subscription.AutomaticTax.Enabled && + !string.IsNullOrEmpty(customer.Address?.PostalCode) && + !string.IsNullOrEmpty(customer.Address?.Country)) + { + subscription = await _stripeFacade.UpdateSubscription(subscription.Id, + new SubscriptionUpdateOptions + { + DefaultTaxRates = new List(), + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } + }); + } } - var (organizationId, userId) = GetIdsFromMetaData(subscription.Metadata); + + var updatedSubscription = pm5766AutomaticTaxIsEnabled + ? subscription + : await VerifyCorrectTaxRateForCharge(invoice, subscription); + + var (organizationId, userId) = GetIdsFromMetaData(updatedSubscription.Metadata); var invoiceLineItemDescriptions = invoice.Lines.Select(i => i.Description).ToList(); @@ -273,7 +290,7 @@ public class StripeController : Controller if (organizationId.HasValue) { - if (IsSponsoredSubscription(subscription)) + if (IsSponsoredSubscription(updatedSubscription)) { await _validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value); } @@ -321,22 +338,20 @@ public class StripeController : Controller Tuple ids = null; Subscription subscription = null; - var subscriptionService = new SubscriptionService(); if (charge.InvoiceId != null) { - var invoiceService = new InvoiceService(); - var invoice = await invoiceService.GetAsync(charge.InvoiceId); + var invoice = await _stripeFacade.GetInvoice(charge.InvoiceId); if (invoice?.SubscriptionId != null) { - subscription = await subscriptionService.GetAsync(invoice.SubscriptionId); + subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId); ids = GetIdsFromMetaData(subscription?.Metadata); } } if (subscription == null || ids == null || (ids.Item1.HasValue && ids.Item2.HasValue)) { - var subscriptions = await subscriptionService.ListAsync(new SubscriptionListOptions + var subscriptions = await _stripeFacade.ListSubscriptions(new SubscriptionListOptions { Customer = charge.CustomerId }); @@ -490,8 +505,7 @@ public class StripeController : Controller var invoice = await _stripeEventService.GetInvoice(parsedEvent, true); if (invoice.Paid && invoice.BillingReason == "subscription_create") { - var subscriptionService = new SubscriptionService(); - var subscription = await subscriptionService.GetAsync(invoice.SubscriptionId); + var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId); if (subscription?.Status == StripeSubscriptionStatus.Active) { if (DateTime.UtcNow - invoice.Created < TimeSpan.FromMinutes(1)) @@ -596,7 +610,6 @@ public class StripeController : Controller return; } - var subscriptionService = new SubscriptionService(); var subscriptionListOptions = new SubscriptionListOptions { Customer = paymentMethod.CustomerId, @@ -607,7 +620,7 @@ public class StripeController : Controller StripeList unpaidSubscriptions; try { - unpaidSubscriptions = await subscriptionService.ListAsync(subscriptionListOptions); + unpaidSubscriptions = await _stripeFacade.ListSubscriptions(subscriptionListOptions); } catch (Exception e) { @@ -702,8 +715,7 @@ public class StripeController : Controller private async Task AttemptToPayInvoiceAsync(Invoice invoice, bool attemptToPayWithStripe = false) { - var customerService = new CustomerService(); - var customer = await customerService.GetAsync(invoice.CustomerId); + var customer = await _stripeFacade.GetCustomer(invoice.CustomerId); if (customer?.Metadata?.ContainsKey("btCustomerId") ?? false) { @@ -728,8 +740,7 @@ public class StripeController : Controller return false; } - var subscriptionService = new SubscriptionService(); - var subscription = await subscriptionService.GetAsync(invoice.SubscriptionId); + var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId); var ids = GetIdsFromMetaData(subscription?.Metadata); if (!ids.Item1.HasValue && !ids.Item2.HasValue) { @@ -797,10 +808,9 @@ public class StripeController : Controller return false; } - var invoiceService = new InvoiceService(); try { - await invoiceService.UpdateAsync(invoice.Id, new InvoiceUpdateOptions + await _stripeFacade.UpdateInvoice(invoice.Id, new InvoiceUpdateOptions { Metadata = new Dictionary { @@ -809,14 +819,14 @@ public class StripeController : Controller transactionResult.Target.PayPalDetails?.AuthorizationId } }); - await invoiceService.PayAsync(invoice.Id, new InvoicePayOptions { PaidOutOfBand = true }); + await _stripeFacade.PayInvoice(invoice.Id, new InvoicePayOptions { PaidOutOfBand = true }); } catch (Exception e) { await _btGateway.Transaction.RefundAsync(transactionResult.Target.Id); if (e.Message.Contains("Invoice is already paid")) { - await invoiceService.UpdateAsync(invoice.Id, new InvoiceUpdateOptions + await _stripeFacade.UpdateInvoice(invoice.Id, new InvoiceUpdateOptions { Metadata = invoice.Metadata }); @@ -834,8 +844,7 @@ public class StripeController : Controller { try { - var invoiceService = new InvoiceService(); - await invoiceService.PayAsync(invoice.Id); + await _stripeFacade.PayInvoice(invoice.Id); return true; } catch (Exception e) @@ -855,6 +864,41 @@ public class StripeController : Controller invoice.BillingReason == "subscription_cycle" && invoice.SubscriptionId != null; } + private async Task VerifyCorrectTaxRateForCharge(Invoice invoice, Subscription subscription) + { + if (string.IsNullOrWhiteSpace(invoice?.CustomerAddress?.Country) || + string.IsNullOrWhiteSpace(invoice?.CustomerAddress?.PostalCode)) + { + return subscription; + } + + var localBitwardenTaxRates = await _taxRateRepository.GetByLocationAsync( + new TaxRate() + { + Country = invoice.CustomerAddress.Country, + PostalCode = invoice.CustomerAddress.PostalCode + } + ); + + if (!localBitwardenTaxRates.Any()) + { + return subscription; + } + + var stripeTaxRate = await _stripeFacade.GetTaxRate(localBitwardenTaxRates.First().Id); + if (stripeTaxRate == null || subscription.DefaultTaxRates.Any(x => x == stripeTaxRate)) + { + return subscription; + } + + subscription.DefaultTaxRates = new List { stripeTaxRate }; + + var subscriptionOptions = new SubscriptionUpdateOptions { DefaultTaxRates = new List { stripeTaxRate.Id } }; + subscription = await _stripeFacade.UpdateSubscription(subscription.Id, subscriptionOptions); + + return subscription; + } + private static bool IsSponsoredSubscription(Subscription subscription) => StaticStore.SponsoredPlans.Any(p => p.StripePlanId == subscription.Id); @@ -862,8 +906,7 @@ public class StripeController : Controller { if (!invoice.Paid && invoice.AttemptCount > 1 && UnpaidAutoChargeInvoiceForSubscriptionCycle(invoice)) { - var subscriptionService = new SubscriptionService(); - var subscription = await subscriptionService.GetAsync(invoice.SubscriptionId); + var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId); // attempt count 4 = 11 days after initial failure if (invoice.AttemptCount <= 3 || !subscription.Items.Any(i => i.Price.Id is PremiumPlanId or PremiumPlanIdAppStore)) @@ -873,23 +916,20 @@ public class StripeController : Controller } } - private async Task CancelSubscription(string subscriptionId) - { - await new SubscriptionService().CancelAsync(subscriptionId, new SubscriptionCancelOptions()); - } + private async Task CancelSubscription(string subscriptionId) => + await _stripeFacade.CancelSubscription(subscriptionId, new SubscriptionCancelOptions()); private async Task VoidOpenInvoices(string subscriptionId) { - var invoiceService = new InvoiceService(); var options = new InvoiceListOptions { Status = StripeInvoiceStatus.Open, Subscription = subscriptionId }; - var invoices = invoiceService.List(options); + var invoices = await _stripeFacade.ListInvoices(options); foreach (var invoice in invoices) { - await invoiceService.VoidInvoiceAsync(invoice.Id); + await _stripeFacade.VoidInvoice(invoice.Id); } } diff --git a/src/Billing/Services/IStripeFacade.cs b/src/Billing/Services/IStripeFacade.cs index 4a49c75ea2..836f15aed0 100644 --- a/src/Billing/Services/IStripeFacade.cs +++ b/src/Billing/Services/IStripeFacade.cs @@ -22,12 +22,40 @@ public interface IStripeFacade RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + Task> ListInvoices( + InvoiceListOptions options = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); + + Task UpdateInvoice( + string invoiceId, + InvoiceUpdateOptions invoiceGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); + + Task PayInvoice( + string invoiceId, + InvoicePayOptions options = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); + + Task VoidInvoice( + string invoiceId, + InvoiceVoidOptions options = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); + Task GetPaymentMethod( string paymentMethodId, PaymentMethodGetOptions paymentMethodGetOptions = null, RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + Task> ListSubscriptions( + SubscriptionListOptions options = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); + Task GetSubscription( string subscriptionId, SubscriptionGetOptions subscriptionGetOptions = null, @@ -39,4 +67,16 @@ public interface IStripeFacade SubscriptionUpdateOptions subscriptionGetOptions = null, RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + + Task CancelSubscription( + string subscriptionId, + SubscriptionCancelOptions options = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); + + Task GetTaxRate( + string taxRateId, + TaxRateGetOptions options = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); } diff --git a/src/Billing/Services/Implementations/StripeFacade.cs b/src/Billing/Services/Implementations/StripeFacade.cs index db60621029..fb42030e0c 100644 --- a/src/Billing/Services/Implementations/StripeFacade.cs +++ b/src/Billing/Services/Implementations/StripeFacade.cs @@ -9,6 +9,7 @@ public class StripeFacade : IStripeFacade private readonly InvoiceService _invoiceService = new(); private readonly PaymentMethodService _paymentMethodService = new(); private readonly SubscriptionService _subscriptionService = new(); + private readonly TaxRateService _taxRateService = new(); public async Task GetCharge( string chargeId, @@ -31,6 +32,31 @@ public class StripeFacade : IStripeFacade CancellationToken cancellationToken = default) => await _invoiceService.GetAsync(invoiceId, invoiceGetOptions, requestOptions, cancellationToken); + public async Task> ListInvoices( + InvoiceListOptions options = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + await _invoiceService.ListAsync(options, requestOptions, cancellationToken); + + public async Task UpdateInvoice( + string invoiceId, + InvoiceUpdateOptions invoiceGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + await _invoiceService.UpdateAsync(invoiceId, invoiceGetOptions, requestOptions, cancellationToken); + + public async Task PayInvoice(string invoiceId, InvoicePayOptions options = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + await _invoiceService.PayAsync(invoiceId, options, requestOptions, cancellationToken); + + public async Task VoidInvoice( + string invoiceId, + InvoiceVoidOptions options = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + await _invoiceService.VoidInvoiceAsync(invoiceId, options, requestOptions, cancellationToken); + public async Task GetPaymentMethod( string paymentMethodId, PaymentMethodGetOptions paymentMethodGetOptions = null, @@ -38,6 +64,11 @@ public class StripeFacade : IStripeFacade CancellationToken cancellationToken = default) => await _paymentMethodService.GetAsync(paymentMethodId, paymentMethodGetOptions, requestOptions, cancellationToken); + public async Task> ListSubscriptions(SubscriptionListOptions options = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + await _subscriptionService.ListAsync(options, requestOptions, cancellationToken); + public async Task GetSubscription( string subscriptionId, SubscriptionGetOptions subscriptionGetOptions = null, @@ -51,4 +82,18 @@ public class StripeFacade : IStripeFacade RequestOptions requestOptions = null, CancellationToken cancellationToken = default) => await _subscriptionService.UpdateAsync(subscriptionId, subscriptionUpdateOptions, requestOptions, cancellationToken); + + public async Task CancelSubscription( + string subscriptionId, + SubscriptionCancelOptions options = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + await _subscriptionService.CancelAsync(subscriptionId, options, requestOptions, cancellationToken); + + public async Task GetTaxRate( + string taxRateId, + TaxRateGetOptions options = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + await _taxRateService.GetAsync(taxRateId, options, requestOptions, cancellationToken); } diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 8ab5293e95..fcd6c78e38 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -139,8 +139,13 @@ public class OrganizationService : IOrganizationService } await _paymentService.SaveTaxInfoAsync(organization, taxInfo); - var updated = await _paymentService.UpdatePaymentMethodAsync(organization, - paymentMethodType, paymentToken, taxInfo); + var updated = await _paymentService.UpdatePaymentMethodAsync( + organization, + paymentMethodType, + paymentToken, + _featureService.IsEnabled(FeatureFlagKeys.PM5766AutomaticTax) + ? taxInfo + : null); if (updated) { await ReplaceAndUpdateCacheAsync(organization); diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 3235e1db3c..1d5073df69 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -116,6 +116,8 @@ public static class FeatureFlagKeys /// public const string FlexibleCollectionsMigration = "flexible-collections-migration"; + public const string PM5766AutomaticTax = "PM-5766-automatic-tax"; + public static List GetAllKeys() { return typeof(FeatureFlagKeys).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index f3a939650a..fcfa40d181 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -21,29 +21,29 @@ public class StripePaymentService : IPaymentService private const string SecretsManagerStandaloneDiscountId = "sm-standalone"; private readonly ITransactionRepository _transactionRepository; - private readonly IUserRepository _userRepository; private readonly ILogger _logger; private readonly Braintree.IBraintreeGateway _btGateway; private readonly ITaxRateRepository _taxRateRepository; private readonly IStripeAdapter _stripeAdapter; private readonly IGlobalSettings _globalSettings; + private readonly IFeatureService _featureService; public StripePaymentService( ITransactionRepository transactionRepository, - IUserRepository userRepository, ILogger logger, ITaxRateRepository taxRateRepository, IStripeAdapter stripeAdapter, Braintree.IBraintreeGateway braintreeGateway, - IGlobalSettings globalSettings) + IGlobalSettings globalSettings, + IFeatureService featureService) { _transactionRepository = transactionRepository; - _userRepository = userRepository; _logger = logger; _taxRateRepository = taxRateRepository; _stripeAdapter = stripeAdapter; _btGateway = braintreeGateway; _globalSettings = globalSettings; + _featureService = featureService; } public async Task PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType, @@ -100,6 +100,28 @@ public class StripePaymentService : IPaymentService throw new GatewayException("Payment method is not supported at this time."); } + var pm5766AutomaticTaxIsEnabled = _featureService.IsEnabled(FeatureFlagKeys.PM5766AutomaticTax); + + if (!pm5766AutomaticTaxIsEnabled && + taxInfo != null && + !string.IsNullOrWhiteSpace(taxInfo.BillingAddressCountry) && + !string.IsNullOrWhiteSpace(taxInfo.BillingAddressPostalCode)) + { + var taxRateSearch = new TaxRate + { + Country = taxInfo.BillingAddressCountry, + PostalCode = taxInfo.BillingAddressPostalCode + }; + var taxRates = await _taxRateRepository.GetByLocationAsync(taxRateSearch); + + // should only be one tax rate per country/zip combo + var taxRate = taxRates.FirstOrDefault(); + if (taxRate != null) + { + taxInfo.StripeTaxRateId = taxRate.Id; + } + } + var subCreateOptions = new OrganizationPurchaseSubscriptionOptions(org, plan, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon , additionalSmSeats, additionalServiceAccount); @@ -153,7 +175,10 @@ public class StripePaymentService : IPaymentService subCreateOptions.AddExpand("latest_invoice.payment_intent"); subCreateOptions.Customer = customer.Id; - subCreateOptions.AutomaticTax = new Stripe.SubscriptionAutomaticTaxOptions { Enabled = true }; + if (pm5766AutomaticTaxIsEnabled) + { + subCreateOptions.AutomaticTax = new Stripe.SubscriptionAutomaticTaxOptions { Enabled = true }; + } subscription = await _stripeAdapter.SubscriptionCreateAsync(subCreateOptions); if (subscription.Status == "incomplete" && subscription.LatestInvoice?.PaymentIntent != null) @@ -236,11 +261,34 @@ public class StripePaymentService : IPaymentService throw new GatewayException("Could not find customer payment profile."); } - if (customer.Address is null && + var pm5766AutomaticTaxIsEnabled = _featureService.IsEnabled(FeatureFlagKeys.PM5766AutomaticTax); + var taxInfo = upgrade.TaxInfo; + + if (!pm5766AutomaticTaxIsEnabled && + taxInfo != null && + !string.IsNullOrWhiteSpace(taxInfo.BillingAddressCountry) && + !string.IsNullOrWhiteSpace(taxInfo.BillingAddressPostalCode)) + { + var taxRateSearch = new TaxRate + { + Country = taxInfo.BillingAddressCountry, + PostalCode = taxInfo.BillingAddressPostalCode + }; + var taxRates = await _taxRateRepository.GetByLocationAsync(taxRateSearch); + + // should only be one tax rate per country/zip combo + var taxRate = taxRates.FirstOrDefault(); + if (taxRate != null) + { + taxInfo.StripeTaxRateId = taxRate.Id; + } + } + + if (pm5766AutomaticTaxIsEnabled && !string.IsNullOrEmpty(upgrade.TaxInfo?.BillingAddressCountry) && !string.IsNullOrEmpty(upgrade.TaxInfo?.BillingAddressPostalCode)) { - var addressOptions = new Stripe.AddressOptions + var addressOptions = new AddressOptions { Country = upgrade.TaxInfo.BillingAddressCountry, PostalCode = upgrade.TaxInfo.BillingAddressPostalCode, @@ -250,17 +298,20 @@ public class StripePaymentService : IPaymentService City = upgrade.TaxInfo.BillingAddressCity, State = upgrade.TaxInfo.BillingAddressState, }; - var customerUpdateOptions = new Stripe.CustomerUpdateOptions { Address = addressOptions }; + var customerUpdateOptions = new CustomerUpdateOptions { Address = addressOptions }; customerUpdateOptions.AddExpand("default_source"); customerUpdateOptions.AddExpand("invoice_settings.default_payment_method"); customer = await _stripeAdapter.CustomerUpdateAsync(org.GatewayCustomerId, customerUpdateOptions); } - var subCreateOptions = new OrganizationUpgradeSubscriptionOptions(customer.Id, org, plan, upgrade) + var subCreateOptions = new OrganizationUpgradeSubscriptionOptions(customer.Id, org, plan, upgrade); + + if (pm5766AutomaticTaxIsEnabled) { - DefaultTaxRates = new List(), - AutomaticTax = new Stripe.SubscriptionAutomaticTaxOptions { Enabled = true } - }; + subCreateOptions.DefaultTaxRates = new List(); + subCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; + } + var (stripePaymentMethod, paymentMethodType) = IdentifyPaymentMethod(customer, subCreateOptions); var subscription = await ChargeForNewSubscriptionAsync(org, customer, false, @@ -457,6 +508,29 @@ public class StripePaymentService : IPaymentService Quantity = 1 }); + var pm5766AutomaticTaxIsEnabled = _featureService.IsEnabled(FeatureFlagKeys.PM5766AutomaticTax); + + if (!pm5766AutomaticTaxIsEnabled && + !string.IsNullOrWhiteSpace(taxInfo?.BillingAddressCountry) && + !string.IsNullOrWhiteSpace(taxInfo?.BillingAddressPostalCode)) + { + var taxRates = await _taxRateRepository.GetByLocationAsync( + new TaxRate + { + Country = taxInfo.BillingAddressCountry, + PostalCode = taxInfo.BillingAddressPostalCode + } + ); + var taxRate = taxRates.FirstOrDefault(); + if (taxRate != null) + { + subCreateOptions.DefaultTaxRates = new List(1) + { + taxRate.Id + }; + } + } + if (additionalStorageGb > 0) { subCreateOptions.Items.Add(new Stripe.SubscriptionItemOptions @@ -466,7 +540,11 @@ public class StripePaymentService : IPaymentService }); } - subCreateOptions.AutomaticTax = new Stripe.SubscriptionAutomaticTaxOptions { Enabled = true }; + if (pm5766AutomaticTaxIsEnabled) + { + subCreateOptions.DefaultTaxRates = new List(); + subCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; + } var subscription = await ChargeForNewSubscriptionAsync(user, customer, createdStripeCustomer, stripePaymentMethod, paymentMethodType, subCreateOptions, braintreeCustomer); @@ -504,11 +582,14 @@ public class StripePaymentService : IPaymentService var previewInvoice = await _stripeAdapter.InvoiceUpcomingAsync(new Stripe.UpcomingInvoiceOptions { Customer = customer.Id, - SubscriptionItems = ToInvoiceSubscriptionItemOptions(subCreateOptions.Items), - AutomaticTax = - new Stripe.InvoiceAutomaticTaxOptions { Enabled = subCreateOptions.AutomaticTax.Enabled } + SubscriptionItems = ToInvoiceSubscriptionItemOptions(subCreateOptions.Items) }); + if (_featureService.IsEnabled(FeatureFlagKeys.PM5766AutomaticTax)) + { + previewInvoice.AutomaticTax = new InvoiceAutomaticTax { Enabled = true }; + } + if (previewInvoice.AmountDue > 0) { var braintreeCustomerId = customer.Metadata != null && @@ -560,13 +641,22 @@ public class StripePaymentService : IPaymentService } else if (paymentMethodType == PaymentMethodType.Credit) { - var previewInvoice = await _stripeAdapter.InvoiceUpcomingAsync(new Stripe.UpcomingInvoiceOptions + var upcomingInvoiceOptions = new UpcomingInvoiceOptions { Customer = customer.Id, SubscriptionItems = ToInvoiceSubscriptionItemOptions(subCreateOptions.Items), - AutomaticTax = - new Stripe.InvoiceAutomaticTaxOptions { Enabled = subCreateOptions.AutomaticTax.Enabled } - }); + SubscriptionDefaultTaxRates = subCreateOptions.DefaultTaxRates, + }; + + var pm5766AutomaticTaxIsEnabled = _featureService.IsEnabled(FeatureFlagKeys.PM5766AutomaticTax); + if (pm5766AutomaticTaxIsEnabled) + { + upcomingInvoiceOptions.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }; + upcomingInvoiceOptions.SubscriptionDefaultTaxRates = new List(); + } + + var previewInvoice = await _stripeAdapter.InvoiceUpcomingAsync(upcomingInvoiceOptions); + if (previewInvoice.AmountDue > 0) { throw new GatewayException("Your account does not have enough credit available."); @@ -575,7 +665,12 @@ public class StripePaymentService : IPaymentService subCreateOptions.OffSession = true; subCreateOptions.AddExpand("latest_invoice.payment_intent"); - subCreateOptions.AutomaticTax = new Stripe.SubscriptionAutomaticTaxOptions { Enabled = true }; + + if (_featureService.IsEnabled(FeatureFlagKeys.PM5766AutomaticTax)) + { + subCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; + } + subscription = await _stripeAdapter.SubscriptionCreateAsync(subCreateOptions); if (subscription.Status == "incomplete" && subscription.LatestInvoice?.PaymentIntent != null) { @@ -675,16 +770,41 @@ public class StripePaymentService : IPaymentService DaysUntilDue = daysUntilDue ?? 1, CollectionMethod = "send_invoice", ProrationDate = prorationDate, - DefaultTaxRates = new List(), - AutomaticTax = new Stripe.SubscriptionAutomaticTaxOptions { Enabled = true } }; + var pm5766AutomaticTaxIsEnabled = _featureService.IsEnabled(FeatureFlagKeys.PM5766AutomaticTax); + if (pm5766AutomaticTaxIsEnabled) + { + subUpdateOptions.DefaultTaxRates = new List(); + subUpdateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; + } + if (!subscriptionUpdate.UpdateNeeded(sub)) { // No need to update subscription, quantity matches return null; } + if (!pm5766AutomaticTaxIsEnabled) + { + var customer = await _stripeAdapter.CustomerGetAsync(sub.CustomerId); + + if (!string.IsNullOrWhiteSpace(customer?.Address?.Country) + && !string.IsNullOrWhiteSpace(customer?.Address?.PostalCode)) + { + var taxRates = await _taxRateRepository.GetByLocationAsync(new TaxRate + { + Country = customer.Address.Country, + PostalCode = customer.Address.PostalCode + }); + var taxRate = taxRates.FirstOrDefault(); + if (taxRate != null && !sub.DefaultTaxRates.Any(x => x.Equals(taxRate.Id))) + { + subUpdateOptions.DefaultTaxRates = new List(1) { taxRate.Id }; + } + } + } + string paymentIntentClientSecret = null; try { diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 1dd20fcb5a..c07e77b1c3 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -214,7 +214,7 @@ public static class ServiceCollectionExtensions PrivateKey = globalSettings.Braintree.PrivateKey }; }); - services.AddSingleton(); + services.AddScoped(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/test/Core.Test/Services/StripePaymentServiceTests.cs b/test/Core.Test/Services/StripePaymentServiceTests.cs index 171fab0fb5..b4dbdaa7f7 100644 --- a/test/Core.Test/Services/StripePaymentServiceTests.cs +++ b/test/Core.Test/Services/StripePaymentServiceTests.cs @@ -701,6 +701,7 @@ public class StripePaymentServiceTests { organization.GatewaySubscriptionId = null; var stripeAdapter = sutProvider.GetDependency(); + var featureService = sutProvider.GetDependency(); stripeAdapter.CustomerGetAsync(default).ReturnsForAnyArgs(new Stripe.Customer { Id = "C-1", @@ -723,6 +724,7 @@ public class StripePaymentServiceTests AmountDue = 0 }); stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription { }); + featureService.IsEnabled(FeatureFlagKeys.PM5766AutomaticTax).Returns(true); var upgrade = new OrganizationUpgrade() { From b20b8099a77e02d17d4e53379182015938cbdf61 Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Fri, 2 Feb 2024 08:57:19 -0500 Subject: [PATCH 053/117] [PM-5314] Upgrade MSSQL cumulative update (#3548) * Upgrade MSSQL cumulative update * Go to 24 --- util/MsSql/Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/util/MsSql/Dockerfile b/util/MsSql/Dockerfile index 330f78208f..87092f2d92 100644 --- a/util/MsSql/Dockerfile +++ b/util/MsSql/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/mssql/server:2019-CU17-ubuntu-20.04 +FROM mcr.microsoft.com/mssql/server:2019-CU24-ubuntu-20.04 LABEL com.bitwarden.product="bitwarden" @@ -6,8 +6,8 @@ USER root:root RUN apt-get update \ && apt-get install -y --no-install-recommends \ - gosu \ - tzdata \ + gosu \ + tzdata \ && rm -rf /var/lib/apt/lists/* COPY backup-db.sql / From 472b1f8d44c1e223aa2e36737650922ef716a004 Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Fri, 2 Feb 2024 09:35:00 -0500 Subject: [PATCH 054/117] [PM-5313] Upgrade to SQL Server 2022 (#3580) * Upgrade to SQL Server 2022 * CU11 --- util/MsSql/Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/util/MsSql/Dockerfile b/util/MsSql/Dockerfile index 87092f2d92..f4acb20622 100644 --- a/util/MsSql/Dockerfile +++ b/util/MsSql/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/mssql/server:2019-CU24-ubuntu-20.04 +FROM mcr.microsoft.com/mssql/server:2022-CU11-ubuntu-22.04 LABEL com.bitwarden.product="bitwarden" @@ -17,7 +17,6 @@ COPY entrypoint.sh / RUN chmod +x /entrypoint.sh \ && chmod +x /backup-db.sh -# Does not work unfortunately (https://github.com/bitwarden/server/issues/286) RUN /opt/mssql/bin/mssql-conf set telemetry.customerfeedback false HEALTHCHECK --start-period=120s --timeout=3s CMD /opt/mssql-tools/bin/sqlcmd \ From 6c3356c73fb153e3d9964c7834eddb2537aa6ae5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 5 Feb 2024 13:09:13 +0100 Subject: [PATCH 055/117] [deps] Tools: Update aws-sdk-net monorepo to v3.7.300.46 (#3738) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Core/Core.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 499b81fa02..42931f7179 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -21,8 +21,8 @@ - - + + From e5bcf7de9a476017bb16bcd1dbfbf78d96651048 Mon Sep 17 00:00:00 2001 From: Vince Grassia <593223+vgrassia@users.noreply.github.com> Date: Mon, 5 Feb 2024 08:49:18 -0500 Subject: [PATCH 056/117] Update Version Bump workflow logic (#3730) --- .github/workflows/version-bump.yml | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index 4eacb4b38e..16e90b73ba 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -37,7 +37,16 @@ jobs: uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 with: ref: main - repository: bitwarden/server + + - name: Check if RC branch exists + if: ${{ inputs.cut_rc_branch == true }} + run: | + remote_rc_branch_check=$(git ls-remote --heads origin rc | wc -l) + if [[ "${remote_rc_branch_check}" -gt 0 ]]; then + echo "Remote RC branch exists." + echo "Please delete current RC branch before running again." + exit 1 + fi - name: Import GPG key uses: crazy-max/ghaction-import-gpg@82a020f1f7f605c65dd2449b392a52c3fcfef7ef # v6.0.0 @@ -157,14 +166,19 @@ jobs: with: ref: main - - name: Check if RC branch exists + - name: Verify version has been updated + env: + NEW_VERSION: ${{ inputs.version_number }} run: | - remote_rc_branch_check=$(git ls-remote --heads origin rc | wc -l) - if [[ "${remote_rc_branch_check}" -gt 0 ]]; then - echo "Remote RC branch exists." - echo "Please delete current RC branch before running again." - exit 1 - fi + CURRENT_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props) + + # Wait for version to change. + while [[ "$NEW_VERSION" != "$CURRENT_VERSION" ]] + do + echo "Waiting for version to be updated..." + sleep 10 + git pull --force + done - name: Cut RC branch run: | From 3c5e9ac1aa711d6e69e475c10119ffdf16f2df94 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Mon, 5 Feb 2024 09:52:36 -0800 Subject: [PATCH 057/117] [AC-2143] Use flexible collections logic in GetManyDetails_vNext() (#3731) --- src/Api/Controllers/CollectionsController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Api/Controllers/CollectionsController.cs b/src/Api/Controllers/CollectionsController.cs index ba3647b25e..cd933739ff 100644 --- a/src/Api/Controllers/CollectionsController.cs +++ b/src/Api/Controllers/CollectionsController.cs @@ -547,7 +547,7 @@ public class CollectionsController : Controller { // We always need to know which collections the current user is assigned to var assignedOrgCollections = await _collectionRepository - .GetManyByUserIdWithAccessAsync(_currentContext.UserId.Value, orgId, false); + .GetManyByUserIdWithAccessAsync(_currentContext.UserId.Value, orgId, true); var readAllAuthorized = (await _authorizationService.AuthorizeAsync(User, CollectionOperations.ReadAllWithAccess(orgId))).Succeeded; From ae1fdb09924e77d6041b645044751ce9de372425 Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Mon, 5 Feb 2024 13:03:42 -0500 Subject: [PATCH 058/117] [PM-5052] Upgrade to .NET 8 (#3461) * Upgrade to .NET 8 * Linting * Clean up old JSON deserialization code * More .NET 8-oriented linting * Light feedback * Get rid of old test we don't know the root issue for * Fix a new test * Remove now-unnecessary Renovate constraint * Use Any() * Somehow a 6.0 tooling config we don't need snuck back in * Space out properties that always change per release * Bump a few core packages since the last update --- .config/dotnet-tools.json | 2 +- Directory.Build.props | 30 ++++++++++--------- bitwarden_license/src/Scim/Dockerfile | 6 ++-- bitwarden_license/src/Sso/Dockerfile | 6 ++-- .../src/Sso/Utilities/Saml2BitHandler.cs | 2 +- .../Factories/ScimApplicationFactory.cs | 4 +-- global.json | 2 +- perf/MicroBenchmarks/MicroBenchmarks.csproj | 1 - src/Admin/Dockerfile | 6 ++-- .../AzureQueueMailHostedService.cs | 4 +-- .../OrganizationConnectionRequestModel.cs | 2 +- src/Api/Dockerfile | 6 ++-- src/Billing/Dockerfile | 6 ++-- .../CreateOrganizationDomainCommand.cs | 2 +- .../Cloud/ValidateSponsorshipCommand.cs | 2 +- src/Core/Utilities/JsonHelpers.cs | 12 -------- .../Utilities/SecurityHeadersMiddleware.cs | 6 ++-- src/Core/Utilities/SpanExtensions.cs | 17 ----------- src/Events/Dockerfile | 6 ++-- .../AzureQueueHostedService.cs | 4 +-- src/EventsProcessor/Dockerfile | 6 ++-- src/Icons/Dockerfile | 6 ++-- src/Identity/Dockerfile | 6 ++-- src/Notifications/Dockerfile | 6 ++-- .../Services/OrganizationServiceTests.cs | 2 +- test/Core.Test/Services/DeviceServiceTests.cs | 5 ++-- .../Utilities/SpanExtensionsTests.cs | 18 ----------- .../Vault/Services/CipherServiceTests.cs | 7 ++--- test/Icons.Test/Models/IconLinkTests.cs | 6 ++-- .../Services/IconFetchingServiceTests.cs | 3 +- .../Endpoints/IdentityServerTests.cs | 2 +- .../OrganizationRepositoryTests.cs | 2 +- .../Factories/IdentityApplicationFactory.cs | 2 +- .../WebApplicationFactoryExtensions.cs | 4 +-- util/MsSqlMigratorUtility/Dockerfile | 2 +- util/Server/Dockerfile | 2 +- util/Setup/Dockerfile | 8 ++--- util/SqliteMigrations/SqliteMigrations.csproj | 1 - 38 files changed, 82 insertions(+), 132 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index a3850de029..e29d6594ea 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -7,7 +7,7 @@ "commands": ["swagger"] }, "dotnet-ef": { - "version": "7.0.15", + "version": "8.0.0", "commands": ["dotnet-ef"] } } diff --git a/Directory.Build.props b/Directory.Build.props index 376ab13177..8cfc297865 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,8 +1,10 @@ - net6.0 + net8.0 + 2024.2.0 + Bit.$(MSBuildProjectName) enable false @@ -17,31 +19,31 @@ - 17.1.0 + 17.8.0 - 2.4.1 + 2.6.6 - 2.4.3 + 2.5.6 - 3.1.2 + 6.0.0 - 4.3.0 + 5.1.0 - 4.17.0 + 4.18.1 - 4.17.0 + 4.18.1 - + diff --git a/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs b/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs index 30c25f6757..8bde8f84a1 100644 --- a/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs +++ b/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs @@ -417,7 +417,7 @@ public class DynamicAuthenticationSchemeProvider : AuthenticationSchemeProvider }; options.IdentityProviders.Add(idp); - return new DynamicAuthenticationScheme(name, name, typeof(Saml2BitHandler), options, SsoType.Saml2); + return new DynamicAuthenticationScheme(name, name, typeof(Saml2Handler), options, SsoType.Saml2); } private NameIdFormat GetNameIdFormat(Saml2NameIdFormat format) diff --git a/bitwarden_license/src/Sso/Utilities/Saml2BitHandler.cs b/bitwarden_license/src/Sso/Utilities/Saml2BitHandler.cs deleted file mode 100644 index 83705369fb..0000000000 --- a/bitwarden_license/src/Sso/Utilities/Saml2BitHandler.cs +++ /dev/null @@ -1,205 +0,0 @@ -using System.Text; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.DataProtection; -using Microsoft.Extensions.Options; -using Sustainsys.Saml2.AspNetCore2; -using Sustainsys.Saml2.WebSso; - -namespace Bit.Sso.Utilities; - -// Temporary handler for validating Saml2 requests -// Most of this is taken from Sustainsys.Saml2.AspNetCore2.Saml2Handler -// TODO: PM-3641 - Remove this handler once there is a proper solution -public class Saml2BitHandler : IAuthenticationRequestHandler -{ - private readonly Saml2Handler _saml2Handler; - private string _scheme; - - private readonly IOptionsMonitorCache _optionsCache; - private Saml2Options _options; - private HttpContext _context; - private readonly IDataProtector _dataProtector; - private readonly IOptionsFactory _optionsFactory; - private bool _emitSameSiteNone; - - public Saml2BitHandler( - IOptionsMonitorCache optionsCache, - IDataProtectionProvider dataProtectorProvider, - IOptionsFactory optionsFactory) - { - if (dataProtectorProvider == null) - { - throw new ArgumentNullException(nameof(dataProtectorProvider)); - } - - _optionsFactory = optionsFactory; - _optionsCache = optionsCache; - - _saml2Handler = new Saml2Handler(optionsCache, dataProtectorProvider, optionsFactory); - _dataProtector = dataProtectorProvider.CreateProtector(_saml2Handler.GetType().FullName); - } - - public Task InitializeAsync(AuthenticationScheme scheme, HttpContext context) - { - _context = context ?? throw new ArgumentNullException(nameof(context)); - _options = _optionsCache.GetOrAdd(scheme.Name, () => _optionsFactory.Create(scheme.Name)); - _emitSameSiteNone = _options.Notifications.EmitSameSiteNone(context.Request.GetUserAgent()); - _scheme = scheme.Name; - - return _saml2Handler.InitializeAsync(scheme, context); - } - - - public async Task HandleRequestAsync() - { - if (!_context.Request.Path.StartsWithSegments(_options.SPOptions.ModulePath, StringComparison.Ordinal)) - { - return false; - } - - var commandName = _context.Request.Path.Value.Substring( - _options.SPOptions.ModulePath.Length).TrimStart('/'); - - var commandResult = CommandFactory.GetCommand(commandName).Run( - _context.ToHttpRequestData(_options.CookieManager, _dataProtector.Unprotect), _options); - - // Scheme is the organization ID since we use dynamic handlers for authentication schemes. - // We need to compare this to the scheme returned in the RelayData to ensure this value hasn't been - // tampered with - if (commandResult.RelayData["scheme"] != _scheme) - { - return false; - } - - await commandResult.Apply( - _context, _dataProtector, _options.CookieManager, _options.SignInScheme, _options.SignOutScheme, _emitSameSiteNone); - - return true; - } - - public Task AuthenticateAsync() => _saml2Handler.AuthenticateAsync(); - - public Task ChallengeAsync(AuthenticationProperties properties) => _saml2Handler.ChallengeAsync(properties); - - public Task ForbidAsync(AuthenticationProperties properties) => _saml2Handler.ForbidAsync(properties); -} - - -static class HttpRequestExtensions -{ - public static HttpRequestData ToHttpRequestData( - this HttpContext httpContext, - ICookieManager cookieManager, - Func cookieDecryptor) - { - var request = httpContext.Request; - - var uri = new Uri( - request.Scheme - + "://" - + request.Host - + request.Path - + request.QueryString); - - var pathBase = httpContext.Request.PathBase.Value; - pathBase = string.IsNullOrEmpty(pathBase) ? "/" : pathBase; - IEnumerable>> formData = null; - if (httpContext.Request.Method == "POST" && httpContext.Request.HasFormContentType) - { - formData = request.Form.Select( - f => new KeyValuePair>(f.Key, f.Value)); - } - - return new HttpRequestData( - httpContext.Request.Method, - uri, - pathBase, - formData, - cookieName => cookieManager.GetRequestCookie(httpContext, cookieName), - cookieDecryptor, - httpContext.User); - } - - public static string GetUserAgent(this HttpRequest request) - { - return request.Headers["user-agent"].FirstOrDefault() ?? ""; - } -} - -static class CommandResultExtensions -{ - public static async Task Apply( - this CommandResult commandResult, - HttpContext httpContext, - IDataProtector dataProtector, - ICookieManager cookieManager, - string signInScheme, - string signOutScheme, - bool emitSameSiteNone) - { - httpContext.Response.StatusCode = (int)commandResult.HttpStatusCode; - - if (commandResult.Location != null) - { - httpContext.Response.Headers["Location"] = commandResult.Location.OriginalString; - } - - if (!string.IsNullOrEmpty(commandResult.SetCookieName)) - { - var cookieData = HttpRequestData.ConvertBinaryData( - dataProtector.Protect(commandResult.GetSerializedRequestState())); - - cookieManager.AppendResponseCookie( - httpContext, - commandResult.SetCookieName, - cookieData, - new CookieOptions() - { - HttpOnly = true, - Secure = commandResult.SetCookieSecureFlag, - // We are expecting a different site to POST back to us, - // so the ASP.Net Core default of Lax is not appropriate in this case - SameSite = emitSameSiteNone ? SameSiteMode.None : (SameSiteMode)(-1), - IsEssential = true - }); - } - - foreach (var h in commandResult.Headers) - { - httpContext.Response.Headers.Append(h.Key, h.Value); - } - - if (!string.IsNullOrEmpty(commandResult.ClearCookieName)) - { - cookieManager.DeleteCookie( - httpContext, - commandResult.ClearCookieName, - new CookieOptions - { - Secure = commandResult.SetCookieSecureFlag - }); - } - - if (!string.IsNullOrEmpty(commandResult.Content)) - { - var buffer = Encoding.UTF8.GetBytes(commandResult.Content); - httpContext.Response.ContentType = commandResult.ContentType; - await httpContext.Response.Body.WriteAsync(buffer, 0, buffer.Length); - } - - if (commandResult.Principal != null) - { - var authProps = new AuthenticationProperties(commandResult.RelayData) - { - RedirectUri = commandResult.Location.OriginalString - }; - await httpContext.SignInAsync(signInScheme, commandResult.Principal, authProps); - } - - if (commandResult.TerminateLocalSession) - { - await httpContext.SignOutAsync(signOutScheme ?? signInScheme); - } - } -} From 59fa6935b48c9947e09e9a5cb944ae2b1924533c Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Fri, 9 Feb 2024 11:58:37 -0500 Subject: [PATCH 086/117] [AC-1608] Send offboarding survey response to Stripe on subscription cancellation (#3734) * Added offboarding survey response to cancellation when FF is on. * Removed service methods to prevent unnecessary upstream registrations * Forgot to actually remove the injected command in the services * Rui's feedback * Add missing summary * Missed [FromBody] --- .../Controllers/OrganizationsController.cs | 58 ++++++- .../Auth/Controllers/AccountsController.cs | 50 +++++- .../SubscriptionCancellationRequestModel.cs | 7 + src/Api/Startup.cs | 1 + .../AdminConsole/Entities/Organization.cs | 6 +- .../Commands/ICancelSubscriptionCommand.cs | 25 +++ .../CancelSubscriptionCommand.cs | 118 +++++++++++++ .../Extensions/ServiceCollectionExtensions.cs | 8 + .../Models/OffboardingSurveyResponse.cs | 8 + .../Billing/Queries/IGetSubscriptionQuery.cs | 18 ++ .../Implementations/GetSubscriptionQuery.cs | 36 ++++ src/Core/Billing/Utilities.cs | 8 + src/Core/Constants.cs | 2 +- src/Core/Entities/ISubscriber.cs | 3 +- src/Core/Entities/User.cs | 6 +- .../OrganizationsControllerTests.cs | 14 +- .../Controllers/AccountsControllerTests.cs | 15 ++ .../CancelSubscriptionCommandTests.cs | 163 ++++++++++++++++++ .../Queries/GetSubscriptionQueryTests.cs | 104 +++++++++++ test/Core.Test/Billing/Utilities.cs | 18 ++ 20 files changed, 656 insertions(+), 12 deletions(-) create mode 100644 src/Api/Models/Request/SubscriptionCancellationRequestModel.cs create mode 100644 src/Core/Billing/Commands/ICancelSubscriptionCommand.cs create mode 100644 src/Core/Billing/Commands/Implementations/CancelSubscriptionCommand.cs create mode 100644 src/Core/Billing/Models/OffboardingSurveyResponse.cs create mode 100644 src/Core/Billing/Queries/IGetSubscriptionQuery.cs create mode 100644 src/Core/Billing/Queries/Implementations/GetSubscriptionQuery.cs create mode 100644 src/Core/Billing/Utilities.cs create mode 100644 test/Core.Test/Billing/Commands/CancelSubscriptionCommandTests.cs create mode 100644 test/Core.Test/Billing/Queries/GetSubscriptionQueryTests.cs create mode 100644 test/Core.Test/Billing/Utilities.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 7e16f75c41..07b005bfc5 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -18,6 +18,9 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Services; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Models; +using Bit.Core.Billing.Queries; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -27,6 +30,9 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Business; +using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -58,6 +64,9 @@ public class OrganizationsController : Controller private readonly IUpgradeOrganizationPlanCommand _upgradeOrganizationPlanCommand; private readonly IAddSecretsManagerSubscriptionCommand _addSecretsManagerSubscriptionCommand; private readonly IPushNotificationService _pushNotificationService; + private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand; + private readonly IGetSubscriptionQuery _getSubscriptionQuery; + private readonly IReferenceEventService _referenceEventService; public OrganizationsController( IOrganizationRepository organizationRepository, @@ -80,7 +89,10 @@ public class OrganizationsController : Controller IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, IUpgradeOrganizationPlanCommand upgradeOrganizationPlanCommand, IAddSecretsManagerSubscriptionCommand addSecretsManagerSubscriptionCommand, - IPushNotificationService pushNotificationService) + IPushNotificationService pushNotificationService, + ICancelSubscriptionCommand cancelSubscriptionCommand, + IGetSubscriptionQuery getSubscriptionQuery, + IReferenceEventService referenceEventService) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -103,6 +115,9 @@ public class OrganizationsController : Controller _upgradeOrganizationPlanCommand = upgradeOrganizationPlanCommand; _addSecretsManagerSubscriptionCommand = addSecretsManagerSubscriptionCommand; _pushNotificationService = pushNotificationService; + _cancelSubscriptionCommand = cancelSubscriptionCommand; + _getSubscriptionQuery = getSubscriptionQuery; + _referenceEventService = referenceEventService; } [HttpGet("{id}")] @@ -447,15 +462,48 @@ public class OrganizationsController : Controller [HttpPost("{id}/cancel")] [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostCancel(string id) + public async Task PostCancel(Guid id, [FromBody] SubscriptionCancellationRequestModel request) { - var orgIdGuid = new Guid(id); - if (!await _currentContext.EditSubscription(orgIdGuid)) + if (!await _currentContext.EditSubscription(id)) { throw new NotFoundException(); } - await _organizationService.CancelSubscriptionAsync(orgIdGuid); + var presentUserWithOffboardingSurvey = + _featureService.IsEnabled(FeatureFlagKeys.AC1607_PresentUsersWithOffboardingSurvey); + + if (presentUserWithOffboardingSurvey) + { + var organization = await _organizationRepository.GetByIdAsync(id); + + if (organization == null) + { + throw new NotFoundException(); + } + + var subscription = await _getSubscriptionQuery.GetSubscription(organization); + + await _cancelSubscriptionCommand.CancelSubscription(subscription, + new OffboardingSurveyResponse + { + UserId = _currentContext.UserId!.Value, + Reason = request.Reason, + Feedback = request.Feedback + }, + organization.IsExpired()); + + await _referenceEventService.RaiseEventAsync(new ReferenceEvent( + ReferenceEventType.CancelSubscription, + organization, + _currentContext) + { + EndOfPeriod = organization.IsExpired() + }); + } + else + { + await _organizationService.CancelSubscriptionAsync(id); + } } [HttpPost("{id}/reinstate")] diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index a4b41310e8..8c4842848e 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -21,6 +21,10 @@ using Bit.Core.Auth.Services; using Bit.Core.Auth.UserFeatures.UserKey; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Auth.Utilities; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Models; +using Bit.Core.Billing.Queries; +using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -31,6 +35,8 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Business; using Bit.Core.Tools.Repositories; using Bit.Core.Tools.Services; using Bit.Core.Utilities; @@ -62,6 +68,10 @@ public class AccountsController : Controller private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand; private readonly IRotateUserKeyCommand _rotateUserKeyCommand; private readonly IFeatureService _featureService; + private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand; + private readonly IGetSubscriptionQuery _getSubscriptionQuery; + private readonly IReferenceEventService _referenceEventService; + private readonly ICurrentContext _currentContext; private bool UseFlexibleCollections => _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections); @@ -93,6 +103,10 @@ public class AccountsController : Controller ISetInitialMasterPasswordCommand setInitialMasterPasswordCommand, IRotateUserKeyCommand rotateUserKeyCommand, IFeatureService featureService, + ICancelSubscriptionCommand cancelSubscriptionCommand, + IGetSubscriptionQuery getSubscriptionQuery, + IReferenceEventService referenceEventService, + ICurrentContext currentContext, IRotationValidator, IEnumerable> cipherValidator, IRotationValidator, IEnumerable> folderValidator, IRotationValidator, IReadOnlyList> sendValidator, @@ -118,6 +132,10 @@ public class AccountsController : Controller _setInitialMasterPasswordCommand = setInitialMasterPasswordCommand; _rotateUserKeyCommand = rotateUserKeyCommand; _featureService = featureService; + _cancelSubscriptionCommand = cancelSubscriptionCommand; + _getSubscriptionQuery = getSubscriptionQuery; + _referenceEventService = referenceEventService; + _currentContext = currentContext; _cipherValidator = cipherValidator; _folderValidator = folderValidator; _sendValidator = sendValidator; @@ -805,15 +823,43 @@ public class AccountsController : Controller [HttpPost("cancel-premium")] [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostCancel() + public async Task PostCancel([FromBody] SubscriptionCancellationRequestModel request) { var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) { throw new UnauthorizedAccessException(); } - await _userService.CancelPremiumAsync(user); + var presentUserWithOffboardingSurvey = + _featureService.IsEnabled(FeatureFlagKeys.AC1607_PresentUsersWithOffboardingSurvey); + + if (presentUserWithOffboardingSurvey) + { + var subscription = await _getSubscriptionQuery.GetSubscription(user); + + await _cancelSubscriptionCommand.CancelSubscription(subscription, + new OffboardingSurveyResponse + { + UserId = user.Id, + Reason = request.Reason, + Feedback = request.Feedback + }, + user.IsExpired()); + + await _referenceEventService.RaiseEventAsync(new ReferenceEvent( + ReferenceEventType.CancelSubscription, + user, + _currentContext) + { + EndOfPeriod = user.IsExpired() + }); + } + else + { + await _userService.CancelPremiumAsync(user); + } } [HttpPost("reinstate-premium")] diff --git a/src/Api/Models/Request/SubscriptionCancellationRequestModel.cs b/src/Api/Models/Request/SubscriptionCancellationRequestModel.cs new file mode 100644 index 0000000000..318c40aa21 --- /dev/null +++ b/src/Api/Models/Request/SubscriptionCancellationRequestModel.cs @@ -0,0 +1,7 @@ +namespace Bit.Api.Models.Request; + +public class SubscriptionCancellationRequestModel +{ + public string Reason { get; set; } + public string Feedback { get; set; } +} diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 7b5067f3fe..9f94325513 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -171,6 +171,7 @@ public class Startup services.AddOrganizationSubscriptionServices(); services.AddCoreLocalizationServices(); services.AddBillingCommands(); + services.AddBillingQueries(); // Authorization Handlers services.AddAuthorizationHandlers(); diff --git a/src/Core/AdminConsole/Entities/Organization.cs b/src/Core/AdminConsole/Entities/Organization.cs index 82041c5097..cd6f317bab 100644 --- a/src/Core/AdminConsole/Entities/Organization.cs +++ b/src/Core/AdminConsole/Entities/Organization.cs @@ -10,7 +10,7 @@ using Bit.Core.Utilities; namespace Bit.Core.AdminConsole.Entities; -public class Organization : ITableObject, ISubscriber, IStorable, IStorableSubscriber, IRevisable, IReferenceable +public class Organization : ITableObject, IStorableSubscriber, IRevisable, IReferenceable { private Dictionary _twoFactorProviders; @@ -139,6 +139,8 @@ public class Organization : ITableObject, ISubscriber, IStorable, IStorabl return "organizationId"; } + public bool IsOrganization() => true; + public bool IsUser() { return false; @@ -149,6 +151,8 @@ public class Organization : ITableObject, ISubscriber, IStorable, IStorabl return "Organization"; } + public bool IsExpired() => ExpirationDate.HasValue && ExpirationDate.Value <= DateTime.UtcNow; + public long StorageBytesRemaining() { if (!MaxStorageGb.HasValue) diff --git a/src/Core/Billing/Commands/ICancelSubscriptionCommand.cs b/src/Core/Billing/Commands/ICancelSubscriptionCommand.cs new file mode 100644 index 0000000000..b23880e650 --- /dev/null +++ b/src/Core/Billing/Commands/ICancelSubscriptionCommand.cs @@ -0,0 +1,25 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Models; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Stripe; + +namespace Bit.Core.Billing.Commands; + +public interface ICancelSubscriptionCommand +{ + /// + /// Cancels a user or organization's subscription while including user-provided feedback via the . + /// If the flag is , + /// this command sets the subscription's "cancel_at_end_of_period" property to . + /// Otherwise, this command cancels the subscription immediately. + /// + /// The or with the subscription to cancel. + /// An DTO containing user-provided feedback on why they are cancelling the subscription. + /// A flag indicating whether to cancel the subscription immediately or at the end of the subscription period. + /// Thrown when the provided subscription is already in an inactive state. + Task CancelSubscription( + Subscription subscription, + OffboardingSurveyResponse offboardingSurveyResponse, + bool cancelImmediately); +} diff --git a/src/Core/Billing/Commands/Implementations/CancelSubscriptionCommand.cs b/src/Core/Billing/Commands/Implementations/CancelSubscriptionCommand.cs new file mode 100644 index 0000000000..09dc5dde90 --- /dev/null +++ b/src/Core/Billing/Commands/Implementations/CancelSubscriptionCommand.cs @@ -0,0 +1,118 @@ +using Bit.Core.Billing.Models; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using Stripe; + +using static Bit.Core.Billing.Utilities; + +namespace Bit.Core.Billing.Commands.Implementations; + +public class CancelSubscriptionCommand( + ILogger logger, + IStripeAdapter stripeAdapter) + : ICancelSubscriptionCommand +{ + private static readonly List _validReasons = + [ + "customer_service", + "low_quality", + "missing_features", + "other", + "switched_service", + "too_complex", + "too_expensive", + "unused" + ]; + + public async Task CancelSubscription( + Subscription subscription, + OffboardingSurveyResponse offboardingSurveyResponse, + bool cancelImmediately) + { + if (IsInactive(subscription)) + { + logger.LogWarning("Cannot cancel subscription ({ID}) that's already inactive.", subscription.Id); + throw ContactSupport(); + } + + var metadata = new Dictionary + { + { "cancellingUserId", offboardingSurveyResponse.UserId.ToString() } + }; + + if (cancelImmediately) + { + if (BelongsToOrganization(subscription)) + { + await stripeAdapter.SubscriptionUpdateAsync(subscription.Id, new SubscriptionUpdateOptions + { + Metadata = metadata + }); + } + + await CancelSubscriptionImmediatelyAsync(subscription.Id, offboardingSurveyResponse); + } + else + { + await CancelSubscriptionAtEndOfPeriodAsync(subscription.Id, offboardingSurveyResponse, metadata); + } + } + + private static bool BelongsToOrganization(IHasMetadata subscription) + => subscription.Metadata != null && subscription.Metadata.ContainsKey("organizationId"); + + private async Task CancelSubscriptionImmediatelyAsync( + string subscriptionId, + OffboardingSurveyResponse offboardingSurveyResponse) + { + var options = new SubscriptionCancelOptions + { + CancellationDetails = new SubscriptionCancellationDetailsOptions + { + Comment = offboardingSurveyResponse.Feedback + } + }; + + if (IsValidCancellationReason(offboardingSurveyResponse.Reason)) + { + options.CancellationDetails.Feedback = offboardingSurveyResponse.Reason; + } + + await stripeAdapter.SubscriptionCancelAsync(subscriptionId, options); + } + + private static bool IsInactive(Subscription subscription) => + subscription.CanceledAt.HasValue || + subscription.Status == "canceled" || + subscription.Status == "unpaid" || + subscription.Status == "incomplete_expired"; + + private static bool IsValidCancellationReason(string reason) => _validReasons.Contains(reason); + + private async Task CancelSubscriptionAtEndOfPeriodAsync( + string subscriptionId, + OffboardingSurveyResponse offboardingSurveyResponse, + Dictionary metadata = null) + { + var options = new SubscriptionUpdateOptions + { + CancelAtPeriodEnd = true, + CancellationDetails = new SubscriptionCancellationDetailsOptions + { + Comment = offboardingSurveyResponse.Feedback + } + }; + + if (IsValidCancellationReason(offboardingSurveyResponse.Reason)) + { + options.CancellationDetails.Feedback = offboardingSurveyResponse.Reason; + } + + if (metadata != null) + { + options.Metadata = metadata; + } + + await stripeAdapter.SubscriptionUpdateAsync(subscriptionId, options); + } +} diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index 37857cf3ce..113fa4d5b7 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,7 @@ using Bit.Core.Billing.Commands; using Bit.Core.Billing.Commands.Implementations; +using Bit.Core.Billing.Queries; +using Bit.Core.Billing.Queries.Implementations; namespace Bit.Core.Billing.Extensions; @@ -9,6 +11,12 @@ public static class ServiceCollectionExtensions { public static void AddBillingCommands(this IServiceCollection services) { + services.AddSingleton(); services.AddSingleton(); } + + public static void AddBillingQueries(this IServiceCollection services) + { + services.AddSingleton(); + } } diff --git a/src/Core/Billing/Models/OffboardingSurveyResponse.cs b/src/Core/Billing/Models/OffboardingSurveyResponse.cs new file mode 100644 index 0000000000..cd966f40cc --- /dev/null +++ b/src/Core/Billing/Models/OffboardingSurveyResponse.cs @@ -0,0 +1,8 @@ +namespace Bit.Core.Billing.Models; + +public class OffboardingSurveyResponse +{ + public Guid UserId { get; set; } + public string Reason { get; set; } + public string Feedback { get; set; } +} diff --git a/src/Core/Billing/Queries/IGetSubscriptionQuery.cs b/src/Core/Billing/Queries/IGetSubscriptionQuery.cs new file mode 100644 index 0000000000..9ba2a85ed5 --- /dev/null +++ b/src/Core/Billing/Queries/IGetSubscriptionQuery.cs @@ -0,0 +1,18 @@ +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Stripe; + +namespace Bit.Core.Billing.Queries; + +public interface IGetSubscriptionQuery +{ + /// + /// Retrieves a Stripe using the 's property. + /// + /// The organization or user to retrieve the subscription for. + /// A Stripe . + /// Thrown when the is . + /// Thrown when the subscriber's is or empty. + /// Thrown when the returned from Stripe's API is null. + Task GetSubscription(ISubscriber subscriber); +} diff --git a/src/Core/Billing/Queries/Implementations/GetSubscriptionQuery.cs b/src/Core/Billing/Queries/Implementations/GetSubscriptionQuery.cs new file mode 100644 index 0000000000..c3b0a29552 --- /dev/null +++ b/src/Core/Billing/Queries/Implementations/GetSubscriptionQuery.cs @@ -0,0 +1,36 @@ +using Bit.Core.Entities; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using Stripe; + +using static Bit.Core.Billing.Utilities; + +namespace Bit.Core.Billing.Queries.Implementations; + +public class GetSubscriptionQuery( + ILogger logger, + IStripeAdapter stripeAdapter) : IGetSubscriptionQuery +{ + public async Task GetSubscription(ISubscriber subscriber) + { + ArgumentNullException.ThrowIfNull(subscriber); + + if (string.IsNullOrEmpty(subscriber.GatewaySubscriptionId)) + { + logger.LogError("Cannot cancel subscription for subscriber ({ID}) with no GatewaySubscriptionId.", subscriber.Id); + + throw ContactSupport(); + } + + var subscription = await stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId); + + if (subscription != null) + { + return subscription; + } + + logger.LogError("Could not find Stripe subscription ({ID}) to cancel.", subscriber.GatewaySubscriptionId); + + throw ContactSupport(); + } +} diff --git a/src/Core/Billing/Utilities.cs b/src/Core/Billing/Utilities.cs new file mode 100644 index 0000000000..54ace07a70 --- /dev/null +++ b/src/Core/Billing/Utilities.cs @@ -0,0 +1,8 @@ +using Bit.Core.Exceptions; + +namespace Bit.Core.Billing; + +public static class Utilities +{ + public static GatewayException ContactSupport() => new("Something went wrong with your request. Please contact support."); +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 1d5073df69..8c013a2f22 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -115,7 +115,7 @@ public static class FeatureFlagKeys /// flexible collections /// public const string FlexibleCollectionsMigration = "flexible-collections-migration"; - + public const string AC1607_PresentUsersWithOffboardingSurvey = "AC-1607_present-user-offboarding-survey"; public const string PM5766AutomaticTax = "PM-5766-automatic-tax"; public static List GetAllKeys() diff --git a/src/Core/Entities/ISubscriber.cs b/src/Core/Entities/ISubscriber.cs index 58510459e6..c4bfc622c8 100644 --- a/src/Core/Entities/ISubscriber.cs +++ b/src/Core/Entities/ISubscriber.cs @@ -14,7 +14,8 @@ public interface ISubscriber string BraintreeCustomerIdPrefix(); string BraintreeIdField(); string BraintreeCloudRegionField(); - string GatewayIdField(); + bool IsOrganization(); bool IsUser(); string SubscriberType(); + bool IsExpired(); } diff --git a/src/Core/Entities/User.cs b/src/Core/Entities/User.cs index b0db21eb14..bf13ff2a7d 100644 --- a/src/Core/Entities/User.cs +++ b/src/Core/Entities/User.cs @@ -9,7 +9,7 @@ using Microsoft.AspNetCore.Identity; namespace Bit.Core.Entities; -public class User : ITableObject, ISubscriber, IStorable, IStorableSubscriber, IRevisable, ITwoFactorProvidersUser, IReferenceable +public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFactorProvidersUser, IReferenceable { private Dictionary _twoFactorProviders; @@ -111,6 +111,8 @@ public class User : ITableObject, ISubscriber, IStorable, IStorableSubscri return "userId"; } + public bool IsOrganization() => false; + public bool IsUser() { return true; @@ -121,6 +123,8 @@ public class User : ITableObject, ISubscriber, IStorable, IStorableSubscri return "Subscriber"; } + public bool IsExpired() => PremiumExpirationDate.HasValue && PremiumExpirationDate.Value <= DateTime.UtcNow; + public Dictionary GetTwoFactorProviders() { if (string.IsNullOrWhiteSpace(TwoFactorProviders)) diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 76e4432b50..45b3d9af3c 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -11,6 +11,8 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Services; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Queries; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -21,6 +23,7 @@ using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Tools.Services; using NSubstitute; using NSubstitute.ReturnsExtensions; using Xunit; @@ -51,6 +54,9 @@ public class OrganizationsControllerTests : IDisposable private readonly IUpgradeOrganizationPlanCommand _upgradeOrganizationPlanCommand; private readonly IAddSecretsManagerSubscriptionCommand _addSecretsManagerSubscriptionCommand; private readonly IPushNotificationService _pushNotificationService; + private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand; + private readonly IGetSubscriptionQuery _getSubscriptionQuery; + private readonly IReferenceEventService _referenceEventService; private readonly OrganizationsController _sut; @@ -77,6 +83,9 @@ public class OrganizationsControllerTests : IDisposable _upgradeOrganizationPlanCommand = Substitute.For(); _addSecretsManagerSubscriptionCommand = Substitute.For(); _pushNotificationService = Substitute.For(); + _cancelSubscriptionCommand = Substitute.For(); + _getSubscriptionQuery = Substitute.For(); + _referenceEventService = Substitute.For(); _sut = new OrganizationsController( _organizationRepository, @@ -99,7 +108,10 @@ public class OrganizationsControllerTests : IDisposable _updateSecretsManagerSubscriptionCommand, _upgradeOrganizationPlanCommand, _addSecretsManagerSubscriptionCommand, - _pushNotificationService); + _pushNotificationService, + _cancelSubscriptionCommand, + _getSubscriptionQuery, + _referenceEventService); } public void Dispose() diff --git a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs index 0321b4f138..79aa2ca13d 100644 --- a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs @@ -14,6 +14,9 @@ using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Services; using Bit.Core.Auth.UserFeatures.UserKey; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Queries; +using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -53,6 +56,10 @@ public class AccountsControllerTests : IDisposable private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand; private readonly IRotateUserKeyCommand _rotateUserKeyCommand; private readonly IFeatureService _featureService; + private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand; + private readonly IGetSubscriptionQuery _getSubscriptionQuery; + private readonly IReferenceEventService _referenceEventService; + private readonly ICurrentContext _currentContext; private readonly IRotationValidator, IEnumerable> _cipherValidator; private readonly IRotationValidator, IEnumerable> _folderValidator; @@ -82,6 +89,10 @@ public class AccountsControllerTests : IDisposable _setInitialMasterPasswordCommand = Substitute.For(); _rotateUserKeyCommand = Substitute.For(); _featureService = Substitute.For(); + _cancelSubscriptionCommand = Substitute.For(); + _getSubscriptionQuery = Substitute.For(); + _referenceEventService = Substitute.For(); + _currentContext = Substitute.For(); _cipherValidator = Substitute.For, IEnumerable>>(); _folderValidator = @@ -110,6 +121,10 @@ public class AccountsControllerTests : IDisposable _setInitialMasterPasswordCommand, _rotateUserKeyCommand, _featureService, + _cancelSubscriptionCommand, + _getSubscriptionQuery, + _referenceEventService, + _currentContext, _cipherValidator, _folderValidator, _sendValidator, diff --git a/test/Core.Test/Billing/Commands/CancelSubscriptionCommandTests.cs b/test/Core.Test/Billing/Commands/CancelSubscriptionCommandTests.cs new file mode 100644 index 0000000000..ba98c26a5b --- /dev/null +++ b/test/Core.Test/Billing/Commands/CancelSubscriptionCommandTests.cs @@ -0,0 +1,163 @@ +using System.Linq.Expressions; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Commands.Implementations; +using Bit.Core.Billing.Models; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Stripe; +using Xunit; + +using static Bit.Core.Test.Billing.Utilities; + +namespace Bit.Core.Test.Billing.Commands; + +[SutProviderCustomize] +public class CancelSubscriptionCommandTests +{ + private const string _subscriptionId = "subscription_id"; + private const string _cancellingUserIdKey = "cancellingUserId"; + + [Theory, BitAutoData] + public async Task CancelSubscription_SubscriptionInactive_ThrowsGatewayException( + SutProvider sutProvider) + { + var subscription = new Subscription + { + Status = "canceled" + }; + + await ThrowsContactSupportAsync(() => + sutProvider.Sut.CancelSubscription(subscription, new OffboardingSurveyResponse(), false)); + + await DidNotUpdateSubscription(sutProvider); + + await DidNotCancelSubscription(sutProvider); + } + + [Theory, BitAutoData] + public async Task CancelSubscription_CancelImmediately_BelongsToOrganization_UpdatesSubscription_CancelSubscriptionImmediately( + SutProvider sutProvider) + { + var userId = Guid.NewGuid(); + + var subscription = new Subscription + { + Id = _subscriptionId, + Status = "active", + Metadata = new Dictionary + { + { "organizationId", "organization_id" } + } + }; + + var offboardingSurveyResponse = new OffboardingSurveyResponse + { + UserId = userId, + Reason = "missing_features", + Feedback = "Lorem ipsum" + }; + + await sutProvider.Sut.CancelSubscription(subscription, offboardingSurveyResponse, true); + + await UpdatedSubscriptionWith(sutProvider, options => options.Metadata[_cancellingUserIdKey] == userId.ToString()); + + await CancelledSubscriptionWith(sutProvider, options => + options.CancellationDetails.Comment == offboardingSurveyResponse.Feedback && + options.CancellationDetails.Feedback == offboardingSurveyResponse.Reason); + } + + [Theory, BitAutoData] + public async Task CancelSubscription_CancelImmediately_BelongsToUser_CancelSubscriptionImmediately( + SutProvider sutProvider) + { + var userId = Guid.NewGuid(); + + var subscription = new Subscription + { + Id = _subscriptionId, + Status = "active", + Metadata = new Dictionary + { + { "userId", "user_id" } + } + }; + + var offboardingSurveyResponse = new OffboardingSurveyResponse + { + UserId = userId, + Reason = "missing_features", + Feedback = "Lorem ipsum" + }; + + await sutProvider.Sut.CancelSubscription(subscription, offboardingSurveyResponse, true); + + await DidNotUpdateSubscription(sutProvider); + + await CancelledSubscriptionWith(sutProvider, options => + options.CancellationDetails.Comment == offboardingSurveyResponse.Feedback && + options.CancellationDetails.Feedback == offboardingSurveyResponse.Reason); + } + + [Theory, BitAutoData] + public async Task CancelSubscription_DoNotCancelImmediately_UpdateSubscriptionToCancelAtEndOfPeriod( + Organization organization, + SutProvider sutProvider) + { + var userId = Guid.NewGuid(); + + organization.ExpirationDate = DateTime.UtcNow.AddDays(5); + + var subscription = new Subscription + { + Id = _subscriptionId, + Status = "active" + }; + + var offboardingSurveyResponse = new OffboardingSurveyResponse + { + UserId = userId, + Reason = "missing_features", + Feedback = "Lorem ipsum" + }; + + await sutProvider.Sut.CancelSubscription(subscription, offboardingSurveyResponse, false); + + await UpdatedSubscriptionWith(sutProvider, options => + options.CancelAtPeriodEnd == true && + options.CancellationDetails.Comment == offboardingSurveyResponse.Feedback && + options.CancellationDetails.Feedback == offboardingSurveyResponse.Reason && + options.Metadata[_cancellingUserIdKey] == userId.ToString()); + + await DidNotCancelSubscription(sutProvider); + } + + private static Task DidNotCancelSubscription(SutProvider sutProvider) + => sutProvider + .GetDependency() + .DidNotReceiveWithAnyArgs() + .SubscriptionCancelAsync(Arg.Any(), Arg.Any()); + + private static Task DidNotUpdateSubscription(SutProvider sutProvider) + => sutProvider + .GetDependency() + .DidNotReceiveWithAnyArgs() + .SubscriptionUpdateAsync(Arg.Any(), Arg.Any()); + + private static Task CancelledSubscriptionWith( + SutProvider sutProvider, + Expression> predicate) + => sutProvider + .GetDependency() + .Received(1) + .SubscriptionCancelAsync(_subscriptionId, Arg.Is(predicate)); + + private static Task UpdatedSubscriptionWith( + SutProvider sutProvider, + Expression> predicate) + => sutProvider + .GetDependency() + .Received(1) + .SubscriptionUpdateAsync(_subscriptionId, Arg.Is(predicate)); +} diff --git a/test/Core.Test/Billing/Queries/GetSubscriptionQueryTests.cs b/test/Core.Test/Billing/Queries/GetSubscriptionQueryTests.cs new file mode 100644 index 0000000000..adae46a791 --- /dev/null +++ b/test/Core.Test/Billing/Queries/GetSubscriptionQueryTests.cs @@ -0,0 +1,104 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Queries.Implementations; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Billing.Queries; + +[SutProviderCustomize] +public class GetSubscriptionQueryTests +{ + [Theory, BitAutoData] + public async Task GetSubscription_NullSubscriber_ThrowsArgumentNullException( + SutProvider sutProvider) + => await Assert.ThrowsAsync( + async () => await sutProvider.Sut.GetSubscription(null)); + + [Theory, BitAutoData] + public async Task GetSubscription_Organization_NoGatewaySubscriptionId_ThrowsGatewayException( + Organization organization, + SutProvider sutProvider) + { + organization.GatewaySubscriptionId = null; + + await ThrowsContactSupportAsync(async () => await sutProvider.Sut.GetSubscription(organization)); + } + + [Theory, BitAutoData] + public async Task GetSubscription_Organization_NoSubscription_ThrowsGatewayException( + Organization organization, + SutProvider sutProvider) + { + sutProvider.GetDependency().SubscriptionGetAsync(organization.GatewaySubscriptionId) + .ReturnsNull(); + + await ThrowsContactSupportAsync(async () => await sutProvider.Sut.GetSubscription(organization)); + } + + [Theory, BitAutoData] + public async Task GetSubscription_Organization_Succeeds( + Organization organization, + SutProvider sutProvider) + { + var subscription = new Subscription(); + + sutProvider.GetDependency().SubscriptionGetAsync(organization.GatewaySubscriptionId) + .Returns(subscription); + + var gotSubscription = await sutProvider.Sut.GetSubscription(organization); + + Assert.Equivalent(subscription, gotSubscription); + } + + [Theory, BitAutoData] + public async Task GetSubscription_User_NoGatewaySubscriptionId_ThrowsGatewayException( + User user, + SutProvider sutProvider) + { + user.GatewaySubscriptionId = null; + + await ThrowsContactSupportAsync(async () => await sutProvider.Sut.GetSubscription(user)); + } + + [Theory, BitAutoData] + public async Task GetSubscription_User_NoSubscription_ThrowsGatewayException( + User user, + SutProvider sutProvider) + { + sutProvider.GetDependency().SubscriptionGetAsync(user.GatewaySubscriptionId) + .ReturnsNull(); + + await ThrowsContactSupportAsync(async () => await sutProvider.Sut.GetSubscription(user)); + } + + [Theory, BitAutoData] + public async Task GetSubscription_User_Succeeds( + User user, + SutProvider sutProvider) + { + var subscription = new Subscription(); + + sutProvider.GetDependency().SubscriptionGetAsync(user.GatewaySubscriptionId) + .Returns(subscription); + + var gotSubscription = await sutProvider.Sut.GetSubscription(user); + + Assert.Equivalent(subscription, gotSubscription); + } + + private static async Task ThrowsContactSupportAsync(Func function) + { + const string message = "Something went wrong with your request. Please contact support."; + + var exception = await Assert.ThrowsAsync(function); + + Assert.Equal(message, exception.Message); + } +} diff --git a/test/Core.Test/Billing/Utilities.cs b/test/Core.Test/Billing/Utilities.cs new file mode 100644 index 0000000000..359c010a29 --- /dev/null +++ b/test/Core.Test/Billing/Utilities.cs @@ -0,0 +1,18 @@ +using Bit.Core.Exceptions; +using Xunit; + +using static Bit.Core.Billing.Utilities; + +namespace Bit.Core.Test.Billing; + +public static class Utilities +{ + public static async Task ThrowsContactSupportAsync(Func function) + { + var contactSupport = ContactSupport(); + + var exception = await Assert.ThrowsAsync(function); + + Assert.Equal(contactSupport.Message, exception.Message); + } +} From 615d6a1cd00397e35a3639446f6fcb694982aef4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 9 Feb 2024 12:08:20 -0500 Subject: [PATCH 087/117] [deps] DbOps: Update dbup-sqlserver to v5.0.40 (#3708) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- util/Migrator/Migrator.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/util/Migrator/Migrator.csproj b/util/Migrator/Migrator.csproj index 28171d71f7..6200f96543 100644 --- a/util/Migrator/Migrator.csproj +++ b/util/Migrator/Migrator.csproj @@ -6,7 +6,7 @@ - + From 58b54692b2af95dda29fe5553556db9642af0bc1 Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Fri, 9 Feb 2024 18:08:36 +0100 Subject: [PATCH 088/117] Net8 follow-ups part2 (#3751) * Bump Microsoft.AspNetCore.Mvc.Testing to 8.0.1 * Bump Microsoft.NET.Test.Sdk to 17.8.0 * Nuget bumps on Infrastructure.Integration to be equal to solution * Use global setting * Use global setting --------- Co-authored-by: Daniel James Smith Co-authored-by: Matt Bishop --- .../Scim.IntegrationTest/Scim.IntegrationTest.csproj | 2 +- .../Identity.IntegrationTest.csproj | 2 +- .../Infrastructure.IntegrationTest.csproj | 10 +++++----- .../IntegrationTestCommon/IntegrationTestCommon.csproj | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/bitwarden_license/test/Scim.IntegrationTest/Scim.IntegrationTest.csproj b/bitwarden_license/test/Scim.IntegrationTest/Scim.IntegrationTest.csproj index 1a2b9bc76e..7ece41ecac 100644 --- a/bitwarden_license/test/Scim.IntegrationTest/Scim.IntegrationTest.csproj +++ b/bitwarden_license/test/Scim.IntegrationTest/Scim.IntegrationTest.csproj @@ -9,7 +9,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/test/Identity.IntegrationTest/Identity.IntegrationTest.csproj b/test/Identity.IntegrationTest/Identity.IntegrationTest.csproj index 3b95e7da49..eb11e2d0a7 100644 --- a/test/Identity.IntegrationTest/Identity.IntegrationTest.csproj +++ b/test/Identity.IntegrationTest/Identity.IntegrationTest.csproj @@ -10,7 +10,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj b/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj index feec25aedc..9b73789572 100644 --- a/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj +++ b/test/Infrastructure.IntegrationTest/Infrastructure.IntegrationTest.csproj @@ -8,16 +8,16 @@ - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/test/IntegrationTestCommon/IntegrationTestCommon.csproj b/test/IntegrationTestCommon/IntegrationTestCommon.csproj index 4995981a10..cc42bb38a0 100644 --- a/test/IntegrationTestCommon/IntegrationTestCommon.csproj +++ b/test/IntegrationTestCommon/IntegrationTestCommon.csproj @@ -5,7 +5,7 @@ - + From a9b9231cfac6a883d11be0c3e0b64e41b788b86b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Fri, 9 Feb 2024 17:42:01 +0000 Subject: [PATCH 089/117] [AC-2114] Downgrade Custom roles to User if flexible collections are enabled and only active permissions are 'Edit/Delete assigned collections' (#3770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [AC-2114] Downgrade Custom roles to User if flexible collections are enabled and only active permissions are 'Edit/Delete assigned collections' * [AC-2114] Undo changes to OrganizationsController * [AC-2114] Updated public API MembersController responses to have downgraded Custom user types for flexible collections --- .../OrganizationUsersController.cs | 80 ++++++++++++++++++- .../ProfileOrganizationResponseModel.cs | 31 +++++++ .../Public/Controllers/MembersController.cs | 26 +++--- .../Public/Models/MemberBaseModel.cs | 39 ++++++++- .../Models/Response/MemberResponseModel.cs | 9 ++- 5 files changed, 165 insertions(+), 20 deletions(-) diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index a6ef6e7b9f..d3fdf47c2a 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -12,6 +12,7 @@ using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; +using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; @@ -83,6 +84,15 @@ public class OrganizationUsersController : Controller } var response = new OrganizationUserDetailsResponseModel(organizationUser.Item1, organizationUser.Item2); + if (await FlexibleCollectionsIsEnabledAsync(organizationUser.Item1.OrganizationId)) + { + // Downgrade Custom users with no other permissions than 'Edit/Delete Assigned Collections' to User + response.Type = GetFlexibleCollectionsUserType(response.Type, response.Permissions); + + // Set 'Edit/Delete Assigned Collections' custom permissions to false + response.Permissions.EditAssignedCollections = false; + response.Permissions.DeleteAssignedCollections = false; + } if (includeGroups) { @@ -95,9 +105,12 @@ public class OrganizationUsersController : Controller [HttpGet("")] public async Task> Get(Guid orgId, bool includeGroups = false, bool includeCollections = false) { - var authorized = await FlexibleCollectionsIsEnabledAsync(orgId) - ? (await _authorizationService.AuthorizeAsync(User, OrganizationUserOperations.ReadAll(orgId))).Succeeded - : await _currentContext.ViewAllCollections(orgId) || + if (await FlexibleCollectionsIsEnabledAsync(orgId)) + { + return await Get_vNext(orgId, includeGroups, includeCollections); + } + + var authorized = await _currentContext.ViewAllCollections(orgId) || await _currentContext.ViewAssignedCollections(orgId) || await _currentContext.ManageGroups(orgId) || await _currentContext.ManageUsers(orgId); @@ -521,4 +534,65 @@ public class OrganizationUsersController : Controller var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId); return organizationAbility?.FlexibleCollections ?? false; } + + private async Task> Get_vNext(Guid orgId, + bool includeGroups = false, bool includeCollections = false) + { + var authorized = (await _authorizationService.AuthorizeAsync( + User, OrganizationUserOperations.ReadAll(orgId))).Succeeded; + if (!authorized) + { + throw new NotFoundException(); + } + + var organizationUsers = await _organizationUserRepository + .GetManyDetailsByOrganizationAsync(orgId, includeGroups, includeCollections); + var responseTasks = organizationUsers + .Select(async o => + { + var orgUser = new OrganizationUserUserDetailsResponseModel(o, + await _userService.TwoFactorIsEnabledAsync(o)); + + // Downgrade Custom users with no other permissions than 'Edit/Delete Assigned Collections' to User + orgUser.Type = GetFlexibleCollectionsUserType(orgUser.Type, orgUser.Permissions); + + // Set 'Edit/Delete Assigned Collections' custom permissions to false + orgUser.Permissions.EditAssignedCollections = false; + orgUser.Permissions.DeleteAssignedCollections = false; + + return orgUser; + }); + var responses = await Task.WhenAll(responseTasks); + + return new ListResponseModel(responses); + } + + private OrganizationUserType GetFlexibleCollectionsUserType(OrganizationUserType type, Permissions permissions) + { + // Downgrade Custom users with no other permissions than 'Edit/Delete Assigned Collections' to User + if (type == OrganizationUserType.Custom) + { + if ((permissions.EditAssignedCollections || permissions.DeleteAssignedCollections) && + permissions is + { + AccessEventLogs: false, + AccessImportExport: false, + AccessReports: false, + CreateNewCollections: false, + EditAnyCollection: false, + DeleteAnyCollection: false, + ManageGroups: false, + ManagePolicies: false, + ManageSso: false, + ManageUsers: false, + ManageResetPassword: false, + ManageScim: false + }) + { + return OrganizationUserType.User; + } + } + + return type; + } } diff --git a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs index a526cb0c70..163b2e27be 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs @@ -69,6 +69,37 @@ public class ProfileOrganizationResponseModel : ResponseModel KeyConnectorEnabled = ssoConfigData.MemberDecryptionType == MemberDecryptionType.KeyConnector && !string.IsNullOrEmpty(ssoConfigData.KeyConnectorUrl); KeyConnectorUrl = ssoConfigData.KeyConnectorUrl; } + + if (FlexibleCollections) + { + // Downgrade Custom users with no other permissions than 'Edit/Delete Assigned Collections' to User + if (Type == OrganizationUserType.Custom) + { + if ((Permissions.EditAssignedCollections || Permissions.DeleteAssignedCollections) && + Permissions is + { + AccessEventLogs: false, + AccessImportExport: false, + AccessReports: false, + CreateNewCollections: false, + EditAnyCollection: false, + DeleteAnyCollection: false, + ManageGroups: false, + ManagePolicies: false, + ManageSso: false, + ManageUsers: false, + ManageResetPassword: false, + ManageScim: false + }) + { + organization.Type = OrganizationUserType.User; + } + } + + // Set 'Edit/Delete Assigned Collections' custom permissions to false + Permissions.EditAssignedCollections = false; + Permissions.DeleteAssignedCollections = false; + } } public Guid Id { get; set; } diff --git a/src/Api/AdminConsole/Public/Controllers/MembersController.cs b/src/Api/AdminConsole/Public/Controllers/MembersController.cs index f2bffb5189..4c40022990 100644 --- a/src/Api/AdminConsole/Public/Controllers/MembersController.cs +++ b/src/Api/AdminConsole/Public/Controllers/MembersController.cs @@ -61,8 +61,9 @@ public class MembersController : Controller { return new NotFoundResult(); } + var flexibleCollectionsIsEnabled = await FlexibleCollectionsIsEnabledAsync(orgUser.OrganizationId); var response = new MemberResponseModel(orgUser, await _userService.TwoFactorIsEnabledAsync(orgUser), - userDetails.Item2); + userDetails.Item2, flexibleCollectionsIsEnabled); return new JsonResult(response); } @@ -101,9 +102,10 @@ public class MembersController : Controller { var users = await _organizationUserRepository.GetManyDetailsByOrganizationAsync( _currentContext.OrganizationId.Value); + var flexibleCollectionsIsEnabled = await FlexibleCollectionsIsEnabledAsync(_currentContext.OrganizationId.Value); // TODO: Get all CollectionUser associations for the organization and marry them up here for the response. var memberResponsesTasks = users.Select(async u => new MemberResponseModel(u, - await _userService.TwoFactorIsEnabledAsync(u), null)); + await _userService.TwoFactorIsEnabledAsync(u), null, flexibleCollectionsIsEnabled)); var memberResponses = await Task.WhenAll(memberResponsesTasks); var response = new ListResponseModel(memberResponses); return new JsonResult(response); @@ -121,11 +123,11 @@ public class MembersController : Controller [ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)] public async Task Post([FromBody] MemberCreateRequestModel model) { - var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(_currentContext.OrganizationId.Value); - var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection(organizationAbility?.FlexibleCollections ?? false)).ToList(); + var flexibleCollectionsIsEnabled = await FlexibleCollectionsIsEnabledAsync(_currentContext.OrganizationId.Value); + var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection(flexibleCollectionsIsEnabled)).ToList(); var user = await _organizationService.InviteUserAsync(_currentContext.OrganizationId.Value, null, model.Email, model.Type.Value, model.AccessAll.Value, model.ExternalId, associations, model.Groups); - var response = new MemberResponseModel(user, associations); + var response = new MemberResponseModel(user, associations, flexibleCollectionsIsEnabled); return new JsonResult(response); } @@ -150,19 +152,19 @@ public class MembersController : Controller return new NotFoundResult(); } var updatedUser = model.ToOrganizationUser(existingUser); - var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(_currentContext.OrganizationId.Value); - var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection(organizationAbility?.FlexibleCollections ?? false)).ToList(); + var flexibleCollectionsIsEnabled = await FlexibleCollectionsIsEnabledAsync(_currentContext.OrganizationId.Value); + var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection(flexibleCollectionsIsEnabled)).ToList(); await _organizationService.SaveUserAsync(updatedUser, null, associations, model.Groups); MemberResponseModel response = null; if (existingUser.UserId.HasValue) { var existingUserDetails = await _organizationUserRepository.GetDetailsByIdAsync(id); response = new MemberResponseModel(existingUserDetails, - await _userService.TwoFactorIsEnabledAsync(existingUserDetails), associations); + await _userService.TwoFactorIsEnabledAsync(existingUserDetails), associations, flexibleCollectionsIsEnabled); } else { - response = new MemberResponseModel(updatedUser, associations); + response = new MemberResponseModel(updatedUser, associations, flexibleCollectionsIsEnabled); } return new JsonResult(response); } @@ -233,4 +235,10 @@ public class MembersController : Controller await _organizationService.ResendInviteAsync(_currentContext.OrganizationId.Value, null, id); return new OkResult(); } + + private async Task FlexibleCollectionsIsEnabledAsync(Guid organizationId) + { + var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId); + return organizationAbility?.FlexibleCollections ?? false; + } } diff --git a/src/Api/AdminConsole/Public/Models/MemberBaseModel.cs b/src/Api/AdminConsole/Public/Models/MemberBaseModel.cs index 69bb0dc6f3..983a35f840 100644 --- a/src/Api/AdminConsole/Public/Models/MemberBaseModel.cs +++ b/src/Api/AdminConsole/Public/Models/MemberBaseModel.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; namespace Bit.Api.AdminConsole.Public.Models; @@ -9,27 +10,27 @@ public abstract class MemberBaseModel { public MemberBaseModel() { } - public MemberBaseModel(OrganizationUser user) + public MemberBaseModel(OrganizationUser user, bool flexibleCollectionsEnabled) { if (user == null) { throw new ArgumentNullException(nameof(user)); } - Type = user.Type; + Type = flexibleCollectionsEnabled ? GetFlexibleCollectionsUserType(user.Type, user.GetPermissions()) : user.Type; AccessAll = user.AccessAll; ExternalId = user.ExternalId; ResetPasswordEnrolled = user.ResetPasswordKey != null; } - public MemberBaseModel(OrganizationUserUserDetails user) + public MemberBaseModel(OrganizationUserUserDetails user, bool flexibleCollectionsEnabled) { if (user == null) { throw new ArgumentNullException(nameof(user)); } - Type = user.Type; + Type = flexibleCollectionsEnabled ? GetFlexibleCollectionsUserType(user.Type, user.GetPermissions()) : user.Type; AccessAll = user.AccessAll; ExternalId = user.ExternalId; ResetPasswordEnrolled = user.ResetPasswordKey != null; @@ -58,4 +59,34 @@ public abstract class MemberBaseModel /// [Required] public bool ResetPasswordEnrolled { get; set; } + + // TODO: AC-2188 - Remove this method when the custom users with no other permissions than 'Edit/Delete Assigned Collections' are migrated + private OrganizationUserType GetFlexibleCollectionsUserType(OrganizationUserType type, Permissions permissions) + { + // Downgrade Custom users with no other permissions than 'Edit/Delete Assigned Collections' to User + if (type == OrganizationUserType.Custom) + { + if ((permissions.EditAssignedCollections || permissions.DeleteAssignedCollections) && + permissions is + { + AccessEventLogs: false, + AccessImportExport: false, + AccessReports: false, + CreateNewCollections: false, + EditAnyCollection: false, + DeleteAnyCollection: false, + ManageGroups: false, + ManagePolicies: false, + ManageSso: false, + ManageUsers: false, + ManageResetPassword: false, + ManageScim: false + }) + { + return OrganizationUserType.User; + } + } + + return type; + } } diff --git a/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs b/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs index 7035b64295..de57e4fc48 100644 --- a/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs +++ b/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs @@ -12,8 +12,9 @@ namespace Bit.Api.AdminConsole.Public.Models.Response; ///
public class MemberResponseModel : MemberBaseModel, IResponseModel { - public MemberResponseModel(OrganizationUser user, IEnumerable collections) - : base(user) + public MemberResponseModel(OrganizationUser user, IEnumerable collections, + bool flexibleCollectionsEnabled) + : base(user, flexibleCollectionsEnabled) { if (user == null) { @@ -28,8 +29,8 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel } public MemberResponseModel(OrganizationUserUserDetails user, bool twoFactorEnabled, - IEnumerable collections) - : base(user) + IEnumerable collections, bool flexibleCollectionsEnabled) + : base(user, flexibleCollectionsEnabled) { if (user == null) { From de294b8299f8964a621ca375bc83f2416171d5bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Fri, 9 Feb 2024 17:57:01 +0000 Subject: [PATCH 090/117] [AC-2154] Logging organization data before migrating for flexible collections (#3761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [AC-2154] Logging organization data before migrating for flexible collections * [AC-2154] Refactored logging command to perform the data migration * [AC-2154] Moved validation inside the command * [AC-2154] PR feedback * [AC-2154] Changed logging level to warning * [AC-2154] Fixed unit test * [AC-2154] Removed logging unnecessary data * [AC-2154] Removed primary constructor * [AC-2154] Added comments --- .../Controllers/OrganizationsController.cs | 16 +-- ...tionEnableCollectionEnhancementsCommand.cs | 12 ++ ...tionEnableCollectionEnhancementsCommand.cs | 112 ++++++++++++++++++ ...OrganizationServiceCollectionExtensions.cs | 8 ++ .../OrganizationsControllerTests.cs | 30 ++--- ...nableCollectionEnhancementsCommandTests.cs | 46 +++++++ 6 files changed, 191 insertions(+), 33 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationCollectionEnhancements/Interfaces/IOrganizationEnableCollectionEnhancementsCommand.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationCollectionEnhancements/OrganizationEnableCollectionEnhancementsCommand.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationCollectionEnhancements/OrganizationEnableCollectionEnhancementsCommandTests.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 07b005bfc5..c83fce7622 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -14,6 +14,7 @@ using Bit.Core; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; @@ -67,6 +68,7 @@ public class OrganizationsController : Controller private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand; private readonly IGetSubscriptionQuery _getSubscriptionQuery; private readonly IReferenceEventService _referenceEventService; + private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand; public OrganizationsController( IOrganizationRepository organizationRepository, @@ -92,7 +94,8 @@ public class OrganizationsController : Controller IPushNotificationService pushNotificationService, ICancelSubscriptionCommand cancelSubscriptionCommand, IGetSubscriptionQuery getSubscriptionQuery, - IReferenceEventService referenceEventService) + IReferenceEventService referenceEventService, + IOrganizationEnableCollectionEnhancementsCommand organizationEnableCollectionEnhancementsCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -118,6 +121,7 @@ public class OrganizationsController : Controller _cancelSubscriptionCommand = cancelSubscriptionCommand; _getSubscriptionQuery = getSubscriptionQuery; _referenceEventService = referenceEventService; + _organizationEnableCollectionEnhancementsCommand = organizationEnableCollectionEnhancementsCommand; } [HttpGet("{id}")] @@ -888,15 +892,7 @@ public class OrganizationsController : Controller throw new NotFoundException(); } - if (organization.FlexibleCollections) - { - throw new BadRequestException("Organization has already been migrated to the new collection enhancements"); - } - - await _organizationRepository.EnableCollectionEnhancements(id); - - organization.FlexibleCollections = true; - await _organizationService.ReplaceAndUpdateCacheAsync(organization); + await _organizationEnableCollectionEnhancementsCommand.EnableCollectionEnhancements(organization); // Force a vault sync for all owners and admins of the organization so that changes show immediately // Custom users are intentionally not handled as they are likely to be less impacted and we want to limit simultaneous syncs diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationCollectionEnhancements/Interfaces/IOrganizationEnableCollectionEnhancementsCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationCollectionEnhancements/Interfaces/IOrganizationEnableCollectionEnhancementsCommand.cs new file mode 100644 index 0000000000..58a639c745 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationCollectionEnhancements/Interfaces/IOrganizationEnableCollectionEnhancementsCommand.cs @@ -0,0 +1,12 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements.Interfaces; + +/// +/// Enable collection enhancements for an organization. +/// This command will be deprecated once all organizations have collection enhancements enabled. +/// +public interface IOrganizationEnableCollectionEnhancementsCommand +{ + Task EnableCollectionEnhancements(Organization organization); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationCollectionEnhancements/OrganizationEnableCollectionEnhancementsCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationCollectionEnhancements/OrganizationEnableCollectionEnhancementsCommand.cs new file mode 100644 index 0000000000..da32e9c517 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationCollectionEnhancements/OrganizationEnableCollectionEnhancementsCommand.cs @@ -0,0 +1,112 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements; + +public class OrganizationEnableCollectionEnhancementsCommand : IOrganizationEnableCollectionEnhancementsCommand +{ + private readonly ICollectionRepository _collectionRepository; + private readonly IGroupRepository _groupRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationService _organizationService; + private readonly ILogger _logger; + + public OrganizationEnableCollectionEnhancementsCommand(ICollectionRepository collectionRepository, + IGroupRepository groupRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationService organizationService, + ILogger logger) + { + _collectionRepository = collectionRepository; + _groupRepository = groupRepository; + _organizationRepository = organizationRepository; + _organizationUserRepository = organizationUserRepository; + _organizationService = organizationService; + _logger = logger; + } + + public async Task EnableCollectionEnhancements(Organization organization) + { + if (organization.FlexibleCollections) + { + throw new BadRequestException("Organization has already been migrated to the new collection enhancements"); + } + + // Log the Organization data that will change when the migration is complete + await LogPreMigrationDataAsync(organization.Id); + + // Run the data migration script + await _organizationRepository.EnableCollectionEnhancements(organization.Id); + + organization.FlexibleCollections = true; + await _organizationService.ReplaceAndUpdateCacheAsync(organization); + } + + /// + /// This method logs the data that will be migrated to the new collection enhancements so that it can be restored if needed + /// + /// + private async Task LogPreMigrationDataAsync(Guid organizationId) + { + var groups = await _groupRepository.GetManyByOrganizationIdAsync(organizationId); + // Grab Group Ids that have AccessAll enabled as it will be removed in the data migration + var groupIdsWithAccessAllEnabled = groups + .Where(g => g.AccessAll) + .Select(g => g.Id) + .ToList(); + + var organizationUsers = await _organizationUserRepository.GetManyByOrganizationAsync(organizationId, type: null); + // Grab OrganizationUser Ids that have AccessAll enabled as it will be removed in the data migration + var organizationUserIdsWithAccessAllEnabled = organizationUsers + .Where(ou => ou.AccessAll) + .Select(ou => ou.Id) + .ToList(); + // Grab OrganizationUser Ids of Manager users as that will be downgraded to User in the data migration + var migratedManagers = organizationUsers + .Where(ou => ou.Type == OrganizationUserType.Manager) + .Select(ou => ou.Id) + .ToList(); + + var usersEligibleToManageCollections = organizationUsers + .Where(ou => + ou.Type == OrganizationUserType.Manager || + (ou.Type == OrganizationUserType.Custom && + !string.IsNullOrEmpty(ou.Permissions) && + ou.GetPermissions().EditAssignedCollections) + ) + .Select(ou => ou.Id) + .ToList(); + var collectionUsers = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(organizationId); + // Grab CollectionUser permissions that will change in the data migration + var collectionUsersData = collectionUsers.SelectMany(tuple => + tuple.Item2.Users.Select(user => + new + { + CollectionId = tuple.Item1.Id, + OrganizationUserId = user.Id, + user.ReadOnly, + user.HidePasswords + })) + .Where(cud => usersEligibleToManageCollections.Any(ou => ou == cud.OrganizationUserId)) + .ToList(); + + var logObject = new + { + OrganizationId = organizationId, + GroupAccessAll = groupIdsWithAccessAllEnabled, + UserAccessAll = organizationUserIdsWithAccessAllEnabled, + MigratedManagers = migratedManagers, + CollectionUsers = collectionUsersData + }; + + _logger.LogWarning("Flexible Collections data migration started. Backup data: {@LogObject}", logObject); + } +} diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index e70738e06a..3f303e3a90 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -4,6 +4,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Groups; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; @@ -50,6 +52,7 @@ public static class OrganizationServiceCollectionExtensions services.AddOrganizationUserCommands(); services.AddOrganizationUserCommandsQueries(); services.AddBaseOrganizationSubscriptionCommandsQueries(); + services.AddOrganizationCollectionEnhancementsCommands(); } private static void AddOrganizationConnectionCommands(this IServiceCollection services) @@ -144,6 +147,11 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); } + private static void AddOrganizationCollectionEnhancementsCommands(this IServiceCollection services) + { + services.AddScoped(); + } + private static void AddTokenizers(this IServiceCollection services) { services.AddSingleton>(serviceProvider => diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 45b3d9af3c..983470d6fb 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -5,6 +5,7 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.Models.Request.Organizations; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; @@ -57,6 +58,7 @@ public class OrganizationsControllerTests : IDisposable private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand; private readonly IGetSubscriptionQuery _getSubscriptionQuery; private readonly IReferenceEventService _referenceEventService; + private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand; private readonly OrganizationsController _sut; @@ -86,6 +88,7 @@ public class OrganizationsControllerTests : IDisposable _cancelSubscriptionCommand = Substitute.For(); _getSubscriptionQuery = Substitute.For(); _referenceEventService = Substitute.For(); + _organizationEnableCollectionEnhancementsCommand = Substitute.For(); _sut = new OrganizationsController( _organizationRepository, @@ -111,7 +114,8 @@ public class OrganizationsControllerTests : IDisposable _pushNotificationService, _cancelSubscriptionCommand, _getSubscriptionQuery, - _referenceEventService); + _referenceEventService, + _organizationEnableCollectionEnhancementsCommand); } public void Dispose() @@ -390,11 +394,7 @@ public class OrganizationsControllerTests : IDisposable await _sut.EnableCollectionEnhancements(organization.Id); - await _organizationRepository.Received(1).EnableCollectionEnhancements(organization.Id); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync( - Arg.Is(o => - o.Id == organization.Id && - o.FlexibleCollections)); + await _organizationEnableCollectionEnhancementsCommand.Received(1).EnableCollectionEnhancements(organization); await _pushNotificationService.Received(1).PushSyncVaultAsync(admin.UserId.Value); await _pushNotificationService.Received(1).PushSyncVaultAsync(owner.UserId.Value); await _pushNotificationService.DidNotReceive().PushSyncVaultAsync(user.UserId.Value); @@ -409,23 +409,7 @@ public class OrganizationsControllerTests : IDisposable await Assert.ThrowsAsync(async () => await _sut.EnableCollectionEnhancements(organization.Id)); - await _organizationRepository.DidNotReceiveWithAnyArgs().EnableCollectionEnhancements(Arg.Any()); - await _organizationService.DidNotReceiveWithAnyArgs().ReplaceAndUpdateCacheAsync(Arg.Any()); - await _pushNotificationService.DidNotReceiveWithAnyArgs().PushSyncVaultAsync(Arg.Any()); - } - - [Theory, AutoData] - public async Task EnableCollectionEnhancements_WhenAlreadyMigrated_Throws(Organization organization) - { - organization.FlexibleCollections = true; - _currentContext.OrganizationOwner(organization.Id).Returns(true); - _organizationRepository.GetByIdAsync(organization.Id).Returns(organization); - - var exception = await Assert.ThrowsAsync(async () => await _sut.EnableCollectionEnhancements(organization.Id)); - Assert.Contains("has already been migrated", exception.Message); - - await _organizationRepository.DidNotReceiveWithAnyArgs().EnableCollectionEnhancements(Arg.Any()); - await _organizationService.DidNotReceiveWithAnyArgs().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _organizationEnableCollectionEnhancementsCommand.DidNotReceiveWithAnyArgs().EnableCollectionEnhancements(Arg.Any()); await _pushNotificationService.DidNotReceiveWithAnyArgs().PushSyncVaultAsync(Arg.Any()); } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationCollectionEnhancements/OrganizationEnableCollectionEnhancementsCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationCollectionEnhancements/OrganizationEnableCollectionEnhancementsCommandTests.cs new file mode 100644 index 0000000000..c63c100adc --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationCollectionEnhancements/OrganizationEnableCollectionEnhancementsCommandTests.cs @@ -0,0 +1,46 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements; +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.OrganizationCollectionEnhancements; + +[SutProviderCustomize] +public class OrganizationEnableCollectionEnhancementsCommandTests +{ + [Theory] + [BitAutoData] + public async Task EnableCollectionEnhancements_Success( + SutProvider sutProvider, + Organization organization) + { + organization.FlexibleCollections = false; + + await sutProvider.Sut.EnableCollectionEnhancements(organization); + + await sutProvider.GetDependency().Received(1).EnableCollectionEnhancements(organization.Id); + await sutProvider.GetDependency().Received(1).ReplaceAndUpdateCacheAsync( + Arg.Is(o => + o.Id == organization.Id && + o.FlexibleCollections)); + } + + [Theory] + [BitAutoData] + public async Task EnableCollectionEnhancements_WhenAlreadyMigrated_Throws( + SutProvider sutProvider, + Organization organization) + { + organization.FlexibleCollections = true; + + var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.EnableCollectionEnhancements(organization)); + Assert.Contains("has already been migrated", exception.Message); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().EnableCollectionEnhancements(Arg.Any()); + } +} From a19ae0159fe0f898ab644a1f6c501a3239871518 Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Fri, 9 Feb 2024 12:08:22 -0800 Subject: [PATCH 091/117] [PM-5424] fix TDE provider user (#3771) * Add Test Asserting Problem * Fix Test --------- Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> --- src/Core/Context/CurrentContext.cs | 8 +++ .../Endpoints/IdentityServerSsoTests.cs | 71 +++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/src/Core/Context/CurrentContext.cs b/src/Core/Context/CurrentContext.cs index cc74e60a8c..90ad275d02 100644 --- a/src/Core/Context/CurrentContext.cs +++ b/src/Core/Context/CurrentContext.cs @@ -489,6 +489,10 @@ public class CurrentContext : ICurrentContext { if (Organizations == null) { + // If we haven't had our user id set, take the one passed in since we are about to get information + // for them anyways. + UserId ??= userId; + var userOrgs = await organizationUserRepository.GetManyDetailsByUserAsync(userId); Organizations = userOrgs.Where(ou => ou.Status == OrganizationUserStatusType.Confirmed) .Select(ou => new CurrentContextOrganization(ou)).ToList(); @@ -501,6 +505,10 @@ public class CurrentContext : ICurrentContext { if (Providers == null) { + // If we haven't had our user id set, take the one passed in since we are about to get information + // for them anyways. + UserId ??= userId; + var userProviders = await providerUserRepository.GetManyByUserAsync(userId); Providers = userProviders.Where(ou => ou.Status == ProviderUserStatusType.Confirmed) .Select(ou => new CurrentContextProvider(ou)).ToList(); diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs index 70f6e5e1af..c775fce5eb 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs @@ -1,6 +1,9 @@ using System.Security.Claims; using System.Text.Json; 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; @@ -380,6 +383,74 @@ public class IdentityServerSsoTests } + [Fact] + public async Task SsoLogin_TrustedDeviceEncryption_ProviderUserHasManageResetPassword_ReturnsCorrectOptions() + { + var challenge = new string('c', 50); + + var factory = await CreateFactoryAsync(new SsoConfigurationData + { + MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption, + }, challenge); + + var user = await factory.Services.GetRequiredService().GetByEmailAsync(TestEmail); + var providerRepository = factory.Services.GetRequiredService(); + var provider = await providerRepository.CreateAsync(new Provider + { + Name = "Test Provider", + }); + + var providerUserRepository = factory.Services.GetRequiredService(); + await providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = provider.Id, + UserId = user.Id, + Status = ProviderUserStatusType.Confirmed, + Permissions = CoreHelpers.ClassToJsonData(new Permissions + { + ManageResetPassword = true, + }), + }); + + var organizationUserRepository = factory.Services.GetRequiredService(); + var organizationUser = (await organizationUserRepository.GetManyByUserAsync(user.Id)).Single(); + + var providerOrganizationRepository = factory.Services.GetRequiredService(); + await providerOrganizationRepository.CreateAsync(new ProviderOrganization + { + ProviderId = provider.Id, + OrganizationId = organizationUser.OrganizationId, + }); + + // Act + var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary + { + { "scope", "api offline_access" }, + { "client_id", "web" }, + { "deviceType", "10" }, + { "deviceIdentifier", "test_id" }, + { "deviceName", "firefox" }, + { "twoFactorToken", "TEST"}, + { "twoFactorProvider", "5" }, // RememberMe Provider + { "twoFactorRemember", "0" }, + { "grant_type", "authorization_code" }, + { "code", "test_code" }, + { "code_verifier", challenge }, + { "redirect_uri", "https://localhost:8080/sso-connector.html" } + })); + + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + using var responseBody = await AssertHelper.AssertResponseTypeIs(context); + var root = responseBody.RootElement; + AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String); + + var userDecryptionOptions = AssertHelper.AssertJsonProperty(root, "UserDecryptionOptions", JsonValueKind.Object); + + var trustedDeviceOption = AssertHelper.AssertJsonProperty(userDecryptionOptions, "TrustedDeviceOption", JsonValueKind.Object); + AssertHelper.AssertJsonProperty(trustedDeviceOption, "HasAdminApproval", JsonValueKind.False); + AssertHelper.AssertJsonProperty(trustedDeviceOption, "HasManageResetPasswordPermission", JsonValueKind.True); + } + [Fact] public async Task SsoLogin_KeyConnector_ReturnsOptions() { From 17118bc74f8138c7c13747ed9de4f361dee822d3 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Fri, 9 Feb 2024 15:44:31 -0500 Subject: [PATCH 092/117] [PM-6208] Move TOTP cache validation logic to providers (#3779) * move totp cache validation logic to providers * remove unused usings * reduce TTL --- .../Identity/AuthenticatorTokenProvider.cs | 35 +++++++++++++++---- src/Core/Auth/Identity/EmailTokenProvider.cs | 34 +++++++++++++++--- .../IdentityServer/BaseRequestValidator.cs | 23 +----------- .../CustomTokenRequestValidator.cs | 5 +-- .../ResourceOwnerPasswordValidator.cs | 5 +-- .../IdentityServer/WebAuthnGrantValidator.cs | 5 +-- 6 files changed, 63 insertions(+), 44 deletions(-) diff --git a/src/Core/Auth/Identity/AuthenticatorTokenProvider.cs b/src/Core/Auth/Identity/AuthenticatorTokenProvider.cs index d6b3d1526f..fae2d23b19 100644 --- a/src/Core/Auth/Identity/AuthenticatorTokenProvider.cs +++ b/src/Core/Auth/Identity/AuthenticatorTokenProvider.cs @@ -2,6 +2,7 @@ using Bit.Core.Entities; using Bit.Core.Services; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; using OtpNet; @@ -9,11 +10,23 @@ namespace Bit.Core.Auth.Identity; public class AuthenticatorTokenProvider : IUserTwoFactorTokenProvider { - private readonly IServiceProvider _serviceProvider; + private const string CacheKeyFormat = "Authenticator_TOTP_{0}_{1}"; - public AuthenticatorTokenProvider(IServiceProvider serviceProvider) + private readonly IServiceProvider _serviceProvider; + private readonly IDistributedCache _distributedCache; + private readonly DistributedCacheEntryOptions _distributedCacheEntryOptions; + + public AuthenticatorTokenProvider( + IServiceProvider serviceProvider, + [FromKeyedServices("persistent")] + IDistributedCache distributedCache) { _serviceProvider = serviceProvider; + _distributedCache = distributedCache; + _distributedCacheEntryOptions = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2) + }; } public async Task CanGenerateTwoFactorTokenAsync(UserManager manager, User user) @@ -32,14 +45,24 @@ public class AuthenticatorTokenProvider : IUserTwoFactorTokenProvider return Task.FromResult(null); } - public Task ValidateAsync(string purpose, string token, UserManager manager, User user) + public async Task ValidateAsync(string purpose, string token, UserManager manager, User user) { + var cacheKey = string.Format(CacheKeyFormat, user.Id, token); + var cachedValue = await _distributedCache.GetAsync(cacheKey); + if (cachedValue != null) + { + return false; + } + var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Authenticator); var otp = new Totp(Base32Encoding.ToBytes((string)provider.MetaData["Key"])); + var valid = otp.VerifyTotp(token, out _, new VerificationWindow(1, 1)); - long timeStepMatched; - var valid = otp.VerifyTotp(token, out timeStepMatched, new VerificationWindow(1, 1)); + if (valid) + { + await _distributedCache.SetAsync(cacheKey, [1], _distributedCacheEntryOptions); + } - return Task.FromResult(valid); + return valid; } } diff --git a/src/Core/Auth/Identity/EmailTokenProvider.cs b/src/Core/Auth/Identity/EmailTokenProvider.cs index e8961c0638..6ef473c4b3 100644 --- a/src/Core/Auth/Identity/EmailTokenProvider.cs +++ b/src/Core/Auth/Identity/EmailTokenProvider.cs @@ -3,17 +3,30 @@ using Bit.Core.Auth.Models; using Bit.Core.Entities; using Bit.Core.Services; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.Auth.Identity; public class EmailTokenProvider : IUserTwoFactorTokenProvider { - private readonly IServiceProvider _serviceProvider; + private const string CacheKeyFormat = "Email_TOTP_{0}_{1}"; - public EmailTokenProvider(IServiceProvider serviceProvider) + private readonly IServiceProvider _serviceProvider; + private readonly IDistributedCache _distributedCache; + private readonly DistributedCacheEntryOptions _distributedCacheEntryOptions; + + public EmailTokenProvider( + IServiceProvider serviceProvider, + [FromKeyedServices("persistent")] + IDistributedCache distributedCache) { _serviceProvider = serviceProvider; + _distributedCache = distributedCache; + _distributedCacheEntryOptions = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(20) + }; } public async Task CanGenerateTwoFactorTokenAsync(UserManager manager, User user) @@ -39,9 +52,22 @@ public class EmailTokenProvider : IUserTwoFactorTokenProvider return Task.FromResult(RedactEmail((string)provider.MetaData["Email"])); } - public Task ValidateAsync(string purpose, string token, UserManager manager, User user) + public async Task ValidateAsync(string purpose, string token, UserManager manager, User user) { - return _serviceProvider.GetRequiredService().VerifyTwoFactorEmailAsync(user, token); + var cacheKey = string.Format(CacheKeyFormat, user.Id, token); + var cachedValue = await _distributedCache.GetAsync(cacheKey); + if (cachedValue != null) + { + return false; + } + + var valid = await _serviceProvider.GetRequiredService().VerifyTwoFactorEmailAsync(user, token); + if (valid) + { + await _distributedCache.SetAsync(cacheKey, [1], _distributedCacheEntryOptions); + } + + return valid; } private bool HasProperMetaData(TwoFactorProvider provider) diff --git a/src/Identity/IdentityServer/BaseRequestValidator.cs b/src/Identity/IdentityServer/BaseRequestValidator.cs index f6d8b3b23a..406fd5bf7e 100644 --- a/src/Identity/IdentityServer/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/BaseRequestValidator.cs @@ -27,7 +27,6 @@ using Bit.Core.Tokens; using Bit.Core.Utilities; using Duende.IdentityServer.Validation; using Microsoft.AspNetCore.Identity; -using Microsoft.Extensions.Caching.Distributed; namespace Bit.Identity.IdentityServer; @@ -47,8 +46,6 @@ public abstract class BaseRequestValidator where T : class private readonly GlobalSettings _globalSettings; private readonly IUserRepository _userRepository; private readonly IDataProtectorTokenFactory _tokenDataFactory; - private readonly IDistributedCache _distributedCache; - private readonly DistributedCacheEntryOptions _cacheEntryOptions; protected ICurrentContext CurrentContext { get; } protected IPolicyService PolicyService { get; } @@ -77,7 +74,6 @@ public abstract class BaseRequestValidator where T : class IDataProtectorTokenFactory tokenDataFactory, IFeatureService featureService, ISsoConfigRepository ssoConfigRepository, - IDistributedCache distributedCache, IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder) { _userManager = userManager; @@ -99,14 +95,6 @@ public abstract class BaseRequestValidator where T : class _tokenDataFactory = tokenDataFactory; FeatureService = featureService; SsoConfigRepository = ssoConfigRepository; - _distributedCache = distributedCache; - _cacheEntryOptions = new DistributedCacheEntryOptions - { - // This sets the time an item is cached to 17 minutes. This value is hard coded - // to 17 because to it covers all time-out windows for both Authenticators and - // Email TOTP. - AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(17) - }; UserDecryptionOptionsBuilder = userDecryptionOptionsBuilder; } @@ -153,11 +141,7 @@ public abstract class BaseRequestValidator where T : class var verified = await VerifyTwoFactor(user, twoFactorOrganization, twoFactorProviderType, twoFactorToken); - - var cacheKey = "TOTP_" + user.Email + "_" + twoFactorToken; - - var isOtpCached = Core.Utilities.DistributedCacheExtensions.TryGetValue(_distributedCache, cacheKey, out string _); - if (!verified || isBot || isOtpCached) + if (!verified || isBot) { if (twoFactorProviderType != TwoFactorProviderType.Remember) { @@ -170,11 +154,6 @@ public abstract class BaseRequestValidator where T : class } return; } - // We only want to track TOTPs in the cache to enforce one time use. - if (twoFactorProviderType == TwoFactorProviderType.Authenticator || twoFactorProviderType == TwoFactorProviderType.Email) - { - await Core.Utilities.DistributedCacheExtensions.SetAsync(_distributedCache, cacheKey, twoFactorToken, _cacheEntryOptions); - } } else { diff --git a/src/Identity/IdentityServer/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/CustomTokenRequestValidator.cs index fbd522c814..b69f4dacb6 100644 --- a/src/Identity/IdentityServer/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/CustomTokenRequestValidator.cs @@ -15,7 +15,6 @@ using Duende.IdentityServer.Extensions; using Duende.IdentityServer.Validation; using IdentityModel; using Microsoft.AspNetCore.Identity; -using Microsoft.Extensions.Caching.Distributed; #nullable enable @@ -46,14 +45,12 @@ public class CustomTokenRequestValidator : BaseRequestValidator tokenDataFactory, IFeatureService featureService, - [FromKeyedServices("persistent")] - IDistributedCache distributedCache, IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder) : base(userManager, deviceRepository, deviceService, userService, eventService, organizationDuoWebTokenProvider, duoWebV4SDKService, organizationRepository, organizationUserRepository, applicationCacheService, mailService, logger, currentContext, globalSettings, userRepository, policyService, tokenDataFactory, featureService, ssoConfigRepository, - distributedCache, userDecryptionOptionsBuilder) + userDecryptionOptionsBuilder) { _userManager = userManager; } diff --git a/src/Identity/IdentityServer/ResourceOwnerPasswordValidator.cs b/src/Identity/IdentityServer/ResourceOwnerPasswordValidator.cs index 4ca31e8bf3..30a5d821da 100644 --- a/src/Identity/IdentityServer/ResourceOwnerPasswordValidator.cs +++ b/src/Identity/IdentityServer/ResourceOwnerPasswordValidator.cs @@ -15,7 +15,6 @@ using Bit.Core.Utilities; using Duende.IdentityServer.Models; using Duende.IdentityServer.Validation; using Microsoft.AspNetCore.Identity; -using Microsoft.Extensions.Caching.Distributed; namespace Bit.Identity.IdentityServer; @@ -49,13 +48,11 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator tokenDataFactory, IFeatureService featureService, ISsoConfigRepository ssoConfigRepository, - [FromKeyedServices("persistent")] - IDistributedCache distributedCache, IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder) : base(userManager, deviceRepository, deviceService, userService, eventService, organizationDuoWebTokenProvider, duoWebV4SDKService, organizationRepository, organizationUserRepository, applicationCacheService, mailService, logger, currentContext, globalSettings, userRepository, policyService, - tokenDataFactory, featureService, ssoConfigRepository, distributedCache, userDecryptionOptionsBuilder) + tokenDataFactory, featureService, ssoConfigRepository, userDecryptionOptionsBuilder) { _userManager = userManager; _userService = userService; diff --git a/src/Identity/IdentityServer/WebAuthnGrantValidator.cs b/src/Identity/IdentityServer/WebAuthnGrantValidator.cs index c9a4ee4ade..fed631eb36 100644 --- a/src/Identity/IdentityServer/WebAuthnGrantValidator.cs +++ b/src/Identity/IdentityServer/WebAuthnGrantValidator.cs @@ -18,7 +18,6 @@ using Duende.IdentityServer.Models; using Duende.IdentityServer.Validation; using Fido2NetLib; using Microsoft.AspNetCore.Identity; -using Microsoft.Extensions.Caching.Distributed; namespace Bit.Identity.IdentityServer; @@ -50,15 +49,13 @@ public class WebAuthnGrantValidator : BaseRequestValidator tokenDataFactory, IDataProtectorTokenFactory assertionOptionsDataProtector, IFeatureService featureService, - [FromKeyedServices("persistent")] - IDistributedCache distributedCache, IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder, IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand ) : base(userManager, deviceRepository, deviceService, userService, eventService, organizationDuoWebTokenProvider, duoWebV4SDKService, organizationRepository, organizationUserRepository, applicationCacheService, mailService, logger, currentContext, globalSettings, - userRepository, policyService, tokenDataFactory, featureService, ssoConfigRepository, distributedCache, userDecryptionOptionsBuilder) + userRepository, policyService, tokenDataFactory, featureService, ssoConfigRepository, userDecryptionOptionsBuilder) { _assertionOptionsDataProtector = assertionOptionsDataProtector; _assertWebAuthnLoginCredentialCommand = assertWebAuthnLoginCredentialCommand; From 1d9fe79ef691510d883b78dff56536bbb8efcfdf Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Mon, 12 Feb 2024 08:50:41 +1000 Subject: [PATCH 093/117] Give creating owner Manage permissions for default collection (#3776) --- .../Implementations/OrganizationService.cs | 45 +++++++++++++------ .../Services/OrganizationServiceTests.cs | 20 ++++++++- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 128486f8b0..7d0907b0ba 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -655,18 +655,6 @@ public class OrganizationService : IOrganizationService }); await _applicationCacheService.UpsertOrganizationAbilityAsync(organization); - if (!string.IsNullOrWhiteSpace(collectionName)) - { - var defaultCollection = new Collection - { - Name = collectionName, - OrganizationId = organization.Id, - CreationDate = organization.CreationDate, - RevisionDate = organization.CreationDate - }; - await _collectionRepository.CreateAsync(defaultCollection); - } - OrganizationUser orgUser = null; if (ownerId != default) { @@ -685,6 +673,7 @@ public class OrganizationService : IOrganizationService CreationDate = organization.CreationDate, RevisionDate = organization.CreationDate }; + orgUser.SetNewId(); await _organizationUserRepository.CreateAsync(orgUser); @@ -694,6 +683,27 @@ public class OrganizationService : IOrganizationService await _pushNotificationService.PushSyncOrgKeysAsync(ownerId); } + if (!string.IsNullOrWhiteSpace(collectionName)) + { + var defaultCollection = new Collection + { + Name = collectionName, + OrganizationId = organization.Id, + CreationDate = organization.CreationDate, + RevisionDate = organization.CreationDate + }; + + // If using Flexible Collections, give the owner Can Manage access over the default collection + List defaultOwnerAccess = null; + if (organization.FlexibleCollections) + { + defaultOwnerAccess = + [new CollectionAccessSelection { Id = orgUser.Id, HidePasswords = false, ReadOnly = false, Manage = true }]; + } + + await _collectionRepository.CreateAsync(defaultCollection, null, defaultOwnerAccess); + } + return new Tuple(organization, orgUser); } catch @@ -2548,12 +2558,21 @@ public class OrganizationService : IOrganizationService if (!string.IsNullOrWhiteSpace(collectionName)) { + // If using Flexible Collections, give the owner Can Manage access over the default collection + List defaultOwnerAccess = null; + if (org.FlexibleCollections) + { + var orgUser = await _organizationUserRepository.GetByOrganizationAsync(org.Id, userId); + defaultOwnerAccess = + [new CollectionAccessSelection { Id = orgUser.Id, HidePasswords = false, ReadOnly = false, Manage = true }]; + } + var defaultCollection = new Collection { Name = collectionName, OrganizationId = org.Id }; - await _collectionRepository.CreateAsync(defaultCollection); + await _collectionRepository.CreateAsync(defaultCollection, null, defaultOwnerAccess); } } } diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index 86f11a278b..4ad13d8fd6 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -259,7 +259,6 @@ public class OrganizationServiceTests (PlanType planType, OrganizationSignup signup, SutProvider sutProvider) { signup.Plan = planType; - var plan = StaticStore.GetPlan(signup.Plan); signup.AdditionalSeats = 0; signup.PaymentMethodType = PaymentMethodType.Card; signup.PremiumAccessAddon = false; @@ -269,13 +268,32 @@ public class OrganizationServiceTests .IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup) .Returns(true); + // Extract orgUserId when created + Guid? orgUserId = null; + await sutProvider.GetDependency() + .CreateAsync(Arg.Do(ou => orgUserId = ou.Id)); + var result = await sutProvider.Sut.SignUpAsync(signup); + // Assert: AccessAll is not used await sutProvider.GetDependency().Received(1).CreateAsync( Arg.Is(o => o.UserId == signup.Owner.Id && o.AccessAll == false)); + // Assert: created a Can Manage association for the default collection instead + Assert.NotNull(orgUserId); + await sutProvider.GetDependency().Received(1).CreateAsync( + Arg.Any(), + Arg.Is>(cas => cas == null), + Arg.Is>(cas => + cas.Count() == 1 && + cas.All(c => + c.Id == orgUserId && + !c.ReadOnly && + !c.HidePasswords && + c.Manage))); + Assert.NotNull(result); Assert.NotNull(result.Item1); Assert.NotNull(result.Item2); From fd3f05da47911ecc195fda5d3a6788554b4b6d5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Mon, 12 Feb 2024 11:04:00 +0100 Subject: [PATCH 094/117] [PM-6137] Fix invalid Swagger generation in knowndevice (#3760) * Fix invalid swagger generation in knowndevice * Format --- src/Api/Controllers/DevicesController.cs | 8 +++++--- .../Models/Request/KnownDeviceRequestModel.cs | 16 ---------------- 2 files changed, 5 insertions(+), 19 deletions(-) delete mode 100644 src/Api/Models/Request/KnownDeviceRequestModel.cs diff --git a/src/Api/Controllers/DevicesController.cs b/src/Api/Controllers/DevicesController.cs index 6787fe515c..46e312bc03 100644 --- a/src/Api/Controllers/DevicesController.cs +++ b/src/Api/Controllers/DevicesController.cs @@ -1,4 +1,4 @@ -using Api.Models.Request; +using System.ComponentModel.DataAnnotations; using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.Models.Request; @@ -207,8 +207,10 @@ public class DevicesController : Controller [AllowAnonymous] [HttpGet("knowndevice")] - public async Task GetByIdentifierQuery([FromHeader] KnownDeviceRequestModel request) - => await GetByIdentifier(CoreHelpers.Base64UrlDecodeString(request.Email), request.DeviceIdentifier); + public async Task GetByIdentifierQuery( + [Required][FromHeader(Name = "X-Request-Email")] string Email, + [Required][FromHeader(Name = "X-Device-Identifier")] string DeviceIdentifier) + => await GetByIdentifier(CoreHelpers.Base64UrlDecodeString(Email), DeviceIdentifier); [Obsolete("Path is deprecated due to encoding issues, use /knowndevice instead.")] [AllowAnonymous] diff --git a/src/Api/Models/Request/KnownDeviceRequestModel.cs b/src/Api/Models/Request/KnownDeviceRequestModel.cs deleted file mode 100644 index 8232f596af..0000000000 --- a/src/Api/Models/Request/KnownDeviceRequestModel.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using Microsoft.AspNetCore.Mvc; - -namespace Api.Models.Request; - -public class KnownDeviceRequestModel -{ - [Required] - [FromHeader(Name = "X-Request-Email")] - public string Email { get; set; } - - [Required] - [FromHeader(Name = "X-Device-Identifier")] - public string DeviceIdentifier { get; set; } - -} From 186a96af30d3417502076c9cb4c8de2d83686ccf Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 12 Feb 2024 13:31:00 +0100 Subject: [PATCH 095/117] [deps] Tools: Update aws-sdk-net monorepo to v3.7.300.48 (#3778) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Core/Core.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index aa6f5778b7..2b3784ea27 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -21,8 +21,8 @@ - - + + From 5c1cecbd02e3032c89b51619d3875d21a404d063 Mon Sep 17 00:00:00 2001 From: Bitwarden DevOps <106330231+bitwarden-devops-bot@users.noreply.github.com> Date: Mon, 12 Feb 2024 09:51:57 -0500 Subject: [PATCH 096/117] Bumped version to 2024.2.2 (#3786) --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 052b8ebd65..7fd4d54306 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2024.2.1 + 2024.2.2 Bit.$(MSBuildProjectName) enable From d2eaadb1589c696377af800531fe4cf357366771 Mon Sep 17 00:00:00 2001 From: Vince Grassia <593223+vgrassia@users.noreply.github.com> Date: Mon, 12 Feb 2024 10:35:13 -0500 Subject: [PATCH 097/117] Version Bump workflow - Add in step for installing xmllint (#3787) --- .github/workflows/version-bump.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index 16e90b73ba..2338753b0e 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -165,20 +165,21 @@ jobs: uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: ref: main + + - name: Install xmllint + run: sudo apt install -y libxml2-utils - name: Verify version has been updated env: NEW_VERSION: ${{ inputs.version_number }} run: | - CURRENT_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props) - # Wait for version to change. - while [[ "$NEW_VERSION" != "$CURRENT_VERSION" ]] do echo "Waiting for version to be updated..." - sleep 10 git pull --force - done + CURRENT_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props) + sleep 10 + done while [[ "$NEW_VERSION" != "$CURRENT_VERSION" ]] - name: Cut RC branch run: | From c0e5d19cb5fc6c44e2110bf4240b4e9e912e6b56 Mon Sep 17 00:00:00 2001 From: Vince Grassia <593223+vgrassia@users.noreply.github.com> Date: Mon, 12 Feb 2024 13:21:00 -0500 Subject: [PATCH 098/117] Fix while loop (#3789) --- .github/workflows/version-bump.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index 2338753b0e..3258a94eb1 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -174,12 +174,15 @@ jobs: NEW_VERSION: ${{ inputs.version_number }} run: | # Wait for version to change. - do + while : ; do echo "Waiting for version to be updated..." git pull --force CURRENT_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props) + + # If the versions don't match we continue the loop, otherwise we break out of the loop. + [[ "$NEW_VERSION" != "$CURRENT_VERSION" ]] || break sleep 10 - done while [[ "$NEW_VERSION" != "$CURRENT_VERSION" ]] + done - name: Cut RC branch run: | From ae4fcfc204ee8951cdb641c502726cc928ff4ab6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 12 Feb 2024 14:00:09 -0500 Subject: [PATCH 099/117] Move DbScripts_finalization to DbScripts (#3675) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> Co-authored-by: Vince Grassia <593223+vgrassia@users.noreply.github.com> --- .../2024-01-16_00_2023-10-FutureMigrations.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename util/Migrator/{DbScripts_finalization/2023-10-FutureMigrations.sql => DbScripts/2024-01-16_00_2023-10-FutureMigrations.sql} (100%) diff --git a/util/Migrator/DbScripts_finalization/2023-10-FutureMigrations.sql b/util/Migrator/DbScripts/2024-01-16_00_2023-10-FutureMigrations.sql similarity index 100% rename from util/Migrator/DbScripts_finalization/2023-10-FutureMigrations.sql rename to util/Migrator/DbScripts/2024-01-16_00_2023-10-FutureMigrations.sql From 789e26679153ccb703b31171f357ec22031bd87f Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Tue, 13 Feb 2024 13:19:15 +1000 Subject: [PATCH 100/117] Delete unused .sql files from updating Collection permissions (#3792) --- .../CollectionUser_UpdateUsers.sql | 79 -------------- .../Collection_CreateWithGroupsAndUsers.sql | 69 ------------ .../Collection_UpdateWithGroupsAndUsers.sql | 103 ------------------ .../Group_CreateWithCollections.sql | 42 ------- .../Group_UpdateWithCollections.sql | 59 ---------- ...OrganizationUser_CreateWithCollections.sql | 47 -------- ...OrganizationUser_UpdateWithCollections.sql | 82 -------------- .../SelectionReadOnlyArray.sql | 5 - 8 files changed, 486 deletions(-) delete mode 100644 src/Sql/dbo/Stored Procedures/CollectionUser_UpdateUsers.sql delete mode 100644 src/Sql/dbo/Stored Procedures/Collection_CreateWithGroupsAndUsers.sql delete mode 100644 src/Sql/dbo/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql delete mode 100644 src/Sql/dbo/Stored Procedures/Group_CreateWithCollections.sql delete mode 100644 src/Sql/dbo/Stored Procedures/Group_UpdateWithCollections.sql delete mode 100644 src/Sql/dbo/Stored Procedures/OrganizationUser_CreateWithCollections.sql delete mode 100644 src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql delete mode 100644 src/Sql/dbo/User Defined Types/SelectionReadOnlyArray.sql diff --git a/src/Sql/dbo/Stored Procedures/CollectionUser_UpdateUsers.sql b/src/Sql/dbo/Stored Procedures/CollectionUser_UpdateUsers.sql deleted file mode 100644 index eb59899e3d..0000000000 --- a/src/Sql/dbo/Stored Procedures/CollectionUser_UpdateUsers.sql +++ /dev/null @@ -1,79 +0,0 @@ -CREATE PROCEDURE [dbo].[CollectionUser_UpdateUsers] - @CollectionId UNIQUEIDENTIFIER, - @Users AS [dbo].[SelectionReadOnlyArray] READONLY -AS -BEGIN - SET NOCOUNT ON - - DECLARE @OrgId UNIQUEIDENTIFIER = ( - SELECT TOP 1 - [OrganizationId] - FROM - [dbo].[Collection] - WHERE - [Id] = @CollectionId - ) - - -- Update - UPDATE - [Target] - SET - [Target].[ReadOnly] = [Source].[ReadOnly], - [Target].[HidePasswords] = [Source].[HidePasswords] - FROM - [dbo].[CollectionUser] [Target] - INNER JOIN - @Users [Source] ON [Source].[Id] = [Target].[OrganizationUserId] - WHERE - [Target].[CollectionId] = @CollectionId - AND ( - [Target].[ReadOnly] != [Source].[ReadOnly] - OR [Target].[HidePasswords] != [Source].[HidePasswords] - ) - - -- Insert (with column list because a value for Manage is not being provided) - INSERT INTO [dbo].[CollectionUser] - ( - [CollectionId], - [OrganizationUserId], - [ReadOnly], - [HidePasswords] - ) - SELECT - @CollectionId, - [Source].[Id], - [Source].[ReadOnly], - [Source].[HidePasswords] - FROM - @Users [Source] - INNER JOIN - [dbo].[OrganizationUser] OU ON [Source].[Id] = OU.[Id] AND OU.[OrganizationId] = @OrgId - WHERE - NOT EXISTS ( - SELECT - 1 - FROM - [dbo].[CollectionUser] - WHERE - [CollectionId] = @CollectionId - AND [OrganizationUserId] = [Source].[Id] - ) - - -- Delete - DELETE - CU - FROM - [dbo].[CollectionUser] CU - WHERE - CU.[CollectionId] = @CollectionId - AND NOT EXISTS ( - SELECT - 1 - FROM - @Users - WHERE - [Id] = CU.[OrganizationUserId] - ) - - EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @CollectionId, @OrgId -END diff --git a/src/Sql/dbo/Stored Procedures/Collection_CreateWithGroupsAndUsers.sql b/src/Sql/dbo/Stored Procedures/Collection_CreateWithGroupsAndUsers.sql deleted file mode 100644 index 120a5e83dd..0000000000 --- a/src/Sql/dbo/Stored Procedures/Collection_CreateWithGroupsAndUsers.sql +++ /dev/null @@ -1,69 +0,0 @@ -CREATE PROCEDURE [dbo].[Collection_CreateWithGroupsAndUsers] - @Id UNIQUEIDENTIFIER, - @OrganizationId UNIQUEIDENTIFIER, - @Name VARCHAR(MAX), - @ExternalId NVARCHAR(300), - @CreationDate DATETIME2(7), - @RevisionDate DATETIME2(7), - @Groups AS [dbo].[SelectionReadOnlyArray] READONLY, - @Users AS [dbo].[SelectionReadOnlyArray] READONLY -AS -BEGIN - SET NOCOUNT ON - - EXEC [dbo].[Collection_Create] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate - - -- Groups - ;WITH [AvailableGroupsCTE] AS( - SELECT - [Id] - FROM - [dbo].[Group] - WHERE - [OrganizationId] = @OrganizationId - ) - INSERT INTO [dbo].[CollectionGroup] - ( - [CollectionId], - [GroupId], - [ReadOnly], - [HidePasswords] - ) - SELECT - @Id, - [Id], - [ReadOnly], - [HidePasswords] - FROM - @Groups - WHERE - [Id] IN (SELECT [Id] FROM [AvailableGroupsCTE]) - - -- Users - ;WITH [AvailableUsersCTE] AS( - SELECT - [Id] - FROM - [dbo].[OrganizationUser] - WHERE - [OrganizationId] = @OrganizationId - ) - INSERT INTO [dbo].[CollectionUser] - ( - [CollectionId], - [OrganizationUserId], - [ReadOnly], - [HidePasswords] - ) - SELECT - @Id, - [Id], - [ReadOnly], - [HidePasswords] - FROM - @Users - WHERE - [Id] IN (SELECT [Id] FROM [AvailableUsersCTE]) - - EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId -END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql b/src/Sql/dbo/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql deleted file mode 100644 index 42ed69e36e..0000000000 --- a/src/Sql/dbo/Stored Procedures/Collection_UpdateWithGroupsAndUsers.sql +++ /dev/null @@ -1,103 +0,0 @@ -CREATE PROCEDURE [dbo].[Collection_UpdateWithGroupsAndUsers] - @Id UNIQUEIDENTIFIER, - @OrganizationId UNIQUEIDENTIFIER, - @Name VARCHAR(MAX), - @ExternalId NVARCHAR(300), - @CreationDate DATETIME2(7), - @RevisionDate DATETIME2(7), - @Groups AS [dbo].[SelectionReadOnlyArray] READONLY, - @Users AS [dbo].[SelectionReadOnlyArray] READONLY -AS -BEGIN - SET NOCOUNT ON - - EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate - - -- Groups - ;WITH [AvailableGroupsCTE] AS( - SELECT - Id - FROM - [dbo].[Group] - WHERE - OrganizationId = @OrganizationId - ) - 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 -- With column list because a value for Manage is not being provided - ( - [CollectionId], - [GroupId], - [ReadOnly], - [HidePasswords] - ) - VALUES - ( - @Id, - [Source].[Id], - [Source].[ReadOnly], - [Source].[HidePasswords] - ) - WHEN MATCHED AND ( - [Target].[ReadOnly] != [Source].[ReadOnly] - OR [Target].[HidePasswords] != [Source].[HidePasswords] - ) THEN - UPDATE SET [Target].[ReadOnly] = [Source].[ReadOnly], - [Target].[HidePasswords] = [Source].[HidePasswords] - WHEN NOT MATCHED BY SOURCE - AND [Target].[CollectionId] = @Id THEN - DELETE - ; - - -- Users - ;WITH [AvailableGroupsCTE] AS( - SELECT - Id - FROM - [dbo].[OrganizationUser] - WHERE - OrganizationId = @OrganizationId - ) - 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 -- With column list because a value for Manage is not being provided - ( - [CollectionId], - [OrganizationUserId], - [ReadOnly], - [HidePasswords] - ) - VALUES - ( - @Id, - [Source].[Id], - [Source].[ReadOnly], - [Source].[HidePasswords] - ) - WHEN MATCHED AND ( - [Target].[ReadOnly] != [Source].[ReadOnly] - OR [Target].[HidePasswords] != [Source].[HidePasswords] - ) THEN - UPDATE SET [Target].[ReadOnly] = [Source].[ReadOnly], - [Target].[HidePasswords] = [Source].[HidePasswords] - WHEN NOT MATCHED BY SOURCE - AND [Target].[CollectionId] = @Id THEN - DELETE - ; - - EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId -END diff --git a/src/Sql/dbo/Stored Procedures/Group_CreateWithCollections.sql b/src/Sql/dbo/Stored Procedures/Group_CreateWithCollections.sql deleted file mode 100644 index b41637522e..0000000000 --- a/src/Sql/dbo/Stored Procedures/Group_CreateWithCollections.sql +++ /dev/null @@ -1,42 +0,0 @@ -CREATE PROCEDURE [dbo].[Group_CreateWithCollections] - @Id UNIQUEIDENTIFIER, - @OrganizationId UNIQUEIDENTIFIER, - @Name NVARCHAR(100), - @AccessAll BIT, - @ExternalId NVARCHAR(300), - @CreationDate DATETIME2(7), - @RevisionDate DATETIME2(7), - @Collections AS [dbo].[SelectionReadOnlyArray] READONLY -AS -BEGIN - SET NOCOUNT ON - - EXEC [dbo].[Group_Create] @Id, @OrganizationId, @Name, @AccessAll, @ExternalId, @CreationDate, @RevisionDate - - ;WITH [AvailableCollectionsCTE] AS( - SELECT - [Id] - FROM - [dbo].[Collection] - WHERE - [OrganizationId] = @OrganizationId - ) - INSERT INTO [dbo].[CollectionGroup] - ( - [CollectionId], - [GroupId], - [ReadOnly], - [HidePasswords] - ) - SELECT - [Id], - @Id, - [ReadOnly], - [HidePasswords] - FROM - @Collections - WHERE - [Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE]) - - EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId -END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/Group_UpdateWithCollections.sql b/src/Sql/dbo/Stored Procedures/Group_UpdateWithCollections.sql deleted file mode 100644 index 86ec4342cf..0000000000 --- a/src/Sql/dbo/Stored Procedures/Group_UpdateWithCollections.sql +++ /dev/null @@ -1,59 +0,0 @@ -CREATE PROCEDURE [dbo].[Group_UpdateWithCollections] - @Id UNIQUEIDENTIFIER, - @OrganizationId UNIQUEIDENTIFIER, - @Name NVARCHAR(100), - @AccessAll BIT, - @ExternalId NVARCHAR(300), - @CreationDate DATETIME2(7), - @RevisionDate DATETIME2(7), - @Collections AS [dbo].[SelectionReadOnlyArray] READONLY -AS -BEGIN - SET NOCOUNT ON - - EXEC [dbo].[Group_Update] @Id, @OrganizationId, @Name, @AccessAll, @ExternalId, @CreationDate, @RevisionDate - - ;WITH [AvailableCollectionsCTE] AS( - SELECT - Id - FROM - [dbo].[Collection] - WHERE - OrganizationId = @OrganizationId - ) - MERGE - [dbo].[CollectionGroup] AS [Target] - USING - @Collections AS [Source] - ON - [Target].[CollectionId] = [Source].[Id] - AND [Target].[GroupId] = @Id - WHEN NOT MATCHED BY TARGET - AND [Source].[Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE]) THEN - INSERT -- With column list because a value for Manage is not being provided - ( - [CollectionId], - [GroupId], - [ReadOnly], - [HidePasswords] - ) - VALUES - ( - [Source].[Id], - @Id, - [Source].[ReadOnly], - [Source].[HidePasswords] - ) - WHEN MATCHED AND ( - [Target].[ReadOnly] != [Source].[ReadOnly] - OR [Target].[HidePasswords] != [Source].[HidePasswords] - ) THEN - UPDATE SET [Target].[ReadOnly] = [Source].[ReadOnly], - [Target].[HidePasswords] = [Source].[HidePasswords] - WHEN NOT MATCHED BY SOURCE - AND [Target].[GroupId] = @Id THEN - DELETE - ; - - EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId -END diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateWithCollections.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateWithCollections.sql deleted file mode 100644 index 98809a0ec2..0000000000 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateWithCollections.sql +++ /dev/null @@ -1,47 +0,0 @@ -CREATE PROCEDURE [dbo].[OrganizationUser_CreateWithCollections] - @Id UNIQUEIDENTIFIER, - @OrganizationId UNIQUEIDENTIFIER, - @UserId UNIQUEIDENTIFIER, - @Email NVARCHAR(256), - @Key VARCHAR(MAX), - @Status SMALLINT, - @Type TINYINT, - @AccessAll BIT, - @ExternalId NVARCHAR(300), - @CreationDate DATETIME2(7), - @RevisionDate DATETIME2(7), - @Permissions NVARCHAR(MAX), - @ResetPasswordKey VARCHAR(MAX), - @Collections AS [dbo].[SelectionReadOnlyArray] READONLY, - @AccessSecretsManager BIT = 0 -AS -BEGIN - SET NOCOUNT ON - - EXEC [dbo].[OrganizationUser_Create] @Id, @OrganizationId, @UserId, @Email, @Key, @Status, @Type, @AccessAll, @ExternalId, @CreationDate, @RevisionDate, @Permissions, @ResetPasswordKey, @AccessSecretsManager - - ;WITH [AvailableCollectionsCTE] AS( - SELECT - [Id] - FROM - [dbo].[Collection] - WHERE - [OrganizationId] = @OrganizationId - ) - INSERT INTO [dbo].[CollectionUser] - ( - [CollectionId], - [OrganizationUserId], - [ReadOnly], - [HidePasswords] - ) - SELECT - [Id], - @Id, - [ReadOnly], - [HidePasswords] - FROM - @Collections - WHERE - [Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE]) -END diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql deleted file mode 100644 index 0a9ff4f034..0000000000 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql +++ /dev/null @@ -1,82 +0,0 @@ -CREATE PROCEDURE [dbo].[OrganizationUser_UpdateWithCollections] - @Id UNIQUEIDENTIFIER, - @OrganizationId UNIQUEIDENTIFIER, - @UserId UNIQUEIDENTIFIER, - @Email NVARCHAR(256), - @Key VARCHAR(MAX), - @Status SMALLINT, - @Type TINYINT, - @AccessAll BIT, - @ExternalId NVARCHAR(300), - @CreationDate DATETIME2(7), - @RevisionDate DATETIME2(7), - @Permissions NVARCHAR(MAX), - @ResetPasswordKey VARCHAR(MAX), - @Collections AS [dbo].[SelectionReadOnlyArray] READONLY, - @AccessSecretsManager BIT = 0 -AS -BEGIN - SET NOCOUNT ON - - EXEC [dbo].[OrganizationUser_Update] @Id, @OrganizationId, @UserId, @Email, @Key, @Status, @Type, @AccessAll, @ExternalId, @CreationDate, @RevisionDate, @Permissions, @ResetPasswordKey, @AccessSecretsManager - -- Update - UPDATE - [Target] - SET - [Target].[ReadOnly] = [Source].[ReadOnly], - [Target].[HidePasswords] = [Source].[HidePasswords] - FROM - [dbo].[CollectionUser] AS [Target] - INNER JOIN - @Collections AS [Source] ON [Source].[Id] = [Target].[CollectionId] - WHERE - [Target].[OrganizationUserId] = @Id - AND ( - [Target].[ReadOnly] != [Source].[ReadOnly] - OR [Target].[HidePasswords] != [Source].[HidePasswords] - ) - - -- Insert (with column list because a value for Manage is not being provided) - INSERT INTO [dbo].[CollectionUser] - ( - [CollectionId], - [OrganizationUserId], - [ReadOnly], - [HidePasswords] - ) - SELECT - [Source].[Id], - @Id, - [Source].[ReadOnly], - [Source].[HidePasswords] - FROM - @Collections AS [Source] - INNER JOIN - [dbo].[Collection] C ON C.[Id] = [Source].[Id] AND C.[OrganizationId] = @OrganizationId - WHERE - NOT EXISTS ( - SELECT - 1 - FROM - [dbo].[CollectionUser] - WHERE - [CollectionId] = [Source].[Id] - AND [OrganizationUserId] = @Id - ) - - -- Delete - DELETE - CU - FROM - [dbo].[CollectionUser] CU - WHERE - CU.[OrganizationUserId] = @Id - AND NOT EXISTS ( - SELECT - 1 - FROM - @Collections - WHERE - [Id] = CU.[CollectionId] - ) -END diff --git a/src/Sql/dbo/User Defined Types/SelectionReadOnlyArray.sql b/src/Sql/dbo/User Defined Types/SelectionReadOnlyArray.sql deleted file mode 100644 index f2e19b1a09..0000000000 --- a/src/Sql/dbo/User Defined Types/SelectionReadOnlyArray.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TYPE [dbo].[SelectionReadOnlyArray] AS TABLE ( - [Id] UNIQUEIDENTIFIER NOT NULL, - [ReadOnly] BIT NOT NULL, - [HidePasswords] BIT NOT NULL); - From 1a3146f77608a526f56256063960abba4692f921 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Tue, 13 Feb 2024 11:15:24 -0500 Subject: [PATCH 101/117] [PM-5800] Remove feature flag checks for PasswordlessLogin (#3713) * Removed feature flag checks for PasswordlessLogin * Removed unused reference. --- src/Api/Auth/Controllers/WebAuthnController.cs | 2 -- src/Identity/Controllers/AccountsController.cs | 2 -- src/Identity/IdentityServer/WebAuthnGrantValidator.cs | 6 ------ 3 files changed, 10 deletions(-) diff --git a/src/Api/Auth/Controllers/WebAuthnController.cs b/src/Api/Auth/Controllers/WebAuthnController.cs index d36b8cf97d..437c1ba20d 100644 --- a/src/Api/Auth/Controllers/WebAuthnController.cs +++ b/src/Api/Auth/Controllers/WebAuthnController.cs @@ -13,7 +13,6 @@ using Bit.Core.Auth.UserFeatures.WebAuthnLogin; using Bit.Core.Exceptions; using Bit.Core.Services; using Bit.Core.Tokens; -using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -21,7 +20,6 @@ namespace Bit.Api.Auth.Controllers; [Route("webauthn")] [Authorize("Web")] -[RequireFeature(FeatureFlagKeys.PasswordlessLogin)] public class WebAuthnController : Controller { private readonly IUserService _userService; diff --git a/src/Identity/Controllers/AccountsController.cs b/src/Identity/Controllers/AccountsController.cs index fe91eeedeb..e6b5cfc261 100644 --- a/src/Identity/Controllers/AccountsController.cs +++ b/src/Identity/Controllers/AccountsController.cs @@ -12,7 +12,6 @@ using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Tokens; -using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; using Microsoft.AspNetCore.Mvc; @@ -85,7 +84,6 @@ public class AccountsController : Controller } [HttpGet("webauthn/assertion-options")] - [RequireFeature(FeatureFlagKeys.PasswordlessLogin)] public WebAuthnLoginAssertionOptionsResponseModel GetWebAuthnLoginAssertionOptions() { var options = _getWebAuthnLoginCredentialAssertionOptionsCommand.GetWebAuthnLoginCredentialAssertionOptions(); diff --git a/src/Identity/IdentityServer/WebAuthnGrantValidator.cs b/src/Identity/IdentityServer/WebAuthnGrantValidator.cs index fed631eb36..8552265652 100644 --- a/src/Identity/IdentityServer/WebAuthnGrantValidator.cs +++ b/src/Identity/IdentityServer/WebAuthnGrantValidator.cs @@ -65,12 +65,6 @@ public class WebAuthnGrantValidator : BaseRequestValidator Date: Tue, 13 Feb 2024 12:42:01 -0500 Subject: [PATCH 102/117] Remove CLOC job (#3796) --- .github/workflows/build.yml | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 421ee309fd..c63ebd669f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,21 +14,6 @@ env: _AZ_REGISTRY: "bitwardenprod.azurecr.io" jobs: - cloc: - name: Count lines of code - runs-on: ubuntu-22.04 - steps: - - name: Check out repo - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 - - - name: Install cloc - run: | - sudo apt-get update - sudo apt-get -y install cloc - - - name: Print lines of code - run: cloc --include-lang C#,SQL,Razor,"Bourne Shell",PowerShell,HTML,CSS,Sass,JavaScript,TypeScript --vcs git - lint: name: Lint runs-on: ubuntu-22.04 @@ -529,7 +514,6 @@ jobs: if: always() runs-on: ubuntu-22.04 needs: - - cloc - lint - build-artifacts - build-docker @@ -544,7 +528,6 @@ jobs: || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc' env: - CLOC_STATUS: ${{ needs.cloc.result }} LINT_STATUS: ${{ needs.lint.result }} TESTING_STATUS: ${{ needs.testing.result }} BUILD_ARTIFACTS_STATUS: ${{ needs.build-artifacts.result }} @@ -554,9 +537,7 @@ jobs: TRIGGER_SELF_HOST_BUILD_STATUS: ${{ needs.self-host-build.result }} TRIGGER_K8S_DEPLOY_STATUS: ${{ needs.trigger-k8s-deploy.result }} run: | - if [ "$CLOC_STATUS" = "failure" ]; then - exit 1 - elif [ "$LINT_STATUS" = "failure" ]; then + if [ "$LINT_STATUS" = "failure" ]; then exit 1 elif [ "$TESTING_STATUS" = "failure" ]; then exit 1 From 0258f4949c915e57d8c45d9c937a5175dd6947f7 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Wed, 14 Feb 2024 04:15:07 +1000 Subject: [PATCH 103/117] [AC-2184] Fix push sync notification on opt-in to Flexible Collections (#3794) * Fix push sync notification on opt-in to Flexible Collections * Fix tests * Fix tests more --- .../Controllers/OrganizationsController.cs | 3 ++- src/Core/Enums/PushType.cs | 2 ++ src/Core/Services/IPushNotificationService.cs | 1 + .../AzureQueuePushNotificationService.cs | 5 +++++ .../MultiServicePushNotificationService.cs | 6 ++++++ .../NotificationHubPushNotificationService.cs | 5 +++++ .../NotificationsApiPushNotificationService.cs | 5 +++++ .../RelayPushNotificationService.cs | 5 +++++ .../NoopPushNotificationService.cs | 5 +++++ src/Notifications/HubHelpers.cs | 1 + .../OrganizationsControllerTests.cs | 18 ++++++++++-------- 11 files changed, 47 insertions(+), 9 deletions(-) diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index c83fce7622..736fb30dde 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -899,8 +899,9 @@ public class OrganizationsController : Controller var orgUsers = await _organizationUserRepository.GetManyByOrganizationAsync(id, null); await Task.WhenAll(orgUsers .Where(ou => ou.UserId.HasValue && + ou.Status == OrganizationUserStatusType.Confirmed && ou.Type is OrganizationUserType.Admin or OrganizationUserType.Owner) - .Select(ou => _pushNotificationService.PushSyncVaultAsync(ou.UserId.Value))); + .Select(ou => _pushNotificationService.PushSyncOrganizationsAsync(ou.UserId.Value))); } private async Task TryGrantOwnerAccessToSecretsManagerAsync(Guid organizationId, Guid userId) diff --git a/src/Core/Enums/PushType.cs b/src/Core/Enums/PushType.cs index 82029e9213..9dbef7b8e2 100644 --- a/src/Core/Enums/PushType.cs +++ b/src/Core/Enums/PushType.cs @@ -23,4 +23,6 @@ public enum PushType : byte AuthRequest = 15, AuthRequestResponse = 16, + + SyncOrganizations = 17, } diff --git a/src/Core/Services/IPushNotificationService.cs b/src/Core/Services/IPushNotificationService.cs index fcb1c3fdae..29a20239d1 100644 --- a/src/Core/Services/IPushNotificationService.cs +++ b/src/Core/Services/IPushNotificationService.cs @@ -15,6 +15,7 @@ public interface IPushNotificationService Task PushSyncFolderDeleteAsync(Folder folder); Task PushSyncCiphersAsync(Guid userId); Task PushSyncVaultAsync(Guid userId); + Task PushSyncOrganizationsAsync(Guid userId); Task PushSyncOrgKeysAsync(Guid userId); Task PushSyncSettingsAsync(Guid userId); Task PushLogOutAsync(Guid userId, bool excludeCurrentContextFromPush = false); diff --git a/src/Core/Services/Implementations/AzureQueuePushNotificationService.cs b/src/Core/Services/Implementations/AzureQueuePushNotificationService.cs index 1c0b1cf600..1e4a7314c4 100644 --- a/src/Core/Services/Implementations/AzureQueuePushNotificationService.cs +++ b/src/Core/Services/Implementations/AzureQueuePushNotificationService.cs @@ -106,6 +106,11 @@ public class AzureQueuePushNotificationService : IPushNotificationService await PushUserAsync(userId, PushType.SyncVault); } + public async Task PushSyncOrganizationsAsync(Guid userId) + { + await PushUserAsync(userId, PushType.SyncOrganizations); + } + public async Task PushSyncOrgKeysAsync(Guid userId) { await PushUserAsync(userId, PushType.SyncOrgKeys); diff --git a/src/Core/Services/Implementations/MultiServicePushNotificationService.cs b/src/Core/Services/Implementations/MultiServicePushNotificationService.cs index 7b878bab6a..b683c05d0f 100644 --- a/src/Core/Services/Implementations/MultiServicePushNotificationService.cs +++ b/src/Core/Services/Implementations/MultiServicePushNotificationService.cs @@ -105,6 +105,12 @@ public class MultiServicePushNotificationService : IPushNotificationService return Task.FromResult(0); } + public Task PushSyncOrganizationsAsync(Guid userId) + { + PushToServices((s) => s.PushSyncOrganizationsAsync(userId)); + return Task.FromResult(0); + } + public Task PushSyncOrgKeysAsync(Guid userId) { PushToServices((s) => s.PushSyncOrgKeysAsync(userId)); diff --git a/src/Core/Services/Implementations/NotificationHubPushNotificationService.cs b/src/Core/Services/Implementations/NotificationHubPushNotificationService.cs index ec66660dff..96c50ca93a 100644 --- a/src/Core/Services/Implementations/NotificationHubPushNotificationService.cs +++ b/src/Core/Services/Implementations/NotificationHubPushNotificationService.cs @@ -117,6 +117,11 @@ public class NotificationHubPushNotificationService : IPushNotificationService await PushUserAsync(userId, PushType.SyncVault); } + public async Task PushSyncOrganizationsAsync(Guid userId) + { + await PushUserAsync(userId, PushType.SyncOrganizations); + } + public async Task PushSyncOrgKeysAsync(Guid userId) { await PushUserAsync(userId, PushType.SyncOrgKeys); diff --git a/src/Core/Services/Implementations/NotificationsApiPushNotificationService.cs b/src/Core/Services/Implementations/NotificationsApiPushNotificationService.cs index 87dc9b694b..9ec1eb31d4 100644 --- a/src/Core/Services/Implementations/NotificationsApiPushNotificationService.cs +++ b/src/Core/Services/Implementations/NotificationsApiPushNotificationService.cs @@ -113,6 +113,11 @@ public class NotificationsApiPushNotificationService : BaseIdentityClientService await PushUserAsync(userId, PushType.SyncVault); } + public async Task PushSyncOrganizationsAsync(Guid userId) + { + await PushUserAsync(userId, PushType.SyncOrganizations); + } + public async Task PushSyncOrgKeysAsync(Guid userId) { await PushUserAsync(userId, PushType.SyncOrgKeys); diff --git a/src/Core/Services/Implementations/RelayPushNotificationService.cs b/src/Core/Services/Implementations/RelayPushNotificationService.cs index 4e0858610b..6cfc0c0a61 100644 --- a/src/Core/Services/Implementations/RelayPushNotificationService.cs +++ b/src/Core/Services/Implementations/RelayPushNotificationService.cs @@ -114,6 +114,11 @@ public class RelayPushNotificationService : BaseIdentityClientService, IPushNoti await PushUserAsync(userId, PushType.SyncVault); } + public async Task PushSyncOrganizationsAsync(Guid userId) + { + await PushUserAsync(userId, PushType.SyncOrganizations); + } + public async Task PushSyncOrgKeysAsync(Guid userId) { await PushUserAsync(userId, PushType.SyncOrgKeys); diff --git a/src/Core/Services/NoopImplementations/NoopPushNotificationService.cs b/src/Core/Services/NoopImplementations/NoopPushNotificationService.cs index dac84f256b..d4eff93ef6 100644 --- a/src/Core/Services/NoopImplementations/NoopPushNotificationService.cs +++ b/src/Core/Services/NoopImplementations/NoopPushNotificationService.cs @@ -42,6 +42,11 @@ public class NoopPushNotificationService : IPushNotificationService return Task.FromResult(0); } + public Task PushSyncOrganizationsAsync(Guid userId) + { + return Task.FromResult(0); + } + public Task PushSyncOrgKeysAsync(Guid userId) { return Task.FromResult(0); diff --git a/src/Notifications/HubHelpers.cs b/src/Notifications/HubHelpers.cs index 71319dd559..8463e1f34a 100644 --- a/src/Notifications/HubHelpers.cs +++ b/src/Notifications/HubHelpers.cs @@ -50,6 +50,7 @@ public static class HubHelpers break; case PushType.SyncCiphers: case PushType.SyncVault: + case PushType.SyncOrganizations: case PushType.SyncOrgKeys: case PushType.SyncSettings: case PushType.LogOut: diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 983470d6fb..fdbcc17e46 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -377,14 +377,15 @@ public class OrganizationsControllerTests : IDisposable public async Task EnableCollectionEnhancements_Success(Organization organization) { organization.FlexibleCollections = false; - var admin = new OrganizationUser { UserId = Guid.NewGuid(), Type = OrganizationUserType.Admin }; - var owner = new OrganizationUser { UserId = Guid.NewGuid(), Type = OrganizationUserType.Owner }; - var user = new OrganizationUser { UserId = Guid.NewGuid(), Type = OrganizationUserType.User }; + var admin = new OrganizationUser { UserId = Guid.NewGuid(), Type = OrganizationUserType.Admin, Status = OrganizationUserStatusType.Confirmed }; + var owner = new OrganizationUser { UserId = Guid.NewGuid(), Type = OrganizationUserType.Owner, Status = OrganizationUserStatusType.Confirmed }; + var user = new OrganizationUser { UserId = Guid.NewGuid(), Type = OrganizationUserType.User, Status = OrganizationUserStatusType.Confirmed }; var invited = new OrganizationUser { UserId = null, Type = OrganizationUserType.Admin, - Email = "invited@example.com" + Email = "invited@example.com", + Status = OrganizationUserStatusType.Invited }; var orgUsers = new List { admin, owner, user, invited }; @@ -395,9 +396,10 @@ public class OrganizationsControllerTests : IDisposable await _sut.EnableCollectionEnhancements(organization.Id); await _organizationEnableCollectionEnhancementsCommand.Received(1).EnableCollectionEnhancements(organization); - await _pushNotificationService.Received(1).PushSyncVaultAsync(admin.UserId.Value); - await _pushNotificationService.Received(1).PushSyncVaultAsync(owner.UserId.Value); - await _pushNotificationService.DidNotReceive().PushSyncVaultAsync(user.UserId.Value); + await _pushNotificationService.Received(1).PushSyncOrganizationsAsync(admin.UserId.Value); + await _pushNotificationService.Received(1).PushSyncOrganizationsAsync(owner.UserId.Value); + await _pushNotificationService.DidNotReceive().PushSyncOrganizationsAsync(user.UserId.Value); + // Invited orgUser does not have a UserId we can use to assert here, but sut will throw if that null isn't handled } [Theory, AutoData] @@ -410,6 +412,6 @@ public class OrganizationsControllerTests : IDisposable await Assert.ThrowsAsync(async () => await _sut.EnableCollectionEnhancements(organization.Id)); await _organizationEnableCollectionEnhancementsCommand.DidNotReceiveWithAnyArgs().EnableCollectionEnhancements(Arg.Any()); - await _pushNotificationService.DidNotReceiveWithAnyArgs().PushSyncVaultAsync(Arg.Any()); + await _pushNotificationService.DidNotReceiveWithAnyArgs().PushSyncOrganizationsAsync(Arg.Any()); } } From accff663c575cbb8ab3a43830d45c81f13674a54 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Tue, 13 Feb 2024 20:28:14 +0100 Subject: [PATCH 104/117] [PM 5864] Resolve root cause of double-charging customers with implementation of PM-3892 (#3762) * Getting dollar threshold to work * Added billing cycle anchor to invoice upcoming call * Added comments for further work * add featureflag Signed-off-by: Cy Okeke * resolve pr comments Signed-off-by: Cy Okeke * Resolve pr comment Signed-off-by: Cy Okeke --------- Signed-off-by: Cy Okeke Co-authored-by: Conner Turnbull --- src/Core/Constants.cs | 15 +++++ .../Implementations/StripePaymentService.cs | 62 ++++++++++++++----- 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 8c013a2f22..f534fd7a1e 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -35,6 +35,20 @@ public static class Constants /// If true, the organization plan assigned to that provider is updated to a 2020 plan. /// public static readonly DateTime ProviderCreatedPriorNov62023 = new DateTime(2023, 11, 6); + + /// + /// When you set the ProrationBehavior to create_prorations, + /// Stripe will automatically create prorations for any changes made to the subscription, + /// such as changing the plan, adding or removing quantities, or applying discounts. + /// + public const string CreateProrations = "create_prorations"; + + /// + /// When you set the ProrationBehavior to always_invoice, + /// Stripe will always generate an invoice when a subscription update occurs, + /// regardless of whether there is a proration or not. + /// + public const string AlwaysInvoice = "always_invoice"; } public static class AuthConstants @@ -117,6 +131,7 @@ public static class FeatureFlagKeys public const string FlexibleCollectionsMigration = "flexible-collections-migration"; public const string AC1607_PresentUsersWithOffboardingSurvey = "AC-1607_present-user-offboarding-survey"; public const string PM5766AutomaticTax = "PM-5766-automatic-tax"; + public const string PM5864DollarThreshold = "PM-5864-dollar-threshold"; public static List GetAllKeys() { diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index fcfa40d181..1f7d488179 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -230,7 +230,7 @@ public class StripePaymentService : IPaymentService null; var subscriptionUpdate = new SponsorOrganizationSubscriptionUpdate(existingPlan, sponsoredPlan, applySponsorship); - await FinalizeSubscriptionChangeAsync(org, subscriptionUpdate, DateTime.UtcNow); + await FinalizeSubscriptionChangeAsync(org, subscriptionUpdate, DateTime.UtcNow, true); var sub = await _stripeAdapter.SubscriptionGetAsync(org.GatewaySubscriptionId); org.ExpirationDate = sub.CurrentPeriodEnd; @@ -743,12 +743,14 @@ public class StripePaymentService : IPaymentService return subItemOptions.Select(si => new Stripe.InvoiceSubscriptionItemOptions { Plan = si.Plan, - Quantity = si.Quantity + Price = si.Price, + Quantity = si.Quantity, + Id = si.Id }).ToList(); } private async Task FinalizeSubscriptionChangeAsync(IStorableSubscriber storableSubscriber, - SubscriptionUpdate subscriptionUpdate, DateTime? prorationDate) + SubscriptionUpdate subscriptionUpdate, DateTime? prorationDate, bool invoiceNow = false) { // remember, when in doubt, throw var sub = await _stripeAdapter.SubscriptionGetAsync(storableSubscriber.GatewaySubscriptionId); @@ -762,15 +764,37 @@ public class StripePaymentService : IPaymentService var daysUntilDue = sub.DaysUntilDue; var chargeNow = collectionMethod == "charge_automatically"; var updatedItemOptions = subscriptionUpdate.UpgradeItemsOptions(sub); + var isPm5864DollarThresholdEnabled = _featureService.IsEnabled(FeatureFlagKeys.PM5864DollarThreshold); var subUpdateOptions = new Stripe.SubscriptionUpdateOptions { Items = updatedItemOptions, - ProrationBehavior = "always_invoice", + ProrationBehavior = !isPm5864DollarThresholdEnabled || invoiceNow + ? Constants.AlwaysInvoice + : Constants.CreateProrations, DaysUntilDue = daysUntilDue ?? 1, CollectionMethod = "send_invoice", ProrationDate = prorationDate, }; + var immediatelyInvoice = false; + if (!invoiceNow && isPm5864DollarThresholdEnabled) + { + var upcomingInvoiceWithChanges = await _stripeAdapter.InvoiceUpcomingAsync(new UpcomingInvoiceOptions + { + Customer = storableSubscriber.GatewayCustomerId, + Subscription = storableSubscriber.GatewaySubscriptionId, + SubscriptionItems = ToInvoiceSubscriptionItemOptions(updatedItemOptions), + SubscriptionProrationBehavior = Constants.CreateProrations, + SubscriptionProrationDate = prorationDate, + SubscriptionBillingCycleAnchor = SubscriptionBillingCycleAnchor.Now + }); + + immediatelyInvoice = upcomingInvoiceWithChanges.AmountRemaining >= 50000; + + subUpdateOptions.BillingCycleAnchor = immediatelyInvoice + ? SubscriptionBillingCycleAnchor.Now + : SubscriptionBillingCycleAnchor.Unchanged; + } var pm5766AutomaticTaxIsEnabled = _featureService.IsEnabled(FeatureFlagKeys.PM5766AutomaticTax); if (pm5766AutomaticTaxIsEnabled) @@ -820,19 +844,21 @@ public class StripePaymentService : IPaymentService { try { - if (chargeNow) + if (!isPm5864DollarThresholdEnabled || immediatelyInvoice || invoiceNow) { - paymentIntentClientSecret = await PayInvoiceAfterSubscriptionChangeAsync( - storableSubscriber, invoice); - } - else - { - invoice = await _stripeAdapter.InvoiceFinalizeInvoiceAsync(subResponse.LatestInvoiceId, new Stripe.InvoiceFinalizeOptions + if (chargeNow) { - AutoAdvance = false, - }); - await _stripeAdapter.InvoiceSendInvoiceAsync(invoice.Id, new Stripe.InvoiceSendOptions()); - paymentIntentClientSecret = null; + paymentIntentClientSecret = await PayInvoiceAfterSubscriptionChangeAsync(storableSubscriber, invoice); + } + else + { + invoice = await _stripeAdapter.InvoiceFinalizeInvoiceAsync(subResponse.LatestInvoiceId, new Stripe.InvoiceFinalizeOptions + { + AutoAdvance = false, + }); + await _stripeAdapter.InvoiceSendInvoiceAsync(invoice.Id, new Stripe.InvoiceSendOptions()); + paymentIntentClientSecret = null; + } } } catch @@ -896,7 +922,7 @@ public class StripePaymentService : IPaymentService PurchasedAdditionalSecretsManagerServiceAccounts = newlyPurchasedAdditionalSecretsManagerServiceAccounts, PurchasedAdditionalStorage = newlyPurchasedAdditionalStorage }), - prorationDate); + prorationDate, true); public Task AdjustSeatsAsync(Organization organization, StaticStore.Plan plan, int additionalSeats, DateTime? prorationDate = null) { @@ -1703,7 +1729,9 @@ public class StripePaymentService : IPaymentService public async Task AddSecretsManagerToSubscription(Organization org, StaticStore.Plan plan, int additionalSmSeats, int additionalServiceAccount, DateTime? prorationDate = null) { - return await FinalizeSubscriptionChangeAsync(org, new SecretsManagerSubscribeUpdate(org, plan, additionalSmSeats, additionalServiceAccount), prorationDate); + return await FinalizeSubscriptionChangeAsync(org, + new SecretsManagerSubscribeUpdate(org, plan, additionalSmSeats, additionalServiceAccount), prorationDate, + true); } public async Task RisksSubscriptionFailure(Organization organization) From 97018e2501bb727131d6fe30567688bcca2ca30c Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 13 Feb 2024 14:34:55 -0500 Subject: [PATCH 105/117] Upgrade logging packages for .NET 8 (#3798) --- src/Core/Core.csproj | 6 +++--- src/Core/Utilities/LoggerFactoryExtensions.cs | 10 +++------- util/Migrator/Migrator.csproj | 2 +- util/MsSqlMigratorUtility/MsSqlMigratorUtility.csproj | 4 ++-- util/Setup/Setup.csproj | 2 +- 5 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 2b3784ea27..96b77f3c1a 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -44,13 +44,13 @@ - - + + - + diff --git a/src/Core/Utilities/LoggerFactoryExtensions.cs b/src/Core/Utilities/LoggerFactoryExtensions.cs index 1d8d2a0c84..362ca22d34 100644 --- a/src/Core/Utilities/LoggerFactoryExtensions.cs +++ b/src/Core/Utilities/LoggerFactoryExtensions.cs @@ -1,5 +1,4 @@ -using System.Security.Authentication; -using System.Security.Cryptography.X509Certificates; +using System.Security.Cryptography.X509Certificates; using Bit.Core.Settings; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -91,20 +90,17 @@ public static class LoggerFactoryExtensions } else if (syslogAddress.Scheme.Equals("tls")) { - // TLS v1.1, v1.2 and v1.3 are explicitly selected (leaving out TLS v1.0) - const SslProtocols protocols = SslProtocols.Tls11 | SslProtocols.Tls12 | SslProtocols.Tls13; - if (CoreHelpers.SettingHasValue(globalSettings.Syslog.CertificateThumbprint)) { config.WriteTo.TcpSyslog(syslogAddress.Host, port, appName, - secureProtocols: protocols, + useTls: true, certProvider: new CertificateStoreProvider(StoreName.My, StoreLocation.CurrentUser, globalSettings.Syslog.CertificateThumbprint)); } else { config.WriteTo.TcpSyslog(syslogAddress.Host, port, appName, - secureProtocols: protocols, + useTls: true, certProvider: new CertificateFileProvider(globalSettings.Syslog.CertificatePath, globalSettings.Syslog?.CertificatePassword ?? string.Empty)); } diff --git a/util/Migrator/Migrator.csproj b/util/Migrator/Migrator.csproj index 6200f96543..548efd00ba 100644 --- a/util/Migrator/Migrator.csproj +++ b/util/Migrator/Migrator.csproj @@ -7,7 +7,7 @@ - + diff --git a/util/MsSqlMigratorUtility/MsSqlMigratorUtility.csproj b/util/MsSqlMigratorUtility/MsSqlMigratorUtility.csproj index 7f19d67459..4876d70128 100644 --- a/util/MsSqlMigratorUtility/MsSqlMigratorUtility.csproj +++ b/util/MsSqlMigratorUtility/MsSqlMigratorUtility.csproj @@ -11,8 +11,8 @@ - - + + diff --git a/util/Setup/Setup.csproj b/util/Setup/Setup.csproj index f558b703c0..000da69879 100644 --- a/util/Setup/Setup.csproj +++ b/util/Setup/Setup.csproj @@ -11,7 +11,7 @@ - + From a07aa8330c60f8199bda16805840ab5763bc0092 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Thu, 15 Feb 2024 00:41:51 +1000 Subject: [PATCH 106/117] [AC-2206] Fix assigning Manage access to default collection (#3799) * Fix assigning Manage access to default collection The previous implementation did not work when creating an org as a provider because the ownerId is null in OrganizationService.SignUp. Added a null check and handled assigning access in ProviderService instead. * Tweaks --- .../AdminConsole/Services/ProviderService.cs | 19 +++++++++++++++++-- .../Services/ProviderServiceTests.cs | 11 ++++++++--- .../Services/IOrganizationService.cs | 13 +++++++++++-- .../Implementations/OrganizationService.cs | 17 ++++++++++------- .../Helpers/OrganizationTestHelpers.cs | 4 +++- .../Services/OrganizationServiceTests.cs | 8 -------- 6 files changed, 49 insertions(+), 23 deletions(-) diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index b6d53c0ada..e2e5437a55 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -496,7 +496,7 @@ public class ProviderService : IProviderService { ThrowOnInvalidPlanType(organizationSignup.Plan); - var (organization, _) = await _organizationService.SignUpAsync(organizationSignup, true); + var (organization, _, defaultCollection) = await _organizationService.SignUpAsync(organizationSignup, true); var providerOrganization = new ProviderOrganization { @@ -508,6 +508,21 @@ public class ProviderService : IProviderService await _providerOrganizationRepository.CreateAsync(providerOrganization); await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Created); + // If using Flexible Collections, give the owner Can Manage access over the default collection + // The orgUser is not available when the org is created so we have to do it here as part of the invite + var defaultOwnerAccess = organization.FlexibleCollections && defaultCollection != null + ? + [ + new CollectionAccessSelection + { + Id = defaultCollection.Id, + HidePasswords = false, + ReadOnly = false, + Manage = true + } + ] + : Array.Empty(); + await _organizationService.InviteUsersAsync(organization.Id, user.Id, new (OrganizationUserInvite, string)[] { @@ -521,7 +536,7 @@ public class ProviderService : IProviderService AccessAll = !organization.FlexibleCollections, Type = OrganizationUserType.Owner, Permissions = null, - Collections = Array.Empty(), + Collections = defaultOwnerAccess, }, null ) diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs index f9b11cad41..b503d0d5a7 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs @@ -523,7 +523,7 @@ public class ProviderServiceTests sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); var providerOrganizationRepository = sutProvider.GetDependency(); sutProvider.GetDependency().SignUpAsync(organizationSignup, true) - .Returns(Tuple.Create(organization, null as OrganizationUser)); + .Returns((organization, null as OrganizationUser, new Collection())); var providerOrganization = await sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user); @@ -539,20 +539,21 @@ public class ProviderServiceTests t.First().Item1.Emails.First() == clientOwnerEmail && t.First().Item1.Type == OrganizationUserType.Owner && t.First().Item1.AccessAll && + !t.First().Item1.Collections.Any() && t.First().Item2 == null)); } [Theory, OrganizationCustomize(FlexibleCollections = true), BitAutoData] public async Task CreateOrganizationAsync_WithFlexibleCollections_SetsAccessAllToFalse (Provider provider, OrganizationSignup organizationSignup, Organization organization, string clientOwnerEmail, - User user, SutProvider sutProvider) + User user, SutProvider sutProvider, Collection defaultCollection) { organizationSignup.Plan = PlanType.EnterpriseAnnually; sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); var providerOrganizationRepository = sutProvider.GetDependency(); sutProvider.GetDependency().SignUpAsync(organizationSignup, true) - .Returns(Tuple.Create(organization, null as OrganizationUser)); + .Returns((organization, null as OrganizationUser, defaultCollection)); var providerOrganization = await sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user); @@ -568,6 +569,10 @@ public class ProviderServiceTests t.First().Item1.Emails.First() == clientOwnerEmail && t.First().Item1.Type == OrganizationUserType.Owner && t.First().Item1.AccessAll == false && + t.First().Item1.Collections.Single().Id == defaultCollection.Id && + !t.First().Item1.Collections.Single().HidePasswords && + !t.First().Item1.Collections.Single().ReadOnly && + t.First().Item1.Collections.Single().Manage && t.First().Item2 == null)); } diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index 768edea9a3..77c92ec4df 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -20,8 +20,17 @@ public interface IOrganizationService Task AutoAddSeatsAsync(Organization organization, int seatsToAdd, DateTime? prorationDate = null); Task AdjustSeatsAsync(Guid organizationId, int seatAdjustment, DateTime? prorationDate = null); Task VerifyBankAsync(Guid organizationId, int amount1, int amount2); - Task> SignUpAsync(OrganizationSignup organizationSignup, bool provider = false); - Task> SignUpAsync(OrganizationLicense license, User owner, + /// + /// Create a new organization in a cloud environment + /// + /// A tuple containing the new organization, the initial organizationUser (if any) and the default collection (if any) +#nullable enable + Task<(Organization organization, OrganizationUser? organizationUser, Collection? defaultCollection)> SignUpAsync(OrganizationSignup organizationSignup, bool provider = false); +#nullable disable + /// + /// Create a new organization on a self-hosted instance + /// + Task<(Organization organization, OrganizationUser organizationUser)> SignUpAsync(OrganizationLicense license, User owner, string ownerKey, string collectionName, string publicKey, string privateKey); Task DeleteAsync(Organization organization); Task EnableAsync(Guid organizationId, DateTime? expirationDate); diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 7d0907b0ba..6d547badc6 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -424,7 +424,7 @@ public class OrganizationService : IOrganizationService /// /// Create a new organization in a cloud environment /// - public async Task> SignUpAsync(OrganizationSignup signup, + public async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignUpAsync(OrganizationSignup signup, bool provider = false) { var plan = StaticStore.GetPlan(signup.Plan); @@ -552,7 +552,7 @@ public class OrganizationService : IOrganizationService /// /// Create a new organization on a self-hosted instance /// - public async Task> SignUpAsync( + public async Task<(Organization organization, OrganizationUser organizationUser)> SignUpAsync( OrganizationLicense license, User owner, string ownerKey, string collectionName, string publicKey, string privateKey) { @@ -633,14 +633,14 @@ public class OrganizationService : IOrganizationService Directory.CreateDirectory(dir); await using var fs = new FileStream(Path.Combine(dir, $"{organization.Id}.json"), FileMode.Create); await JsonSerializer.SerializeAsync(fs, license, JsonHelpers.Indented); - return result; + return (result.organization, result.organizationUser); } /// /// Private helper method to create a new organization. /// This is common code used by both the cloud and self-hosted methods. /// - private async Task> SignUpAsync(Organization organization, + private async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignUpAsync(Organization organization, Guid ownerId, string ownerKey, string collectionName, bool withPayment) { try @@ -655,6 +655,8 @@ public class OrganizationService : IOrganizationService }); await _applicationCacheService.UpsertOrganizationAbilityAsync(organization); + // ownerId == default if the org is created by a provider - in this case it's created without an + // owner and the first owner is immediately invited afterwards OrganizationUser orgUser = null; if (ownerId != default) { @@ -683,9 +685,10 @@ public class OrganizationService : IOrganizationService await _pushNotificationService.PushSyncOrgKeysAsync(ownerId); } + Collection defaultCollection = null; if (!string.IsNullOrWhiteSpace(collectionName)) { - var defaultCollection = new Collection + defaultCollection = new Collection { Name = collectionName, OrganizationId = organization.Id, @@ -695,7 +698,7 @@ public class OrganizationService : IOrganizationService // If using Flexible Collections, give the owner Can Manage access over the default collection List defaultOwnerAccess = null; - if (organization.FlexibleCollections) + if (orgUser != null && organization.FlexibleCollections) { defaultOwnerAccess = [new CollectionAccessSelection { Id = orgUser.Id, HidePasswords = false, ReadOnly = false, Manage = true }]; @@ -704,7 +707,7 @@ public class OrganizationService : IOrganizationService await _collectionRepository.CreateAsync(defaultCollection, null, defaultOwnerAccess); } - return new Tuple(organization, orgUser); + return (organization, orgUser, defaultCollection); } catch { diff --git a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs index 39fe8a99d0..2144c6301f 100644 --- a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs +++ b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs @@ -22,7 +22,7 @@ public static class OrganizationTestHelpers var owner = await userRepository.GetByEmailAsync(ownerEmail); - return await organizationService.SignUpAsync(new OrganizationSignup + var signUpResult = await organizationService.SignUpAsync(new OrganizationSignup { Name = name, BillingEmail = billingEmail, @@ -30,6 +30,8 @@ public static class OrganizationTestHelpers OwnerKey = ownerKey, Owner = owner, }); + + return new Tuple(signUpResult.organization, signUpResult.organizationUser); } public static async Task CreateUserAsync( diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index 4ad13d8fd6..a713935dd3 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -232,10 +232,8 @@ public class OrganizationServiceTests referenceEvent.Storage == result.Item1.MaxStorageGb)); // TODO: add reference events for SmSeats and Service Accounts - see AC-1481 - Assert.NotNull(result); Assert.NotNull(result.Item1); Assert.NotNull(result.Item2); - Assert.IsType>(result); await sutProvider.GetDependency().Received(1).PurchaseOrganizationAsync( Arg.Any(), @@ -294,10 +292,8 @@ public class OrganizationServiceTests !c.HidePasswords && c.Manage))); - Assert.NotNull(result); Assert.NotNull(result.Item1); Assert.NotNull(result.Item2); - Assert.IsType>(result); } [Theory] @@ -323,10 +319,8 @@ public class OrganizationServiceTests o.UserId == signup.Owner.Id && o.AccessAll == true)); - Assert.NotNull(result); Assert.NotNull(result.Item1); Assert.NotNull(result.Item2); - Assert.IsType>(result); } [Theory] @@ -367,10 +361,8 @@ public class OrganizationServiceTests referenceEvent.Storage == result.Item1.MaxStorageGb)); // TODO: add reference events for SmSeats and Service Accounts - see AC-1481 - Assert.NotNull(result); Assert.NotNull(result.Item1); Assert.NotNull(result.Item2); - Assert.IsType>(result); await sutProvider.GetDependency().Received(1).PurchaseOrganizationAsync( Arg.Any(), From 06dcdd7d1364efa80223f679b6f2a89832fdfc49 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Thu, 15 Feb 2024 00:42:07 +1000 Subject: [PATCH 107/117] Fix Flexible Collections block in Public API (#3800) Only throw if collection.Manage is true --- .../Models/Request/AssociationWithPermissionsRequestModel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Api/AdminConsole/Public/Models/Request/AssociationWithPermissionsRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/AssociationWithPermissionsRequestModel.cs index b54fe60b27..acd6855842 100644 --- a/src/Api/AdminConsole/Public/Models/Request/AssociationWithPermissionsRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/AssociationWithPermissionsRequestModel.cs @@ -16,7 +16,7 @@ public class AssociationWithPermissionsRequestModel : AssociationWithPermissions }; // Throws if the org has not migrated to use FC but has passed in a Manage value in the request - if (!migratedToFlexibleCollections && Manage.HasValue) + if (!migratedToFlexibleCollections && Manage.GetValueOrDefault()) { throw new BadRequestException( "Your organization must be using the latest collection enhancements to use the Manage property."); From 4830a352e8bf7a87475fee605cdd9fc344d04113 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Wed, 14 Feb 2024 14:42:16 +0000 Subject: [PATCH 108/117] [AC-2154] Log backup data in OrganizationEnableCollectionEnhancementsCommand as Json (#3802) --- .../OrganizationEnableCollectionEnhancementsCommand.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationCollectionEnhancements/OrganizationEnableCollectionEnhancementsCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationCollectionEnhancements/OrganizationEnableCollectionEnhancementsCommand.cs index da32e9c517..80f2a935ca 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationCollectionEnhancements/OrganizationEnableCollectionEnhancementsCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationCollectionEnhancements/OrganizationEnableCollectionEnhancementsCommand.cs @@ -1,4 +1,5 @@ -using Bit.Core.AdminConsole.Entities; +using System.Text.Json; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Enums; @@ -107,6 +108,6 @@ public class OrganizationEnableCollectionEnhancementsCommand : IOrganizationEnab CollectionUsers = collectionUsersData }; - _logger.LogWarning("Flexible Collections data migration started. Backup data: {@LogObject}", logObject); + _logger.LogWarning("Flexible Collections data migration started. Backup data: {LogObject}", JsonSerializer.Serialize(logObject)); } } From 744d21ec5e4744b91b23dc2768dbeb993dd1c96a Mon Sep 17 00:00:00 2001 From: rkac-bw <148072202+rkac-bw@users.noreply.github.com> Date: Wed, 14 Feb 2024 09:48:58 -0700 Subject: [PATCH 109/117] [PM-4767] Update Grant_Save procedure (#3641) * modify grant_save sql script to migration and Auth SQL scripts to not use merge * Update formatting for sql files * Fix formatting for sql files * Format using Prettier * Rename 2024-01-03_00_FixGrantSave.sql to 2024-02-12_00_FixGrantSave.sql --------- Co-authored-by: Matt Bishop --- .../Auth/dbo/Stored Procedures/Grant_Save.sql | 99 +++++++------------ .../DbScripts/2024-02-12_00_FixGrantSave.sql | 61 ++++++++++++ 2 files changed, 97 insertions(+), 63 deletions(-) create mode 100644 util/Migrator/DbScripts/2024-02-12_00_FixGrantSave.sql diff --git a/src/Sql/Auth/dbo/Stored Procedures/Grant_Save.sql b/src/Sql/Auth/dbo/Stored Procedures/Grant_Save.sql index bac37f1400..3067f3712f 100644 --- a/src/Sql/Auth/dbo/Stored Procedures/Grant_Save.sql +++ b/src/Sql/Auth/dbo/Stored Procedures/Grant_Save.sql @@ -1,6 +1,6 @@ CREATE PROCEDURE [dbo].[Grant_Save] - @Key NVARCHAR(200), - @Type NVARCHAR(50), + @Key NVARCHAR(200), + @Type NVARCHAR(50), @SubjectId NVARCHAR(200), @SessionId NVARCHAR(100), @ClientId NVARCHAR(200), @@ -13,53 +13,26 @@ AS BEGIN SET NOCOUNT ON - MERGE - [dbo].[Grant] AS [Target] - USING - ( - VALUES - ( - @Key, - @Type, - @SubjectId, - @SessionId, - @ClientId, - @Description, - @CreationDate, - @ExpirationDate, - @ConsumedDate, - @Data - ) - ) AS [Source] - ( - [Key], - [Type], - [SubjectId], - [SessionId], - [ClientId], - [Description], - [CreationDate], - [ExpirationDate], - [ConsumedDate], - [Data] - ) - ON - [Target].[Key] = [Source].[Key] - WHEN MATCHED THEN - UPDATE - SET - [Type] = [Source].[Type], - [SubjectId] = [Source].[SubjectId], - [SessionId] = [Source].[SessionId], - [ClientId] = [Source].[ClientId], - [Description] = [Source].[Description], - [CreationDate] = [Source].[CreationDate], - [ExpirationDate] = [Source].[ExpirationDate], - [ConsumedDate] = [Source].[ConsumedDate], - [Data] = [Source].[Data] - WHEN NOT MATCHED THEN - INSERT - ( + -- First, try to update the existing row + UPDATE [dbo].[Grant] + SET + [Type] = @Type, + [SubjectId] = @SubjectId, + [SessionId] = @SessionId, + [ClientId] = @ClientId, + [Description] = @Description, + [CreationDate] = @CreationDate, + [ExpirationDate] = @ExpirationDate, + [ConsumedDate] = @ConsumedDate, + [Data] = @Data + WHERE + [Key] = @Key + + -- If no row was updated, insert a new one + IF @@ROWCOUNT = 0 + BEGIN + INSERT INTO [dbo].[Grant] + ( [Key], [Type], [SubjectId], @@ -70,19 +43,19 @@ BEGIN [ExpirationDate], [ConsumedDate], [Data] - ) + ) VALUES - ( - [Source].[Key], - [Source].[Type], - [Source].[SubjectId], - [Source].[SessionId], - [Source].[ClientId], - [Source].[Description], - [Source].[CreationDate], - [Source].[ExpirationDate], - [Source].[ConsumedDate], - [Source].[Data] - ) - ; + ( + @Key, + @Type, + @SubjectId, + @SessionId, + @ClientId, + @Description, + @CreationDate, + @ExpirationDate, + @ConsumedDate, + @Data + ) + END END diff --git a/util/Migrator/DbScripts/2024-02-12_00_FixGrantSave.sql b/util/Migrator/DbScripts/2024-02-12_00_FixGrantSave.sql new file mode 100644 index 0000000000..33325b192b --- /dev/null +++ b/util/Migrator/DbScripts/2024-02-12_00_FixGrantSave.sql @@ -0,0 +1,61 @@ +CREATE OR ALTER PROCEDURE [dbo].[Grant_Save] + @Key NVARCHAR(200), + @Type NVARCHAR(50), + @SubjectId NVARCHAR(200), + @SessionId NVARCHAR(100), + @ClientId NVARCHAR(200), + @Description NVARCHAR(200), + @CreationDate DATETIME2, + @ExpirationDate DATETIME2, + @ConsumedDate DATETIME2, + @Data NVARCHAR(MAX) +AS +BEGIN + SET NOCOUNT ON + + -- First, try to update the existing row + UPDATE [dbo].[Grant] + SET + [Type] = @Type, + [SubjectId] = @SubjectId, + [SessionId] = @SessionId, + [ClientId] = @ClientId, + [Description] = @Description, + [CreationDate] = @CreationDate, + [ExpirationDate] = @ExpirationDate, + [ConsumedDate] = @ConsumedDate, + [Data] = @Data + WHERE + [Key] = @Key + + -- If no row was updated, insert a new one + IF @@ROWCOUNT = 0 + BEGIN + INSERT INTO [dbo].[Grant] + ( + [Key], + [Type], + [SubjectId], + [SessionId], + [ClientId], + [Description], + [CreationDate], + [ExpirationDate], + [ConsumedDate], + [Data] + ) + VALUES + ( + @Key, + @Type, + @SubjectId, + @SessionId, + @ClientId, + @Description, + @CreationDate, + @ExpirationDate, + @ConsumedDate, + @Data + ) + END +END From d99d3b8380ad9d475ef620a6a555984d479cb8da Mon Sep 17 00:00:00 2001 From: Jake Fink Date: Wed, 14 Feb 2024 18:00:46 -0500 Subject: [PATCH 110/117] [PM-6303] Add duo state to 2fa (#3806) * add duo state to 2fa * Id to UserId --- .../Identity/TemporaryDuoWebV4SDKService.cs | 27 +++++++++++--- .../Tokenables/DuoUserStateTokenable.cs | 37 +++++++++++++++++++ .../Utilities/ServiceCollectionExtensions.cs | 6 +++ 3 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 src/Core/Auth/Models/Business/Tokenables/DuoUserStateTokenable.cs diff --git a/src/Core/Auth/Identity/TemporaryDuoWebV4SDKService.cs b/src/Core/Auth/Identity/TemporaryDuoWebV4SDKService.cs index 316abbe9a2..0f9f982a8b 100644 --- a/src/Core/Auth/Identity/TemporaryDuoWebV4SDKService.cs +++ b/src/Core/Auth/Identity/TemporaryDuoWebV4SDKService.cs @@ -1,12 +1,14 @@ using Bit.Core.Auth.Models; +using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Settings; +using Bit.Core.Tokens; using Duo = DuoUniversal; namespace Bit.Core.Auth.Identity; -/* +/* PM-5156 addresses tech debt Interface to allow for DI, will end up being removed as part of the removal of the old Duo SDK v2 flows. This service is to support SDK v4 flows for Duo. At some time in the future we will need @@ -22,6 +24,7 @@ public class TemporaryDuoWebV4SDKService : ITemporaryDuoWebV4SDKService { private readonly ICurrentContext _currentContext; private readonly GlobalSettings _globalSettings; + private readonly IDataProtectorTokenFactory _tokenDataFactory; /// /// Constructor for the DuoUniversalPromptService. Used to supplement v2 implementation of Duo with v4 SDK @@ -30,10 +33,12 @@ public class TemporaryDuoWebV4SDKService : ITemporaryDuoWebV4SDKService /// used to fetch vault URL for Redirect URL public TemporaryDuoWebV4SDKService( ICurrentContext currentContext, - GlobalSettings globalSettings) + GlobalSettings globalSettings, + IDataProtectorTokenFactory tokenDataFactory) { _currentContext = currentContext; _globalSettings = globalSettings; + _tokenDataFactory = tokenDataFactory; } /// @@ -56,7 +61,7 @@ public class TemporaryDuoWebV4SDKService : ITemporaryDuoWebV4SDKService return null; } - var state = Duo.Client.GenerateState(); //? Not sure on this yet. But required for GenerateAuthUrl + var state = _tokenDataFactory.Protect(new DuoUserStateTokenable(user)); var authUrl = duoClient.GenerateAuthUri(user.Email, state); return authUrl; @@ -82,8 +87,20 @@ public class TemporaryDuoWebV4SDKService : ITemporaryDuoWebV4SDKService return false; } + var parts = token.Split("|"); + var authCode = parts[0]; + var state = parts[1]; + + _tokenDataFactory.TryUnprotect(state, out var tokenable); + if (!tokenable.Valid || !tokenable.TokenIsValid(user)) + { + return false; + } + + // duoClient compares the email from the received IdToken with user.Email to verify a bad actor hasn't used + // their authCode with a victims credentials + var res = await duoClient.ExchangeAuthorizationCodeFor2faResult(authCode, user.Email); // If the result of the exchange doesn't throw an exception and it's not null, then it's valid - var res = await duoClient.ExchangeAuthorizationCodeFor2faResult(token, user.Email); return res.AuthResult.Result == "allow"; } @@ -100,7 +117,7 @@ public class TemporaryDuoWebV4SDKService : ITemporaryDuoWebV4SDKService /// Duo.Client object or null private async Task BuildDuoClientAsync(TwoFactorProvider provider) { - // Fetch Client name from header value since duo auth can be initiated from multiple clients and we want + // Fetch Client name from header value since duo auth can be initiated from multiple clients and we want // to redirect back to the correct client _currentContext.HttpContext.Request.Headers.TryGetValue("Bitwarden-Client-Name", out var bitwardenClientName); var redirectUri = string.Format("{0}/duo-redirect-connector.html?client={1}", diff --git a/src/Core/Auth/Models/Business/Tokenables/DuoUserStateTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/DuoUserStateTokenable.cs new file mode 100644 index 0000000000..45d00bd865 --- /dev/null +++ b/src/Core/Auth/Models/Business/Tokenables/DuoUserStateTokenable.cs @@ -0,0 +1,37 @@ +using Bit.Core.Entities; +using Bit.Core.Tokens; +using Newtonsoft.Json; + +namespace Bit.Core.Auth.Models.Business.Tokenables; + +public class DuoUserStateTokenable : Tokenable +{ + public const string ClearTextPrefix = "BwDuoUserId"; + public const string DataProtectorPurpose = "DuoUserIdTokenDataProtector"; + public const string TokenIdentifier = "DuoUserIdToken"; + public string Identifier { get; set; } = TokenIdentifier; + public Guid UserId { get; set; } + + public override bool Valid => Identifier == TokenIdentifier && + UserId != default; + + [JsonConstructor] + public DuoUserStateTokenable() + { + } + + public DuoUserStateTokenable(User user) + { + UserId = user?.Id ?? default; + } + + public bool TokenIsValid(User user) + { + if (UserId == default || user == null) + { + return false; + } + + return UserId == user.Id; + } +} diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 1bd805fb8e..7535aba882 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -197,6 +197,12 @@ public static class ServiceCollectionExtensions OrgUserInviteTokenable.DataProtectorPurpose, serviceProvider.GetDataProtectionProvider(), serviceProvider.GetRequiredService>>())); + services.AddSingleton>(serviceProvider => + new DataProtectorTokenFactory( + DuoUserStateTokenable.ClearTextPrefix, + DuoUserStateTokenable.DataProtectorPurpose, + serviceProvider.GetDataProtectionProvider(), + serviceProvider.GetRequiredService>>())); } public static void AddDefaultServices(this IServiceCollection services, GlobalSettings globalSettings) From 0b486b05851070a60012d5f9beea434296f35929 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 15 Feb 2024 11:14:30 +0100 Subject: [PATCH 111/117] [deps] Tools: Update SignalR to v8.0.2 (#3803) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Notifications/Notifications.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Notifications/Notifications.csproj b/src/Notifications/Notifications.csproj index 557367f965..3b82f9b5f0 100644 --- a/src/Notifications/Notifications.csproj +++ b/src/Notifications/Notifications.csproj @@ -7,8 +7,8 @@ - - + + From 179b7fb498ea9e1a2984970905cb512b0aa89fc4 Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Thu, 15 Feb 2024 08:01:40 -0500 Subject: [PATCH 112/117] Exclude tests from Checkmarx (#3797) * Exclude tests from Checkmarx * Leading slash * Simpler path --- .checkmarx/config.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .checkmarx/config.yml diff --git a/.checkmarx/config.yml b/.checkmarx/config.yml new file mode 100644 index 0000000000..7688854cd9 --- /dev/null +++ b/.checkmarx/config.yml @@ -0,0 +1,11 @@ +version: 1 + +# Checkmarx configuration file +# +# https://checkmarx.com/resource/documents/en/34965-68549-configuring-projects-using-config-as-code-files.html +checkmarx: + scan: + configs: + sast: + # Exclude test directory + filter: "!test" From 8a7779d30c97fea921d97ba918ba9022432e07bc Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Thu, 15 Feb 2024 14:53:03 +0100 Subject: [PATCH 113/117] Exclude dev directory from iac scans (#3807) * Exclude dev directory from iac scans --- .checkmarx/config.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.checkmarx/config.yml b/.checkmarx/config.yml index 7688854cd9..641da0eacb 100644 --- a/.checkmarx/config.yml +++ b/.checkmarx/config.yml @@ -9,3 +9,5 @@ checkmarx: sast: # Exclude test directory filter: "!test" + kics: + filter: "!dev,!.devcontainer" From 7f752fbd624205ac4152dc92fdb450e6c0118da0 Mon Sep 17 00:00:00 2001 From: Opeyemi Date: Thu, 15 Feb 2024 17:15:37 +0100 Subject: [PATCH 114/117] Remove individual linter file (#3808) --- .github/workflows/workflow-linter.yml | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 .github/workflows/workflow-linter.yml diff --git a/.github/workflows/workflow-linter.yml b/.github/workflows/workflow-linter.yml deleted file mode 100644 index 24f10f1e46..0000000000 --- a/.github/workflows/workflow-linter.yml +++ /dev/null @@ -1,12 +0,0 @@ ---- -name: Workflow linter - -on: - pull_request: - paths: - - .github/workflows/** - -jobs: - call-workflow: - name: Lint - uses: bitwarden/gh-actions/.github/workflows/workflow-linter.yml@main From da0da772e907f149d1c6bc26c73ec02b95248f5b Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Thu, 15 Feb 2024 09:49:37 -0800 Subject: [PATCH 115/117] [PM-6325] Include permission details for non FC organizations when creating/updating a collection (#3810) --- src/Api/Controllers/CollectionsController.cs | 26 ++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/Api/Controllers/CollectionsController.cs b/src/Api/Controllers/CollectionsController.cs index cd933739ff..3eeae17a50 100644 --- a/src/Api/Controllers/CollectionsController.cs +++ b/src/Api/Controllers/CollectionsController.cs @@ -253,7 +253,18 @@ public class CollectionsController : Controller await _collectionService.SaveAsync(collection, groups, users); - return new CollectionResponseModel(collection); + if (!_currentContext.UserId.HasValue || await _currentContext.ProviderUserForOrgAsync(orgId)) + { + return new CollectionResponseModel(collection); + } + + // If we have a user, fetch the collection to get the latest permission details + var userCollectionDetails = await _collectionRepository.GetByIdAsync(collection.Id, + _currentContext.UserId.Value, await FlexibleCollectionsIsEnabledAsync(collection.OrganizationId)); + + return userCollectionDetails == null + ? new CollectionResponseModel(collection) + : new CollectionDetailsResponseModel(userCollectionDetails); } [HttpPut("{id}")] @@ -276,7 +287,18 @@ public class CollectionsController : Controller var groups = model.Groups?.Select(g => g.ToSelectionReadOnly()); var users = model.Users?.Select(g => g.ToSelectionReadOnly()); await _collectionService.SaveAsync(model.ToCollection(collection), groups, users); - return new CollectionResponseModel(collection); + + if (!_currentContext.UserId.HasValue || await _currentContext.ProviderUserForOrgAsync(collection.OrganizationId)) + { + return new CollectionResponseModel(collection); + } + + // If we have a user, fetch the collection details to get the latest permission details for the user + var updatedCollectionDetails = await _collectionRepository.GetByIdAsync(id, _currentContext.UserId.Value, await FlexibleCollectionsIsEnabledAsync(collection.OrganizationId)); + + return updatedCollectionDetails == null + ? new CollectionResponseModel(collection) + : new CollectionDetailsResponseModel(updatedCollectionDetails); } [HttpPut("{id}/users")] From 268db7d45e61c804208be04003d8e2184d833359 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 16 Feb 2024 09:42:15 +0100 Subject: [PATCH 116/117] [deps] Tools: Update aws-sdk-net monorepo to v3.7.300.51 (#3804) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Core/Core.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 96b77f3c1a..67a25ff24d 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -21,8 +21,8 @@ - - + + From d187487cb74a716213032cae253d4d1028727391 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Fri, 16 Feb 2024 11:49:05 +0000 Subject: [PATCH 117/117] [AC-2077] Set a minimum number of seats for the tested Organization (#3702) * [AC-2077] Set a minimum number of seats for the tested Organization * [AC-2077] Added PlanType property to OrganizationCustomization * [AC-2077] Set up the test secrets manager seats to be null in case the plan does not support it --- .../AutoFixture/OrganizationFixtures.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/test/Core.Test/AdminConsole/AutoFixture/OrganizationFixtures.cs b/test/Core.Test/AdminConsole/AutoFixture/OrganizationFixtures.cs index 1a60900597..1b5a61edb4 100644 --- a/test/Core.Test/AdminConsole/AutoFixture/OrganizationFixtures.cs +++ b/test/Core.Test/AdminConsole/AutoFixture/OrganizationFixtures.cs @@ -19,17 +19,26 @@ public class OrganizationCustomization : ICustomization { public bool UseGroups { get; set; } public bool FlexibleCollections { get; set; } + public PlanType PlanType { get; set; } public void Customize(IFixture fixture) { var organizationId = Guid.NewGuid(); var maxCollections = (short)new Random().Next(10, short.MaxValue); + var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == PlanType); + var seats = (short)new Random().Next(plan.PasswordManager.BaseSeats, plan.PasswordManager.MaxSeats ?? short.MaxValue); + var smSeats = plan.SupportsSecretsManager + ? (short?)new Random().Next(plan.SecretsManager.BaseSeats, plan.SecretsManager.MaxSeats ?? short.MaxValue) + : null; fixture.Customize(composer => composer .With(o => o.Id, organizationId) .With(o => o.MaxCollections, maxCollections) .With(o => o.UseGroups, UseGroups) - .With(o => o.FlexibleCollections, FlexibleCollections)); + .With(o => o.FlexibleCollections, FlexibleCollections) + .With(o => o.PlanType, PlanType) + .With(o => o.Seats, seats) + .With(o => o.SmSeats, smSeats)); fixture.Customize(composer => composer @@ -186,10 +195,12 @@ public class OrganizationCustomizeAttribute : BitCustomizeAttribute { public bool UseGroups { get; set; } public bool FlexibleCollections { get; set; } + public PlanType PlanType { get; set; } = PlanType.EnterpriseAnnually; public override ICustomization GetCustomization() => new OrganizationCustomization() { UseGroups = UseGroups, - FlexibleCollections = FlexibleCollections + FlexibleCollections = FlexibleCollections, + PlanType = PlanType }; }