From df4d1d555226974e1cfb77a435e9c9b1f0ddb602 Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Wed, 8 May 2024 17:25:22 -0500 Subject: [PATCH 01/36] [AC-2086] Update CanDelete to handle V1 flag logic (#3979) * feat: Update authorization handler to handle V1 collection enhancement, refs AC-2086 * feat: update tests to account for new V1 flag/setting logic, refs AC-2086 * feat: update CanDelete with all collection enhancement combinations, refs AC-2086 * feat: add tests for new delete flows, refs AC-2086 * fix: update new conditionals with bool return value, refs AC-2086 * feat: simplify conditional in regards to LimitCollectionCreationDeletion, refs AC-2086 * feat: simplify AllowAdminAccessToAllCollectionItems conditional, refs AC-2086 * feat: add unit test making sure admins can't delete collections without can manage, refs AC-2086 --- .../BulkCollectionAuthorizationHandler.cs | 31 +- ...BulkCollectionAuthorizationHandlerTests.cs | 269 ++++++++++++++++-- 2 files changed, 257 insertions(+), 43 deletions(-) diff --git a/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs b/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs index 497c8b9a18..0638d16d62 100644 --- a/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs +++ b/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs @@ -227,24 +227,29 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler CanDeleteAsync(ICollection resources, CurrentContextOrganization? org) { - // Owners, Admins, and users with DeleteAnyCollection permission can always delete collections - if (org is - { Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or - { Permissions.DeleteAnyCollection: true }) + // Users with DeleteAnyCollection permission can always delete collections + if (org is { Permissions.DeleteAnyCollection: true }) { return true; } - // Check for non-null org here: the user must be apart of the organization for this setting to take affect - // The limit collection management setting is disabled, - // ensure acting user has manage permissions for all collections being deleted - if (await GetOrganizationAbilityAsync(org) is { LimitCollectionCreationDeletion: false }) + // If AllowAdminAccessToAllCollectionItems is true, Owners and Admins can delete any collection, regardless of LimitCollectionCreationDeletion setting + var organizationAbility = await GetOrganizationAbilityAsync(org); + var allowAdminAccessToAllCollectionItems = !_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) || + organizationAbility is { AllowAdminAccessToAllCollectionItems: true }; + if (allowAdminAccessToAllCollectionItems && org is { Type: OrganizationUserType.Owner or OrganizationUserType.Admin }) { - var canManageCollections = await CanManageCollectionsAsync(resources, org); - if (canManageCollections) - { - return true; - } + return true; + } + + // If LimitCollectionCreationDeletion is false, AllowAdminAccessToAllCollectionItems setting is irrelevant. + // Ensure acting user has manage permissions for all collections being deleted + // If LimitCollectionCreationDeletion is true, only Owners and Admins can delete collections they manage + var canDeleteManagedCollections = organizationAbility is { LimitCollectionCreationDeletion: false } || + org is { Type: OrganizationUserType.Owner or OrganizationUserType.Admin }; + if (canDeleteManagedCollections && await CanManageCollectionsAsync(resources, org)) + { + return true; } // Allow providers to delete collections if they are a provider for the target organization diff --git a/test/Api.Test/Vault/AuthorizationHandlers/BulkCollectionAuthorizationHandlerTests.cs b/test/Api.Test/Vault/AuthorizationHandlers/BulkCollectionAuthorizationHandlerTests.cs index dd8bacaa9e..527896f930 100644 --- a/test/Api.Test/Vault/AuthorizationHandlers/BulkCollectionAuthorizationHandlerTests.cs +++ b/test/Api.Test/Vault/AuthorizationHandlers/BulkCollectionAuthorizationHandlerTests.cs @@ -903,33 +903,6 @@ public class BulkCollectionAuthorizationHandlerTests } } - [Theory, CollectionCustomization] - [BitAutoData(OrganizationUserType.Admin)] - [BitAutoData(OrganizationUserType.Owner)] - public async Task CanDeleteAsync_WhenAdminOrOwner_Success( - OrganizationUserType userType, - Guid userId, SutProvider sutProvider, - ICollection collections, - CurrentContextOrganization organization) - { - organization.Type = userType; - organization.Permissions = new Permissions(); - - ArrangeOrganizationAbility(sutProvider, organization, true); - - var context = new AuthorizationHandlerContext( - new[] { BulkCollectionOperations.Delete }, - new ClaimsPrincipal(), - collections); - - sutProvider.GetDependency().UserId.Returns(userId); - sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - - await sutProvider.Sut.HandleAsync(context); - - Assert.True(context.HasSucceeded); - } - [Theory, BitAutoData, CollectionCustomization] public async Task CanDeleteAsync_WithDeleteAnyCollectionPermission_Success( SutProvider sutProvider, @@ -959,8 +932,63 @@ public class BulkCollectionAuthorizationHandlerTests Assert.True(context.HasSucceeded); } + [Theory, CollectionCustomization] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.Owner)] + public async Task CanDeleteAsync_WhenAdminOrOwner_AllowAdminAccessToAllCollectionItemsTrue_Success( + OrganizationUserType userType, + Guid userId, SutProvider sutProvider, + ICollection collections, + CurrentContextOrganization organization) + { + organization.Type = userType; + organization.Permissions = new Permissions(); + + ArrangeOrganizationAbility(sutProvider, organization, true); + + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.Delete }, + new ClaimsPrincipal(), + collections); + + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasSucceeded); + } + + [Theory, CollectionCustomization] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.Owner)] + public async Task CanDeleteAsync_WhenAdminOrOwner_V1FlagDisabled_Success( + OrganizationUserType userType, + Guid userId, SutProvider sutProvider, + ICollection collections, + CurrentContextOrganization organization) + { + organization.Type = userType; + organization.Permissions = new Permissions(); + + ArrangeOrganizationAbility(sutProvider, organization, true, false); + + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.Delete }, + new ClaimsPrincipal(), + collections); + + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1).Returns(false); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasSucceeded); + } + [Theory, BitAutoData, CollectionCustomization] - public async Task CanDeleteAsync_WithManageCollectionPermission_Success( + public async Task CanDeleteAsync_WhenUser_LimitCollectionCreationDeletionFalse_WithCanManagePermission_Success( SutProvider sutProvider, ICollection collections, CurrentContextOrganization organization) @@ -991,6 +1019,184 @@ public class BulkCollectionAuthorizationHandlerTests Assert.True(context.HasSucceeded); } + [Theory, CollectionCustomization] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.Owner)] + [BitAutoData(OrganizationUserType.User)] + public async Task CanDeleteAsync_LimitCollectionCreationDeletionFalse_AllowAdminAccessToAllCollectionItemsFalse_WithCanManagePermission_Success( + OrganizationUserType userType, + SutProvider sutProvider, + ICollection collections, + CurrentContextOrganization organization) + { + var actingUserId = Guid.NewGuid(); + + organization.Type = userType; + organization.Permissions = new Permissions(); + + ArrangeOrganizationAbility(sutProvider, organization, false, false); + + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId, Arg.Any()).Returns(collections); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1).Returns(true); + + foreach (var c in collections) + { + c.Manage = true; + } + + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.Delete }, + new ClaimsPrincipal(), + collections); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasSucceeded); + } + + [Theory, CollectionCustomization] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.Owner)] + public async Task CanDeleteAsync_WhenAdminOrOwner_LimitCollectionCreationDeletionTrue_AllowAdminAccessToAllCollectionItemsFalse_WithCanManagePermission_Success( + OrganizationUserType userType, + SutProvider sutProvider, + ICollection collections, + CurrentContextOrganization organization) + { + var actingUserId = Guid.NewGuid(); + + organization.Type = userType; + organization.Permissions = new Permissions(); + + ArrangeOrganizationAbility(sutProvider, organization, true, false); + + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId, Arg.Any()).Returns(collections); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1).Returns(true); + + foreach (var c in collections) + { + c.Manage = true; + } + + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.Delete }, + new ClaimsPrincipal(), + collections); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasSucceeded); + } + + [Theory, CollectionCustomization] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.Owner)] + public async Task CanDeleteAsync_WhenAdminOrOwner_LimitCollectionCreationDeletionTrue_AllowAdminAccessToAllCollectionItemsFalse_WithoutCanManagePermission_Failure( + OrganizationUserType userType, + SutProvider sutProvider, + ICollection collections, + CurrentContextOrganization organization) + { + var actingUserId = Guid.NewGuid(); + + organization.Type = userType; + organization.Permissions = new Permissions(); + + ArrangeOrganizationAbility(sutProvider, organization, true, false); + + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId, Arg.Any()).Returns(collections); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1).Returns(true); + sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); + + foreach (var c in collections) + { + c.Manage = false; + } + + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.Delete }, + new ClaimsPrincipal(), + collections); + + await sutProvider.Sut.HandleAsync(context); + + Assert.False(context.HasSucceeded); + } + + [Theory, BitAutoData, CollectionCustomization] + public async Task CanDeleteAsync_WhenUser_LimitCollectionCreationDeletionTrue_AllowAdminAccessToAllCollectionItemsTrue_Failure( + SutProvider sutProvider, + ICollection collections, + CurrentContextOrganization organization) + { + var actingUserId = Guid.NewGuid(); + + organization.Type = OrganizationUserType.User; + organization.Permissions = new Permissions(); + + ArrangeOrganizationAbility(sutProvider, organization, true); + + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId, Arg.Any()).Returns(collections); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1).Returns(true); + sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); + + foreach (var c in collections) + { + c.Manage = true; + } + + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.Delete }, + new ClaimsPrincipal(), + collections); + + await sutProvider.Sut.HandleAsync(context); + + Assert.False(context.HasSucceeded); + } + + [Theory, BitAutoData, CollectionCustomization] + public async Task CanDeleteAsync_WhenUser_LimitCollectionCreationDeletionTrue_AllowAdminAccessToAllCollectionItemsFalse_Failure( + SutProvider sutProvider, + ICollection collections, + CurrentContextOrganization organization) + { + var actingUserId = Guid.NewGuid(); + + organization.Type = OrganizationUserType.User; + organization.Permissions = new Permissions(); + + ArrangeOrganizationAbility(sutProvider, organization, true, false); + + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId, Arg.Any()).Returns(collections); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1).Returns(true); + sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); + + foreach (var c in collections) + { + c.Manage = true; + } + + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.Delete }, + new ClaimsPrincipal(), + collections); + + await sutProvider.Sut.HandleAsync(context); + + Assert.False(context.HasSucceeded); + } + [Theory, CollectionCustomization] [BitAutoData(OrganizationUserType.User)] [BitAutoData(OrganizationUserType.Custom)] @@ -1102,7 +1308,8 @@ public class BulkCollectionAuthorizationHandlerTests { collections.First().OrganizationId, new OrganizationAbility { - LimitCollectionCreationDeletion = true + LimitCollectionCreationDeletion = true, + AllowAdminAccessToAllCollectionItems = true } } }; @@ -1177,12 +1384,14 @@ public class BulkCollectionAuthorizationHandlerTests private static void ArrangeOrganizationAbility( SutProvider sutProvider, - CurrentContextOrganization organization, bool limitCollectionCreationDeletion) + CurrentContextOrganization organization, bool limitCollectionCreationDeletion, + bool allowAdminAccessToAllCollectionItems = true) { var organizationAbility = new OrganizationAbility(); organizationAbility.Id = organization.Id; organizationAbility.FlexibleCollections = true; organizationAbility.LimitCollectionCreationDeletion = limitCollectionCreationDeletion; + organizationAbility.AllowAdminAccessToAllCollectionItems = allowAdminAccessToAllCollectionItems; sutProvider.GetDependency().GetOrganizationAbilityAsync(organizationAbility.Id) .Returns(organizationAbility); From 109cb9f672a4ece79e193e834983668189cc1273 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Thu, 9 May 2024 12:36:53 +1000 Subject: [PATCH 02/36] Fix 404 error when creating users/groups (#4066) --- src/Api/AdminConsole/Controllers/GroupsController.cs | 4 +++- .../AdminConsole/Controllers/OrganizationUsersController.cs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Api/AdminConsole/Controllers/GroupsController.cs b/src/Api/AdminConsole/Controllers/GroupsController.cs index 9e5b8ec264..cdbbcf6200 100644 --- a/src/Api/AdminConsole/Controllers/GroupsController.cs +++ b/src/Api/AdminConsole/Controllers/GroupsController.cs @@ -137,7 +137,9 @@ public class GroupsController : Controller } // Flexible Collections - check the user has permission to grant access to the collections for the new group - if (await FlexibleCollectionsIsEnabledAsync(orgId) && _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1)) + if (await FlexibleCollectionsIsEnabledAsync(orgId) && + _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) && + model.Collections?.Any() == true) { var collections = await _collectionRepository.GetManyByManyIdsAsync(model.Collections.Select(a => a.Id)); var authorized = diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 78127cbfd2..544dbb87a7 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -195,7 +195,9 @@ public class OrganizationUsersController : Controller } // Flexible Collections - check the user has permission to grant access to the collections for the new user - if (await FlexibleCollectionsIsEnabledAsync(orgId) && _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1)) + if (await FlexibleCollectionsIsEnabledAsync(orgId) && + _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) && + model.Collections?.Any() == true) { var collections = await _collectionRepository.GetManyByManyIdsAsync(model.Collections.Select(a => a.Id)); var authorized = From fa7b00a728a9dbc35d5abc21e73d6dc747214f48 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Thu, 9 May 2024 09:09:23 -0400 Subject: [PATCH 03/36] Send reference event on payment success for provider (#4063) --- src/Billing/Controllers/StripeController.cs | 64 +++++++++++++++++++- src/Core/Tools/Enums/ReferenceEventSource.cs | 2 + 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index bc6434e6b7..ee27876244 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -3,6 +3,8 @@ using Bit.Billing.Models; using Bit.Billing.Services; using Bit.Core; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; using Bit.Core.Context; using Bit.Core.Enums; @@ -23,6 +25,7 @@ using Stripe; using Customer = Stripe.Customer; using Event = Stripe.Event; using JsonSerializer = System.Text.Json.JsonSerializer; +using Plan = Stripe.Plan; using Subscription = Stripe.Subscription; using TaxRate = Bit.Core.Entities.TaxRate; using Transaction = Bit.Core.Entities.Transaction; @@ -55,6 +58,7 @@ public class StripeController : Controller private readonly IStripeEventService _stripeEventService; private readonly IStripeFacade _stripeFacade; private readonly IFeatureService _featureService; + private readonly IProviderRepository _providerRepository; public StripeController( GlobalSettings globalSettings, @@ -74,7 +78,8 @@ public class StripeController : Controller ICurrentContext currentContext, IStripeEventService stripeEventService, IStripeFacade stripeFacade, - IFeatureService featureService) + IFeatureService featureService, + IProviderRepository providerRepository) { _billingSettings = billingSettings?.Value; _hostingEnvironment = hostingEnvironment; @@ -102,6 +107,7 @@ public class StripeController : Controller _stripeEventService = stripeEventService; _stripeFacade = stripeFacade; _featureService = featureService; + _providerRepository = providerRepository; } [HttpPost("webhook")] @@ -425,7 +431,61 @@ public class StripeController : Controller } var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription.Metadata); - if (organizationId.HasValue) + + if (providerId.HasValue) + { + var provider = await _providerRepository.GetByIdAsync(providerId.Value); + + if (provider == null) + { + _logger.LogError( + "Received invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) that does not exist", + parsedEvent.Id, + providerId.Value); + + return; + } + + var teamsMonthly = StaticStore.GetPlan(PlanType.TeamsMonthly); + + var enterpriseMonthly = StaticStore.GetPlan(PlanType.EnterpriseMonthly); + + var teamsMonthlyLineItem = + subscription.Items.Data.FirstOrDefault(item => + item.Plan.Id == teamsMonthly.PasswordManager.StripeSeatPlanId); + + var enterpriseMonthlyLineItem = + subscription.Items.Data.FirstOrDefault(item => + item.Plan.Id == enterpriseMonthly.PasswordManager.StripeSeatPlanId); + + if (teamsMonthlyLineItem == null || enterpriseMonthlyLineItem == null) + { + _logger.LogError("invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) indicates missing subscription line items", + parsedEvent.Id, + provider.Id); + + return; + } + + await _referenceEventService.RaiseEventAsync(new ReferenceEvent + { + Type = ReferenceEventType.Rebilled, + Source = ReferenceEventSource.Provider, + Id = provider.Id, + PlanType = PlanType.TeamsMonthly, + Seats = (int)teamsMonthlyLineItem.Quantity + }); + + await _referenceEventService.RaiseEventAsync(new ReferenceEvent + { + Type = ReferenceEventType.Rebilled, + Source = ReferenceEventSource.Provider, + Id = provider.Id, + PlanType = PlanType.EnterpriseMonthly, + Seats = (int)enterpriseMonthlyLineItem.Quantity + }); + } + else if (organizationId.HasValue) { if (!subscription.Items.Any(i => StaticStore.Plans.Any(p => p.PasswordManager.StripePlanId == i.Plan.Id))) diff --git a/src/Core/Tools/Enums/ReferenceEventSource.cs b/src/Core/Tools/Enums/ReferenceEventSource.cs index 6f2bd72732..2c60a5a157 100644 --- a/src/Core/Tools/Enums/ReferenceEventSource.cs +++ b/src/Core/Tools/Enums/ReferenceEventSource.cs @@ -8,4 +8,6 @@ public enum ReferenceEventSource Organization, [EnumMember(Value = "user")] User, + [EnumMember(Value = "provider")] + Provider, } From ac4ccafe194c3c7a1ea77a21f84b604af0f136ad Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Thu, 9 May 2024 09:20:02 -0400 Subject: [PATCH 04/36] [AC-2471] Prevent calls to Stripe when unlinking client org has no Stripe objects (#3999) * Prevent calls to Stripe when unlinking client org has no Stripe objects * Thomas' feedback * Check for stripe when org unlinked from org page --------- Co-authored-by: Conner Turnbull --- .../RemoveOrganizationFromProviderCommand.cs | 45 +++++++++++++---- ...oveOrganizationFromProviderCommandTests.cs | 50 +++++++++++++++++++ .../Controllers/OrganizationsController.cs | 5 +- .../ProviderOrganizationsController.cs | 6 ++- .../ProviderOrganizationsController.cs | 6 ++- .../Billing/Extensions/BillingExtensions.cs | 4 ++ 6 files changed, 103 insertions(+), 13 deletions(-) diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs index 9207c64ac4..3fb0b05988 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs @@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Commands; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Extensions; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; @@ -76,6 +77,35 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv organization.BillingEmail = organizationOwnerEmails.MinBy(email => email); + await ResetOrganizationBillingAsync(organization, provider, organizationOwnerEmails); + + await _organizationRepository.ReplaceAsync(organization); + + await _providerOrganizationRepository.DeleteAsync(providerOrganization); + + await _eventService.LogProviderOrganizationEventAsync( + providerOrganization, + EventType.ProviderOrganization_Removed); + } + + /// + /// When a client organization is unlinked from a provider, we have to check if they're Stripe-enabled + /// and, if they are, we remove their MSP discount and set their Subscription to `send_invoice`. This is because + /// the provider's payment method will be removed from their Stripe customer causing ensuing charges to fail. Lastly, + /// we email the organization owners letting them know they need to add a new payment method. + /// + private async Task ResetOrganizationBillingAsync( + Organization organization, + Provider provider, + IEnumerable organizationOwnerEmails) + { + if (!organization.IsStripeEnabled()) + { + return; + } + + var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); + var customerUpdateOptions = new CustomerUpdateOptions { Coupon = string.Empty, @@ -84,11 +114,10 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, customerUpdateOptions); - var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); - if (isConsolidatedBillingEnabled && provider.Status == ProviderStatusType.Billable) { var plan = StaticStore.GetPlan(organization.PlanType).PasswordManager; + var subscriptionCreateOptions = new SubscriptionCreateOptions { Customer = organization.GatewayCustomerId, @@ -103,8 +132,11 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations, Items = [new SubscriptionItemOptions { Price = plan.StripeSeatPlanId, Quantity = organization.Seats }] }; + var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); + organization.GatewaySubscriptionId = subscription.Id; + await _scaleSeatsCommand.ScalePasswordManagerSeats(provider, organization.PlanType, -(organization.Seats ?? 0)); } @@ -115,21 +147,14 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv CollectionMethod = "send_invoice", DaysUntilDue = 30 }; + await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, subscriptionUpdateOptions); } - await _organizationRepository.ReplaceAsync(organization); - 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/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs index e175b653d9..e5b0a4e3d3 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs @@ -14,6 +14,7 @@ using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Stripe; using Xunit; +using IMailService = Bit.Core.Services.IMailService; namespace Bit.Commercial.Core.Test.AdminConsole.ProviderFeatures; @@ -83,6 +84,55 @@ public class RemoveOrganizationFromProviderCommandTests Assert.Equal("Organization must have at least one confirmed owner.", exception.Message); } + [Theory, BitAutoData] + public async Task RemoveOrganizationFromProvider_NoStripeObjects_MakesCorrectInvocations( + Provider provider, + ProviderOrganization providerOrganization, + Organization organization, + SutProvider sutProvider) + { + organization.GatewayCustomerId = null; + organization.GatewaySubscriptionId = null; + + 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.DidNotReceiveWithAnyArgs().CustomerUpdateAsync(Arg.Any(), Arg.Any()); + + await stripeAdapter.DidNotReceiveWithAnyArgs().SubscriptionUpdateAsync(Arg.Any(), Arg.Any()); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().SendProviderUpdatePaymentMethod( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + + await sutProvider.GetDependency().Received(1) + .DeleteAsync(providerOrganization); + + await sutProvider.GetDependency().Received(1).LogProviderOrganizationEventAsync( + providerOrganization, + EventType.ProviderOrganization_Removed); + } + [Theory, BitAutoData] public async Task RemoveOrganizationFromProvider_MakesCorrectInvocations__FeatureFlagOff( Provider provider, diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index 5d89be0c42..19b4506860 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -349,7 +349,10 @@ public class OrganizationsController : Controller providerOrganization, organization); - await _removePaymentMethodCommand.RemovePaymentMethod(organization); + if (organization.IsStripeEnabled()) + { + await _removePaymentMethodCommand.RemovePaymentMethod(organization); + } return Json(null); } diff --git a/src/Admin/AdminConsole/Controllers/ProviderOrganizationsController.cs b/src/Admin/AdminConsole/Controllers/ProviderOrganizationsController.cs index a017b2b435..b143a2c42b 100644 --- a/src/Admin/AdminConsole/Controllers/ProviderOrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/ProviderOrganizationsController.cs @@ -3,6 +3,7 @@ using Bit.Admin.Utilities; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Extensions; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Utilities; @@ -69,7 +70,10 @@ public class ProviderOrganizationsController : Controller } - await _removePaymentMethodCommand.RemovePaymentMethod(organization); + if (organization.IsStripeEnabled()) + { + await _removePaymentMethodCommand.RemovePaymentMethod(organization); + } return Json(null); } diff --git a/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs b/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs index 136119848a..e2becc9b89 100644 --- a/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs @@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Extensions; using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Repositories; @@ -112,6 +113,9 @@ public class ProviderOrganizationsController : Controller providerOrganization, organization); - await _removePaymentMethodCommand.RemovePaymentMethod(organization); + if (organization.IsStripeEnabled()) + { + await _removePaymentMethodCommand.RemovePaymentMethod(organization); + } } } diff --git a/src/Core/Billing/Extensions/BillingExtensions.cs b/src/Core/Billing/Extensions/BillingExtensions.cs index 9b1bb9e92b..f0ee8989c4 100644 --- a/src/Core/Billing/Extensions/BillingExtensions.cs +++ b/src/Core/Billing/Extensions/BillingExtensions.cs @@ -22,6 +22,10 @@ public static class BillingExtensions PlanType: PlanType.TeamsMonthly or PlanType.EnterpriseMonthly }; + public static bool IsStripeEnabled(this Organization organization) + => !string.IsNullOrEmpty(organization.GatewayCustomerId) && + !string.IsNullOrEmpty(organization.GatewaySubscriptionId); + public static bool SupportsConsolidatedBilling(this PlanType planType) => planType is PlanType.TeamsMonthly or PlanType.EnterpriseMonthly; } From 479f8319c271584140a1cbe55137012fcbd8fd80 Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Thu, 9 May 2024 08:43:43 -0700 Subject: [PATCH 05/36] remove alias (#4058) --- src/Identity/Controllers/SsoController.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Identity/Controllers/SsoController.cs b/src/Identity/Controllers/SsoController.cs index e5dfc0ac60..f3dc301a61 100644 --- a/src/Identity/Controllers/SsoController.cs +++ b/src/Identity/Controllers/SsoController.cs @@ -14,8 +14,6 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Identity.Controllers; -// TODO: 2023-10-16, Remove account alias (https://bitwarden.atlassian.net/browse/PM-1247) -[Route("account/[action]")] [Route("sso/[action]")] public class SsoController : Controller { From 7f9d7c0c5d5e04248e03fd5f9f524d71679419b2 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Thu, 9 May 2024 13:24:02 -0400 Subject: [PATCH 06/36] [PM-7029] Remove conditional logic for KeyRotationImprovements feature flag (#4002) * Removed business logic that references flag * Removed using statement. * Undid accidental keystroke. * Removed unused method. * Removed unused imports. --- .../Auth/Controllers/AccountsController.cs | 63 ++----- src/Core/Services/IUserService.cs | 4 - .../Services/Implementations/UserService.cs | 35 ---- .../Vault/Repositories/ICipherRepository.cs | 2 - .../Vault/Repositories/CipherRepository.cs | 164 ------------------ .../Vault/Repositories/CipherRepository.cs | 18 -- 6 files changed, 11 insertions(+), 275 deletions(-) diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 28135c4714..da76f35400 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -43,7 +43,6 @@ using Bit.Core.Utilities; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Repositories; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; namespace Bit.Api.Auth.Controllers; @@ -438,59 +437,19 @@ public class AccountsController : Controller throw new UnauthorizedAccessException(); } - IdentityResult result; - if (_featureService.IsEnabled(FeatureFlagKeys.KeyRotationImprovements)) + var dataModel = new RotateUserKeyData { - var dataModel = new RotateUserKeyData - { - MasterPasswordHash = model.MasterPasswordHash, - Key = model.Key, - PrivateKey = model.PrivateKey, - Ciphers = await _cipherValidator.ValidateAsync(user, model.Ciphers), - Folders = await _folderValidator.ValidateAsync(user, model.Folders), - Sends = await _sendValidator.ValidateAsync(user, model.Sends), - EmergencyAccesses = await _emergencyAccessValidator.ValidateAsync(user, model.EmergencyAccessKeys), - OrganizationUsers = await _organizationUserValidator.ValidateAsync(user, model.ResetPasswordKeys) - }; - - result = await _rotateUserKeyCommand.RotateUserKeyAsync(user, dataModel); - } - else - { - var ciphers = new List(); - if (model.Ciphers.Any()) - { - var existingCiphers = await _cipherRepository.GetManyByUserIdAsync(user.Id, useFlexibleCollections: UseFlexibleCollections); - ciphers.AddRange(existingCiphers - .Join(model.Ciphers, c => c.Id, c => c.Id, (existing, c) => c.ToCipher(existing))); - } - - var folders = new List(); - if (model.Folders.Any()) - { - var existingFolders = await _folderRepository.GetManyByUserIdAsync(user.Id); - folders.AddRange(existingFolders - .Join(model.Folders, f => f.Id, f => f.Id, (existing, f) => f.ToFolder(existing))); - } - - var sends = new List(); - if (model.Sends?.Any() == true) - { - var existingSends = await _sendRepository.GetManyByUserIdAsync(user.Id); - sends.AddRange(existingSends - .Join(model.Sends, s => s.Id, s => s.Id, (existing, s) => s.ToSend(existing, _sendService))); - } - - result = await _userService.UpdateKeyAsync( - user, - model.MasterPasswordHash, - model.Key, - model.PrivateKey, - ciphers, - folders, - sends); - } + MasterPasswordHash = model.MasterPasswordHash, + Key = model.Key, + PrivateKey = model.PrivateKey, + Ciphers = await _cipherValidator.ValidateAsync(user, model.Ciphers), + Folders = await _folderValidator.ValidateAsync(user, model.Folders), + Sends = await _sendValidator.ValidateAsync(user, model.Sends), + EmergencyAccesses = await _emergencyAccessValidator.ValidateAsync(user, model.EmergencyAccessKeys), + OrganizationUsers = await _organizationUserValidator.ValidateAsync(user, model.ResetPasswordKeys) + }; + var result = await _rotateUserKeyCommand.RotateUserKeyAsync(user, dataModel); if (result.Succeeded) { diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index a8cd537e72..f4751623a0 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -4,8 +4,6 @@ using Bit.Core.Auth.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; -using Bit.Core.Tools.Entities; -using Bit.Core.Vault.Entities; using Fido2NetLib; using Microsoft.AspNetCore.Identity; @@ -39,8 +37,6 @@ public interface IUserService Task UpdateTempPasswordAsync(User user, string newMasterPassword, string key, string hint); Task ChangeKdfAsync(User user, string masterPassword, string newMasterPassword, string key, KdfType kdf, int kdfIterations, int? kdfMemory, int? kdfParallelism); - Task UpdateKeyAsync(User user, string masterPassword, string key, string privateKey, - IEnumerable ciphers, IEnumerable folders, IEnumerable sends); Task RefreshSecurityStampAsync(User user, string masterPasswordHash); Task UpdateTwoFactorProviderAsync(User user, TwoFactorProviderType type, bool setEnabled = true, bool logEvent = true); Task DisableTwoFactorProviderAsync(User user, TwoFactorProviderType type, diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 80e6aa8ca2..148c60a144 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -14,12 +14,10 @@ using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Repositories; using Bit.Core.Settings; using Bit.Core.Tokens; -using Bit.Core.Tools.Entities; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Business; using Bit.Core.Tools.Services; using Bit.Core.Utilities; -using Bit.Core.Vault.Entities; using Bit.Core.Vault.Repositories; using Fido2NetLib; using Fido2NetLib.Objects; @@ -862,39 +860,6 @@ public class UserService : UserManager, IUserService, IDisposable return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); } - public async Task UpdateKeyAsync(User user, string masterPassword, string key, string privateKey, - IEnumerable ciphers, IEnumerable folders, IEnumerable sends) - { - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - - if (await CheckPasswordAsync(user, masterPassword)) - { - var now = DateTime.UtcNow; - user.RevisionDate = user.AccountRevisionDate = now; - user.LastKeyRotationDate = now; - user.SecurityStamp = Guid.NewGuid().ToString(); - user.Key = key; - user.PrivateKey = privateKey; - if (ciphers.Any() || folders.Any() || sends.Any()) - { - await _cipherRepository.UpdateUserKeysAndCiphersAsync(user, ciphers, folders, sends); - } - else - { - await _userRepository.ReplaceAsync(user); - } - - await _pushService.PushLogOutAsync(user.Id, excludeCurrentContextFromPush: true); - return IdentityResult.Success; - } - - Logger.LogWarning("Update key failed for user {userId}.", user.Id); - return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); - } - public async Task RefreshSecurityStampAsync(User user, string secret) { if (user == null) diff --git a/src/Core/Vault/Repositories/ICipherRepository.cs b/src/Core/Vault/Repositories/ICipherRepository.cs index f801f6f6f7..80a1e260d6 100644 --- a/src/Core/Vault/Repositories/ICipherRepository.cs +++ b/src/Core/Vault/Repositories/ICipherRepository.cs @@ -1,7 +1,6 @@ using Bit.Core.Auth.UserFeatures.UserKey; using Bit.Core.Entities; using Bit.Core.Repositories; -using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Models.Data; @@ -30,7 +29,6 @@ public interface ICipherRepository : IRepository Task MoveAsync(IEnumerable ids, Guid? folderId, Guid userId, bool useFlexibleCollections); Task DeleteByUserIdAsync(Guid userId); Task DeleteByOrganizationIdAsync(Guid organizationId); - Task UpdateUserKeysAndCiphersAsync(User user, IEnumerable ciphers, IEnumerable folders, IEnumerable sends); Task UpdateCiphersAsync(Guid userId, IEnumerable ciphers); Task CreateAsync(IEnumerable ciphers, IEnumerable folders); Task CreateAsync(IEnumerable ciphers, IEnumerable collections, diff --git a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs index 5bfcac7b5e..9e767844e4 100644 --- a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs @@ -380,170 +380,6 @@ public class CipherRepository : Repository, ICipherRepository }; } - public Task UpdateUserKeysAndCiphersAsync(User user, IEnumerable ciphers, IEnumerable folders, IEnumerable sends) - { - using (var connection = new SqlConnection(ConnectionString)) - { - connection.Open(); - - using (var transaction = connection.BeginTransaction()) - { - try - { - // 1. Update user. - - using (var cmd = new SqlCommand("[dbo].[User_UpdateKeys]", connection, transaction)) - { - cmd.CommandType = CommandType.StoredProcedure; - cmd.Parameters.Add("@Id", SqlDbType.UniqueIdentifier).Value = user.Id; - cmd.Parameters.Add("@SecurityStamp", SqlDbType.NVarChar).Value = user.SecurityStamp; - cmd.Parameters.Add("@Key", SqlDbType.VarChar).Value = user.Key; - - if (string.IsNullOrWhiteSpace(user.PrivateKey)) - { - cmd.Parameters.Add("@PrivateKey", SqlDbType.VarChar).Value = DBNull.Value; - } - else - { - cmd.Parameters.Add("@PrivateKey", SqlDbType.VarChar).Value = user.PrivateKey; - } - - cmd.Parameters.Add("@RevisionDate", SqlDbType.DateTime2).Value = user.RevisionDate; - cmd.Parameters.Add("@AccountRevisionDate", SqlDbType.DateTime2).Value = user.AccountRevisionDate; - cmd.Parameters.Add("@LastKeyRotationDate", SqlDbType.DateTime2).Value = user.LastKeyRotationDate; - cmd.ExecuteNonQuery(); - } - - // 2. Create temp tables to bulk copy into. - - var sqlCreateTemp = @" - SELECT TOP 0 * - INTO #TempCipher - FROM [dbo].[Cipher] - - SELECT TOP 0 * - INTO #TempFolder - FROM [dbo].[Folder] - - SELECT TOP 0 * - INTO #TempSend - FROM [dbo].[Send]"; - - using (var cmd = new SqlCommand(sqlCreateTemp, connection, transaction)) - { - cmd.ExecuteNonQuery(); - } - - // 3. Bulk copy into temp tables. - - if (ciphers.Any()) - { - using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction)) - { - bulkCopy.DestinationTableName = "#TempCipher"; - var dataTable = BuildCiphersTable(bulkCopy, ciphers); - bulkCopy.WriteToServer(dataTable); - } - } - - if (folders.Any()) - { - using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction)) - { - bulkCopy.DestinationTableName = "#TempFolder"; - var dataTable = BuildFoldersTable(bulkCopy, folders); - bulkCopy.WriteToServer(dataTable); - } - } - - if (sends.Any()) - { - using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction)) - { - bulkCopy.DestinationTableName = "#TempSend"; - var dataTable = BuildSendsTable(bulkCopy, sends); - bulkCopy.WriteToServer(dataTable); - } - } - - // 4. Insert into real tables from temp tables and clean up. - - var sql = string.Empty; - - if (ciphers.Any()) - { - sql += @" - UPDATE - [dbo].[Cipher] - SET - [Data] = TC.[Data], - [Attachments] = TC.[Attachments], - [RevisionDate] = TC.[RevisionDate], - [Key] = TC.[Key] - FROM - [dbo].[Cipher] C - INNER JOIN - #TempCipher TC ON C.Id = TC.Id - WHERE - C.[UserId] = @UserId"; - } - - if (folders.Any()) - { - sql += @" - UPDATE - [dbo].[Folder] - SET - [Name] = TF.[Name], - [RevisionDate] = TF.[RevisionDate] - FROM - [dbo].[Folder] F - INNER JOIN - #TempFolder TF ON F.Id = TF.Id - WHERE - F.[UserId] = @UserId"; - } - - if (sends.Any()) - { - sql += @" - UPDATE - [dbo].[Send] - SET - [Key] = TS.[Key], - [RevisionDate] = TS.[RevisionDate] - FROM - [dbo].[Send] S - INNER JOIN - #TempSend TS ON S.Id = TS.Id - WHERE - S.[UserId] = @UserId"; - } - - sql += @" - DROP TABLE #TempCipher - DROP TABLE #TempFolder - DROP TABLE #TempSend"; - - using (var cmd = new SqlCommand(sql, connection, transaction)) - { - cmd.Parameters.Add("@UserId", SqlDbType.UniqueIdentifier).Value = user.Id; - cmd.ExecuteNonQuery(); - } - - transaction.Commit(); - } - catch - { - transaction.Rollback(); - throw; - } - } - } - - return Task.FromResult(0); - } - public async Task UpdateCiphersAsync(Guid userId, IEnumerable ciphers) { if (!ciphers.Any()) diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs index b199fd06ef..05f660ca4e 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs @@ -19,7 +19,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using NS = Newtonsoft.Json; using NSL = Newtonsoft.Json.Linq; -using User = Bit.Core.Entities.User; namespace Bit.Infrastructure.EntityFramework.Vault.Repositories; @@ -865,23 +864,6 @@ public class CipherRepository : Repository ciphers, IEnumerable folders, IEnumerable sends) - { - using (var scope = ServiceScopeFactory.CreateScope()) - { - var dbContext = GetDatabaseContext(scope); - await UserUpdateKeys(user); - var cipherEntities = Mapper.Map>(ciphers); - await dbContext.BulkCopyAsync(base.DefaultBulkCopyOptions, cipherEntities); - var folderEntities = Mapper.Map>(folders); - await dbContext.BulkCopyAsync(base.DefaultBulkCopyOptions, folderEntities); - var sendEntities = Mapper.Map>(sends); - await dbContext.BulkCopyAsync(base.DefaultBulkCopyOptions, sendEntities); - await dbContext.SaveChangesAsync(); - } - } - public async Task UpsertAsync(CipherDetails cipher) { if (cipher.Id.Equals(default)) From f94ddb2a9083e231f47fc2f3f30a636aa163ad44 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Mon, 13 May 2024 20:35:22 +0100 Subject: [PATCH 07/36] [BEEEP][AC-2497] Create unit test for the SubscriptionUpdate classes (#4054) * Add unit tests for the StorageSubscriptionUpdateTests.cs Signed-off-by: Cy Okeke * remove unwanted comment from the class Signed-off-by: Cy Okeke * Create a class file and add unit tests for SmSeatSubscriptionUpdateTest.cs Signed-off-by: Cy Okeke * Add unit test for the secrets manager seat update Signed-off-by: Cy Okeke * Fix the failing test cases Signed-off-by: Cy Okeke * Add unit test for service account update Signed-off-by: Cy Okeke --------- Signed-off-by: Cy Okeke --- .../Business/SeatSubscriptionUpdateTests.cs | 99 ++++++++++++++++ .../ServiceAccountSubscriptionUpdateTests.cs | 100 +++++++++++++++++ .../Business/SmSeatSubscriptionUpdateTests.cs | 101 +++++++++++++++++ .../StorageSubscriptionUpdateTests.cs | 106 ++++++++++++++++++ 4 files changed, 406 insertions(+) create mode 100644 test/Core.Test/Models/Business/SeatSubscriptionUpdateTests.cs create mode 100644 test/Core.Test/Models/Business/ServiceAccountSubscriptionUpdateTests.cs create mode 100644 test/Core.Test/Models/Business/SmSeatSubscriptionUpdateTests.cs create mode 100644 test/Core.Test/Models/Business/StorageSubscriptionUpdateTests.cs diff --git a/test/Core.Test/Models/Business/SeatSubscriptionUpdateTests.cs b/test/Core.Test/Models/Business/SeatSubscriptionUpdateTests.cs new file mode 100644 index 0000000000..5cf4b8fbf6 --- /dev/null +++ b/test/Core.Test/Models/Business/SeatSubscriptionUpdateTests.cs @@ -0,0 +1,99 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Business; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture.Attributes; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Models.Business; + +public class SeatSubscriptionUpdateTests +{ + [Theory] + [BitAutoData(PlanType.EnterpriseMonthly2019)] + [BitAutoData(PlanType.EnterpriseMonthly2020)] + [BitAutoData(PlanType.EnterpriseMonthly)] + [BitAutoData(PlanType.EnterpriseAnnually2019)] + [BitAutoData(PlanType.EnterpriseAnnually2020)] + [BitAutoData(PlanType.EnterpriseAnnually)] + [BitAutoData(PlanType.TeamsMonthly2019)] + [BitAutoData(PlanType.TeamsMonthly2020)] + [BitAutoData(PlanType.TeamsMonthly)] + [BitAutoData(PlanType.TeamsAnnually2019)] + [BitAutoData(PlanType.TeamsAnnually2020)] + [BitAutoData(PlanType.TeamsAnnually)] + [BitAutoData(PlanType.TeamsStarter)] + + public void UpgradeItemsOptions_ReturnsCorrectOptions(PlanType planType, Organization organization) + { + var plan = StaticStore.GetPlan(planType); + organization.PlanType = planType; + var subscription = new Subscription + { + Items = new StripeList + { + Data = new List + { + new () + { + Id = "subscription_item", + Price = new Price { Id = plan.PasswordManager.StripeSeatPlanId }, + Quantity = 1 + } + } + } + }; + var update = new SeatSubscriptionUpdate(organization, plan, 100); + + var options = update.UpgradeItemsOptions(subscription); + + Assert.Single(options); + Assert.Equal(plan.PasswordManager.StripeSeatPlanId, options[0].Plan); + Assert.Equal(100, options[0].Quantity); + Assert.Null(options[0].Deleted); + } + + [Theory] + [BitAutoData(PlanType.EnterpriseMonthly2019)] + [BitAutoData(PlanType.EnterpriseMonthly2020)] + [BitAutoData(PlanType.EnterpriseMonthly)] + [BitAutoData(PlanType.EnterpriseAnnually2019)] + [BitAutoData(PlanType.EnterpriseAnnually2020)] + [BitAutoData(PlanType.EnterpriseAnnually)] + [BitAutoData(PlanType.TeamsMonthly2019)] + [BitAutoData(PlanType.TeamsMonthly2020)] + [BitAutoData(PlanType.TeamsMonthly)] + [BitAutoData(PlanType.TeamsAnnually2019)] + [BitAutoData(PlanType.TeamsAnnually2020)] + [BitAutoData(PlanType.TeamsAnnually)] + public void RevertItemsOptions_ReturnsCorrectOptions(PlanType planType, Organization organization) + { + var plan = StaticStore.GetPlan(planType); + organization.PlanType = planType; + var subscription = new Subscription + { + Items = new StripeList + { + Data = new List + { + new () + { + Id = "subscription_item", + Price = new Price { Id = plan.PasswordManager.StripeSeatPlanId }, + Quantity = 100 + } + } + } + }; + var update = new SeatSubscriptionUpdate(organization, plan, 100); + update.UpgradeItemsOptions(subscription); + + var options = update.RevertItemsOptions(subscription); + + Assert.Single(options); + Assert.Equal(plan.PasswordManager.StripeSeatPlanId, options[0].Plan); + Assert.Equal(organization.Seats, options[0].Quantity); + Assert.Null(options[0].Deleted); + } +} diff --git a/test/Core.Test/Models/Business/ServiceAccountSubscriptionUpdateTests.cs b/test/Core.Test/Models/Business/ServiceAccountSubscriptionUpdateTests.cs new file mode 100644 index 0000000000..259a53bda0 --- /dev/null +++ b/test/Core.Test/Models/Business/ServiceAccountSubscriptionUpdateTests.cs @@ -0,0 +1,100 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Business; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture.Attributes; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Models.Business; + +public class ServiceAccountSubscriptionUpdateTests +{ + [Theory] + [BitAutoData(PlanType.EnterpriseMonthly2019)] + [BitAutoData(PlanType.EnterpriseMonthly2020)] + [BitAutoData(PlanType.EnterpriseMonthly)] + [BitAutoData(PlanType.EnterpriseAnnually2019)] + [BitAutoData(PlanType.EnterpriseAnnually2020)] + [BitAutoData(PlanType.EnterpriseAnnually)] + [BitAutoData(PlanType.TeamsMonthly2019)] + [BitAutoData(PlanType.TeamsMonthly2020)] + [BitAutoData(PlanType.TeamsMonthly)] + [BitAutoData(PlanType.TeamsAnnually2019)] + [BitAutoData(PlanType.TeamsAnnually2020)] + [BitAutoData(PlanType.TeamsAnnually)] + [BitAutoData(PlanType.TeamsStarter)] + + public void UpgradeItemsOptions_ReturnsCorrectOptions(PlanType planType, Organization organization) + { + var plan = StaticStore.GetPlan(planType); + organization.PlanType = planType; + var subscription = new Subscription + { + Items = new StripeList + { + Data = new List + { + new () + { + Id = "subscription_item", + Price = new Price { Id = plan.SecretsManager.StripeServiceAccountPlanId }, + Quantity = 1 + } + } + } + }; + var update = new ServiceAccountSubscriptionUpdate(organization, plan, 3); + + var options = update.UpgradeItemsOptions(subscription); + + Assert.Single(options); + Assert.Equal(plan.SecretsManager.StripeServiceAccountPlanId, options[0].Plan); + Assert.Equal(3, options[0].Quantity); + Assert.Null(options[0].Deleted); + } + + [Theory] + [BitAutoData(PlanType.EnterpriseMonthly2019)] + [BitAutoData(PlanType.EnterpriseMonthly2020)] + [BitAutoData(PlanType.EnterpriseMonthly)] + [BitAutoData(PlanType.EnterpriseAnnually2019)] + [BitAutoData(PlanType.EnterpriseAnnually2020)] + [BitAutoData(PlanType.EnterpriseAnnually)] + [BitAutoData(PlanType.TeamsMonthly2019)] + [BitAutoData(PlanType.TeamsMonthly2020)] + [BitAutoData(PlanType.TeamsMonthly)] + [BitAutoData(PlanType.TeamsAnnually2019)] + [BitAutoData(PlanType.TeamsAnnually2020)] + [BitAutoData(PlanType.TeamsAnnually)] + public void RevertItemsOptions_ReturnsCorrectOptions(PlanType planType, Organization organization) + { + var plan = StaticStore.GetPlan(planType); + organization.PlanType = planType; + var quantity = 5; + var subscription = new Subscription + { + Items = new StripeList + { + Data = new List + { + new () + { + Id = "subscription_item", + Price = new Price { Id = plan.SecretsManager.StripeServiceAccountPlanId }, + Quantity = quantity + } + } + } + }; + var update = new ServiceAccountSubscriptionUpdate(organization, plan, quantity); + update.UpgradeItemsOptions(subscription); + + var options = update.RevertItemsOptions(subscription); + + Assert.Single(options); + Assert.Equal(plan.SecretsManager.StripeServiceAccountPlanId, options[0].Plan); + Assert.Equal(quantity, options[0].Quantity); + Assert.Null(options[0].Deleted); + } +} diff --git a/test/Core.Test/Models/Business/SmSeatSubscriptionUpdateTests.cs b/test/Core.Test/Models/Business/SmSeatSubscriptionUpdateTests.cs new file mode 100644 index 0000000000..f7ce31167e --- /dev/null +++ b/test/Core.Test/Models/Business/SmSeatSubscriptionUpdateTests.cs @@ -0,0 +1,101 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Business; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture.Attributes; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Models.Business; + +public class SmSeatSubscriptionUpdateTests +{ + [Theory] + [BitAutoData(PlanType.EnterpriseMonthly2019)] + [BitAutoData(PlanType.EnterpriseMonthly2020)] + [BitAutoData(PlanType.EnterpriseMonthly)] + [BitAutoData(PlanType.EnterpriseAnnually2019)] + [BitAutoData(PlanType.EnterpriseAnnually2020)] + [BitAutoData(PlanType.EnterpriseAnnually)] + [BitAutoData(PlanType.TeamsMonthly2019)] + [BitAutoData(PlanType.TeamsMonthly2020)] + [BitAutoData(PlanType.TeamsMonthly)] + [BitAutoData(PlanType.TeamsAnnually2019)] + [BitAutoData(PlanType.TeamsAnnually2020)] + [BitAutoData(PlanType.TeamsAnnually)] + [BitAutoData(PlanType.TeamsStarter)] + + public void UpgradeItemsOptions_ReturnsCorrectOptions(PlanType planType, Organization organization) + { + var plan = StaticStore.GetPlan(planType); + organization.PlanType = planType; + var quantity = 3; + var subscription = new Subscription + { + Items = new StripeList + { + Data = new List + { + new () + { + Id = "subscription_item", + Price = new Price { Id = plan.SecretsManager.StripeSeatPlanId }, + Quantity = quantity + } + } + } + }; + var update = new SmSeatSubscriptionUpdate(organization, plan, quantity); + + var options = update.UpgradeItemsOptions(subscription); + + Assert.Single(options); + Assert.Equal(plan.SecretsManager.StripeSeatPlanId, options[0].Plan); + Assert.Equal(quantity, options[0].Quantity); + Assert.Null(options[0].Deleted); + } + + [Theory] + [BitAutoData(PlanType.EnterpriseMonthly2019)] + [BitAutoData(PlanType.EnterpriseMonthly2020)] + [BitAutoData(PlanType.EnterpriseMonthly)] + [BitAutoData(PlanType.EnterpriseAnnually2019)] + [BitAutoData(PlanType.EnterpriseAnnually2020)] + [BitAutoData(PlanType.EnterpriseAnnually)] + [BitAutoData(PlanType.TeamsMonthly2019)] + [BitAutoData(PlanType.TeamsMonthly2020)] + [BitAutoData(PlanType.TeamsMonthly)] + [BitAutoData(PlanType.TeamsAnnually2019)] + [BitAutoData(PlanType.TeamsAnnually2020)] + [BitAutoData(PlanType.TeamsAnnually)] + public void RevertItemsOptions_ReturnsCorrectOptions(PlanType planType, Organization organization) + { + var plan = StaticStore.GetPlan(planType); + organization.PlanType = planType; + var quantity = 5; + var subscription = new Subscription + { + Items = new StripeList + { + Data = new List + { + new () + { + Id = "subscription_item", + Price = new Price { Id = plan.SecretsManager.StripeSeatPlanId }, + Quantity = quantity + } + } + } + }; + var update = new SmSeatSubscriptionUpdate(organization, plan, quantity); + update.UpgradeItemsOptions(subscription); + + var options = update.RevertItemsOptions(subscription); + + Assert.Single(options); + Assert.Equal(plan.SecretsManager.StripeSeatPlanId, options[0].Plan); + Assert.Equal(organization.SmSeats, options[0].Quantity); + Assert.Null(options[0].Deleted); + } +} diff --git a/test/Core.Test/Models/Business/StorageSubscriptionUpdateTests.cs b/test/Core.Test/Models/Business/StorageSubscriptionUpdateTests.cs new file mode 100644 index 0000000000..aae4e64bc7 --- /dev/null +++ b/test/Core.Test/Models/Business/StorageSubscriptionUpdateTests.cs @@ -0,0 +1,106 @@ +using Bit.Core.Enums; +using Bit.Core.Models.Business; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture.Attributes; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Models.Business; + +public class StorageSubscriptionUpdateTests +{ + [Theory] + [BitAutoData(PlanType.EnterpriseMonthly2019)] + [BitAutoData(PlanType.EnterpriseMonthly2020)] + [BitAutoData(PlanType.EnterpriseMonthly)] + [BitAutoData(PlanType.EnterpriseAnnually2019)] + [BitAutoData(PlanType.EnterpriseAnnually2020)] + [BitAutoData(PlanType.EnterpriseAnnually)] + [BitAutoData(PlanType.TeamsMonthly2019)] + [BitAutoData(PlanType.TeamsMonthly2020)] + [BitAutoData(PlanType.TeamsMonthly)] + [BitAutoData(PlanType.TeamsAnnually2019)] + [BitAutoData(PlanType.TeamsAnnually2020)] + [BitAutoData(PlanType.TeamsAnnually)] + [BitAutoData(PlanType.TeamsStarter)] + + public void UpgradeItemsOptions_ReturnsCorrectOptions(PlanType planType) + { + var plan = StaticStore.GetPlan(planType); + var subscription = new Subscription + { + Items = new StripeList + { + Data = new List + { + new () + { + Id = "subscription_item", + Price = new Price { Id = plan.PasswordManager.StripeStoragePlanId }, + Quantity = 1 + } + } + } + }; + var update = new StorageSubscriptionUpdate("plan_id", 100); + + var options = update.UpgradeItemsOptions(subscription); + + Assert.Single(options); + Assert.Equal("plan_id", options[0].Plan); + Assert.Equal(100, options[0].Quantity); + Assert.Null(options[0].Deleted); + } + + [Fact] + public void RevertItemsOptions_ThrowsExceptionIfPrevStorageIsNull() + { + var subscription = new Subscription(); + var update = new StorageSubscriptionUpdate("plan_id", 100); + + Assert.Throws(() => update.RevertItemsOptions(subscription)); + } + + [Theory] + [BitAutoData(PlanType.EnterpriseMonthly2019)] + [BitAutoData(PlanType.EnterpriseMonthly2020)] + [BitAutoData(PlanType.EnterpriseMonthly)] + [BitAutoData(PlanType.EnterpriseAnnually2019)] + [BitAutoData(PlanType.EnterpriseAnnually2020)] + [BitAutoData(PlanType.EnterpriseAnnually)] + [BitAutoData(PlanType.TeamsMonthly2019)] + [BitAutoData(PlanType.TeamsMonthly2020)] + [BitAutoData(PlanType.TeamsMonthly)] + [BitAutoData(PlanType.TeamsAnnually2019)] + [BitAutoData(PlanType.TeamsAnnually2020)] + [BitAutoData(PlanType.TeamsAnnually)] + [BitAutoData(PlanType.TeamsStarter)] + public void RevertItemsOptions_ReturnsCorrectOptions(PlanType planType) + { + var plan = StaticStore.GetPlan(planType); + var subscription = new Subscription + { + Items = new StripeList + { + Data = new List + { + new () + { + Id = "subscription_item", + Price = new Price { Id = plan.PasswordManager.StripeStoragePlanId }, + Quantity = 100 + } + } + } + }; + var update = new StorageSubscriptionUpdate(plan.PasswordManager.StripeStoragePlanId, 100); + update.UpgradeItemsOptions(subscription); + + var options = update.RevertItemsOptions(subscription); + + Assert.Single(options); + Assert.Equal(plan.PasswordManager.StripeStoragePlanId, options[0].Plan); + Assert.Equal(100, options[0].Quantity); + Assert.Null(options[0].Deleted); + } +} From 989908151dce15198376a1654942dc8266cb8cd6 Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Tue, 14 May 2024 03:59:04 -0500 Subject: [PATCH 08/36] Remove unneeded using (#4084) --- src/Billing/Controllers/StripeController.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index ee27876244..278d7c5f84 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -3,7 +3,6 @@ using Bit.Billing.Models; using Bit.Billing.Services; using Bit.Core; using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; using Bit.Core.Context; @@ -25,7 +24,6 @@ using Stripe; using Customer = Stripe.Customer; using Event = Stripe.Event; using JsonSerializer = System.Text.Json.JsonSerializer; -using Plan = Stripe.Plan; using Subscription = Stripe.Subscription; using TaxRate = Bit.Core.Entities.TaxRate; using Transaction = Bit.Core.Entities.Transaction; From 9b9318caac66aca26e3c1ec9d2a462fe3429b7b6 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 14 May 2024 09:16:24 -0400 Subject: [PATCH 09/36] [AC-2313] Add Gateway fields to Provider edit in Admin (#4057) * Formatting * Add Gateway fields to provider edit * Remove unnecessary usings * Thomas' feedback * Removing unnecessary using for linter * Removing unused file * Removing unused file --- .../Providers/CreateProviderCommand.cs | 9 +- .../Controllers/ProvidersController.cs | 39 ++-- .../Models/CreateProviderModel.cs | 16 +- .../AdminConsole/Models/ProviderEditModel.cs | 61 +++-- .../Views/Providers/Create.cshtml | 8 +- .../AdminConsole/Views/Providers/Edit.cshtml | 209 +++++++++++------- 6 files changed, 196 insertions(+), 146 deletions(-) diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs index 1f96202d86..16d62d69c3 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs @@ -46,6 +46,13 @@ public class CreateProviderCommand : ICreateProviderCommand throw new BadRequestException("Invalid owner. Owner must be an existing Bitwarden user."); } + var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); + + if (isConsolidatedBillingEnabled) + { + provider.Gateway = GatewayType.Stripe; + } + await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Pending); var providerUser = new ProviderUser @@ -56,8 +63,6 @@ public class CreateProviderCommand : ICreateProviderCommand Status = ProviderUserStatusType.Confirmed, }; - var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); - if (isConsolidatedBillingEnabled) { var providerPlans = new List diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index 0d3f7f996a..d58d132bbc 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -17,7 +17,6 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -36,7 +35,6 @@ public class ProvidersController : Controller private readonly GlobalSettings _globalSettings; private readonly IApplicationCacheService _applicationCacheService; private readonly IProviderService _providerService; - private readonly IReferenceEventService _referenceEventService; private readonly IUserService _userService; private readonly ICreateProviderCommand _createProviderCommand; private readonly IFeatureService _featureService; @@ -51,7 +49,6 @@ public class ProvidersController : Controller IProviderService providerService, GlobalSettings globalSettings, IApplicationCacheService applicationCacheService, - IReferenceEventService referenceEventService, IUserService userService, ICreateProviderCommand createProviderCommand, IFeatureService featureService, @@ -65,7 +62,6 @@ public class ProvidersController : Controller _providerService = providerService; _globalSettings = globalSettings; _applicationCacheService = applicationCacheService; - _referenceEventService = referenceEventService; _userService = userService; _createProviderCommand = createProviderCommand; _featureService = featureService; @@ -104,8 +100,8 @@ public class ProvidersController : Controller return View(new CreateProviderModel { OwnerEmail = ownerEmail, - TeamsMinimumSeats = teamsMinimumSeats, - EnterpriseMinimumSeats = enterpriseMinimumSeats + TeamsMonthlySeatMinimum = teamsMinimumSeats, + EnterpriseMonthlySeatMinimum = enterpriseMinimumSeats }); } @@ -123,8 +119,11 @@ public class ProvidersController : Controller switch (provider.Type) { case ProviderType.Msp: - await _createProviderCommand.CreateMspAsync(provider, model.OwnerEmail, model.TeamsMinimumSeats, - model.EnterpriseMinimumSeats); + await _createProviderCommand.CreateMspAsync( + provider, + model.OwnerEmail, + model.TeamsMonthlySeatMinimum, + model.EnterpriseMonthlySeatMinimum); break; case ProviderType.Reseller: await _createProviderCommand.CreateResellerAsync(provider); @@ -167,8 +166,9 @@ public class ProvidersController : Controller return View(new ProviderEditModel(provider, users, providerOrganizations, new List())); } - var providerPlan = await _providerPlanRepository.GetByProviderId(id); - return View(new ProviderEditModel(provider, users, providerOrganizations, providerPlan)); + var providerPlans = await _providerPlanRepository.GetByProviderId(id); + + return View(new ProviderEditModel(provider, users, providerOrganizations, providerPlans.ToList())); } [HttpPost] @@ -177,14 +177,15 @@ public class ProvidersController : Controller [RequirePermission(Permission.Provider_Edit)] public async Task Edit(Guid id, ProviderEditModel model) { - var providerPlans = await _providerPlanRepository.GetByProviderId(id); var provider = await _providerRepository.GetByIdAsync(id); + if (provider == null) { return RedirectToAction("Index"); } model.ToProvider(provider); + await _providerRepository.ReplaceAsync(provider); await _applicationCacheService.UpsertProviderAbilityAsync(provider); @@ -195,13 +196,14 @@ public class ProvidersController : Controller return RedirectToAction("Edit", new { id }); } - model.ToProviderPlan(providerPlans); + var providerPlans = await _providerPlanRepository.GetByProviderId(id); + if (providerPlans.Count == 0) { var newProviderPlans = new List { - new() {ProviderId = provider.Id, PlanType = PlanType.TeamsMonthly, SeatMinimum= model.TeamsMinimumSeats, PurchasedSeats = 0, AllocatedSeats = 0}, - new() {ProviderId = provider.Id, PlanType = PlanType.EnterpriseMonthly, SeatMinimum= model.EnterpriseMinimumSeats, PurchasedSeats = 0, AllocatedSeats = 0} + new () { ProviderId = provider.Id, PlanType = PlanType.TeamsMonthly, SeatMinimum = model.TeamsMonthlySeatMinimum, PurchasedSeats = 0, AllocatedSeats = 0 }, + new () { ProviderId = provider.Id, PlanType = PlanType.EnterpriseMonthly, SeatMinimum = model.EnterpriseMonthlySeatMinimum, PurchasedSeats = 0, AllocatedSeats = 0 } }; foreach (var newProviderPlan in newProviderPlans) @@ -213,6 +215,15 @@ public class ProvidersController : Controller { foreach (var providerPlan in providerPlans) { + if (providerPlan.PlanType == PlanType.EnterpriseMonthly) + { + providerPlan.SeatMinimum = model.EnterpriseMonthlySeatMinimum; + } + else if (providerPlan.PlanType == PlanType.TeamsMonthly) + { + providerPlan.SeatMinimum = model.TeamsMonthlySeatMinimum; + } + await _providerPlanRepository.ReplaceAsync(providerPlan); } } diff --git a/src/Admin/AdminConsole/Models/CreateProviderModel.cs b/src/Admin/AdminConsole/Models/CreateProviderModel.cs index 2efbbb54f6..07bb1b6e4c 100644 --- a/src/Admin/AdminConsole/Models/CreateProviderModel.cs +++ b/src/Admin/AdminConsole/Models/CreateProviderModel.cs @@ -24,11 +24,11 @@ public class CreateProviderModel : IValidatableObject [Display(Name = "Primary Billing Email")] public string BillingEmail { get; set; } - [Display(Name = "Teams minimum seats")] - public int TeamsMinimumSeats { get; set; } + [Display(Name = "Teams (Monthly) Seat Minimum")] + public int TeamsMonthlySeatMinimum { get; set; } - [Display(Name = "Enterprise minimum seats")] - public int EnterpriseMinimumSeats { get; set; } + [Display(Name = "Enterprise (Monthly) Seat Minimum")] + public int EnterpriseMonthlySeatMinimum { get; set; } public virtual Provider ToProvider() { @@ -51,14 +51,14 @@ public class CreateProviderModel : IValidatableObject var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute()?.GetName() ?? nameof(OwnerEmail); yield return new ValidationResult($"The {ownerEmailDisplayName} field is required."); } - if (TeamsMinimumSeats < 0) + if (TeamsMonthlySeatMinimum < 0) { - var teamsMinimumSeatsDisplayName = nameof(TeamsMinimumSeats).GetDisplayAttribute()?.GetName() ?? nameof(TeamsMinimumSeats); + var teamsMinimumSeatsDisplayName = nameof(TeamsMonthlySeatMinimum).GetDisplayAttribute()?.GetName() ?? nameof(TeamsMonthlySeatMinimum); yield return new ValidationResult($"The {teamsMinimumSeatsDisplayName} field can not be negative."); } - if (EnterpriseMinimumSeats < 0) + if (EnterpriseMonthlySeatMinimum < 0) { - var enterpriseMinimumSeatsDisplayName = nameof(EnterpriseMinimumSeats).GetDisplayAttribute()?.GetName() ?? nameof(EnterpriseMinimumSeats); + var enterpriseMinimumSeatsDisplayName = nameof(EnterpriseMonthlySeatMinimum).GetDisplayAttribute()?.GetName() ?? nameof(EnterpriseMonthlySeatMinimum); yield return new ValidationResult($"The {enterpriseMinimumSeatsDisplayName} field can not be negative."); } break; diff --git a/src/Admin/AdminConsole/Models/ProviderEditModel.cs b/src/Admin/AdminConsole/Models/ProviderEditModel.cs index 1055d0cba4..078731aaa7 100644 --- a/src/Admin/AdminConsole/Models/ProviderEditModel.cs +++ b/src/Admin/AdminConsole/Models/ProviderEditModel.cs @@ -10,16 +10,21 @@ public class ProviderEditModel : ProviderViewModel { public ProviderEditModel() { } - public ProviderEditModel(Provider provider, IEnumerable providerUsers, - IEnumerable organizations, IEnumerable providerPlans) - : base(provider, providerUsers, organizations) + public ProviderEditModel( + Provider provider, + IEnumerable providerUsers, + IEnumerable organizations, + IReadOnlyCollection providerPlans) : base(provider, providerUsers, organizations) { Name = provider.DisplayName(); BusinessName = provider.DisplayBusinessName(); BillingEmail = provider.BillingEmail; BillingPhone = provider.BillingPhone; - TeamsMinimumSeats = GetMinimumSeats(providerPlans, PlanType.TeamsMonthly); - EnterpriseMinimumSeats = GetMinimumSeats(providerPlans, PlanType.EnterpriseMonthly); + TeamsMonthlySeatMinimum = GetSeatMinimum(providerPlans, PlanType.TeamsMonthly); + EnterpriseMonthlySeatMinimum = GetSeatMinimum(providerPlans, PlanType.EnterpriseMonthly); + Gateway = provider.Gateway; + GatewayCustomerId = provider.GatewayCustomerId; + GatewaySubscriptionId = provider.GatewaySubscriptionId; } [Display(Name = "Billing Email")] @@ -29,38 +34,28 @@ public class ProviderEditModel : ProviderViewModel [Display(Name = "Business Name")] public string BusinessName { get; set; } public string Name { get; set; } - [Display(Name = "Teams minimum seats")] - public int TeamsMinimumSeats { get; set; } + [Display(Name = "Teams (Monthly) Seat Minimum")] + public int TeamsMonthlySeatMinimum { get; set; } - [Display(Name = "Enterprise minimum seats")] - public int EnterpriseMinimumSeats { get; set; } - [Display(Name = "Events")] + [Display(Name = "Enterprise (Monthly) Seat Minimum")] + public int EnterpriseMonthlySeatMinimum { get; set; } + [Display(Name = "Gateway")] + public GatewayType? Gateway { get; set; } + [Display(Name = "Gateway Customer Id")] + public string GatewayCustomerId { get; set; } + [Display(Name = "Gateway Subscription Id")] + public string GatewaySubscriptionId { get; set; } - public IEnumerable ToProviderPlan(IEnumerable existingProviderPlans) + public virtual Provider ToProvider(Provider existingProvider) { - var providerPlans = existingProviderPlans.ToList(); - foreach (var existingProviderPlan in providerPlans) - { - existingProviderPlan.SeatMinimum = existingProviderPlan.PlanType switch - { - PlanType.TeamsMonthly => TeamsMinimumSeats, - PlanType.EnterpriseMonthly => EnterpriseMinimumSeats, - _ => existingProviderPlan.SeatMinimum - }; - } - return providerPlans; - } - - public Provider ToProvider(Provider existingProvider) - { - existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant()?.Trim(); - existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant()?.Trim(); + existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant().Trim(); + existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant().Trim(); + existingProvider.Gateway = Gateway; + existingProvider.GatewayCustomerId = GatewayCustomerId; + existingProvider.GatewaySubscriptionId = GatewaySubscriptionId; return existingProvider; } - - private int GetMinimumSeats(IEnumerable providerPlans, PlanType planType) - { - return (from providerPlan in providerPlans where providerPlan.PlanType == planType select (int)providerPlan.SeatMinimum).FirstOrDefault(); - } + private static int GetSeatMinimum(IEnumerable providerPlans, PlanType planType) + => providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == planType)?.SeatMinimum ?? 0; } diff --git a/src/Admin/AdminConsole/Views/Providers/Create.cshtml b/src/Admin/AdminConsole/Views/Providers/Create.cshtml index 7b10de3724..41855895e1 100644 --- a/src/Admin/AdminConsole/Views/Providers/Create.cshtml +++ b/src/Admin/AdminConsole/Views/Providers/Create.cshtml @@ -46,14 +46,14 @@
- - + +
- - + +
diff --git a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml index b9348d3474..1d58a16a29 100644 --- a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml +++ b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml @@ -48,103 +48,142 @@
- - + +
- - + + +
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+ +
+ +
+ +
+
+
+
+
+
+ +
+ +
+ +
+
} @await Html.PartialAsync("Organizations", Model) - @if (canEdit) - { - - - - +@if (canEdit) +{ + + + + - + -
- - @if (FeatureService.IsEnabled(FeatureFlagKeys.EnableDeleteProvider)) - { -
- - +
+ + @if (FeatureService.IsEnabled(FeatureFlagKeys.EnableDeleteProvider)) + { +
+ + - - + + - + -
- } -
- - } +
+ } +
+} From b960d25c9710962638d7e35e5313d553bd20cd48 Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Tue, 14 May 2024 09:45:50 -0400 Subject: [PATCH 10/36] added feature flag constant for vault bullk management action (#4075) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index fc3faa9d06..6e0bc556de 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -140,6 +140,7 @@ public static class FeatureFlagKeys public const string AnhFcmv1Migration = "anh-fcmv1-migration"; public const string ExtensionRefresh = "extension-refresh"; public const string RestrictProviderAccess = "restrict-provider-access"; + public const string VaultBulkManagementAction = "vault-bulk-management-action"; public static List GetAllKeys() { From e93894a6fd3669301dfab99b62ea099ca159dc1a Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Tue, 14 May 2024 11:00:32 -0400 Subject: [PATCH 11/36] Removed unused feature flags (#4083) * Removed unused feature flags * Removed 2 more flags. --- src/Core/Constants.cs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 6e0bc556de..a95d0290c0 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -102,11 +102,6 @@ public static class AuthenticationSchemes public static class FeatureFlagKeys { - public const string DisplayEuEnvironment = "display-eu-environment"; - public const string DisplayLowKdfIterationWarning = "display-kdf-iteration-warning"; - public const string PasswordlessLogin = "passwordless-login"; - public const string TrustedDeviceEncryption = "trusted-device-encryption"; - public const string Fido2VaultCredentials = "fido2-vault-credentials"; public const string VaultOnboarding = "vault-onboarding"; public const string BrowserFilelessImport = "browser-fileless-import"; public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; @@ -155,8 +150,6 @@ public static class FeatureFlagKeys // place overriding values when needed locally (offline), or return null return new Dictionary() { - { TrustedDeviceEncryption, "true" }, - { Fido2VaultCredentials, "true" }, { DuoRedirect, "true" }, { UnassignedItemsBanner, "true"}, { FlexibleCollectionsSignup, "true" } From fd173e81b6fb720494984d642f845036f49f835f Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 14 May 2024 11:26:08 -0400 Subject: [PATCH 12/36] [AC-2426] Allow editing of client organization name (#4072) * Allow editing of client organization name * Removing unnecessary using for linter --- .../Controllers/ProviderClientsController.cs | 15 ++++-- .../UpdateClientOrganizationRequestBody.cs | 3 ++ .../ProviderClientsControllerTests.cs | 46 ++++++++++++++++++- 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/Api/Billing/Controllers/ProviderClientsController.cs b/src/Api/Billing/Controllers/ProviderClientsController.cs index 6f7bd809fd..a47ab568bc 100644 --- a/src/Api/Billing/Controllers/ProviderClientsController.cs +++ b/src/Api/Billing/Controllers/ProviderClientsController.cs @@ -133,10 +133,17 @@ public class ProviderClientsController( return TypedResults.Problem(); } - await assignSeatsToClientOrganizationCommand.AssignSeatsToClientOrganization( - provider, - clientOrganization, - requestBody.AssignedSeats); + if (clientOrganization.Seats != requestBody.AssignedSeats) + { + await assignSeatsToClientOrganizationCommand.AssignSeatsToClientOrganization( + provider, + clientOrganization, + requestBody.AssignedSeats); + } + + clientOrganization.Name = requestBody.Name; + + await organizationRepository.ReplaceAsync(clientOrganization); return TypedResults.Ok(); } diff --git a/src/Api/Billing/Models/Requests/UpdateClientOrganizationRequestBody.cs b/src/Api/Billing/Models/Requests/UpdateClientOrganizationRequestBody.cs index c6e04aa798..6ed1083b42 100644 --- a/src/Api/Billing/Models/Requests/UpdateClientOrganizationRequestBody.cs +++ b/src/Api/Billing/Models/Requests/UpdateClientOrganizationRequestBody.cs @@ -7,4 +7,7 @@ public class UpdateClientOrganizationRequestBody [Required] [Range(0, int.MaxValue, ErrorMessage = "You cannot assign negative seats to a client organization.")] public int AssignedSeats { get; set; } + + [Required] + public string Name { get; set; } } diff --git a/test/Api.Test/Billing/Controllers/ProviderClientsControllerTests.cs b/test/Api.Test/Billing/Controllers/ProviderClientsControllerTests.cs index b6acb73e48..e0c9a27a62 100644 --- a/test/Api.Test/Billing/Controllers/ProviderClientsControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/ProviderClientsControllerTests.cs @@ -301,7 +301,7 @@ public class ProviderClientsControllerTests } [Theory, BitAutoData] - public async Task UpdateAsync_NoContent( + public async Task UpdateAsync_AssignedSeats_NoContent( Guid providerId, Guid providerOrganizationId, UpdateClientOrganizationRequestBody requestBody, @@ -333,6 +333,50 @@ public class ProviderClientsControllerTests organization, requestBody.AssignedSeats); + await sutProvider.GetDependency().Received(1) + .ReplaceAsync(Arg.Is(org => org.Name == requestBody.Name)); + + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_Name_NoContent( + Guid providerId, + Guid providerOrganizationId, + UpdateClientOrganizationRequestBody requestBody, + Provider provider, + ProviderOrganization providerOrganization, + Organization organization, + SutProvider sutProvider) + { + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) + .Returns(true); + + sutProvider.GetDependency().ProviderProviderAdmin(providerId) + .Returns(true); + + sutProvider.GetDependency().GetByIdAsync(providerId) + .Returns(provider); + + sutProvider.GetDependency().GetByIdAsync(providerOrganizationId) + .Returns(providerOrganization); + + sutProvider.GetDependency().GetByIdAsync(providerOrganization.OrganizationId) + .Returns(organization); + + requestBody.AssignedSeats = organization.Seats!.Value; + + var result = await sutProvider.Sut.UpdateAsync(providerId, providerOrganizationId, requestBody); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .AssignSeatsToClientOrganization( + Arg.Any(), + Arg.Any(), + Arg.Any()); + + await sutProvider.GetDependency().Received(1) + .ReplaceAsync(Arg.Is(org => org.Name == requestBody.Name)); + Assert.IsType(result); } #endregion From e619508f3f8178e5a5a6d5a22c9ebb61ea8095e6 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Thu, 16 May 2024 00:17:15 +1000 Subject: [PATCH 13/36] [AC-2602] Fix error when provider edits existing group (#4086) * Add null check to groups endpoint - providers may not be OrgUsers --- .../Controllers/GroupsController.cs | 3 +- .../Controllers/GroupsControllerTests.cs | 48 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/Api/AdminConsole/Controllers/GroupsController.cs b/src/Api/AdminConsole/Controllers/GroupsController.cs index cdbbcf6200..e0e057ff80 100644 --- a/src/Api/AdminConsole/Controllers/GroupsController.cs +++ b/src/Api/AdminConsole/Controllers/GroupsController.cs @@ -200,7 +200,8 @@ public class GroupsController : Controller var userId = _userService.GetProperUserId(User).Value; var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(orgId, userId); var currentGroupUsers = await _groupRepository.GetManyUserIdsByIdAsync(id); - if (!currentGroupUsers.Contains(organizationUser.Id) && model.Users.Contains(organizationUser.Id)) + // OrganizationUser may be null if the current user is a provider + if (organizationUser != null && !currentGroupUsers.Contains(organizationUser.Id) && model.Users.Contains(organizationUser.Id)) { throw new BadRequestException("You cannot add yourself to groups."); } diff --git a/test/Api.Test/AdminConsole/Controllers/GroupsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/GroupsControllerTests.cs index 161f48f70c..526838f360 100644 --- a/test/Api.Test/AdminConsole/Controllers/GroupsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/GroupsControllerTests.cs @@ -260,6 +260,54 @@ public class GroupsControllerTests Assert.Equal(groupRequestModel.AccessAll, response.AccessAll); } + [Theory] + [BitAutoData] + public async Task Put_UpdateMembers_AdminsCannotAccessAllCollections_ProviderUser_Success(Organization organization, Group group, + GroupRequestModel groupRequestModel, List currentGroupUsers, Guid savingUserId, + SutProvider sutProvider) + { + group.OrganizationId = organization.Id; + + // Enable FC and v1 + sutProvider.GetDependency().GetOrganizationAbilityAsync(organization.Id).Returns( + new OrganizationAbility + { + Id = organization.Id, + AllowAdminAccessToAllCollectionItems = false, + FlexibleCollections = true + }); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1).Returns(true); + + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + sutProvider.GetDependency().GetByIdWithCollectionsAsync(group.Id) + .Returns(new Tuple>(group, new List())); + sutProvider.GetDependency().ManageGroups(organization.Id).Returns(true); + sutProvider.GetDependency() + .GetByOrganizationAsync(organization.Id, Arg.Any()) + .Returns((OrganizationUser)null); // Provider is not an OrganizationUser, so it will always return null + sutProvider.GetDependency().GetProperUserId(Arg.Any()) + .Returns(savingUserId); + sutProvider.GetDependency().GetManyUserIdsByIdAsync(group.Id) + .Returns(currentGroupUsers); + + // Make collection authorization pass, it's not being tested here + groupRequestModel.Collections = Array.Empty(); + + var response = await sutProvider.Sut.Put(organization.Id, group.Id, groupRequestModel); + + await sutProvider.GetDependency().Received(1).ManageGroups(organization.Id); + await sutProvider.GetDependency().Received(1).UpdateGroupAsync( + Arg.Is(g => + g.OrganizationId == organization.Id && g.Name == groupRequestModel.Name && + g.AccessAll == groupRequestModel.AccessAll), + Arg.Is(o => o.Id == organization.Id), + Arg.Any>(), + Arg.Any>()); + Assert.Equal(groupRequestModel.Name, response.Name); + Assert.Equal(organization.Id, response.OrganizationId); + Assert.Equal(groupRequestModel.AccessAll, response.AccessAll); + } + [Theory] [BitAutoData] public async Task Put_UpdateCollections_OnlyUpdatesCollectionsTheSavingUserCanUpdate(GroupRequestModel groupRequestModel, From 7d65d8dd4f9063fcb24caa8ce58ddbf4d8ec3436 Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Thu, 16 May 2024 13:16:01 -0400 Subject: [PATCH 14/36] Resolved razor syntax error by updating expression to be explicit instead of implicit (#4094) --- src/Admin/Views/Tools/StripeSubscriptions.cshtml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Admin/Views/Tools/StripeSubscriptions.cshtml b/src/Admin/Views/Tools/StripeSubscriptions.cshtml index c8900125d2..a8de5ba904 100644 --- a/src/Admin/Views/Tools/StripeSubscriptions.cshtml +++ b/src/Admin/Views/Tools/StripeSubscriptions.cshtml @@ -109,7 +109,7 @@ @foreach (var price in Model.Prices) { - + } @@ -119,7 +119,7 @@ @foreach (var clock in Model.TestClocks) { - + } @@ -278,4 +278,4 @@ - \ No newline at end of file + From 3bb8cce2e61d56b8aa0c0420f873e2b9747a77b6 Mon Sep 17 00:00:00 2001 From: Jake Fink Date: Thu, 16 May 2024 15:47:44 -0400 Subject: [PATCH 15/36] add login redirect url to identity server (#4092) --- src/Identity/Utilities/ServiceCollectionExtensions.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Identity/Utilities/ServiceCollectionExtensions.cs b/src/Identity/Utilities/ServiceCollectionExtensions.cs index 6674e0c3b4..95002d8371 100644 --- a/src/Identity/Utilities/ServiceCollectionExtensions.cs +++ b/src/Identity/Utilities/ServiceCollectionExtensions.cs @@ -39,6 +39,7 @@ public static class ServiceCollectionExtensions } options.InputLengthRestrictions.UserName = 256; options.KeyManagement.Enabled = false; + options.UserInteraction.LoginUrl = "/sso/Login"; }) .AddInMemoryCaching() .AddInMemoryApiResources(ApiResources.GetApiResources()) From 0b5c21accaf6cd72c82fd171d29f5598f9e56046 Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Fri, 17 May 2024 09:21:12 -0400 Subject: [PATCH 16/36] Hiding teams starter option (#4044) --- .../AdminConsole/Views/Shared/_OrganizationForm.cshtml | 7 ++++--- src/Core/Models/StaticStore/Plans/TeamsStarterPlan.cs | 2 ++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml index 49b8234d98..97f6219ea2 100644 --- a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml +++ b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml @@ -70,9 +70,10 @@ @{ var planTypes = Enum.GetValues() .Where(p => - Model.Provider == null || - (Model.Provider != null - && p is >= PlanType.TeamsMonthly2019 and <= PlanType.EnterpriseAnnually2019 or >= PlanType.TeamsMonthly2020 and <= PlanType.EnterpriseAnnually) + (Model.Provider == null || + p is >= PlanType.TeamsMonthly2019 and <= PlanType.EnterpriseAnnually2019 or + >= PlanType.TeamsMonthly2020 and <= PlanType.EnterpriseAnnually) && + p != PlanType.TeamsStarter ) .Select(e => new SelectListItem { diff --git a/src/Core/Models/StaticStore/Plans/TeamsStarterPlan.cs b/src/Core/Models/StaticStore/Plans/TeamsStarterPlan.cs index b1919376ea..9e308b5e57 100644 --- a/src/Core/Models/StaticStore/Plans/TeamsStarterPlan.cs +++ b/src/Core/Models/StaticStore/Plans/TeamsStarterPlan.cs @@ -28,6 +28,8 @@ public record TeamsStarterPlan : Plan PasswordManager = new TeamsStarterPasswordManagerFeatures(); SecretsManager = new TeamsStarterSecretsManagerFeatures(); + + LegacyYear = 2024; } private record TeamsStarterSecretsManagerFeatures : SecretsManagerPlanFeatures From a60180230d49cb79126e6475c05d4219baa28138 Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Fri, 17 May 2024 14:16:03 -0400 Subject: [PATCH 17/36] [AC-2513] Scaling PM seat count with SM seat count (#4040) * For SM Trial orgs, now scaling PM seat count with SM seat count adjustments * Split Billing related organization endpoints into billing owned controller * Updated billing organizations controller to use a primary constructor to reduce boilerplate * Fixed error where ID couldn't be mapped to subscription endpoint guid param * Updated billing OrganizationController endpoints to not manually create the GUID from the string ID * Banished magic string back to the pit from whence it came * Resolved errors in unit tests --- .../Controllers/OrganizationsController.cs | 367 ----------------- .../OrganizationBillingController.cs | 30 +- .../Controllers/OrganizationsController.cs | 385 ++++++++++++++++++ .../OrganizationsControllerTests.cs | 226 ---------- .../OrganizationsControllerTests.cs | 317 ++++++++++++++ 5 files changed, 731 insertions(+), 594 deletions(-) create mode 100644 src/Api/Billing/Controllers/OrganizationsController.cs create mode 100644 test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index a05fe050e8..917da4aaff 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -6,7 +6,6 @@ using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.Auth.Models.Request.Organizations; using Bit.Api.Auth.Models.Response.Organizations; -using Bit.Api.Models.Request; using Bit.Api.Models.Request.Accounts; using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Response; @@ -21,20 +20,12 @@ using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Services; using Bit.Core.Billing.Commands; using Bit.Core.Billing.Extensions; -using Bit.Core.Billing.Models; -using Bit.Core.Billing.Queries; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Business; -using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; -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; @@ -50,7 +41,6 @@ public class OrganizationsController : Controller private readonly IPolicyRepository _policyRepository; private readonly IOrganizationService _organizationService; private readonly IUserService _userService; - private readonly IPaymentService _paymentService; private readonly ICurrentContext _currentContext; private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ISsoConfigService _ssoConfigService; @@ -58,17 +48,9 @@ public class OrganizationsController : Controller private readonly IRotateOrganizationApiKeyCommand _rotateOrganizationApiKeyCommand; private readonly ICreateOrganizationApiKeyCommand _createOrganizationApiKeyCommand; private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; - private readonly ICloudGetOrganizationLicenseQuery _cloudGetOrganizationLicenseQuery; private readonly IFeatureService _featureService; private readonly GlobalSettings _globalSettings; - private readonly ILicensingService _licensingService; - private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; - private readonly IUpgradeOrganizationPlanCommand _upgradeOrganizationPlanCommand; - private readonly IAddSecretsManagerSubscriptionCommand _addSecretsManagerSubscriptionCommand; private readonly IPushNotificationService _pushNotificationService; - private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand; - private readonly ISubscriberQueries _subscriberQueries; - private readonly IReferenceEventService _referenceEventService; private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand; private readonly IProviderRepository _providerRepository; private readonly IScaleSeatsCommand _scaleSeatsCommand; @@ -79,7 +61,6 @@ public class OrganizationsController : Controller IPolicyRepository policyRepository, IOrganizationService organizationService, IUserService userService, - IPaymentService paymentService, ICurrentContext currentContext, ISsoConfigRepository ssoConfigRepository, ISsoConfigService ssoConfigService, @@ -87,17 +68,9 @@ public class OrganizationsController : Controller IRotateOrganizationApiKeyCommand rotateOrganizationApiKeyCommand, ICreateOrganizationApiKeyCommand createOrganizationApiKeyCommand, IOrganizationApiKeyRepository organizationApiKeyRepository, - ICloudGetOrganizationLicenseQuery cloudGetOrganizationLicenseQuery, IFeatureService featureService, GlobalSettings globalSettings, - ILicensingService licensingService, - IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, - IUpgradeOrganizationPlanCommand upgradeOrganizationPlanCommand, - IAddSecretsManagerSubscriptionCommand addSecretsManagerSubscriptionCommand, IPushNotificationService pushNotificationService, - ICancelSubscriptionCommand cancelSubscriptionCommand, - ISubscriberQueries subscriberQueries, - IReferenceEventService referenceEventService, IOrganizationEnableCollectionEnhancementsCommand organizationEnableCollectionEnhancementsCommand, IProviderRepository providerRepository, IScaleSeatsCommand scaleSeatsCommand) @@ -107,7 +80,6 @@ public class OrganizationsController : Controller _policyRepository = policyRepository; _organizationService = organizationService; _userService = userService; - _paymentService = paymentService; _currentContext = currentContext; _ssoConfigRepository = ssoConfigRepository; _ssoConfigService = ssoConfigService; @@ -115,17 +87,9 @@ public class OrganizationsController : Controller _rotateOrganizationApiKeyCommand = rotateOrganizationApiKeyCommand; _createOrganizationApiKeyCommand = createOrganizationApiKeyCommand; _organizationApiKeyRepository = organizationApiKeyRepository; - _cloudGetOrganizationLicenseQuery = cloudGetOrganizationLicenseQuery; _featureService = featureService; _globalSettings = globalSettings; - _licensingService = licensingService; - _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; - _upgradeOrganizationPlanCommand = upgradeOrganizationPlanCommand; - _addSecretsManagerSubscriptionCommand = addSecretsManagerSubscriptionCommand; _pushNotificationService = pushNotificationService; - _cancelSubscriptionCommand = cancelSubscriptionCommand; - _subscriberQueries = subscriberQueries; - _referenceEventService = referenceEventService; _organizationEnableCollectionEnhancementsCommand = organizationEnableCollectionEnhancementsCommand; _providerRepository = providerRepository; _scaleSeatsCommand = scaleSeatsCommand; @@ -149,83 +113,6 @@ public class OrganizationsController : Controller return new OrganizationResponseModel(organization); } - [HttpGet("{id}/billing")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task GetBilling(string id) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.ViewBillingHistory(orgIdGuid)) - { - throw new NotFoundException(); - } - - var organization = await _organizationRepository.GetByIdAsync(orgIdGuid); - if (organization == null) - { - throw new NotFoundException(); - } - - var billingInfo = await _paymentService.GetBillingAsync(organization); - return new BillingResponseModel(billingInfo); - } - - [HttpGet("{id}/subscription")] - public async Task GetSubscription(string id) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.ViewSubscription(orgIdGuid)) - { - throw new NotFoundException(); - } - - var organization = await _organizationRepository.GetByIdAsync(orgIdGuid); - if (organization == null) - { - throw new NotFoundException(); - } - - if (!_globalSettings.SelfHosted && organization.Gateway != null) - { - var subscriptionInfo = await _paymentService.GetSubscriptionAsync(organization); - if (subscriptionInfo == null) - { - throw new NotFoundException(); - } - - var hideSensitiveData = !await _currentContext.EditSubscription(orgIdGuid); - - return new OrganizationSubscriptionResponseModel(organization, subscriptionInfo, hideSensitiveData); - } - - if (_globalSettings.SelfHosted) - { - var orgLicense = await _licensingService.ReadOrganizationLicenseAsync(organization); - return new OrganizationSubscriptionResponseModel(organization, orgLicense); - } - - return new OrganizationSubscriptionResponseModel(organization); - } - - [HttpGet("{id}/license")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task GetLicense(string id, [FromQuery] Guid installationId) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.OrganizationOwner(orgIdGuid)) - { - throw new NotFoundException(); - } - - var org = await _organizationRepository.GetByIdAsync(new Guid(id)); - var license = await _cloudGetOrganizationLicenseQuery.GetLicenseAsync(org, installationId); - if (license == null) - { - throw new NotFoundException(); - } - - return license; - } - [HttpGet("")] public async Task> GetUser() { @@ -268,21 +155,6 @@ public class OrganizationsController : Controller return new OrganizationAutoEnrollStatusResponseModel(organization.Id, data?.AutoEnrollEnabled ?? false); } - [HttpGet("{id}/billing-status")] - public async Task GetBillingStatus(Guid id) - { - if (!await _currentContext.EditPaymentMethods(id)) - { - throw new NotFoundException(); - } - - var organization = await _organizationRepository.GetByIdAsync(id); - - var risksSubscriptionFailure = await _paymentService.RisksSubscriptionFailure(organization); - - return new OrganizationBillingStatusResponseModel(organization, risksSubscriptionFailure); - } - [HttpPost("")] [SelfHosted(NotSelfHostedOnly = true)] public async Task Post([FromBody] OrganizationCreateRequestModel model) @@ -326,124 +198,6 @@ public class OrganizationsController : Controller return new OrganizationResponseModel(organization); } - [HttpPost("{id}/payment")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostPayment(string id, [FromBody] PaymentRequestModel model) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.EditPaymentMethods(orgIdGuid)) - { - throw new NotFoundException(); - } - - await _organizationService.ReplacePaymentMethodAsync(orgIdGuid, model.PaymentToken, - model.PaymentMethodType.Value, new TaxInfo - { - BillingAddressLine1 = model.Line1, - BillingAddressLine2 = model.Line2, - BillingAddressState = model.State, - BillingAddressCity = model.City, - BillingAddressPostalCode = model.PostalCode, - BillingAddressCountry = model.Country, - TaxIdNumber = model.TaxId, - }); - } - - [HttpPost("{id}/upgrade")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostUpgrade(string id, [FromBody] OrganizationUpgradeRequestModel model) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.EditSubscription(orgIdGuid)) - { - throw new NotFoundException(); - } - - var (success, paymentIntentClientSecret) = await _upgradeOrganizationPlanCommand.UpgradePlanAsync(orgIdGuid, model.ToOrganizationUpgrade()); - - if (model.UseSecretsManager && success) - { - var userId = _userService.GetProperUserId(User).Value; - - await TryGrantOwnerAccessToSecretsManagerAsync(orgIdGuid, userId); - } - - return new PaymentResponseModel { Success = success, PaymentIntentClientSecret = paymentIntentClientSecret }; - } - - [HttpPost("{id}/subscription")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostSubscription(string id, [FromBody] OrganizationSubscriptionUpdateRequestModel model) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.EditSubscription(orgIdGuid)) - { - throw new NotFoundException(); - } - await _organizationService.UpdateSubscription(orgIdGuid, model.SeatAdjustment, model.MaxAutoscaleSeats); - } - - [HttpPost("{id}/sm-subscription")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostSmSubscription(Guid id, [FromBody] SecretsManagerSubscriptionUpdateRequestModel model) - { - var organization = await _organizationRepository.GetByIdAsync(id); - if (organization == null) - { - throw new NotFoundException(); - } - - if (!await _currentContext.EditSubscription(id)) - { - throw new NotFoundException(); - } - - var organizationUpdate = model.ToSecretsManagerSubscriptionUpdate(organization); - await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(organizationUpdate); - } - - [HttpPost("{id}/subscribe-secrets-manager")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostSubscribeSecretsManagerAsync(Guid id, [FromBody] SecretsManagerSubscribeRequestModel model) - { - var organization = await _organizationRepository.GetByIdAsync(id); - if (organization == null) - { - throw new NotFoundException(); - } - - if (!await _currentContext.EditSubscription(id)) - { - throw new NotFoundException(); - } - - await _addSecretsManagerSubscriptionCommand.SignUpAsync(organization, model.AdditionalSmSeats, - model.AdditionalServiceAccounts); - - var userId = _userService.GetProperUserId(User).Value; - - await TryGrantOwnerAccessToSecretsManagerAsync(organization.Id, userId); - - var organizationDetails = await _organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id, - OrganizationUserStatusType.Confirmed); - - return new ProfileOrganizationResponseModel(organizationDetails); - } - - [HttpPost("{id}/seat")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostSeat(string id, [FromBody] OrganizationSeatRequestModel model) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.EditSubscription(orgIdGuid)) - { - throw new NotFoundException(); - } - - var result = await _organizationService.AdjustSeatsAsync(orgIdGuid, model.SeatAdjustment.Value); - return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result }; - } - [HttpPost("{id}/storage")] [SelfHosted(NotSelfHostedOnly = true)] public async Task PostStorage(string id, [FromBody] StorageRequestModel model) @@ -458,67 +212,6 @@ public class OrganizationsController : Controller return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result }; } - [HttpPost("{id}/verify-bank")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostVerifyBank(string id, [FromBody] OrganizationVerifyBankRequestModel model) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.EditSubscription(orgIdGuid)) - { - throw new NotFoundException(); - } - - await _organizationService.VerifyBankAsync(orgIdGuid, model.Amount1.Value, model.Amount2.Value); - } - - [HttpPost("{id}/cancel")] - public async Task PostCancel(Guid id, [FromBody] SubscriptionCancellationRequestModel request) - { - if (!await _currentContext.EditSubscription(id)) - { - throw new NotFoundException(); - } - - var organization = await _organizationRepository.GetByIdAsync(id); - - if (organization == null) - { - throw new NotFoundException(); - } - - var subscription = await _subscriberQueries.GetSubscriptionOrThrow(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() - }); - } - - [HttpPost("{id}/reinstate")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostReinstate(string id) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.EditSubscription(orgIdGuid)) - { - throw new NotFoundException(); - } - - await _organizationService.ReinstateSubscriptionAsync(orgIdGuid); - } - [HttpPost("{id}/leave")] public async Task Leave(string id) { @@ -722,55 +415,6 @@ public class OrganizationsController : Controller }; } - [HttpGet("{id}/tax")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task GetTaxInfo(string id) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.OrganizationOwner(orgIdGuid)) - { - throw new NotFoundException(); - } - - var organization = await _organizationRepository.GetByIdAsync(orgIdGuid); - if (organization == null) - { - throw new NotFoundException(); - } - - var taxInfo = await _paymentService.GetTaxInfoAsync(organization); - return new TaxInfoResponseModel(taxInfo); - } - - [HttpPut("{id}/tax")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PutTaxInfo(string id, [FromBody] ExpandedTaxInfoUpdateRequestModel model) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.OrganizationOwner(orgIdGuid)) - { - throw new NotFoundException(); - } - - var organization = await _organizationRepository.GetByIdAsync(orgIdGuid); - if (organization == null) - { - throw new NotFoundException(); - } - - var taxInfo = new TaxInfo - { - TaxIdNumber = model.TaxId, - BillingAddressLine1 = model.Line1, - BillingAddressLine2 = model.Line2, - BillingAddressCity = model.City, - BillingAddressState = model.State, - BillingAddressPostalCode = model.PostalCode, - BillingAddressCountry = model.Country, - }; - await _paymentService.SaveTaxInfoAsync(organization, taxInfo); - } - [HttpGet("{id}/public-key")] public async Task GetPublicKey(string id) { @@ -912,15 +556,4 @@ public class OrganizationsController : Controller ou.Type is OrganizationUserType.Admin or OrganizationUserType.Owner) .Select(ou => _pushNotificationService.PushSyncOrganizationsAsync(ou.UserId.Value))); } - - private async Task TryGrantOwnerAccessToSecretsManagerAsync(Guid organizationId, Guid userId) - { - var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(organizationId, userId); - - if (organizationUser != null) - { - organizationUser.AccessSecretsManager = true; - await _organizationUserRepository.ReplaceAsync(organizationUser); - } - } } diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index d26de81113..a16c0c42fd 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -1,5 +1,11 @@ using Bit.Api.Billing.Models.Responses; +using Bit.Api.Models.Response; using Bit.Core.Billing.Queries; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -8,7 +14,10 @@ namespace Bit.Api.Billing.Controllers; [Route("organizations/{organizationId:guid}/billing")] [Authorize("Application")] public class OrganizationBillingController( - IOrganizationBillingQueries organizationBillingQueries) : Controller + IOrganizationBillingQueries organizationBillingQueries, + ICurrentContext currentContext, + IOrganizationRepository organizationRepository, + IPaymentService paymentService) : Controller { [HttpGet("metadata")] public async Task GetMetadataAsync([FromRoute] Guid organizationId) @@ -24,4 +33,23 @@ public class OrganizationBillingController( return TypedResults.Ok(response); } + + [HttpGet] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task GetBilling(Guid organizationId) + { + if (!await currentContext.ViewBillingHistory(organizationId)) + { + throw new NotFoundException(); + } + + var organization = await organizationRepository.GetByIdAsync(organizationId); + if (organization == null) + { + throw new NotFoundException(); + } + + var billingInfo = await paymentService.GetBillingAsync(organization); + return new BillingResponseModel(billingInfo); + } } diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs new file mode 100644 index 0000000000..f418e07f97 --- /dev/null +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -0,0 +1,385 @@ +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.AdminConsole.Models.Response; +using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Api.Models.Request; +using Bit.Api.Models.Request.Organizations; +using Bit.Api.Models.Response; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Models; +using Bit.Core.Billing.Queries; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; +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; + +namespace Bit.Api.Billing.Controllers; + +[Route("organizations")] +[Authorize("Application")] +public class OrganizationsController( + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationService organizationService, + IUserService userService, + IPaymentService paymentService, + ICurrentContext currentContext, + ICloudGetOrganizationLicenseQuery cloudGetOrganizationLicenseQuery, + GlobalSettings globalSettings, + ILicensingService licensingService, + IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, + IUpgradeOrganizationPlanCommand upgradeOrganizationPlanCommand, + IAddSecretsManagerSubscriptionCommand addSecretsManagerSubscriptionCommand, + ICancelSubscriptionCommand cancelSubscriptionCommand, + ISubscriberQueries subscriberQueries, + IReferenceEventService referenceEventService) + : Controller +{ + [HttpGet("{id}/billing-status")] + public async Task GetBillingStatus(Guid id) + { + if (!await currentContext.EditPaymentMethods(id)) + { + throw new NotFoundException(); + } + + var organization = await organizationRepository.GetByIdAsync(id); + + var risksSubscriptionFailure = await paymentService.RisksSubscriptionFailure(organization); + + return new OrganizationBillingStatusResponseModel(organization, risksSubscriptionFailure); + } + + [HttpGet("{id:guid}/subscription")] + public async Task GetSubscription(Guid id) + { + if (!await currentContext.ViewSubscription(id)) + { + throw new NotFoundException(); + } + + var organization = await organizationRepository.GetByIdAsync(id); + if (organization == null) + { + throw new NotFoundException(); + } + + if (!globalSettings.SelfHosted && organization.Gateway != null) + { + var subscriptionInfo = await paymentService.GetSubscriptionAsync(organization); + if (subscriptionInfo == null) + { + throw new NotFoundException(); + } + + var hideSensitiveData = !await currentContext.EditSubscription(id); + + return new OrganizationSubscriptionResponseModel(organization, subscriptionInfo, hideSensitiveData); + } + + if (globalSettings.SelfHosted) + { + var orgLicense = await licensingService.ReadOrganizationLicenseAsync(organization); + return new OrganizationSubscriptionResponseModel(organization, orgLicense); + } + + return new OrganizationSubscriptionResponseModel(organization); + } + + [HttpGet("{id:guid}/license")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task GetLicense(Guid id, [FromQuery] Guid installationId) + { + if (!await currentContext.OrganizationOwner(id)) + { + throw new NotFoundException(); + } + + var org = await organizationRepository.GetByIdAsync(id); + var license = await cloudGetOrganizationLicenseQuery.GetLicenseAsync(org, installationId); + if (license == null) + { + throw new NotFoundException(); + } + + return license; + } + + [HttpPost("{id:guid}/payment")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task PostPayment(Guid id, [FromBody] PaymentRequestModel model) + { + if (!await currentContext.EditPaymentMethods(id)) + { + throw new NotFoundException(); + } + + await organizationService.ReplacePaymentMethodAsync(id, model.PaymentToken, + model.PaymentMethodType.Value, new TaxInfo + { + BillingAddressLine1 = model.Line1, + BillingAddressLine2 = model.Line2, + BillingAddressState = model.State, + BillingAddressCity = model.City, + BillingAddressPostalCode = model.PostalCode, + BillingAddressCountry = model.Country, + TaxIdNumber = model.TaxId, + }); + } + + [HttpPost("{id:guid}/upgrade")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task PostUpgrade(Guid id, [FromBody] OrganizationUpgradeRequestModel model) + { + if (!await currentContext.EditSubscription(id)) + { + throw new NotFoundException(); + } + + var (success, paymentIntentClientSecret) = await upgradeOrganizationPlanCommand.UpgradePlanAsync(id, model.ToOrganizationUpgrade()); + + if (model.UseSecretsManager && success) + { + var userId = userService.GetProperUserId(User).Value; + + await TryGrantOwnerAccessToSecretsManagerAsync(id, userId); + } + + return new PaymentResponseModel { Success = success, PaymentIntentClientSecret = paymentIntentClientSecret }; + } + + [HttpPost("{id}/sm-subscription")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task PostSmSubscription(Guid id, [FromBody] SecretsManagerSubscriptionUpdateRequestModel model) + { + var organization = await organizationRepository.GetByIdAsync(id); + if (organization == null) + { + throw new NotFoundException(); + } + + if (!await currentContext.EditSubscription(id)) + { + throw new NotFoundException(); + } + + organization = await AdjustOrganizationSeatsForSmTrialAsync(id, organization, model); + + var organizationUpdate = model.ToSecretsManagerSubscriptionUpdate(organization); + + await updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(organizationUpdate); + } + + [HttpPost("{id:guid}/subscription")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task PostSubscription(Guid id, [FromBody] OrganizationSubscriptionUpdateRequestModel model) + { + if (!await currentContext.EditSubscription(id)) + { + throw new NotFoundException(); + } + await organizationService.UpdateSubscription(id, model.SeatAdjustment, model.MaxAutoscaleSeats); + } + + [HttpPost("{id:guid}/subscribe-secrets-manager")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task PostSubscribeSecretsManagerAsync(Guid id, [FromBody] SecretsManagerSubscribeRequestModel model) + { + var organization = await organizationRepository.GetByIdAsync(id); + if (organization == null) + { + throw new NotFoundException(); + } + + if (!await currentContext.EditSubscription(id)) + { + throw new NotFoundException(); + } + + await addSecretsManagerSubscriptionCommand.SignUpAsync(organization, model.AdditionalSmSeats, + model.AdditionalServiceAccounts); + + var userId = userService.GetProperUserId(User).Value; + + await TryGrantOwnerAccessToSecretsManagerAsync(organization.Id, userId); + + var organizationDetails = await organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id, + OrganizationUserStatusType.Confirmed); + + return new ProfileOrganizationResponseModel(organizationDetails); + } + + [HttpPost("{id:guid}/seat")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task PostSeat(Guid id, [FromBody] OrganizationSeatRequestModel model) + { + if (!await currentContext.EditSubscription(id)) + { + throw new NotFoundException(); + } + + var result = await organizationService.AdjustSeatsAsync(id, model.SeatAdjustment.Value); + return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result }; + } + + [HttpPost("{id:guid}/verify-bank")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task PostVerifyBank(Guid id, [FromBody] OrganizationVerifyBankRequestModel model) + { + if (!await currentContext.EditSubscription(id)) + { + throw new NotFoundException(); + } + + await organizationService.VerifyBankAsync(id, model.Amount1.Value, model.Amount2.Value); + } + + [HttpPost("{id}/cancel")] + public async Task PostCancel(Guid id, [FromBody] SubscriptionCancellationRequestModel request) + { + if (!await currentContext.EditSubscription(id)) + { + throw new NotFoundException(); + } + + var organization = await organizationRepository.GetByIdAsync(id); + + if (organization == null) + { + throw new NotFoundException(); + } + + var subscription = await subscriberQueries.GetSubscriptionOrThrow(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() + }); + } + + [HttpPost("{id:guid}/reinstate")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task PostReinstate(Guid id) + { + if (!await currentContext.EditSubscription(id)) + { + throw new NotFoundException(); + } + + await organizationService.ReinstateSubscriptionAsync(id); + } + + [HttpGet("{id:guid}/tax")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task GetTaxInfo(Guid id) + { + if (!await currentContext.OrganizationOwner(id)) + { + throw new NotFoundException(); + } + + var organization = await organizationRepository.GetByIdAsync(id); + if (organization == null) + { + throw new NotFoundException(); + } + + var taxInfo = await paymentService.GetTaxInfoAsync(organization); + return new TaxInfoResponseModel(taxInfo); + } + + [HttpPut("{id:guid}/tax")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task PutTaxInfo(Guid id, [FromBody] ExpandedTaxInfoUpdateRequestModel model) + { + if (!await currentContext.OrganizationOwner(id)) + { + throw new NotFoundException(); + } + + var organization = await organizationRepository.GetByIdAsync(id); + if (organization == null) + { + throw new NotFoundException(); + } + + var taxInfo = new TaxInfo + { + TaxIdNumber = model.TaxId, + BillingAddressLine1 = model.Line1, + BillingAddressLine2 = model.Line2, + BillingAddressCity = model.City, + BillingAddressState = model.State, + BillingAddressPostalCode = model.PostalCode, + BillingAddressCountry = model.Country, + }; + await paymentService.SaveTaxInfoAsync(organization, taxInfo); + } + + /// + /// Tries to grant owner access to the Secrets Manager for the organization + /// + /// + /// + private async Task TryGrantOwnerAccessToSecretsManagerAsync(Guid organizationId, Guid userId) + { + var organizationUser = await organizationUserRepository.GetByOrganizationAsync(organizationId, userId); + + if (organizationUser != null) + { + organizationUser.AccessSecretsManager = true; + await organizationUserRepository.ReplaceAsync(organizationUser); + } + } + + /// + /// Adjusts the organization seats for the Secrets Manager trial to match the new seat count for secrets manager + /// + /// + /// + /// + private async Task AdjustOrganizationSeatsForSmTrialAsync(Guid id, Organization organization, + SecretsManagerSubscriptionUpdateRequestModel model) + { + if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId) || + string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId) || + model.SeatAdjustment == 0) + { + return organization; + } + + var subscriptionInfo = await paymentService.GetSubscriptionAsync(organization); + if (subscriptionInfo?.CustomerDiscount?.Id != StripeConstants.CouponIDs.SecretsManagerStandalone) + { + return organization; + } + + await organizationService.UpdateSubscription(id, model.SeatAdjustment, null); + + return await organizationRepository.GetByIdAsync(id); + } +} diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 831212f1b9..31bcef0bdb 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -1,9 +1,7 @@ using System.Security.Claims; using AutoFixture.Xunit2; using Bit.Api.AdminConsole.Controllers; -using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.Auth.Models.Request.Accounts; -using Bit.Api.Models.Request.Organizations; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; @@ -16,21 +14,14 @@ 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; using Bit.Core.Exceptions; -using Bit.Core.Models.Business; -using Bit.Core.Models.Data.Organizations.OrganizationUsers; -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 Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider; using NSubstitute; -using NSubstitute.ReturnsExtensions; using Xunit; using GlobalSettings = Bit.Core.Settings.GlobalSettings; @@ -43,7 +34,6 @@ public class OrganizationsControllerTests : IDisposable private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationService _organizationService; private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IPaymentService _paymentService; private readonly IPolicyRepository _policyRepository; private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ISsoConfigService _ssoConfigService; @@ -51,17 +41,9 @@ public class OrganizationsControllerTests : IDisposable private readonly IGetOrganizationApiKeyQuery _getOrganizationApiKeyQuery; private readonly IRotateOrganizationApiKeyCommand _rotateOrganizationApiKeyCommand; private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; - private readonly ICloudGetOrganizationLicenseQuery _cloudGetOrganizationLicenseQuery; private readonly ICreateOrganizationApiKeyCommand _createOrganizationApiKeyCommand; private readonly IFeatureService _featureService; - private readonly ILicensingService _licensingService; - private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; - private readonly IUpgradeOrganizationPlanCommand _upgradeOrganizationPlanCommand; - private readonly IAddSecretsManagerSubscriptionCommand _addSecretsManagerSubscriptionCommand; private readonly IPushNotificationService _pushNotificationService; - private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand; - private readonly ISubscriberQueries _subscriberQueries; - private readonly IReferenceEventService _referenceEventService; private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand; private readonly IProviderRepository _providerRepository; private readonly IScaleSeatsCommand _scaleSeatsCommand; @@ -75,7 +57,6 @@ public class OrganizationsControllerTests : IDisposable _organizationRepository = Substitute.For(); _organizationService = Substitute.For(); _organizationUserRepository = Substitute.For(); - _paymentService = Substitute.For(); _policyRepository = Substitute.For(); _ssoConfigRepository = Substitute.For(); _ssoConfigService = Substitute.For(); @@ -83,17 +64,9 @@ public class OrganizationsControllerTests : IDisposable _rotateOrganizationApiKeyCommand = Substitute.For(); _organizationApiKeyRepository = Substitute.For(); _userService = Substitute.For(); - _cloudGetOrganizationLicenseQuery = Substitute.For(); _createOrganizationApiKeyCommand = Substitute.For(); _featureService = Substitute.For(); - _licensingService = Substitute.For(); - _updateSecretsManagerSubscriptionCommand = Substitute.For(); - _upgradeOrganizationPlanCommand = Substitute.For(); - _addSecretsManagerSubscriptionCommand = Substitute.For(); _pushNotificationService = Substitute.For(); - _cancelSubscriptionCommand = Substitute.For(); - _subscriberQueries = Substitute.For(); - _referenceEventService = Substitute.For(); _organizationEnableCollectionEnhancementsCommand = Substitute.For(); _providerRepository = Substitute.For(); _scaleSeatsCommand = Substitute.For(); @@ -104,7 +77,6 @@ public class OrganizationsControllerTests : IDisposable _policyRepository, _organizationService, _userService, - _paymentService, _currentContext, _ssoConfigRepository, _ssoConfigService, @@ -112,17 +84,9 @@ public class OrganizationsControllerTests : IDisposable _rotateOrganizationApiKeyCommand, _createOrganizationApiKeyCommand, _organizationApiKeyRepository, - _cloudGetOrganizationLicenseQuery, _featureService, _globalSettings, - _licensingService, - _updateSecretsManagerSubscriptionCommand, - _upgradeOrganizationPlanCommand, - _addSecretsManagerSubscriptionCommand, _pushNotificationService, - _cancelSubscriptionCommand, - _subscriberQueries, - _referenceEventService, _organizationEnableCollectionEnhancementsCommand, _providerRepository, _scaleSeatsCommand); @@ -193,196 +157,6 @@ public class OrganizationsControllerTests : IDisposable await _organizationService.Received(1).DeleteUserAsync(orgId, user.Id); } - [Theory, AutoData] - public async Task OrganizationsController_PostUpgrade_UserCannotEditSubscription_ThrowsNotFoundException( - Guid organizationId, - OrganizationUpgradeRequestModel model) - { - _currentContext.EditSubscription(organizationId).Returns(false); - - await Assert.ThrowsAsync(() => _sut.PostUpgrade(organizationId.ToString(), model)); - } - - [Theory, AutoData] - public async Task OrganizationsController_PostUpgrade_NonSMUpgrade_ReturnsCorrectResponse( - Guid organizationId, - OrganizationUpgradeRequestModel model, - bool success, - string paymentIntentClientSecret) - { - model.UseSecretsManager = false; - - _currentContext.EditSubscription(organizationId).Returns(true); - - _upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any()) - .Returns(new Tuple(success, paymentIntentClientSecret)); - - var response = await _sut.PostUpgrade(organizationId.ToString(), model); - - Assert.Equal(success, response.Success); - Assert.Equal(paymentIntentClientSecret, response.PaymentIntentClientSecret); - } - - [Theory, AutoData] - public async Task OrganizationsController_PostUpgrade_SMUpgrade_ProvidesAccess_ReturnsCorrectResponse( - Guid organizationId, - Guid userId, - OrganizationUpgradeRequestModel model, - bool success, - string paymentIntentClientSecret, - OrganizationUser organizationUser) - { - model.UseSecretsManager = true; - organizationUser.AccessSecretsManager = false; - - _currentContext.EditSubscription(organizationId).Returns(true); - - _upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any()) - .Returns(new Tuple(success, paymentIntentClientSecret)); - - _userService.GetProperUserId(Arg.Any()).Returns(userId); - - _organizationUserRepository.GetByOrganizationAsync(organizationId, userId).Returns(organizationUser); - - var response = await _sut.PostUpgrade(organizationId.ToString(), model); - - Assert.Equal(success, response.Success); - Assert.Equal(paymentIntentClientSecret, response.PaymentIntentClientSecret); - - await _organizationUserRepository.Received(1).ReplaceAsync(Arg.Is(orgUser => - orgUser.Id == organizationUser.Id && orgUser.AccessSecretsManager == true)); - } - - [Theory, AutoData] - public async Task OrganizationsController_PostUpgrade_SMUpgrade_NullOrgUser_ReturnsCorrectResponse( - Guid organizationId, - Guid userId, - OrganizationUpgradeRequestModel model, - bool success, - string paymentIntentClientSecret) - { - model.UseSecretsManager = true; - - _currentContext.EditSubscription(organizationId).Returns(true); - - _upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any()) - .Returns(new Tuple(success, paymentIntentClientSecret)); - - _userService.GetProperUserId(Arg.Any()).Returns(userId); - - _organizationUserRepository.GetByOrganizationAsync(organizationId, userId).ReturnsNull(); - - var response = await _sut.PostUpgrade(organizationId.ToString(), model); - - Assert.Equal(success, response.Success); - Assert.Equal(paymentIntentClientSecret, response.PaymentIntentClientSecret); - - await _organizationUserRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(Arg.Any()); - } - - [Theory, AutoData] - public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_NullOrg_ThrowsNotFoundException( - Guid organizationId, - SecretsManagerSubscribeRequestModel model) - { - _organizationRepository.GetByIdAsync(organizationId).ReturnsNull(); - - await Assert.ThrowsAsync(() => _sut.PostSubscribeSecretsManagerAsync(organizationId, model)); - } - - [Theory, AutoData] - public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_UserCannotEditSubscription_ThrowsNotFoundException( - Guid organizationId, - SecretsManagerSubscribeRequestModel model, - Organization organization) - { - _organizationRepository.GetByIdAsync(organizationId).Returns(organization); - - _currentContext.EditSubscription(organizationId).Returns(false); - - await Assert.ThrowsAsync(() => _sut.PostSubscribeSecretsManagerAsync(organizationId, model)); - } - - [Theory, AutoData] - public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_ProvidesAccess_ReturnsCorrectResponse( - Guid organizationId, - SecretsManagerSubscribeRequestModel model, - Organization organization, - Guid userId, - OrganizationUser organizationUser, - OrganizationUserOrganizationDetails organizationUserOrganizationDetails) - { - organizationUser.AccessSecretsManager = false; - - var ssoConfigurationData = new SsoConfigurationData - { - MemberDecryptionType = MemberDecryptionType.KeyConnector, - KeyConnectorUrl = "https://example.com" - }; - - organizationUserOrganizationDetails.Permissions = string.Empty; - organizationUserOrganizationDetails.SsoConfig = ssoConfigurationData.Serialize(); - - _organizationRepository.GetByIdAsync(organizationId).Returns(organization); - - _currentContext.EditSubscription(organizationId).Returns(true); - - _userService.GetProperUserId(Arg.Any()).Returns(userId); - - _organizationUserRepository.GetByOrganizationAsync(organization.Id, userId).Returns(organizationUser); - - _organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id, OrganizationUserStatusType.Confirmed) - .Returns(organizationUserOrganizationDetails); - - var response = await _sut.PostSubscribeSecretsManagerAsync(organizationId, model); - - Assert.Equal(response.Id, organizationUserOrganizationDetails.OrganizationId); - Assert.Equal(response.Name, organizationUserOrganizationDetails.Name); - - await _addSecretsManagerSubscriptionCommand.Received(1) - .SignUpAsync(organization, model.AdditionalSmSeats, model.AdditionalServiceAccounts); - await _organizationUserRepository.Received(1).ReplaceAsync(Arg.Is(orgUser => - orgUser.Id == organizationUser.Id && orgUser.AccessSecretsManager == true)); - } - - [Theory, AutoData] - public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_NullOrgUser_ReturnsCorrectResponse( - Guid organizationId, - SecretsManagerSubscribeRequestModel model, - Organization organization, - Guid userId, - OrganizationUserOrganizationDetails organizationUserOrganizationDetails) - { - var ssoConfigurationData = new SsoConfigurationData - { - MemberDecryptionType = MemberDecryptionType.KeyConnector, - KeyConnectorUrl = "https://example.com" - }; - - organizationUserOrganizationDetails.Permissions = string.Empty; - organizationUserOrganizationDetails.SsoConfig = ssoConfigurationData.Serialize(); - - _organizationRepository.GetByIdAsync(organizationId).Returns(organization); - - _currentContext.EditSubscription(organizationId).Returns(true); - - _userService.GetProperUserId(Arg.Any()).Returns(userId); - - _organizationUserRepository.GetByOrganizationAsync(organization.Id, userId).ReturnsNull(); - - _organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id, OrganizationUserStatusType.Confirmed) - .Returns(organizationUserOrganizationDetails); - - var response = await _sut.PostSubscribeSecretsManagerAsync(organizationId, model); - - Assert.Equal(response.Id, organizationUserOrganizationDetails.OrganizationId); - Assert.Equal(response.Name, organizationUserOrganizationDetails.Name); - - await _addSecretsManagerSubscriptionCommand.Received(1) - .SignUpAsync(organization, model.AdditionalSmSeats, model.AdditionalServiceAccounts); - await _organizationUserRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(Arg.Any()); - } - [Theory, AutoData] public async Task EnableCollectionEnhancements_Success(Organization organization) { diff --git a/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs new file mode 100644 index 0000000000..b5737837e8 --- /dev/null +++ b/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs @@ -0,0 +1,317 @@ +using System.Security.Claims; +using AutoFixture.Xunit2; +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.Billing.Controllers; +using Bit.Api.Models.Request.Organizations; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Entities; +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; +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +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; +using GlobalSettings = Bit.Core.Settings.GlobalSettings; + +namespace Bit.Api.Test.Billing.Controllers; + +public class OrganizationsControllerTests : IDisposable +{ + private readonly GlobalSettings _globalSettings; + private readonly ICurrentContext _currentContext; + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationService _organizationService; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IPaymentService _paymentService; + private readonly ISsoConfigRepository _ssoConfigRepository; + private readonly IUserService _userService; + private readonly ICloudGetOrganizationLicenseQuery _cloudGetOrganizationLicenseQuery; + private readonly ILicensingService _licensingService; + private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; + private readonly IUpgradeOrganizationPlanCommand _upgradeOrganizationPlanCommand; + private readonly IAddSecretsManagerSubscriptionCommand _addSecretsManagerSubscriptionCommand; + private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand; + private readonly ISubscriberQueries _subscriberQueries; + private readonly IReferenceEventService _referenceEventService; + + private readonly OrganizationsController _sut; + + public OrganizationsControllerTests() + { + _currentContext = Substitute.For(); + _globalSettings = Substitute.For(); + _organizationRepository = Substitute.For(); + _organizationService = Substitute.For(); + _organizationUserRepository = Substitute.For(); + _paymentService = Substitute.For(); + Substitute.For(); + _ssoConfigRepository = Substitute.For(); + Substitute.For(); + _userService = Substitute.For(); + _cloudGetOrganizationLicenseQuery = Substitute.For(); + _licensingService = Substitute.For(); + _updateSecretsManagerSubscriptionCommand = Substitute.For(); + _upgradeOrganizationPlanCommand = Substitute.For(); + _addSecretsManagerSubscriptionCommand = Substitute.For(); + _cancelSubscriptionCommand = Substitute.For(); + _subscriberQueries = Substitute.For(); + _referenceEventService = Substitute.For(); + + _sut = new OrganizationsController( + _organizationRepository, + _organizationUserRepository, + _organizationService, + _userService, + _paymentService, + _currentContext, + _cloudGetOrganizationLicenseQuery, + _globalSettings, + _licensingService, + _updateSecretsManagerSubscriptionCommand, + _upgradeOrganizationPlanCommand, + _addSecretsManagerSubscriptionCommand, + _cancelSubscriptionCommand, + _subscriberQueries, + _referenceEventService); + } + + public void Dispose() + { + _sut?.Dispose(); + } + + [Theory] + [InlineAutoData(true, false)] + [InlineAutoData(false, true)] + [InlineAutoData(false, false)] + public async Task OrganizationsController_UserCanLeaveOrganizationThatDoesntProvideKeyConnector( + bool keyConnectorEnabled, bool userUsesKeyConnector, Guid orgId, User user) + { + var ssoConfig = new SsoConfig + { + Id = default, + Data = new SsoConfigurationData + { + MemberDecryptionType = keyConnectorEnabled + ? MemberDecryptionType.KeyConnector + : MemberDecryptionType.MasterPassword + }.Serialize(), + Enabled = true, + OrganizationId = orgId, + }; + + user.UsesKeyConnector = userUsesKeyConnector; + + _currentContext.OrganizationUser(orgId).Returns(true); + _ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig); + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + + await _organizationService.DeleteUserAsync(orgId, user.Id); + await _organizationService.Received(1).DeleteUserAsync(orgId, user.Id); + } + + [Theory, AutoData] + public async Task OrganizationsController_PostUpgrade_UserCannotEditSubscription_ThrowsNotFoundException( + Guid organizationId, + OrganizationUpgradeRequestModel model) + { + _currentContext.EditSubscription(organizationId).Returns(false); + + await Assert.ThrowsAsync(() => _sut.PostUpgrade(organizationId, model)); + } + + [Theory, AutoData] + public async Task OrganizationsController_PostUpgrade_NonSMUpgrade_ReturnsCorrectResponse( + Guid organizationId, + OrganizationUpgradeRequestModel model, + bool success, + string paymentIntentClientSecret) + { + model.UseSecretsManager = false; + + _currentContext.EditSubscription(organizationId).Returns(true); + + _upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any()) + .Returns(new Tuple(success, paymentIntentClientSecret)); + + var response = await _sut.PostUpgrade(organizationId, model); + + Assert.Equal(success, response.Success); + Assert.Equal(paymentIntentClientSecret, response.PaymentIntentClientSecret); + } + + [Theory, AutoData] + public async Task OrganizationsController_PostUpgrade_SMUpgrade_ProvidesAccess_ReturnsCorrectResponse( + Guid organizationId, + Guid userId, + OrganizationUpgradeRequestModel model, + bool success, + string paymentIntentClientSecret, + OrganizationUser organizationUser) + { + model.UseSecretsManager = true; + organizationUser.AccessSecretsManager = false; + + _currentContext.EditSubscription(organizationId).Returns(true); + + _upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any()) + .Returns(new Tuple(success, paymentIntentClientSecret)); + + _userService.GetProperUserId(Arg.Any()).Returns(userId); + + _organizationUserRepository.GetByOrganizationAsync(organizationId, userId).Returns(organizationUser); + + var response = await _sut.PostUpgrade(organizationId, model); + + Assert.Equal(success, response.Success); + Assert.Equal(paymentIntentClientSecret, response.PaymentIntentClientSecret); + + await _organizationUserRepository.Received(1).ReplaceAsync(Arg.Is(orgUser => + orgUser.Id == organizationUser.Id && orgUser.AccessSecretsManager == true)); + } + + [Theory, AutoData] + public async Task OrganizationsController_PostUpgrade_SMUpgrade_NullOrgUser_ReturnsCorrectResponse( + Guid organizationId, + Guid userId, + OrganizationUpgradeRequestModel model, + bool success, + string paymentIntentClientSecret) + { + model.UseSecretsManager = true; + + _currentContext.EditSubscription(organizationId).Returns(true); + + _upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any()) + .Returns(new Tuple(success, paymentIntentClientSecret)); + + _userService.GetProperUserId(Arg.Any()).Returns(userId); + + _organizationUserRepository.GetByOrganizationAsync(organizationId, userId).ReturnsNull(); + + var response = await _sut.PostUpgrade(organizationId, model); + + Assert.Equal(success, response.Success); + Assert.Equal(paymentIntentClientSecret, response.PaymentIntentClientSecret); + + await _organizationUserRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(Arg.Any()); + } + + [Theory, AutoData] + public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_NullOrg_ThrowsNotFoundException( + Guid organizationId, + SecretsManagerSubscribeRequestModel model) + { + _organizationRepository.GetByIdAsync(organizationId).ReturnsNull(); + + await Assert.ThrowsAsync(() => _sut.PostSubscribeSecretsManagerAsync(organizationId, model)); + } + + [Theory, AutoData] + public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_UserCannotEditSubscription_ThrowsNotFoundException( + Guid organizationId, + SecretsManagerSubscribeRequestModel model, + Organization organization) + { + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + _currentContext.EditSubscription(organizationId).Returns(false); + + await Assert.ThrowsAsync(() => _sut.PostSubscribeSecretsManagerAsync(organizationId, model)); + } + + [Theory, AutoData] + public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_ProvidesAccess_ReturnsCorrectResponse( + Guid organizationId, + SecretsManagerSubscribeRequestModel model, + Organization organization, + Guid userId, + OrganizationUser organizationUser, + OrganizationUserOrganizationDetails organizationUserOrganizationDetails) + { + organizationUser.AccessSecretsManager = false; + + var ssoConfigurationData = new SsoConfigurationData + { + MemberDecryptionType = MemberDecryptionType.KeyConnector, + KeyConnectorUrl = "https://example.com" + }; + + organizationUserOrganizationDetails.Permissions = string.Empty; + organizationUserOrganizationDetails.SsoConfig = ssoConfigurationData.Serialize(); + + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + _currentContext.EditSubscription(organizationId).Returns(true); + + _userService.GetProperUserId(Arg.Any()).Returns(userId); + + _organizationUserRepository.GetByOrganizationAsync(organization.Id, userId).Returns(organizationUser); + + _organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id, OrganizationUserStatusType.Confirmed) + .Returns(organizationUserOrganizationDetails); + + var response = await _sut.PostSubscribeSecretsManagerAsync(organizationId, model); + + Assert.Equal(response.Id, organizationUserOrganizationDetails.OrganizationId); + Assert.Equal(response.Name, organizationUserOrganizationDetails.Name); + + await _addSecretsManagerSubscriptionCommand.Received(1) + .SignUpAsync(organization, model.AdditionalSmSeats, model.AdditionalServiceAccounts); + await _organizationUserRepository.Received(1).ReplaceAsync(Arg.Is(orgUser => + orgUser.Id == organizationUser.Id && orgUser.AccessSecretsManager == true)); + } + + [Theory, AutoData] + public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_NullOrgUser_ReturnsCorrectResponse( + Guid organizationId, + SecretsManagerSubscribeRequestModel model, + Organization organization, + Guid userId, + OrganizationUserOrganizationDetails organizationUserOrganizationDetails) + { + var ssoConfigurationData = new SsoConfigurationData + { + MemberDecryptionType = MemberDecryptionType.KeyConnector, + KeyConnectorUrl = "https://example.com" + }; + + organizationUserOrganizationDetails.Permissions = string.Empty; + organizationUserOrganizationDetails.SsoConfig = ssoConfigurationData.Serialize(); + + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + _currentContext.EditSubscription(organizationId).Returns(true); + + _userService.GetProperUserId(Arg.Any()).Returns(userId); + + _organizationUserRepository.GetByOrganizationAsync(organization.Id, userId).ReturnsNull(); + + _organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id, OrganizationUserStatusType.Confirmed) + .Returns(organizationUserOrganizationDetails); + + var response = await _sut.PostSubscribeSecretsManagerAsync(organizationId, model); + + Assert.Equal(response.Id, organizationUserOrganizationDetails.OrganizationId); + Assert.Equal(response.Name, organizationUserOrganizationDetails.Name); + + await _addSecretsManagerSubscriptionCommand.Received(1) + .SignUpAsync(organization, model.AdditionalSmSeats, model.AdditionalServiceAccounts); + await _organizationUserRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(Arg.Any()); + } +} From febc696c804b58e8e9ac690e9a139451238d590b Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Fri, 17 May 2024 14:28:51 -0500 Subject: [PATCH 18/36] [AC-240] - BUG - Confirm Admin/Owners to org when excluded from Single Org Policy (#4087) * fix: align policy checks for excluded types, update tests, create fixture, refs AC-240 * fix: update final policy check against other orgs (not including the current), refs AC-240 --- .../Implementations/OrganizationService.cs | 28 +++-- .../OrganizationUserPolicyDetailsFixtures.cs | 40 +++++++ .../Services/OrganizationServiceTests.cs | 108 ++++++++++++++---- 3 files changed, 145 insertions(+), 31 deletions(-) create mode 100644 test/Core.Test/AdminConsole/AutoFixture/OrganizationUserPolicyDetailsFixtures.cs diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index dca45ceba0..b4a243d706 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -1323,7 +1323,6 @@ public class OrganizationService : IOrganizationService var validOrganizationUserIds = validOrganizationUsers.Select(u => u.UserId.Value).ToList(); var organization = await GetOrgById(organizationId); - var policies = await _policyRepository.GetManyByOrganizationIdAsync(organizationId); var usersOrgs = await _organizationUserRepository.GetManyByManyUsersAsync(validOrganizationUserIds); var users = await _userRepository.GetManyAsync(validOrganizationUserIds); @@ -1355,7 +1354,7 @@ public class OrganizationService : IOrganizationService } } - await CheckPolicies(policies, organizationId, user, orgUsers, userService); + await CheckPolicies(organizationId, user, orgUsers, userService); orgUser.Status = OrganizationUserStatusType.Confirmed; orgUser.Key = keys[orgUser.Id]; orgUser.Email = null; @@ -1449,22 +1448,29 @@ public class OrganizationService : IOrganizationService } } - private async Task CheckPolicies(ICollection policies, Guid organizationId, User user, + private async Task CheckPolicies(Guid organizationId, User user, ICollection userOrgs, IUserService userService) { - var usingTwoFactorPolicy = policies.Any(p => p.Type == PolicyType.TwoFactorAuthentication && p.Enabled); - if (usingTwoFactorPolicy && !await userService.TwoFactorIsEnabledAsync(user)) + // Enforce Two Factor Authentication Policy for this organization + var orgRequiresTwoFactor = (await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication)).Any(p => p.OrganizationId == organizationId); + if (orgRequiresTwoFactor && !await userService.TwoFactorIsEnabledAsync(user)) { throw new BadRequestException("User does not have two-step login enabled."); } - var usingSingleOrgPolicy = policies.Any(p => p.Type == PolicyType.SingleOrg && p.Enabled); - if (usingSingleOrgPolicy) + var hasOtherOrgs = userOrgs.Any(ou => ou.OrganizationId != organizationId); + var singleOrgPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg); + var otherSingleOrgPolicies = + singleOrgPolicies.Where(p => p.OrganizationId != organizationId); + // Enforce Single Organization Policy for this organization + if (hasOtherOrgs && singleOrgPolicies.Any(p => p.OrganizationId == organizationId)) { - if (userOrgs.Any(ou => ou.OrganizationId != organizationId && ou.Status != OrganizationUserStatusType.Invited)) - { - throw new BadRequestException("User is a member of another organization."); - } + throw new BadRequestException("Cannot confirm this member to the organization until they leave or remove all other organizations."); + } + // Enforce Single Organization Policy of other organizations user is a member of + if (otherSingleOrgPolicies.Any()) + { + throw new BadRequestException("Cannot confirm this member to the organization because they are in another organization which forbids it."); } } diff --git a/test/Core.Test/AdminConsole/AutoFixture/OrganizationUserPolicyDetailsFixtures.cs b/test/Core.Test/AdminConsole/AutoFixture/OrganizationUserPolicyDetailsFixtures.cs new file mode 100644 index 0000000000..634b234e70 --- /dev/null +++ b/test/Core.Test/AdminConsole/AutoFixture/OrganizationUserPolicyDetailsFixtures.cs @@ -0,0 +1,40 @@ +using System.Reflection; +using AutoFixture; +using AutoFixture.Xunit2; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; + +namespace Bit.Core.Test.AdminConsole.AutoFixture; + +internal class OrganizationUserPolicyDetailsCustomization : ICustomization +{ + public PolicyType Type { get; set; } + + public OrganizationUserPolicyDetailsCustomization(PolicyType type) + { + Type = type; + } + + public void Customize(IFixture fixture) + { + fixture.Customize(composer => composer + .With(o => o.OrganizationId, Guid.NewGuid()) + .With(o => o.PolicyType, Type) + .With(o => o.PolicyEnabled, true)); + } +} + +public class OrganizationUserPolicyDetailsAttribute : CustomizeAttribute +{ + private readonly PolicyType _type; + + public OrganizationUserPolicyDetailsAttribute(PolicyType type) + { + _type = type; + } + + public override ICustomization GetCustomization(ParameterInfo parameter) + { + return new OrganizationUserPolicyDetailsCustomization(_type); + } +} diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index fa2090d37d..db6805c097 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Business.Tokenables; @@ -39,7 +40,6 @@ using NSubstitute.ReturnsExtensions; using Xunit; using Organization = Bit.Core.AdminConsole.Entities.Organization; using OrganizationUser = Bit.Core.Entities.OrganizationUser; -using Policy = Bit.Core.AdminConsole.Entities.Policy; namespace Bit.Core.Test.Services; @@ -1441,15 +1441,15 @@ OrganizationUserInvite invite, SutProvider sutProvider) [Theory, BitAutoData] - public async Task ConfirmUser_SingleOrgPolicy(Organization org, OrganizationUser confirmingUser, + public async Task ConfirmUser_AsUser_SingleOrgPolicy_AppliedFromConfirmingOrg_Throws(Organization org, OrganizationUser confirmingUser, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, - OrganizationUser orgUserAnotherOrg, [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + OrganizationUser orgUserAnotherOrg, [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy, string key, SutProvider sutProvider) { var organizationUserRepository = sutProvider.GetDependency(); var organizationRepository = sutProvider.GetDependency(); - var policyRepository = sutProvider.GetDependency(); var userRepository = sutProvider.GetDependency(); + var policyService = sutProvider.GetDependency(); var userService = Substitute.For(); org.PlanType = PlanType.EnterpriseAnnually; @@ -1460,23 +1460,84 @@ OrganizationUserInvite invite, SutProvider sutProvider) organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); organizationRepository.GetByIdAsync(org.Id).Returns(org); userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - policyRepository.GetManyByOrganizationIdAsync(org.Id).Returns(new[] { singleOrgPolicy }); + singleOrgPolicy.OrganizationId = org.Id; + policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg).Returns(new[] { singleOrgPolicy }); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, userService)); - Assert.Contains("User is a member of another organization.", exception.Message); + Assert.Contains("Cannot confirm this member to the organization until they leave or remove all other organizations.", exception.Message); } [Theory, BitAutoData] - public async Task ConfirmUser_TwoFactorPolicy(Organization org, OrganizationUser confirmingUser, + public async Task ConfirmUser_AsUser_SingleOrgPolicy_AppliedFromOtherOrg_Throws(Organization org, OrganizationUser confirmingUser, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, - OrganizationUser orgUserAnotherOrg, [Policy(PolicyType.TwoFactorAuthentication)] Policy twoFactorPolicy, + OrganizationUser orgUserAnotherOrg, [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy, string key, SutProvider sutProvider) { var organizationUserRepository = sutProvider.GetDependency(); var organizationRepository = sutProvider.GetDependency(); - var policyRepository = sutProvider.GetDependency(); var userRepository = sutProvider.GetDependency(); + var policyService = sutProvider.GetDependency(); + var userService = Substitute.For(); + + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.Status = OrganizationUserStatusType.Accepted; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = orgUserAnotherOrg.UserId = user.Id; + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); + organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); + organizationRepository.GetByIdAsync(org.Id).Returns(org); + userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); + singleOrgPolicy.OrganizationId = orgUserAnotherOrg.Id; + policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg).Returns(new[] { singleOrgPolicy }); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, userService)); + Assert.Contains("Cannot confirm this member to the organization because they are in another organization which forbids it.", exception.Message); + } + + [Theory] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.Owner)] + public async Task ConfirmUser_AsOwnerOrAdmin_SingleOrgPolicy_ExcludedViaUserType_Success( + OrganizationUserType userType, Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + OrganizationUser orgUserAnotherOrg, + string key, SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var userService = Substitute.For(); + + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.Type = userType; + orgUser.Status = OrganizationUserStatusType.Accepted; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = orgUserAnotherOrg.UserId = user.Id; + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); + organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); + organizationRepository.GetByIdAsync(org.Id).Returns(org); + userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); + + await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, userService); + + await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); + await sutProvider.GetDependency().Received(1).SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email); + await organizationUserRepository.Received(1).ReplaceManyAsync(Arg.Is>(users => users.Contains(orgUser) && users.Count == 1)); + } + + [Theory, BitAutoData] + public async Task ConfirmUser_TwoFactorPolicy_NotEnabled_Throws(Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + OrganizationUser orgUserAnotherOrg, + [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy, + string key, SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var policyService = sutProvider.GetDependency(); var userService = Substitute.For(); org.PlanType = PlanType.EnterpriseAnnually; @@ -1486,7 +1547,8 @@ OrganizationUserInvite invite, SutProvider sutProvider) organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); organizationRepository.GetByIdAsync(org.Id).Returns(org); userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - policyRepository.GetManyByOrganizationIdAsync(org.Id).Returns(new[] { twoFactorPolicy }); + twoFactorPolicy.OrganizationId = org.Id; + policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy }); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, userService)); @@ -1494,15 +1556,15 @@ OrganizationUserInvite invite, SutProvider sutProvider) } [Theory, BitAutoData] - public async Task ConfirmUser_Success(Organization org, OrganizationUser confirmingUser, + public async Task ConfirmUser_TwoFactorPolicy_Enabled_Success(Organization org, OrganizationUser confirmingUser, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, - [Policy(PolicyType.TwoFactorAuthentication)] Policy twoFactorPolicy, - [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, string key, SutProvider sutProvider) + [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy, + string key, SutProvider sutProvider) { var organizationUserRepository = sutProvider.GetDependency(); var organizationRepository = sutProvider.GetDependency(); - var policyRepository = sutProvider.GetDependency(); var userRepository = sutProvider.GetDependency(); + var policyService = sutProvider.GetDependency(); var userService = Substitute.For(); org.PlanType = PlanType.EnterpriseAnnually; @@ -1511,7 +1573,8 @@ OrganizationUserInvite invite, SutProvider sutProvider) organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); organizationRepository.GetByIdAsync(org.Id).Returns(org); userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - policyRepository.GetManyByOrganizationIdAsync(org.Id).Returns(new[] { twoFactorPolicy, singleOrgPolicy }); + twoFactorPolicy.OrganizationId = org.Id; + policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy }); userService.TwoFactorIsEnabledAsync(user).Returns(true); await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, userService); @@ -1524,13 +1587,14 @@ OrganizationUserInvite invite, SutProvider sutProvider) [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser2, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser3, OrganizationUser anotherOrgUser, User user1, User user2, User user3, - [Policy(PolicyType.TwoFactorAuthentication)] Policy twoFactorPolicy, - [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, string key, SutProvider sutProvider) + [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy, + [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy, + string key, SutProvider sutProvider) { var organizationUserRepository = sutProvider.GetDependency(); var organizationRepository = sutProvider.GetDependency(); - var policyRepository = sutProvider.GetDependency(); var userRepository = sutProvider.GetDependency(); + var policyService = sutProvider.GetDependency(); var userService = Substitute.For(); org.PlanType = PlanType.EnterpriseAnnually; @@ -1543,10 +1607,14 @@ OrganizationUserInvite invite, SutProvider sutProvider) organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(orgUsers); organizationRepository.GetByIdAsync(org.Id).Returns(org); userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user1, user2, user3 }); - policyRepository.GetManyByOrganizationIdAsync(org.Id).Returns(new[] { twoFactorPolicy, singleOrgPolicy }); + twoFactorPolicy.OrganizationId = org.Id; + policyService.GetPoliciesApplicableToUserAsync(Arg.Any(), PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy }); userService.TwoFactorIsEnabledAsync(user1).Returns(true); userService.TwoFactorIsEnabledAsync(user2).Returns(false); userService.TwoFactorIsEnabledAsync(user3).Returns(true); + singleOrgPolicy.OrganizationId = org.Id; + policyService.GetPoliciesApplicableToUserAsync(user3.Id, PolicyType.SingleOrg) + .Returns(new[] { singleOrgPolicy }); organizationUserRepository.GetManyByManyUsersAsync(default) .ReturnsForAnyArgs(new[] { orgUser1, orgUser2, orgUser3, anotherOrgUser }); @@ -1554,7 +1622,7 @@ OrganizationUserInvite invite, SutProvider sutProvider) var result = await sutProvider.Sut.ConfirmUsersAsync(confirmingUser.OrganizationId, keys, confirmingUser.Id, userService); Assert.Contains("", result[0].Item2); Assert.Contains("User does not have two-step login enabled.", result[1].Item2); - Assert.Contains("User is a member of another organization.", result[2].Item2); + Assert.Contains("Cannot confirm this member to the organization until they leave or remove all other organizations.", result[2].Item2); } [Theory, BitAutoData] From 0be40d1bd9d35627f8156df6897a36535c7ae9ab Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Mon, 20 May 2024 10:22:16 -0400 Subject: [PATCH 19/36] [AC-2489] Resolve SM Standalone issues with SCIM & Directory Connector (#4011) * Add auto-scale support to standalone SM for SCIM * Mark users for SM when using SM Stadalone with Directory Connector --- bitwarden_license/src/Scim/Users/PostUserCommand.cs | 13 ++++++++++++- .../test/Scim.Test/Users/PostUserCommandTests.cs | 13 +++++++++---- .../AdminConsole/Services/IOrganizationService.cs | 2 +- .../Services/Implementations/OrganizationService.cs | 12 ++++++++---- src/Core/Services/IPaymentService.cs | 1 + .../Implementations/StripePaymentService.cs | 12 ++++++++++++ 6 files changed, 43 insertions(+), 10 deletions(-) diff --git a/bitwarden_license/src/Scim/Users/PostUserCommand.cs b/bitwarden_license/src/Scim/Users/PostUserCommand.cs index 920ca6c77b..a96633e013 100644 --- a/bitwarden_license/src/Scim/Users/PostUserCommand.cs +++ b/bitwarden_license/src/Scim/Users/PostUserCommand.cs @@ -14,17 +14,23 @@ namespace Bit.Scim.Users; public class PostUserCommand : IPostUserCommand { + private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationService _organizationService; + private readonly IPaymentService _paymentService; private readonly IScimContext _scimContext; public PostUserCommand( + IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, IOrganizationService organizationService, + IPaymentService paymentService, IScimContext scimContext) { + _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; _organizationService = organizationService; + _paymentService = paymentService; _scimContext = scimContext; } @@ -80,8 +86,13 @@ public class PostUserCommand : IPostUserCommand throw new ConflictException(); } + var organization = await _organizationRepository.GetByIdAsync(organizationId); + + var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization); + var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, EventSystemUser.SCIM, email, - OrganizationUserType.User, false, externalId, new List(), new List()); + OrganizationUserType.User, false, externalId, new List(), new List(), hasStandaloneSecretsManager); + var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id); return orgUser; diff --git a/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs b/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs index 304a290709..4c67f0173e 100644 --- a/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs +++ b/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs @@ -1,4 +1,5 @@ -using Bit.Core.Enums; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; @@ -19,7 +20,7 @@ public class PostUserCommandTests { [Theory] [BitAutoData] - public async Task PostUser_Success(SutProvider sutProvider, string externalId, Guid organizationId, List emails, ICollection organizationUsers, Core.Entities.OrganizationUser newUser) + public async Task PostUser_Success(SutProvider sutProvider, string externalId, Guid organizationId, List emails, ICollection organizationUsers, Core.Entities.OrganizationUser newUser, Organization organization) { var scimUserRequestModel = new ScimUserRequestModel { @@ -33,16 +34,20 @@ public class PostUserCommandTests .GetManyDetailsByOrganizationAsync(organizationId) .Returns(organizationUsers); + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + + sutProvider.GetDependency().HasSecretsManagerStandalone(organization).Returns(true); + sutProvider.GetDependency() .InviteUserAsync(organizationId, EventSystemUser.SCIM, scimUserRequestModel.PrimaryEmail.ToLowerInvariant(), OrganizationUserType.User, false, externalId, Arg.Any>(), - Arg.Any>()) + Arg.Any>(), true) .Returns(newUser); var user = await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel); await sutProvider.GetDependency().Received(1).InviteUserAsync(organizationId, EventSystemUser.SCIM, scimUserRequestModel.PrimaryEmail.ToLowerInvariant(), - OrganizationUserType.User, false, scimUserRequestModel.ExternalId, Arg.Any>(), Arg.Any>()); + OrganizationUserType.User, false, scimUserRequestModel.ExternalId, Arg.Any>(), Arg.Any>(), true); await sutProvider.GetDependency().Received(1).GetDetailsByIdAsync(newUser.Id); } diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index 32e3ca0ef1..86611bdd5d 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -49,7 +49,7 @@ public interface IOrganizationService Task InviteUserAsync(Guid organizationId, Guid? invitingUserId, string email, OrganizationUserType type, bool accessAll, string externalId, ICollection collections, IEnumerable groups); Task InviteUserAsync(Guid organizationId, EventSystemUser systemUser, string email, - OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections, IEnumerable groups); + OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections, IEnumerable groups, bool accessSecretsManager); Task>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable organizationUsersId); Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, bool initOrganization = false); Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index b4a243d706..8bf86a8eee 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -1679,14 +1679,14 @@ public class OrganizationService : IOrganizationService public async Task InviteUserAsync(Guid organizationId, EventSystemUser systemUser, string email, OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections, - IEnumerable groups) + IEnumerable groups, bool accessSecretsManager) { // Collection associations validation not required as they are always an empty list - created via system user (scim) - return await SaveUserSendInviteAsync(organizationId, invitingUserId: null, systemUser, email, type, accessAll, externalId, collections, groups); + return await SaveUserSendInviteAsync(organizationId, invitingUserId: null, systemUser, email, type, accessAll, externalId, collections, groups, accessSecretsManager); } private async Task SaveUserSendInviteAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser, string email, - OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections, IEnumerable groups) + OrganizationUserType type, bool accessAll, string externalId, IEnumerable collections, IEnumerable groups, bool accessSecretsManager = false) { var invite = new OrganizationUserInvite() { @@ -1694,7 +1694,8 @@ public class OrganizationService : IOrganizationService Type = type, AccessAll = accessAll, Collections = collections, - Groups = groups + Groups = groups, + AccessSecretsManager = accessSecretsManager }; var results = systemUser.HasValue ? await InviteUsersAsync(organizationId, systemUser.Value, new (OrganizationUserInvite, string)[] { (invite, externalId) }) : await InviteUsersAsync(organizationId, invitingUserId, @@ -1793,6 +1794,8 @@ public class OrganizationService : IOrganizationService enoughSeatsAvailable = seatsAvailable >= usersToAdd.Count; } + var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization); + var userInvites = new List<(OrganizationUserInvite, string)>(); foreach (var user in newUsers) { @@ -1809,6 +1812,7 @@ public class OrganizationService : IOrganizationService Type = OrganizationUserType.User, AccessAll = false, Collections = new List(), + AccessSecretsManager = hasStandaloneSecretsManager }; userInvites.Add((invite, user.ExternalId)); } diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index e0d2e95dc9..3c78c585f9 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -57,4 +57,5 @@ public interface IPaymentService Task AddSecretsManagerToSubscription(Organization org, Plan plan, int additionalSmSeats, int additionalServiceAccount, DateTime? prorationDate = null); Task RisksSubscriptionFailure(Organization organization); + Task HasSecretsManagerStandalone(Organization organization); } diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index a0ab2378be..cc2bee06bb 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -1800,6 +1800,18 @@ public class StripePaymentService : IPaymentService return paymentSource == null; } + public async Task HasSecretsManagerStandalone(Organization organization) + { + if (string.IsNullOrEmpty(organization.GatewayCustomerId)) + { + return false; + } + + var customer = await _stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId); + + return customer?.Discount?.Coupon?.Id == SecretsManagerStandaloneDiscountId; + } + private PaymentMethod GetLatestCardPaymentMethod(string customerId) { var cardPaymentMethods = _stripeAdapter.PaymentMethodListAutoPaging( From 489f6246b1922960f23902ab3db823d456503c42 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 20 May 2024 14:21:12 -0700 Subject: [PATCH 20/36] [deps] Auth: Update DuoUniversal to v1.2.4 (#4080) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ike <137194738+ike-kottlowski@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 f0f18b86bf..acb42deac3 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -29,7 +29,7 @@ - + From 98b7866c95a58596d48b72f8f2c84acff461cc7a Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Tue, 21 May 2024 10:44:57 +1000 Subject: [PATCH 21/36] [AC-2605] Restrict collection access for some custom users (#4096) * Make custom users subject to collection settings Affects ManageUsers and ManageGroups --- .../BulkCollectionAuthorizationHandler.cs | 21 ++- ...BulkCollectionAuthorizationHandlerTests.cs | 176 +++++++++++++----- 2 files changed, 151 insertions(+), 46 deletions(-) diff --git a/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs b/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs index 0638d16d62..d836b18e36 100644 --- a/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs +++ b/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs @@ -217,12 +217,22 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler CanUpdateUserAccessAsync(ICollection resources, CurrentContextOrganization? org) { - return await CanUpdateCollectionAsync(resources, org) || org?.Permissions.ManageUsers == true; + if (await AllowAdminAccessToAllCollectionItems(org) && org?.Permissions.ManageUsers == true) + { + return true; + } + + return await CanUpdateCollectionAsync(resources, org); } private async Task CanUpdateGroupAccessAsync(ICollection resources, CurrentContextOrganization? org) { - return await CanUpdateCollectionAsync(resources, org) || org?.Permissions.ManageGroups == true; + if (await AllowAdminAccessToAllCollectionItems(org) && org?.Permissions.ManageGroups == true) + { + return true; + } + + return await CanUpdateCollectionAsync(resources, org); } private async Task CanDeleteAsync(ICollection resources, CurrentContextOrganization? org) @@ -313,4 +323,11 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler AllowAdminAccessToAllCollectionItems(CurrentContextOrganization? org) + { + var organizationAbility = await GetOrganizationAbilityAsync(org); + return !_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) || + organizationAbility is { AllowAdminAccessToAllCollectionItems: true }; + } } diff --git a/test/Api.Test/Vault/AuthorizationHandlers/BulkCollectionAuthorizationHandlerTests.cs b/test/Api.Test/Vault/AuthorizationHandlers/BulkCollectionAuthorizationHandlerTests.cs index 527896f930..dbc076bf75 100644 --- a/test/Api.Test/Vault/AuthorizationHandlers/BulkCollectionAuthorizationHandlerTests.cs +++ b/test/Api.Test/Vault/AuthorizationHandlers/BulkCollectionAuthorizationHandlerTests.cs @@ -828,79 +828,167 @@ public class BulkCollectionAuthorizationHandlerTests } [Theory, BitAutoData, CollectionCustomization] - public async Task CanUpdateUsers_WithManageUsersCustomPermission_Success( - SutProvider sutProvider, - ICollection collections, - CurrentContextOrganization organization) + public async Task CanUpdateUsers_WithManageUsersCustomPermission_V1Disabled_Success( + SutProvider sutProvider, ICollection collections, + CurrentContextOrganization organization, Guid actingUserId) { - var actingUserId = Guid.NewGuid(); - organization.Type = OrganizationUserType.Custom; organization.Permissions = new Permissions { ManageUsers = true }; - var operationsToTest = new[] - { - BulkCollectionOperations.ModifyUserAccess, - }; + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) + .Returns(false); - foreach (var op in operationsToTest) - { - sutProvider.GetDependency().UserId.Returns(actingUserId); - sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.ModifyUserAccess }, + new ClaimsPrincipal(), + collections); - var context = new AuthorizationHandlerContext( - new[] { op }, - new ClaimsPrincipal(), - collections); + await sutProvider.Sut.HandleAsync(context); - await sutProvider.Sut.HandleAsync(context); - - Assert.True(context.HasSucceeded); - - // Recreate the SUT to reset the mocks/dependencies between tests - sutProvider.Recreate(); - } + Assert.True(context.HasSucceeded); } [Theory, BitAutoData, CollectionCustomization] - public async Task CanUpdateGroups_WithManageGroupsCustomPermission_Success( - SutProvider sutProvider, - ICollection collections, - CurrentContextOrganization organization) + public async Task CanUpdateUsers_WithManageUsersCustomPermission_AllowAdminAccessIsTrue_Success( + SutProvider sutProvider, ICollection collections, + CurrentContextOrganization organization, Guid actingUserId) { - var actingUserId = Guid.NewGuid(); + organization.Type = OrganizationUserType.Custom; + organization.Permissions = new Permissions + { + ManageUsers = true + }; + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) + .Returns(true); + sutProvider.GetDependency().GetOrganizationAbilityAsync(organization.Id) + .Returns(new OrganizationAbility { AllowAdminAccessToAllCollectionItems = true }); + + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.ModifyUserAccess }, + new ClaimsPrincipal(), + collections); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasSucceeded); + } + + [Theory, BitAutoData, CollectionCustomization] + public async Task CanUpdateUsers_WithManageUsersCustomPermission_AllowAdminAccessIsFalse_Failure( + SutProvider sutProvider, ICollection collections, + CurrentContextOrganization organization, Guid actingUserId) + { + organization.Type = OrganizationUserType.Custom; + organization.Permissions = new Permissions + { + ManageUsers = true + }; + + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) + .Returns(true); + sutProvider.GetDependency().GetOrganizationAbilityAsync(organization.Id) + .Returns(new OrganizationAbility { AllowAdminAccessToAllCollectionItems = false }); + + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.ModifyUserAccess }, + new ClaimsPrincipal(), + collections); + + await sutProvider.Sut.HandleAsync(context); + + Assert.False(context.HasSucceeded); + } + + [Theory, BitAutoData, CollectionCustomization] + public async Task CanUpdateGroups_WithManageGroupsCustomPermission_V1Disabled_Success( + SutProvider sutProvider, ICollection collections, + CurrentContextOrganization organization, Guid actingUserId) + { organization.Type = OrganizationUserType.Custom; organization.Permissions = new Permissions { ManageGroups = true }; - var operationsToTest = new[] + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) + .Returns(false); + + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.ModifyGroupAccess }, + new ClaimsPrincipal(), + collections); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasSucceeded); + } + + [Theory, BitAutoData, CollectionCustomization] + public async Task CanUpdateGroups_WithManageGroupsCustomPermission_AllowAdminAccessIsTrue_Success( + SutProvider sutProvider, ICollection collections, + CurrentContextOrganization organization, Guid actingUserId) + { + organization.Type = OrganizationUserType.Custom; + organization.Permissions = new Permissions { - BulkCollectionOperations.ModifyGroupAccess, + ManageGroups = true }; - foreach (var op in operationsToTest) + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) + .Returns(true); + sutProvider.GetDependency().GetOrganizationAbilityAsync(organization.Id) + .Returns(new OrganizationAbility { AllowAdminAccessToAllCollectionItems = true }); + + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.ModifyGroupAccess }, + new ClaimsPrincipal(), + collections); + + await sutProvider.Sut.HandleAsync(context); + + Assert.True(context.HasSucceeded); + } + + [Theory, BitAutoData, CollectionCustomization] + public async Task CanUpdateGroups_WithManageGroupsCustomPermission_AllowAdminAccessIsFalse_Failure( + SutProvider sutProvider, ICollection collections, + CurrentContextOrganization organization, Guid actingUserId) + { + organization.Type = OrganizationUserType.Custom; + organization.Permissions = new Permissions { - sutProvider.GetDependency().UserId.Returns(actingUserId); - sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + ManageGroups = true + }; - var context = new AuthorizationHandlerContext( - new[] { op }, - new ClaimsPrincipal(), - collections); + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) + .Returns(true); + sutProvider.GetDependency().GetOrganizationAbilityAsync(organization.Id) + .Returns(new OrganizationAbility { AllowAdminAccessToAllCollectionItems = false }); - await sutProvider.Sut.HandleAsync(context); + var context = new AuthorizationHandlerContext( + new[] { BulkCollectionOperations.ModifyGroupAccess }, + new ClaimsPrincipal(), + collections); - Assert.True(context.HasSucceeded); + await sutProvider.Sut.HandleAsync(context); - // Recreate the SUT to reset the mocks/dependencies between tests - sutProvider.Recreate(); - } + Assert.False(context.HasSucceeded); } [Theory, BitAutoData, CollectionCustomization] From 53ed608ba1118d29d026db3706075bb7fe02d201 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Tue, 21 May 2024 14:40:05 +1000 Subject: [PATCH 22/36] [AC-2604] Fix aggregation of CollectionGroup permissions (#4097) * Fix aggregation of CollectionGroup permissions - use MAX on Manage column instead of MIN --- .../Repositories/CollectionRepository.cs | 4 +- .../Collection_ReadByIdUserId.sql | 2 +- .../Collection_ReadByIdUserId_V2.sql | 2 +- .../Collection_ReadByUserId.sql | 2 +- .../Collection_ReadByUserId_V2.sql | 2 +- .../2024-05-20_00_FixManageAggregation.sql | 124 ++++++++++++++++++ 6 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 util/Migrator/DbScripts/2024-05-20_00_FixManageAggregation.sql diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs index ee6f1e8794..8773a69a81 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs @@ -340,7 +340,7 @@ public class CollectionRepository : Repository Convert.ToInt32(c.ReadOnly))), HidePasswords = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))), - Manage = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.Manage))), + Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), }) .ToList(); } @@ -365,7 +365,7 @@ public class CollectionRepository : Repository Convert.ToInt32(c.ReadOnly))), HidePasswords = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))), - Manage = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.Manage))), + Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), }).ToListAsync(); } } diff --git a/src/Sql/dbo/Stored Procedures/Collection_ReadByIdUserId.sql b/src/Sql/dbo/Stored Procedures/Collection_ReadByIdUserId.sql index 85f2584359..b38709507e 100644 --- a/src/Sql/dbo/Stored Procedures/Collection_ReadByIdUserId.sql +++ b/src/Sql/dbo/Stored Procedures/Collection_ReadByIdUserId.sql @@ -13,7 +13,7 @@ BEGIN ExternalId, MIN([ReadOnly]) AS [ReadOnly], MIN([HidePasswords]) AS [HidePasswords], - MIN([Manage]) AS [Manage] + MAX([Manage]) AS [Manage] FROM [dbo].[UserCollectionDetails](@UserId) WHERE diff --git a/src/Sql/dbo/Stored Procedures/Collection_ReadByIdUserId_V2.sql b/src/Sql/dbo/Stored Procedures/Collection_ReadByIdUserId_V2.sql index 42b61671a6..e753fc89ac 100644 --- a/src/Sql/dbo/Stored Procedures/Collection_ReadByIdUserId_V2.sql +++ b/src/Sql/dbo/Stored Procedures/Collection_ReadByIdUserId_V2.sql @@ -13,7 +13,7 @@ BEGIN ExternalId, MIN([ReadOnly]) AS [ReadOnly], MIN([HidePasswords]) AS [HidePasswords], - MIN([Manage]) AS [Manage] + MAX([Manage]) AS [Manage] FROM [dbo].[UserCollectionDetails_V2](@UserId) WHERE diff --git a/src/Sql/dbo/Stored Procedures/Collection_ReadByUserId.sql b/src/Sql/dbo/Stored Procedures/Collection_ReadByUserId.sql index b31c0c2b4e..f0eab509ac 100644 --- a/src/Sql/dbo/Stored Procedures/Collection_ReadByUserId.sql +++ b/src/Sql/dbo/Stored Procedures/Collection_ReadByUserId.sql @@ -13,7 +13,7 @@ BEGIN ExternalId, MIN([ReadOnly]) AS [ReadOnly], MIN([HidePasswords]) AS [HidePasswords], - MIN([Manage]) AS [Manage] + MAX([Manage]) AS [Manage] FROM [dbo].[UserCollectionDetails](@UserId) GROUP BY diff --git a/src/Sql/dbo/Stored Procedures/Collection_ReadByUserId_V2.sql b/src/Sql/dbo/Stored Procedures/Collection_ReadByUserId_V2.sql index adcca40a6d..4538dc8da0 100644 --- a/src/Sql/dbo/Stored Procedures/Collection_ReadByUserId_V2.sql +++ b/src/Sql/dbo/Stored Procedures/Collection_ReadByUserId_V2.sql @@ -13,7 +13,7 @@ BEGIN ExternalId, MIN([ReadOnly]) AS [ReadOnly], MIN([HidePasswords]) AS [HidePasswords], - MIN([Manage]) AS [Manage] + MAX([Manage]) AS [Manage] FROM [dbo].[UserCollectionDetails_V2](@UserId) GROUP BY diff --git a/util/Migrator/DbScripts/2024-05-20_00_FixManageAggregation.sql b/util/Migrator/DbScripts/2024-05-20_00_FixManageAggregation.sql new file mode 100644 index 0000000000..d4bd662787 --- /dev/null +++ b/util/Migrator/DbScripts/2024-05-20_00_FixManageAggregation.sql @@ -0,0 +1,124 @@ +-- We were aggregating CollectionGroup permissions using MIN([Manage]) instead of MAX. +-- If the user is a member of multiple groups with overlapping collection permissions, they should get the most +-- generous permissions, not the least. This is consistent with ReadOnly and HidePasswords columns. +-- Updating both current and V2 sprocs out of caution and because they still need to be reviewed/cleaned up. + +-- Collection_ReadByIdUserId +CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadByIdUserId] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + SELECT + Id, + OrganizationId, + [Name], + CreationDate, + RevisionDate, + ExternalId, + MIN([ReadOnly]) AS [ReadOnly], + MIN([HidePasswords]) AS [HidePasswords], + MAX([Manage]) AS [Manage] + FROM + [dbo].[UserCollectionDetails](@UserId) + WHERE + [Id] = @Id + GROUP BY + Id, + OrganizationId, + [Name], + CreationDate, + RevisionDate, + ExternalId +END +GO; + +-- Collection_ReadByIdUserId_V2 +CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadByIdUserId_V2] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + SELECT + Id, + OrganizationId, + [Name], + CreationDate, + RevisionDate, + ExternalId, + MIN([ReadOnly]) AS [ReadOnly], + MIN([HidePasswords]) AS [HidePasswords], + MAX([Manage]) AS [Manage] + FROM + [dbo].[UserCollectionDetails_V2](@UserId) + WHERE + [Id] = @Id + GROUP BY + Id, + OrganizationId, + [Name], + CreationDate, + RevisionDate, + ExternalId +END +GO; + +-- Collection_ReadByUserId +CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadByUserId] + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + Id, + OrganizationId, + [Name], + CreationDate, + RevisionDate, + ExternalId, + MIN([ReadOnly]) AS [ReadOnly], + MIN([HidePasswords]) AS [HidePasswords], + MAX([Manage]) AS [Manage] + FROM + [dbo].[UserCollectionDetails](@UserId) + GROUP BY + Id, + OrganizationId, + [Name], + CreationDate, + RevisionDate, + ExternalId +END +GO; + +-- Collection_ReadByUserId_V2 +CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadByUserId_V2] + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + Id, + OrganizationId, + [Name], + CreationDate, + RevisionDate, + ExternalId, + MIN([ReadOnly]) AS [ReadOnly], + MIN([HidePasswords]) AS [HidePasswords], + MAX([Manage]) AS [Manage] + FROM + [dbo].[UserCollectionDetails_V2](@UserId) + GROUP BY + Id, + OrganizationId, + [Name], + CreationDate, + RevisionDate, + ExternalId +END +GO; From 476e5adfbe134529c5cea8a6cbb8c57fe3af9fc5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 May 2024 11:51:40 +0200 Subject: [PATCH 23/36] [deps] Tools: Update LaunchDarkly.ServerSdk to v8.5.0 (#4105) 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 acb42deac3..43d6ca9898 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -57,7 +57,7 @@ - + From 1b47d237749ec9669a61c434be2cc0f2b3af0bab Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 May 2024 11:53:40 +0200 Subject: [PATCH 24/36] [deps] Tools: Update MailKit to v4.6.0 (#4106) 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 43d6ca9898..c1d522b92e 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -33,7 +33,7 @@ - + From 74fff55c18fa8b5acd8dfcd14303bd72e44c2964 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 May 2024 11:56:09 +0200 Subject: [PATCH 25/36] [deps] Tools: Update SignalR to v8.0.5 (#4103) 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 d6a38ff0d4..6d63155f4a 100644 --- a/src/Notifications/Notifications.csproj +++ b/src/Notifications/Notifications.csproj @@ -7,8 +7,8 @@ - - + + From f2242186d0726434eb2ebdd03fb53e9f9c8fa0d4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 May 2024 12:00:17 +0200 Subject: [PATCH 26/36] [deps] Tools: Update aws-sdk-net monorepo (#4104) 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 c1d522b92e..b667e3a3b1 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -21,8 +21,8 @@ - - + + From 87865e8f5cb2b9fe7e29391c1ff7109cacd2caa4 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Tue, 21 May 2024 11:31:22 -0400 Subject: [PATCH 27/36] [AC-2447] Update PutCollection to return Unavailable cipher when last Can Manage Access is Removed (#4074) * update CiphersController to return a unavailable value to the client so it can determine if the user removed the final Can Manage access of an item --- .../Vault/Controllers/CiphersController.cs | 30 +++++++ .../OptionalCipherDetailsResponseModel.cs | 13 +++ .../Controllers/CiphersControllerTests.cs | 90 +++++++++++++++++++ 3 files changed, 133 insertions(+) create mode 100644 src/Api/Vault/Models/Response/OptionalCipherDetailsResponseModel.cs diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 96de4317fd..0eb6469024 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -619,9 +619,39 @@ public class CiphersController : Controller var updatedCipher = await GetByIdAsync(id, userId); var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, id, UseFlexibleCollections); + return new CipherDetailsResponseModel(updatedCipher, _globalSettings, collectionCiphers); } + [HttpPut("{id}/collections_v2")] + [HttpPost("{id}/collections_v2")] + public async Task PutCollections_vNext(Guid id, [FromBody] CipherCollectionsRequestModel model) + { + var userId = _userService.GetProperUserId(User).Value; + var cipher = await GetByIdAsync(id, userId); + if (cipher == null || !cipher.OrganizationId.HasValue || + !await _currentContext.OrganizationUser(cipher.OrganizationId.Value)) + { + throw new NotFoundException(); + } + + await _cipherService.SaveCollectionsAsync(cipher, + model.CollectionIds.Select(c => new Guid(c)), userId, false); + + var updatedCipher = await GetByIdAsync(id, userId); + var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, id, UseFlexibleCollections); + // If a user removes the last Can Manage access of a cipher, the "updatedCipher" will return null + // We will be returning an "Unavailable" property so the client knows the user can no longer access this + var response = new OptionalCipherDetailsResponseModel() + { + Unavailable = updatedCipher is null, + Cipher = updatedCipher is null + ? null + : new CipherDetailsResponseModel(updatedCipher, _globalSettings, collectionCiphers) + }; + return response; + } + [HttpPut("{id}/collections-admin")] [HttpPost("{id}/collections-admin")] public async Task PutCollectionsAdmin(string id, [FromBody] CipherCollectionsRequestModel model) diff --git a/src/Api/Vault/Models/Response/OptionalCipherDetailsResponseModel.cs b/src/Api/Vault/Models/Response/OptionalCipherDetailsResponseModel.cs new file mode 100644 index 0000000000..018185e04f --- /dev/null +++ b/src/Api/Vault/Models/Response/OptionalCipherDetailsResponseModel.cs @@ -0,0 +1,13 @@ +using Bit.Api.Vault.Models.Response; +using Bit.Core.Models.Api; + +public class OptionalCipherDetailsResponseModel : ResponseModel +{ + public bool Unavailable { get; set; } + + public CipherDetailsResponseModel? Cipher { get; set; } + + public OptionalCipherDetailsResponseModel() + : base("optionalCipherDetails") + { } +} diff --git a/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs b/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs index 14f42db723..13f172af64 100644 --- a/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs +++ b/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs @@ -3,9 +3,11 @@ using Bit.Api.Vault.Controllers; using Bit.Api.Vault.Models.Request; using Bit.Core; using Bit.Core.Context; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations; +using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Models.Data; @@ -14,7 +16,9 @@ using Bit.Core.Vault.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; +using NSubstitute.ReturnsExtensions; using Xunit; +using CipherType = Bit.Core.Vault.Enums.CipherType; namespace Bit.Api.Test.Controllers; @@ -50,6 +54,92 @@ public class CiphersControllerTests Assert.Equal(isFavorite, result.Favorite); } + [Theory, BitAutoData] + public async Task PutCollections_vNextShouldThrowExceptionWhenCipherIsNullOrNoOrgValue(Guid id, CipherCollectionsRequestModel model, Guid userId, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetProperUserId(default).Returns(userId); + sutProvider.GetDependency().OrganizationUser(Guid.NewGuid()).Returns(false); + sutProvider.GetDependency().GetByIdAsync(id, userId, true).ReturnsNull(); + + var requestAction = async () => await sutProvider.Sut.PutCollections_vNext(id, model); + + await Assert.ThrowsAsync(requestAction); + } + + [Theory, BitAutoData] + public async Task PutCollections_vNextShouldSaveUpdatedCipher(Guid id, CipherCollectionsRequestModel model, Guid userId, SutProvider sutProvider) + { + SetupUserAndOrgMocks(id, userId, sutProvider); + var cipherDetails = CreateCipherDetailsMock(id, userId); + sutProvider.GetDependency().GetByIdAsync(id, userId, true).ReturnsForAnyArgs(cipherDetails); + + sutProvider.GetDependency().GetManyByUserIdCipherIdAsync(userId, id, Arg.Any()).Returns((ICollection)new List()); + var cipherService = sutProvider.GetDependency(); + + await sutProvider.Sut.PutCollections_vNext(id, model); + + await cipherService.ReceivedWithAnyArgs().SaveCollectionsAsync(default, default, default, default); + } + + [Theory, BitAutoData] + public async Task PutCollections_vNextReturnOptionalDetailsCipherUnavailableFalse(Guid id, CipherCollectionsRequestModel model, Guid userId, SutProvider sutProvider) + { + SetupUserAndOrgMocks(id, userId, sutProvider); + var cipherDetails = CreateCipherDetailsMock(id, userId); + sutProvider.GetDependency().GetByIdAsync(id, userId, true).ReturnsForAnyArgs(cipherDetails); + + sutProvider.GetDependency().GetManyByUserIdCipherIdAsync(userId, id, Arg.Any()).Returns((ICollection)new List()); + + var result = await sutProvider.Sut.PutCollections_vNext(id, model); + + Assert.IsType(result); + Assert.False(result.Unavailable); + } + + [Theory, BitAutoData] + public async Task PutCollections_vNextReturnOptionalDetailsCipherUnavailableTrue(Guid id, CipherCollectionsRequestModel model, Guid userId, SutProvider sutProvider) + { + SetupUserAndOrgMocks(id, userId, sutProvider); + var cipherDetails = CreateCipherDetailsMock(id, userId); + sutProvider.GetDependency().GetByIdAsync(id, userId, true).ReturnsForAnyArgs(cipherDetails, [(CipherDetails)null]); + + sutProvider.GetDependency().GetManyByUserIdCipherIdAsync(userId, id, Arg.Any()).Returns((ICollection)new List()); + + var result = await sutProvider.Sut.PutCollections_vNext(id, model); + + Assert.IsType(result); + Assert.True(result.Unavailable); + } + + private void SetupUserAndOrgMocks(Guid id, Guid userId, SutProvider sutProvider) + { + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs(userId); + sutProvider.GetDependency().OrganizationUser(default).ReturnsForAnyArgs(true); + sutProvider.GetDependency().GetManyByUserIdCipherIdAsync(userId, id, Arg.Any()).Returns(new List()); + } + + private CipherDetails CreateCipherDetailsMock(Guid id, Guid userId) + { + return new CipherDetails + { + Id = id, + UserId = userId, + OrganizationId = Guid.NewGuid(), + Type = CipherType.Login, + Data = @" + { + ""Uris"": [ + { + ""Uri"": ""https://bitwarden.com"" + } + ], + ""Username"": ""testuser"", + ""Password"": ""securepassword123"" + }" + }; + } + [Theory] [BitAutoData(OrganizationUserType.Admin, true, true)] [BitAutoData(OrganizationUserType.Owner, true, true)] From b863454a4e399a20a43797414183e3c43b335145 Mon Sep 17 00:00:00 2001 From: Alex Urbina <42731074+urbinaalex17@users.noreply.github.com> Date: Tue, 21 May 2024 10:54:07 -0600 Subject: [PATCH 28/36] BRE-40 Add step to report upcoming release version to Slack (#4090) * BRE-40 ADD: step to report upcoming release version to Slack * BRE-40 ADD: AZURE_KV_CI_SERVICE_PRINCIPAL secret to version-bump.yml workflow --- .github/workflows/version-bump.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index b848b9918a..57c8ac6fca 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -198,6 +198,13 @@ jobs: PR_NUMBER: ${{ steps.create-pr.outputs.pr_number }} run: gh pr merge $PR_NUMBER --squash --auto --delete-branch + - name: Report upcoming release version to Slack + if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} + uses: bitwarden/gh-actions/report-upcoming-release-version@main + with: + version: ${{ steps.set-final-version-output.outputs.version }} + project: ${{ github.repository }} + AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} cut_rc: name: Cut RC branch From aee180adfc19d31b3cae87bf75cbcfaa8b12be17 Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Tue, 21 May 2024 14:42:47 -0400 Subject: [PATCH 29/36] [PM-8004] Move Unmanaged collection logic out of component for better reuse (#4108) * Updated sprocs to return unmanaged collection column, updated reponse to return to return unmanaged * reformatted sproc --- .../Response/CollectionResponseModel.cs | 2 + .../Models/Data/CollectionAdminDetails.cs | 5 + .../Repositories/CollectionRepository.cs | 18 +- .../Queries/CollectionAdminDetailsQuery.cs | 15 +- .../Collection_ReadByIdWithPermissions.sql | 24 ++- ...on_ReadByOrganizationIdWithPermissions.sql | 24 ++- .../Repositories/CollectionRepositoryTests.cs | 6 + ...tionWithPermissionsAndUnmanagedQueries.sql | 171 ++++++++++++++++++ 8 files changed, 256 insertions(+), 9 deletions(-) create mode 100644 util/Migrator/DbScripts/2024-05-17_00_CollectionWithPermissionsAndUnmanagedQueries.sql diff --git a/src/Api/Models/Response/CollectionResponseModel.cs b/src/Api/Models/Response/CollectionResponseModel.cs index 253acbfdff..d56ef5469a 100644 --- a/src/Api/Models/Response/CollectionResponseModel.cs +++ b/src/Api/Models/Response/CollectionResponseModel.cs @@ -89,6 +89,7 @@ public class CollectionAccessDetailsResponseModel : CollectionResponseModel ReadOnly = collection.ReadOnly; HidePasswords = collection.HidePasswords; Manage = collection.Manage; + Unmanaged = collection.Unmanaged; Groups = collection.Groups?.Select(g => new SelectionReadOnlyResponseModel(g)) ?? Enumerable.Empty(); Users = collection.Users?.Select(g => new SelectionReadOnlyResponseModel(g)) ?? Enumerable.Empty(); } @@ -104,4 +105,5 @@ public class CollectionAccessDetailsResponseModel : CollectionResponseModel public bool ReadOnly { get; set; } public bool HidePasswords { get; set; } public bool Manage { get; set; } + public bool Unmanaged { get; set; } } diff --git a/src/Core/Models/Data/CollectionAdminDetails.cs b/src/Core/Models/Data/CollectionAdminDetails.cs index 8b96eb4dbd..036f7d0371 100644 --- a/src/Core/Models/Data/CollectionAdminDetails.cs +++ b/src/Core/Models/Data/CollectionAdminDetails.cs @@ -14,4 +14,9 @@ public class CollectionAdminDetails : CollectionDetails /// Flag for whether the user has been explicitly assigned to the collection either directly or through a group. /// public bool Assigned { get; set; } + + /// + /// Flag for whether a collection is managed by an active user or group. + /// + public bool Unmanaged { get; set; } } diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs index 8773a69a81..b1c5463733 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs @@ -391,7 +391,8 @@ public class CollectionRepository : Repository new CollectionAdminDetails { Id = collectionGroup.Key.Id, @@ -404,7 +405,8 @@ public class CollectionRepository : Repository Convert.ToInt32(c.HidePasswords))), Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), - Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))) + Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))), + Unmanaged = collectionGroup.Key.Unmanaged }).ToList(); } else @@ -417,7 +419,8 @@ public class CollectionRepository : Repository Convert.ToInt32(c.HidePasswords))), Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), - Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))) + Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))), + Unmanaged = collectionGroup.Key.Unmanaged }).ToListAsync(); } @@ -511,7 +515,8 @@ public class CollectionRepository : Repository Convert.ToInt32(c.HidePasswords))), Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), - Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))) + Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))), + Unmanaged = collectionGroup.Select(c => c.Unmanaged).FirstOrDefault() }).FirstOrDefault(); } else @@ -539,7 +544,8 @@ public class CollectionRepository : Repository Convert.ToInt32(c.HidePasswords))), Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), - Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))) + Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))), + Unmanaged = collectionGroup.Select(c => c.Unmanaged).FirstOrDefault() }).FirstOrDefaultAsync(); } diff --git a/src/Infrastructure.EntityFramework/Repositories/Queries/CollectionAdminDetailsQuery.cs b/src/Infrastructure.EntityFramework/Repositories/Queries/CollectionAdminDetailsQuery.cs index f7b4984946..6b0c313a34 100644 --- a/src/Infrastructure.EntityFramework/Repositories/Queries/CollectionAdminDetailsQuery.cs +++ b/src/Infrastructure.EntityFramework/Repositories/Queries/CollectionAdminDetailsQuery.cs @@ -1,4 +1,5 @@ -using Bit.Core.Models.Data; +using Bit.Core.Enums; +using Bit.Core.Models.Data; namespace Bit.Infrastructure.EntityFramework.Repositories.Queries; @@ -46,6 +47,17 @@ public class CollectionAdminDetailsQuery : IQuery from cg in cg_g.DefaultIfEmpty() select new { c, cu, cg }; + // Subqueries to determine if a colection is managed by an active user or group. + var activeUserManageRights = from cu in dbContext.CollectionUsers + join ou in dbContext.OrganizationUsers + on cu.OrganizationUserId equals ou.Id + where ou.Status == OrganizationUserStatusType.Confirmed && cu.Manage + select cu.CollectionId; + + var activeGroupManageRights = from cg in dbContext.CollectionGroups + where cg.Manage + select cg.CollectionId; + if (_organizationId.HasValue) { baseCollectionQuery = baseCollectionQuery.Where(x => x.c.OrganizationId == _organizationId); @@ -71,6 +83,7 @@ public class CollectionAdminDetailsQuery : IQuery HidePasswords = (bool?)x.cu.HidePasswords ?? (bool?)x.cg.HidePasswords ?? false, Manage = (bool?)x.cu.Manage ?? (bool?)x.cg.Manage ?? false, Assigned = x.cu != null || x.cg != null, + Unmanaged = !activeUserManageRights.Contains(x.c.Id) && !activeGroupManageRights.Contains(x.c.Id), }); } diff --git a/src/Sql/Vault/dbo/Stored Procedures/Collections/Collection_ReadByIdWithPermissions.sql b/src/Sql/Vault/dbo/Stored Procedures/Collections/Collection_ReadByIdWithPermissions.sql index 5cd6cc93ae..0d1df79c37 100644 --- a/src/Sql/Vault/dbo/Stored Procedures/Collections/Collection_ReadByIdWithPermissions.sql +++ b/src/Sql/Vault/dbo/Stored Procedures/Collections/Collection_ReadByIdWithPermissions.sql @@ -31,7 +31,29 @@ BEGIN CU.[CollectionId] IS NULL AND CG.[CollectionId] IS NULL THEN 0 ELSE 1 - END) AS [Assigned] + END) AS [Assigned], + CASE + WHEN + -- No active user or group has manage rights + NOT EXISTS( + SELECT 1 + FROM [dbo].[CollectionUser] CU2 + JOIN [dbo].[OrganizationUser] OU2 ON CU2.[OrganizationUserId] = OU2.[Id] + WHERE + CU2.[CollectionId] = C.[Id] AND + OU2.[Status] = 2 AND + CU2.[Manage] = 1 + ) + AND NOT EXISTS ( + SELECT 1 + FROM [dbo].[CollectionGroup] CG2 + WHERE + CG2.[CollectionId] = C.[Id] AND + CG2.[Manage] = 1 + ) + THEN 1 + ELSE 0 + END AS [Unmanaged] FROM [dbo].[CollectionView] C LEFT JOIN diff --git a/src/Sql/Vault/dbo/Stored Procedures/Collections/Collection_ReadByOrganizationIdWithPermissions.sql b/src/Sql/Vault/dbo/Stored Procedures/Collections/Collection_ReadByOrganizationIdWithPermissions.sql index 88905eb6c3..61384852b1 100644 --- a/src/Sql/Vault/dbo/Stored Procedures/Collections/Collection_ReadByOrganizationIdWithPermissions.sql +++ b/src/Sql/Vault/dbo/Stored Procedures/Collections/Collection_ReadByOrganizationIdWithPermissions.sql @@ -31,7 +31,29 @@ BEGIN CU.[CollectionId] IS NULL AND CG.[CollectionId] IS NULL THEN 0 ELSE 1 - END) AS [Assigned] + END) AS [Assigned], + CASE + WHEN + -- No active user or group has manage rights + NOT EXISTS( + SELECT 1 + FROM [dbo].[CollectionUser] CU2 + JOIN [dbo].[OrganizationUser] OU2 ON CU2.[OrganizationUserId] = OU2.[Id] + WHERE + CU2.[CollectionId] = C.[Id] AND + OU2.[Status] = 2 AND + CU2.[Manage] = 1 + ) + AND NOT EXISTS ( + SELECT 1 + FROM [dbo].[CollectionGroup] CG2 + WHERE + CG2.[CollectionId] = C.[Id] AND + CG2.[Manage] = 1 + ) + THEN 1 + ELSE 0 + END AS [Unmanaged] FROM [dbo].[CollectionView] C LEFT JOIN diff --git a/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionRepositoryTests.cs index dc420c8764..9b7e8f5196 100644 --- a/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionRepositoryTests.cs @@ -310,6 +310,7 @@ public class CollectionRepositoryTests Assert.True(c1.Manage); Assert.False(c1.ReadOnly); Assert.False(c1.HidePasswords); + Assert.False(c1.Unmanaged); }, c2 => { Assert.NotNull(c2); @@ -319,6 +320,7 @@ public class CollectionRepositoryTests Assert.False(c2.Manage); Assert.True(c2.ReadOnly); Assert.False(c2.HidePasswords); + Assert.True(c2.Unmanaged); }, c3 => { Assert.NotNull(c3); @@ -328,6 +330,7 @@ public class CollectionRepositoryTests Assert.False(c3.Manage); Assert.False(c3.ReadOnly); Assert.False(c3.HidePasswords); + Assert.False(c3.Unmanaged); }); } @@ -436,6 +439,7 @@ public class CollectionRepositoryTests Assert.True(c1.Manage); Assert.False(c1.ReadOnly); Assert.False(c1.HidePasswords); + Assert.False(c1.Unmanaged); }, c2 => { Assert.NotNull(c2); @@ -445,6 +449,7 @@ public class CollectionRepositoryTests Assert.False(c2.Manage); Assert.True(c2.ReadOnly); Assert.False(c2.HidePasswords); + Assert.True(c2.Unmanaged); }, c3 => { Assert.NotNull(c3); @@ -454,6 +459,7 @@ public class CollectionRepositoryTests Assert.True(c3.Manage); // Group 2 is Manage Assert.False(c3.ReadOnly); Assert.False(c3.HidePasswords); + Assert.False(c3.Unmanaged); }); } } diff --git a/util/Migrator/DbScripts/2024-05-17_00_CollectionWithPermissionsAndUnmanagedQueries.sql b/util/Migrator/DbScripts/2024-05-17_00_CollectionWithPermissionsAndUnmanagedQueries.sql new file mode 100644 index 0000000000..0e1526ca2a --- /dev/null +++ b/util/Migrator/DbScripts/2024-05-17_00_CollectionWithPermissionsAndUnmanagedQueries.sql @@ -0,0 +1,171 @@ +CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadByOrganizationIdWithPermissions] + @OrganizationId UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @IncludeAccessRelationships BIT +AS +BEGIN + SET NOCOUNT ON + + SELECT + C.*, + MIN(CASE + WHEN + COALESCE(CU.[ReadOnly], CG.[ReadOnly], 0) = 0 + THEN 0 + ELSE 1 + END) AS [ReadOnly], + MIN(CASE + WHEN + COALESCE(CU.[HidePasswords], CG.[HidePasswords], 0) = 0 + THEN 0 + ELSE 1 + END) AS [HidePasswords], + MAX(CASE + WHEN + COALESCE(CU.[Manage], CG.[Manage], 0) = 0 + THEN 0 + ELSE 1 + END) AS [Manage], + MAX(CASE + WHEN + CU.[CollectionId] IS NULL AND CG.[CollectionId] IS NULL + THEN 0 + ELSE 1 + END) AS [Assigned], + CASE + WHEN + -- No active user or group has manage rights + NOT EXISTS( + SELECT 1 + FROM [dbo].[CollectionUser] CU2 + JOIN [dbo].[OrganizationUser] OU2 ON CU2.[OrganizationUserId] = OU2.[Id] + WHERE + CU2.[CollectionId] = C.[Id] AND + OU2.[Status] = 2 AND + CU2.[Manage] = 1 + ) + AND NOT EXISTS ( + SELECT 1 + FROM [dbo].[CollectionGroup] CG2 + WHERE + CG2.[CollectionId] = C.[Id] AND + CG2.[Manage] = 1 + ) + THEN 1 + ELSE 0 + END AS [Unmanaged] + FROM + [dbo].[CollectionView] C + LEFT JOIN + [dbo].[OrganizationUser] OU ON C.[OrganizationId] = OU.[OrganizationId] AND OU.[UserId] = @UserId + LEFT JOIN + [dbo].[CollectionUser] CU ON CU.[CollectionId] = C.[Id] AND CU.[OrganizationUserId] = [OU].[Id] + LEFT JOIN + [dbo].[GroupUser] GU ON CU.[CollectionId] IS NULL AND GU.[OrganizationUserId] = OU.[Id] + LEFT JOIN + [dbo].[Group] G ON G.[Id] = GU.[GroupId] + LEFT JOIN + [dbo].[CollectionGroup] CG ON CG.[CollectionId] = C.[Id] AND CG.[GroupId] = GU.[GroupId] + WHERE + C.[OrganizationId] = @OrganizationId + GROUP BY + C.[Id], + C.[OrganizationId], + C.[Name], + C.[CreationDate], + C.[RevisionDate], + C.[ExternalId] + + IF (@IncludeAccessRelationships = 1) + BEGIN + EXEC [dbo].[CollectionGroup_ReadByOrganizationId] @OrganizationId + EXEC [dbo].[CollectionUser_ReadByOrganizationId] @OrganizationId + END +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadByIdWithPermissions] + @CollectionId UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @IncludeAccessRelationships BIT +AS +BEGIN + SET NOCOUNT ON + + SELECT + C.*, + MIN(CASE + WHEN + COALESCE(CU.[ReadOnly], CG.[ReadOnly], 0) = 0 + THEN 0 + ELSE 1 + END) AS [ReadOnly], + MIN (CASE + WHEN + COALESCE(CU.[HidePasswords], CG.[HidePasswords], 0) = 0 + THEN 0 + ELSE 1 + END) AS [HidePasswords], + MAX(CASE + WHEN + COALESCE(CU.[Manage], CG.[Manage], 0) = 0 + THEN 0 + ELSE 1 + END) AS [Manage], + MAX(CASE + WHEN + CU.[CollectionId] IS NULL AND CG.[CollectionId] IS NULL + THEN 0 + ELSE 1 + END) AS [Assigned], + CASE + WHEN + -- No active user or group has manage rights + NOT EXISTS( + SELECT 1 + FROM [dbo].[CollectionUser] CU2 + JOIN [dbo].[OrganizationUser] OU2 ON CU2.[OrganizationUserId] = OU2.[Id] + WHERE + CU2.[CollectionId] = C.[Id] AND + OU2.[Status] = 2 AND + CU2.[Manage] = 1 + ) + AND NOT EXISTS ( + SELECT 1 + FROM [dbo].[CollectionGroup] CG2 + WHERE + CG2.[CollectionId] = C.[Id] AND + CG2.[Manage] = 1 + ) + THEN 1 + ELSE 0 + END AS [Unmanaged] + FROM + [dbo].[CollectionView] C + LEFT JOIN + [dbo].[OrganizationUser] OU ON C.[OrganizationId] = OU.[OrganizationId] AND OU.[UserId] = @UserId + LEFT JOIN + [dbo].[CollectionUser] CU ON CU.[CollectionId] = C.[Id] AND CU.[OrganizationUserId] = [OU].[Id] + LEFT JOIN + [dbo].[GroupUser] GU ON CU.[CollectionId] IS NULL AND GU.[OrganizationUserId] = OU.[Id] + LEFT JOIN + [dbo].[Group] G ON G.[Id] = GU.[GroupId] + LEFT JOIN + [dbo].[CollectionGroup] CG ON CG.[CollectionId] = C.[Id] AND CG.[GroupId] = GU.[GroupId] + WHERE + C.[Id] = @CollectionId + GROUP BY + C.[Id], + C.[OrganizationId], + C.[Name], + C.[CreationDate], + C.[RevisionDate], + C.[ExternalId] + + IF (@IncludeAccessRelationships = 1) + BEGIN + EXEC [dbo].[CollectionGroup_ReadByCollectionId] @CollectionId + EXEC [dbo].[CollectionUser_ReadByCollectionId] @CollectionId + END +END +GO From 5ddb854f1a15836473802cce2ff9369e02831845 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 May 2024 13:12:43 -0700 Subject: [PATCH 30/36] [deps] Auth: Update azure azure-sdk-for-net monorepo (#3540) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ike <137194738+ike-kottlowski@users.noreply.github.com> --- src/Api/Api.csproj | 2 +- src/Core/Core.csproj | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj index 532c0f6e12..d8b2ab4753 100644 --- a/src/Api/Api.csproj +++ b/src/Api/Api.csproj @@ -34,7 +34,7 @@ - + diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index b667e3a3b1..48274b31b3 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -24,10 +24,10 @@ - - - - + + + + From b679f98c1908f8eef967e3b729ab352ac161879f Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Wed, 22 May 2024 19:15:44 +1000 Subject: [PATCH 31/36] [AC-2626] Re-run collection enhancements migration (#4111) * Re-run collection enhancements migration * Bump date * Disambiguate name --- ...ableAllOrgCollectionEnhancements_Rerun.sql | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 util/Migrator/DbScripts/2024-05-22_00_EnableAllOrgCollectionEnhancements_Rerun.sql diff --git a/util/Migrator/DbScripts/2024-05-22_00_EnableAllOrgCollectionEnhancements_Rerun.sql b/util/Migrator/DbScripts/2024-05-22_00_EnableAllOrgCollectionEnhancements_Rerun.sql new file mode 100644 index 0000000000..208d8fcb16 --- /dev/null +++ b/util/Migrator/DbScripts/2024-05-22_00_EnableAllOrgCollectionEnhancements_Rerun.sql @@ -0,0 +1,42 @@ +-- This script will enable collection enhancements for organizations that don't have Collection Enhancements enabled. +-- This is a copy/paste of an earlier migration script: 2024-04-25_00_EnableAllOrgCollectionEnhancements.sql. +-- The earlier migration was accidentally released for self-host before the feature was enabled for new organizations, +-- so there was a window in time where existing self-host organizations were migrated, but it was still possible to create +-- a new organization that needed migration. +-- This script is being re-run to catch any organizations created during that window. + +-- Step 1: Create a temporary table to store the Organizations with FlexibleCollections = 0 +SELECT [Id] AS [OrganizationId] +INTO #TempOrg +FROM [dbo].[Organization] +WHERE [FlexibleCollections] = 0 + +-- Step 2: Execute the stored procedure for each OrganizationId +DECLARE @OrganizationId UNIQUEIDENTIFIER; + +DECLARE OrgCursor CURSOR FOR +SELECT [OrganizationId] +FROM #TempOrg; + +OPEN OrgCursor; + +FETCH NEXT FROM OrgCursor INTO @OrganizationId; + +WHILE (@@FETCH_STATUS = 0) +BEGIN + -- Execute the stored procedure for the current OrganizationId + EXEC [dbo].[Organization_EnableCollectionEnhancements] @OrganizationId; + + -- Update the Organization to set FlexibleCollections = 1 + UPDATE [dbo].[Organization] + SET [FlexibleCollections] = 1 + WHERE [Id] = @OrganizationId; + + FETCH NEXT FROM OrgCursor INTO @OrganizationId; +END; + +CLOSE OrgCursor; +DEALLOCATE OrgCursor; + +-- Step 3: Drop the temporary table +DROP TABLE #TempOrg; From e3f3392ec67e734b7f2926b13712ad71cb3fd663 Mon Sep 17 00:00:00 2001 From: Bitwarden DevOps <106330231+bitwarden-devops-bot@users.noreply.github.com> Date: Wed, 22 May 2024 10:41:37 -0400 Subject: [PATCH 32/36] Bumped version to 2024.5.1 (#4113) --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 804c958d34..be81357bbe 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2024.5.0 + 2024.5.1 Bit.$(MSBuildProjectName) enable From 56c523f76ff99496455584381fc7f2fa10dd22e5 Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Wed, 22 May 2024 11:55:31 -0500 Subject: [PATCH 33/36] Allow for bulk updating `AuthRequest` database objects (#4053) * Declare a new repository interface method To facilitate a new bulk device login request approval workflow in the admin console we need to update `IAuthRequestRepisitory` (owned by Auth team) to include an`UpdateManyAsync()` method. It should accept a list of `AuthRequest` table objects, and implementations will do a very simple 1:1 update of the passed in data. This commit adds an `UpdateManyAsync()` method to the `AuthRequestRepository` interface. * Stub out method implementations to enable unit testing This commit stubs out implementations of `IAuthRequestRepository.UpdateManyAsync()` so the method signature can be called in unit tests. At this stage the methods are not implemented. * Assert a happy path integration test * Establish a user defined SQL type for Auth Requests To facilitate a bulk update operation for auth requests a new user defined type will need to be written that can be used as a table input to the stored procedure. This will follow a similar pattern to how the `OragnizationSponsorshipType` works and is used by the stored procedure `OrganizationSponsorship_UpdateMany`. * Establish a new stored procedure To facilitate the bulk updating of auth request table objects this commit adds a new stored procedure to update a collection of entities on `AuthRequest` table by their primary key. It updates all properties, for convention, but the endpoint created later will only change the `Approved`, `ResponseDate`, `Key`, `MasterPasswordHash`, and `AuthenticationDate` properties. * Apply a SQL server migration script This commit simply applies a migration script containing the new user defined type and stored procedure comitted previously. * Enable converting an `IEnumerable` to a `DataTable` The current pattern in place for bulk update stored procedures is to pass a `DataTable` through Dapper as an input for the update stored procedure being run. In order to facilitate the new bulk update procedure for the`AuthRequest` type we need a function added that can convert an `IEnumerable` to a `DataTable`. This is commit follows the convention of having a static class with a conversion method in a `Helpers` folder: `AuthRequestHelpers.ToDataTable()`. * Implement `Dapper/../AuthRequestRepository.UpdateMany()` This commit implements `AuthRequestRepository.UpdateMany()` for the Dapper implementation of `AuthRequestRepository`. It connects the stored procedure, `DataTable` converter, and Dapper-focused unit test commits written previously into one exposed method that can be referenced by service callers. * Implement `EntityFramework/../AuthRequestRepository.UpdateMany()` This commit implements the new `IAuthRequestRepository.UpdateManyAsync()`method in the Entity Framework skew of the repository layer. It checks to make sure the passed in list has auth requests, converts them all to an Entity Framework entity, and then uses `UpdateRange` to apply the whole thing over in the database context. * Assert that `UpdateManyAsync` can not create any new auth requests * Use a json object as stored procedure input * Fix the build * Continuing to troubleshoot the build * Move `AuthRequest_UpdateMany` to the Auth folder * Remove extra comment * Delete type that never got used * intentionally break a test * Unbreak it --- .../Repositories/IAuthRequestRepository.cs | 1 + .../Repositories/AuthRequestRepository.cs | 17 +++ .../Repositories/AuthRequestRepository.cs | 25 ++++ .../AuthRequest_UpdateMany.sql | 45 ++++++++ .../AuthRequestRepositoryTests.cs | 107 ++++++++++++++++++ .../2024-05-05_00_UpdateManyAuthRequests.sql | 45 ++++++++ 6 files changed, 240 insertions(+) create mode 100644 src/Sql/Auth/dbo/Stored Procedures/AuthRequest_UpdateMany.sql create mode 100644 util/Migrator/DbScripts/2024-05-05_00_UpdateManyAuthRequests.sql diff --git a/src/Core/Auth/Repositories/IAuthRequestRepository.cs b/src/Core/Auth/Repositories/IAuthRequestRepository.cs index b414b2206b..6662dd15fc 100644 --- a/src/Core/Auth/Repositories/IAuthRequestRepository.cs +++ b/src/Core/Auth/Repositories/IAuthRequestRepository.cs @@ -9,4 +9,5 @@ public interface IAuthRequestRepository : IRepository Task> GetManyByUserIdAsync(Guid userId); Task> GetManyPendingByOrganizationIdAsync(Guid organizationId); Task> GetManyAdminApprovalRequestsByManyIdsAsync(Guid organizationId, IEnumerable ids); + Task UpdateManyAsync(IEnumerable authRequests); } diff --git a/src/Infrastructure.Dapper/Auth/Repositories/AuthRequestRepository.cs b/src/Infrastructure.Dapper/Auth/Repositories/AuthRequestRepository.cs index 67e636b4dd..df68c06d05 100644 --- a/src/Infrastructure.Dapper/Auth/Repositories/AuthRequestRepository.cs +++ b/src/Infrastructure.Dapper/Auth/Repositories/AuthRequestRepository.cs @@ -1,4 +1,5 @@ using System.Data; +using System.Text.Json; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Data; using Bit.Core.Repositories; @@ -74,4 +75,20 @@ public class AuthRequestRepository : Repository, IAuthRequest return results.ToList(); } } + + public async Task UpdateManyAsync(IEnumerable authRequests) + { + if (!authRequests.Any()) + { + return; + } + + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.ExecuteAsync( + $"[dbo].[AuthRequest_UpdateMany]", + new { jsonData = JsonSerializer.Serialize(authRequests) }, + commandType: CommandType.StoredProcedure); + } + } } diff --git a/src/Infrastructure.EntityFramework/Auth/Repositories/AuthRequestRepository.cs b/src/Infrastructure.EntityFramework/Auth/Repositories/AuthRequestRepository.cs index af3ae195dc..11e5b3f65c 100644 --- a/src/Infrastructure.EntityFramework/Auth/Repositories/AuthRequestRepository.cs +++ b/src/Infrastructure.EntityFramework/Auth/Repositories/AuthRequestRepository.cs @@ -69,4 +69,29 @@ public class AuthRequestRepository : Repository authRequests) + { + if (!authRequests.Any()) + { + return; + } + + var entities = new List(); + foreach (var authRequest in authRequests) + { + if (!authRequest.Id.Equals(default)) + { + var entity = Mapper.Map(authRequest); + entities.Add(entity); + } + } + + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + dbContext.UpdateRange(entities); + await dbContext.SaveChangesAsync(); + } + } } diff --git a/src/Sql/Auth/dbo/Stored Procedures/AuthRequest_UpdateMany.sql b/src/Sql/Auth/dbo/Stored Procedures/AuthRequest_UpdateMany.sql new file mode 100644 index 0000000000..227abbb3e1 --- /dev/null +++ b/src/Sql/Auth/dbo/Stored Procedures/AuthRequest_UpdateMany.sql @@ -0,0 +1,45 @@ +CREATE PROCEDURE AuthRequest_UpdateMany + @jsonData NVARCHAR(MAX) +AS +BEGIN + UPDATE AR + SET + [Id] = ARI.[Id], + [UserId] = ARI.[UserId], + [Type] = ARI.[Type], + [RequestDeviceIdentifier] = ARI.[RequestDeviceIdentifier], + [RequestDeviceType] = ARI.[RequestDeviceType], + [RequestIpAddress] = ARI.[RequestIpAddress], + [ResponseDeviceId] = ARI.[ResponseDeviceId], + [AccessCode] = ARI.[AccessCode], + [PublicKey] = ARI.[PublicKey], + [Key] = ARI.[Key], + [MasterPasswordHash] = ARI.[MasterPasswordHash], + [Approved] = ARI.[Approved], + [CreationDate] = ARI.[CreationDate], + [ResponseDate] = ARI.[ResponseDate], + [AuthenticationDate] = ARI.[AuthenticationDate], + [OrganizationId] = ARI.[OrganizationId] + FROM + [dbo].[AuthRequest] AR + INNER JOIN + OPENJSON(@jsonData) + WITH ( + Id UNIQUEIDENTIFIER '$.Id', + UserId UNIQUEIDENTIFIER '$.UserId', + Type SMALLINT '$.Type', + RequestDeviceIdentifier NVARCHAR(50) '$.RequestDeviceIdentifier', + RequestDeviceType SMALLINT '$.RequestDeviceType', + RequestIpAddress VARCHAR(50) '$.RequestIpAddress', + ResponseDeviceId UNIQUEIDENTIFIER '$.ResponseDeviceId', + AccessCode VARCHAR(25) '$.AccessCode', + PublicKey VARCHAR(MAX) '$.PublicKey', + [Key] VARCHAR(MAX) '$.Key', + MasterPasswordHash VARCHAR(MAX) '$.MasterPasswordHash', + Approved BIT '$.Approved', + CreationDate DATETIME2 '$.CreationDate', + ResponseDate DATETIME2 '$.ResponseDate', + AuthenticationDate DATETIME2 '$.AuthenticationDate', + OrganizationId UNIQUEIDENTIFIER '$.OrganizationId' + ) ARI ON AR.Id = ARI.Id; +END diff --git a/test/Infrastructure.IntegrationTest/Auth/Repositories/AuthRequestRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Auth/Repositories/AuthRequestRepositoryTests.cs index 6c5bf135e1..835d1da74c 100644 --- a/test/Infrastructure.IntegrationTest/Auth/Repositories/AuthRequestRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Auth/Repositories/AuthRequestRepositoryTests.cs @@ -72,6 +72,113 @@ public class AuthRequestRepositoryTests Assert.Equal(4, numberOfDeleted); } + [DatabaseTheory, DatabaseData] + public async Task UpdateManyAsync_Works( + IAuthRequestRepository authRequestRepository, + IUserRepository userRepository) + { + // Create two distinct real users for foreign key requirements + var user1 = await userRepository.CreateAsync(new User + { + Name = "First Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var user2 = await userRepository.CreateAsync(new User + { + Name = "Second Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var user3 = await userRepository.CreateAsync(new User + { + Name = "Third Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + // Create two different and still valid (not expired or responded to) auth requests + var authRequests = new List + { + await authRequestRepository.CreateAsync(CreateAuthRequest(user1.Id, AuthRequestType.AdminApproval, DateTime.UtcNow.AddMinutes(-5))), + await authRequestRepository.CreateAsync(CreateAuthRequest(user3.Id, AuthRequestType.AdminApproval, DateTime.UtcNow.AddMinutes(-7))), + await authRequestRepository.CreateAsync(CreateAuthRequest(user2.Id, AuthRequestType.AdminApproval, DateTime.UtcNow.AddMinutes(-10))), + // This last auth request is not created manually, and will be + // used to make sure entity framework's `UpdateRange` method + // doesn't create requests too. + CreateAuthRequest(user2.Id, AuthRequestType.AdminApproval, DateTime.UtcNow.AddMinutes(-11)) + }; + + // Update some properties on two auth request, but leave the other one + // alone to be a control value + var authRequestToBeUpdated1 = authRequests[0]; + var authRequestToBeUpdated2 = authRequests[1]; + var authRequestNotToBeUpdated = authRequests[2]; + authRequests[0].Approved = true; + authRequests[0].ResponseDate = DateTime.UtcNow.AddMinutes(-1); + authRequests[0].Key = "UPDATED_KEY_1"; + authRequests[0].MasterPasswordHash = "UPDATED_MASTERPASSWORDHASH_1"; + + authRequests[1].Approved = false; + authRequests[1].ResponseDate = DateTime.UtcNow.AddMinutes(-2); + + // Run the method being tested + await authRequestRepository.UpdateManyAsync(authRequests); + + // Define what "Equality" really means in this context + // This includes stripping milliseconds off of dates, because we can't + // reliably compare that deep + static DateTime? TrimMilliseconds(DateTime? dt) + { + if (!dt.HasValue) + { + return null; + } + return new DateTime(dt.Value.Year, dt.Value.Month, dt.Value.Day, dt.Value.Hour, dt.Value.Minute, dt.Value.Second, 0, dt.Value.Kind); + } + + bool AuthRequestEquals(AuthRequest x, AuthRequest y) + { + return + x.Id == y.Id && + x.UserId == y.UserId && + x.Type == y.Type && + x.RequestDeviceIdentifier == y.RequestDeviceIdentifier && + x.RequestDeviceType == y.RequestDeviceType && + x.RequestIpAddress == y.RequestIpAddress && + x.ResponseDeviceId == y.ResponseDeviceId && + x.AccessCode == y.AccessCode && + x.PublicKey == y.PublicKey && + x.Key == y.Key && + x.MasterPasswordHash == y.MasterPasswordHash && + x.Approved == y.Approved && + TrimMilliseconds(x.CreationDate) == TrimMilliseconds(y.CreationDate) && + TrimMilliseconds(x.ResponseDate) == TrimMilliseconds(y.ResponseDate) && + TrimMilliseconds(x.AuthenticationDate) == TrimMilliseconds(y.AuthenticationDate) && + x.OrganizationId == y.OrganizationId; + } + + // Assert that the unchanged auth request is still unchanged + var skippedAuthRequest = await authRequestRepository.GetByIdAsync(authRequestNotToBeUpdated.Id); + Assert.True(AuthRequestEquals(skippedAuthRequest, authRequestNotToBeUpdated)); + + // Assert that the values updated on the changed auth requests were updated, and no others + var updatedAuthRequest1 = await authRequestRepository.GetByIdAsync(authRequestToBeUpdated1.Id); + Assert.True(AuthRequestEquals(authRequestToBeUpdated1, updatedAuthRequest1)); + var updatedAuthRequest2 = await authRequestRepository.GetByIdAsync(authRequestToBeUpdated2.Id); + Assert.True(AuthRequestEquals(authRequestToBeUpdated2, updatedAuthRequest2)); + + // Assert that the auth request we never created is not created by + // the update method. + var uncreatedAuthRequest = await authRequestRepository.GetByIdAsync(authRequests[3].Id); + Assert.Null(uncreatedAuthRequest); + } + private static AuthRequest CreateAuthRequest(Guid userId, AuthRequestType authRequestType, DateTime creationDate, bool? approved = null, DateTime? responseDate = null) { return new AuthRequest diff --git a/util/Migrator/DbScripts/2024-05-05_00_UpdateManyAuthRequests.sql b/util/Migrator/DbScripts/2024-05-05_00_UpdateManyAuthRequests.sql new file mode 100644 index 0000000000..227abbb3e1 --- /dev/null +++ b/util/Migrator/DbScripts/2024-05-05_00_UpdateManyAuthRequests.sql @@ -0,0 +1,45 @@ +CREATE PROCEDURE AuthRequest_UpdateMany + @jsonData NVARCHAR(MAX) +AS +BEGIN + UPDATE AR + SET + [Id] = ARI.[Id], + [UserId] = ARI.[UserId], + [Type] = ARI.[Type], + [RequestDeviceIdentifier] = ARI.[RequestDeviceIdentifier], + [RequestDeviceType] = ARI.[RequestDeviceType], + [RequestIpAddress] = ARI.[RequestIpAddress], + [ResponseDeviceId] = ARI.[ResponseDeviceId], + [AccessCode] = ARI.[AccessCode], + [PublicKey] = ARI.[PublicKey], + [Key] = ARI.[Key], + [MasterPasswordHash] = ARI.[MasterPasswordHash], + [Approved] = ARI.[Approved], + [CreationDate] = ARI.[CreationDate], + [ResponseDate] = ARI.[ResponseDate], + [AuthenticationDate] = ARI.[AuthenticationDate], + [OrganizationId] = ARI.[OrganizationId] + FROM + [dbo].[AuthRequest] AR + INNER JOIN + OPENJSON(@jsonData) + WITH ( + Id UNIQUEIDENTIFIER '$.Id', + UserId UNIQUEIDENTIFIER '$.UserId', + Type SMALLINT '$.Type', + RequestDeviceIdentifier NVARCHAR(50) '$.RequestDeviceIdentifier', + RequestDeviceType SMALLINT '$.RequestDeviceType', + RequestIpAddress VARCHAR(50) '$.RequestIpAddress', + ResponseDeviceId UNIQUEIDENTIFIER '$.ResponseDeviceId', + AccessCode VARCHAR(25) '$.AccessCode', + PublicKey VARCHAR(MAX) '$.PublicKey', + [Key] VARCHAR(MAX) '$.Key', + MasterPasswordHash VARCHAR(MAX) '$.MasterPasswordHash', + Approved BIT '$.Approved', + CreationDate DATETIME2 '$.CreationDate', + ResponseDate DATETIME2 '$.ResponseDate', + AuthenticationDate DATETIME2 '$.AuthenticationDate', + OrganizationId UNIQUEIDENTIFIER '$.OrganizationId' + ) ARI ON AR.Id = ARI.Id; +END From 4264fc07294fe27d1d69f22ac7f89d665d448377 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Wed, 22 May 2024 12:59:19 -0400 Subject: [PATCH 34/36] [PM-7004] Org Admin Initiate Delete (#3905) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * org delete * move org id to URL path * tweaks * lint fixes * Update src/Core/Services/Implementations/HandlebarsMailService.cs Co-authored-by: Rui Tomé <108268980+r-tome@users.noreply.github.com> * Update src/Core/Services/Implementations/HandlebarsMailService.cs Co-authored-by: Rui Tomé <108268980+r-tome@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Rui Tomé <108268980+r-tome@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Rui Tomé <108268980+r-tome@users.noreply.github.com> * PR feedback * fix id * [PM-7004] Move OrgDeleteTokenable to AdminConsole ownership * [PM-7004] Add consolidated billing logic into organization delete request acceptance endpoint * [PM-7004] Delete unused IOrganizationService.DeleteAsync(Organization organization, string token) method * [PM-7004] Fix unit tests * [PM-7004] Update delete organization request email templates * Add success message when initiating organization deletion * Refactor OrganizationsController request delete initiation action to handle exceptions --------- Co-authored-by: Rui Tomé <108268980+r-tome@users.noreply.github.com> Co-authored-by: Rui Tome --- .../Controllers/OrganizationsController.cs | 29 ++++++++ .../Models/OrganizationInitiateDeleteModel.cs | 12 +++ .../Views/Organizations/Edit.cshtml | 73 ++++++++++++------- src/Admin/Views/Shared/_Layout.cshtml | 8 ++ .../Controllers/OrganizationsController.cs | 38 +++++++++- ...nizationVerifyDeleteRecoverRequestModel.cs | 9 +++ .../Business/Tokenables/OrgDeleteTokenable.cs | 32 ++++++++ .../Services/IOrganizationService.cs | 1 + .../Implementations/OrganizationService.cs | 21 ++++++ .../InitiateDeleteOrganzation.html.hbs | 39 ++++++++++ .../InitiateDeleteOrganzation.text.hbs | 17 +++++ .../Mail/OrganizationInitiateDeleteModel.cs | 23 ++++++ src/Core/Services/IMailService.cs | 1 + .../Implementations/HandlebarsMailService.cs | 24 ++++++ .../NoopImplementations/NoopMailService.cs | 5 ++ src/Core/Utilities/ModelStateExtensions.cs | 16 ++++ .../Utilities/ServiceCollectionExtensions.cs | 7 ++ .../OrganizationsControllerTests.cs | 7 +- 18 files changed, 334 insertions(+), 28 deletions(-) create mode 100644 src/Admin/AdminConsole/Models/OrganizationInitiateDeleteModel.cs create mode 100644 src/Api/AdminConsole/Models/Request/Organizations/OrganizationVerifyDeleteRecoverRequestModel.cs create mode 100644 src/Core/AdminConsole/Models/Business/Tokenables/OrgDeleteTokenable.cs create mode 100644 src/Core/MailTemplates/Handlebars/InitiateDeleteOrganzation.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/InitiateDeleteOrganzation.text.hbs create mode 100644 src/Core/Models/Mail/OrganizationInitiateDeleteModel.cs create mode 100644 src/Core/Utilities/ModelStateExtensions.cs diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index 19b4506860..88c0467460 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -269,6 +269,35 @@ public class OrganizationsController : Controller return RedirectToAction("Index"); } + [HttpPost] + [ValidateAntiForgeryToken] + [RequirePermission(Permission.Org_Delete)] + public async Task DeleteInitiation(Guid id, OrganizationInitiateDeleteModel model) + { + if (!ModelState.IsValid) + { + TempData["Error"] = ModelState.GetErrorMessage(); + } + else + { + try + { + var organization = await _organizationRepository.GetByIdAsync(id); + if (organization != null) + { + await _organizationService.InitiateDeleteAsync(organization, model.AdminEmail); + TempData["Success"] = "The request to initiate deletion of the organization has been sent."; + } + } + catch (Exception ex) + { + TempData["Error"] = ex.Message; + } + } + + return RedirectToAction("Edit", new { id }); + } + public async Task TriggerBillingSync(Guid id) { var organization = await _organizationRepository.GetByIdAsync(id); diff --git a/src/Admin/AdminConsole/Models/OrganizationInitiateDeleteModel.cs b/src/Admin/AdminConsole/Models/OrganizationInitiateDeleteModel.cs new file mode 100644 index 0000000000..5e9055be55 --- /dev/null +++ b/src/Admin/AdminConsole/Models/OrganizationInitiateDeleteModel.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Admin.AdminConsole.Models; + +public class OrganizationInitiateDeleteModel +{ + [Required] + [EmailAddress] + [StringLength(256)] + [Display(Name = "Admin Email")] + public string AdminEmail { get; set; } +} diff --git a/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml b/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml index 23ba63bbeb..ad64e6e4f5 100644 --- a/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml +++ b/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml @@ -1,4 +1,4 @@ -@using Bit.Admin.Enums; +@using Bit.Admin.Enums; @using Bit.Admin.Models @using Bit.Core.Enums @inject Bit.Admin.Services.IAccessControlService AccessControlService @@ -18,24 +18,43 @@ } + @if (TempData["Success"] != null) + { + + } @RenderSection("Scripts", required: false) diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 917da4aaff..979c5d16d4 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -11,6 +11,7 @@ using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Response; using Bit.Core; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements.Interfaces; @@ -26,6 +27,7 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.Core.Tokens; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -54,6 +56,7 @@ public class OrganizationsController : Controller private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand; private readonly IProviderRepository _providerRepository; private readonly IScaleSeatsCommand _scaleSeatsCommand; + private readonly IDataProtectorTokenFactory _orgDeleteTokenDataFactory; public OrganizationsController( IOrganizationRepository organizationRepository, @@ -73,7 +76,8 @@ public class OrganizationsController : Controller IPushNotificationService pushNotificationService, IOrganizationEnableCollectionEnhancementsCommand organizationEnableCollectionEnhancementsCommand, IProviderRepository providerRepository, - IScaleSeatsCommand scaleSeatsCommand) + IScaleSeatsCommand scaleSeatsCommand, + IDataProtectorTokenFactory orgDeleteTokenDataFactory) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -93,6 +97,7 @@ public class OrganizationsController : Controller _organizationEnableCollectionEnhancementsCommand = organizationEnableCollectionEnhancementsCommand; _providerRepository = providerRepository; _scaleSeatsCommand = scaleSeatsCommand; + _orgDeleteTokenDataFactory = orgDeleteTokenDataFactory; } [HttpGet("{id}")] @@ -279,6 +284,37 @@ public class OrganizationsController : Controller await _organizationService.DeleteAsync(organization); } + [HttpPost("{id}/delete-recover-token")] + [AllowAnonymous] + public async Task PostDeleteRecoverToken(Guid id, [FromBody] OrganizationVerifyDeleteRecoverRequestModel model) + { + var organization = await _organizationRepository.GetByIdAsync(id); + if (organization == null) + { + throw new NotFoundException(); + } + + if (!_orgDeleteTokenDataFactory.TryUnprotect(model.Token, out var data) || !data.IsValid(organization)) + { + throw new BadRequestException("Invalid token."); + } + + var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); + if (consolidatedBillingEnabled && organization.IsValidClient()) + { + var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id); + if (provider.IsBillable()) + { + await _scaleSeatsCommand.ScalePasswordManagerSeats( + provider, + organization.PlanType, + -organization.Seats ?? 0); + } + } + + await _organizationService.DeleteAsync(organization); + } + [HttpPost("{id}/import")] public async Task Import(string id, [FromBody] ImportOrganizationUsersRequestModel model) { diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationVerifyDeleteRecoverRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationVerifyDeleteRecoverRequestModel.cs new file mode 100644 index 0000000000..36dba6ed98 --- /dev/null +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationVerifyDeleteRecoverRequestModel.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.AdminConsole.Models.Request.Organizations; + +public class OrganizationVerifyDeleteRecoverRequestModel +{ + [Required] + public string Token { get; set; } +} diff --git a/src/Core/AdminConsole/Models/Business/Tokenables/OrgDeleteTokenable.cs b/src/Core/AdminConsole/Models/Business/Tokenables/OrgDeleteTokenable.cs new file mode 100644 index 0000000000..6a769010a2 --- /dev/null +++ b/src/Core/AdminConsole/Models/Business/Tokenables/OrgDeleteTokenable.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.Models.Business.Tokenables; + +public class OrgDeleteTokenable : Tokens.ExpiringTokenable +{ + public const string ClearTextPrefix = ""; + public const string DataProtectorPurpose = "OrgDeleteDataProtector"; + public const string TokenIdentifier = "OrgDelete"; + public string Identifier { get; set; } = TokenIdentifier; + public Guid Id { get; set; } + + [JsonConstructor] + public OrgDeleteTokenable(DateTime expirationDate) + { + ExpirationDate = expirationDate; + } + + public OrgDeleteTokenable(Organization organization, int hoursTillExpiration) + { + Id = organization.Id; + ExpirationDate = DateTime.UtcNow.AddHours(hoursTillExpiration); + } + + public bool IsValid(Organization organization) + { + return Id == organization.Id; + } + + protected override bool TokenIsValid() => Identifier == TokenIdentifier && Id != default; +} diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index 86611bdd5d..0d2472b95c 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -34,6 +34,7 @@ public interface IOrganizationService /// Task<(Organization organization, OrganizationUser organizationUser)> SignUpAsync(OrganizationLicense license, User owner, string ownerKey, string collectionName, string publicKey, string privateKey); + Task InitiateDeleteAsync(Organization organization, string orgAdminEmail); Task DeleteAsync(Organization organization); Task EnableAsync(Guid organizationId, DateTime? expirationDate); Task DisableAsync(Guid organizationId, DateTime? expirationDate); diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 8bf86a8eee..f32b29d83e 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Repositories; @@ -60,6 +61,7 @@ public class OrganizationService : IOrganizationService private readonly IProviderUserRepository _providerUserRepository; private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery; private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; + private readonly IDataProtectorTokenFactory _orgDeleteTokenDataFactory; private readonly IProviderRepository _providerRepository; private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory; private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; @@ -94,6 +96,7 @@ public class OrganizationService : IOrganizationService IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory, IDataProtectorTokenFactory orgUserInviteTokenDataFactory, IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, + IDataProtectorTokenFactory orgDeleteTokenDataFactory, IProviderRepository providerRepository, IFeatureService featureService) { @@ -123,6 +126,7 @@ public class OrganizationService : IOrganizationService _providerUserRepository = providerUserRepository; _countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery; _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; + _orgDeleteTokenDataFactory = orgDeleteTokenDataFactory; _providerRepository = providerRepository; _orgUserInviteTokenableFactory = orgUserInviteTokenableFactory; _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; @@ -811,6 +815,23 @@ public class OrganizationService : IOrganizationService } } + public async Task InitiateDeleteAsync(Organization organization, string orgAdminEmail) + { + var orgAdmin = await _userRepository.GetByEmailAsync(orgAdminEmail); + if (orgAdmin == null) + { + throw new BadRequestException("Org admin not found."); + } + var orgAdminOrgUser = await _organizationUserRepository.GetDetailsByUserAsync(orgAdmin.Id, organization.Id); + if (orgAdminOrgUser == null || orgAdminOrgUser.Status != OrganizationUserStatusType.Confirmed || + (orgAdminOrgUser.Type != OrganizationUserType.Admin && orgAdminOrgUser.Type != OrganizationUserType.Owner)) + { + throw new BadRequestException("Org admin not found."); + } + var token = _orgDeleteTokenDataFactory.Protect(new OrgDeleteTokenable(organization, 1)); + await _mailService.SendInitiateDeleteOrganzationEmailAsync(orgAdminEmail, organization, token); + } + public async Task DeleteAsync(Organization organization) { await ValidateDeleteOrganizationAsync(organization); diff --git a/src/Core/MailTemplates/Handlebars/InitiateDeleteOrganzation.html.hbs b/src/Core/MailTemplates/Handlebars/InitiateDeleteOrganzation.html.hbs new file mode 100644 index 0000000000..7118fcfef4 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/InitiateDeleteOrganzation.html.hbs @@ -0,0 +1,39 @@ +{{#>FullHtmlLayout}} + + + + + + + + + + + + + + + + +
+ We recently received your request to permanently delete the following Bitwarden organization: +
+ Name: {{OrganizationName}}
+ ID: {{OrganizationId}}
+ Created: {{OrganizationCreationDate}} at {{OrganizationCreationTime}} {{TimeZone}}
+ Plan: {{OrganizationPlan}}
+ Number of seats: {{OrganizationSeats}}
+ Billing email address: {{OrganizationBillingEmail}} +
+ Click the link below to delete your Bitwarden organization. +
+ If you did not request this email to delete your Bitwarden organization, please contact us. +
+
+
+ + Delete Your Organization + +
+
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/InitiateDeleteOrganzation.text.hbs b/src/Core/MailTemplates/Handlebars/InitiateDeleteOrganzation.text.hbs new file mode 100644 index 0000000000..0977438381 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/InitiateDeleteOrganzation.text.hbs @@ -0,0 +1,17 @@ +{{#>BasicTextLayout}} +We recently received your request to permanently delete the following Bitwarden organization: + +- Name: {{OrganizationName}} +- ID: {{OrganizationId}} +- Created: {{OrganizationCreationDate}} at {{OrganizationCreationTime}} {{TimeZone}} +- Plan: {{OrganizationPlan}} +- Number of seats: {{OrganizationSeats}} +- Billing email address: {{OrganizationBillingEmail}} + +Click the link below to complete the deletion of your organization. + +If you did not request this email to delete your Bitwarden organization, please contact us. + +{{{Url}}} + +{{/BasicTextLayout}} diff --git a/src/Core/Models/Mail/OrganizationInitiateDeleteModel.cs b/src/Core/Models/Mail/OrganizationInitiateDeleteModel.cs new file mode 100644 index 0000000000..4e13abf656 --- /dev/null +++ b/src/Core/Models/Mail/OrganizationInitiateDeleteModel.cs @@ -0,0 +1,23 @@ +using Bit.Core.Models.Mail; + +namespace Bit.Core.Auth.Models.Mail; + +public class OrganizationInitiateDeleteModel : BaseMailModel +{ + public string Url => string.Format("{0}/verify-recover-delete-org?orgId={1}&token={2}&name={3}", + WebVaultUrl, + OrganizationId, + Token, + OrganizationNameUrlEncoded); + + public string Token { get; set; } + public Guid OrganizationId { get; set; } + public string OrganizationName { get; set; } + public string OrganizationNameUrlEncoded { get; set; } + public string OrganizationPlan { get; set; } + public string OrganizationSeats { get; set; } + public string OrganizationBillingEmail { get; set; } + public string OrganizationCreationDate { get; set; } + public string OrganizationCreationTime { get; set; } + public string TimeZone { get; set; } +} diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 7943eca955..4db8f14fd6 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -79,5 +79,6 @@ public interface IMailService Task SendTrustedDeviceAdminApprovalEmailAsync(string email, DateTime utcNow, string ip, string deviceTypeAndIdentifier); Task SendTrialInitiationEmailAsync(string email); Task SendInitiateDeletProviderEmailAsync(string email, Provider provider, string token); + Task SendInitiateDeleteOrganzationEmailAsync(string email, Organization organization, string token); } diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 496d0ea0c3..7e8de10ce8 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -1002,6 +1002,30 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendInitiateDeleteOrganzationEmailAsync(string email, Organization organization, string token) + { + var message = CreateDefaultMessage("Request to Delete Your Organization", email); + var model = new OrganizationInitiateDeleteModel + { + Token = WebUtility.UrlEncode(token), + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName, + OrganizationId = organization.Id, + OrganizationName = CoreHelpers.SanitizeForEmail(organization.DisplayName(), false), + OrganizationNameUrlEncoded = WebUtility.UrlEncode(organization.Name), + OrganizationBillingEmail = organization.BillingEmail, + OrganizationPlan = organization.Plan, + OrganizationSeats = organization.Seats.ToString(), + OrganizationCreationDate = organization.CreationDate.ToLongDateString(), + OrganizationCreationTime = organization.CreationDate.ToShortTimeString(), + TimeZone = _utcTimeZoneDisplay, + }; + await AddMessageContentAsync(message, "InitiateDeleteOrganzation", model); + message.MetaData.Add("SendGridBypassListManagement", true); + message.Category = "InitiateDeleteOrganzation"; + await _mailDeliveryService.SendEmailAsync(message); + } + private static string GetUserIdentifier(string email, string userName) { return string.IsNullOrEmpty(userName) ? email : CoreHelpers.SanitizeForEmail(userName, false); diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index f86d40a198..198738e3d8 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -270,5 +270,10 @@ public class NoopMailService : IMailService } public Task SendInitiateDeletProviderEmailAsync(string email, Provider provider, string token) => throw new NotImplementedException(); + + public Task SendInitiateDeleteOrganzationEmailAsync(string email, Organization organization, string token) + { + return Task.FromResult(0); + } } diff --git a/src/Core/Utilities/ModelStateExtensions.cs b/src/Core/Utilities/ModelStateExtensions.cs new file mode 100644 index 0000000000..dc4f95c156 --- /dev/null +++ b/src/Core/Utilities/ModelStateExtensions.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Bit.Core.Utilities; + +public static class ModelStateExtensions +{ + public static string GetErrorMessage(this ModelStateDictionary modelState) + { + var errors = modelState.Values + .SelectMany(v => v.Errors) + .Select(e => e.ErrorMessage) + .ToList(); + + return string.Join("; ", errors); + } +} diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index bc3c68e87e..66048f91ac 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -150,6 +150,13 @@ public static class ServiceCollectionExtensions public static void AddTokenizers(this IServiceCollection services) { + services.AddSingleton>(serviceProvider => + new DataProtectorTokenFactory( + OrgDeleteTokenable.ClearTextPrefix, + OrgDeleteTokenable.DataProtectorPurpose, + serviceProvider.GetDataProtectionProvider(), + serviceProvider.GetRequiredService>>()) + ); services.AddSingleton>(serviceProvider => new DataProtectorTokenFactory( EmergencyAccessInviteTokenable.ClearTextPrefix, diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 31bcef0bdb..a6844d8c2d 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -5,6 +5,7 @@ using Bit.Api.Auth.Models.Request.Accounts; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements.Interfaces; using Bit.Core.AdminConsole.Repositories; @@ -20,6 +21,7 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Tokens; using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider; using NSubstitute; using Xunit; @@ -47,6 +49,7 @@ public class OrganizationsControllerTests : IDisposable private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand; private readonly IProviderRepository _providerRepository; private readonly IScaleSeatsCommand _scaleSeatsCommand; + private readonly IDataProtectorTokenFactory _orgDeleteTokenDataFactory; private readonly OrganizationsController _sut; @@ -70,6 +73,7 @@ public class OrganizationsControllerTests : IDisposable _organizationEnableCollectionEnhancementsCommand = Substitute.For(); _providerRepository = Substitute.For(); _scaleSeatsCommand = Substitute.For(); + _orgDeleteTokenDataFactory = Substitute.For>(); _sut = new OrganizationsController( _organizationRepository, @@ -89,7 +93,8 @@ public class OrganizationsControllerTests : IDisposable _pushNotificationService, _organizationEnableCollectionEnhancementsCommand, _providerRepository, - _scaleSeatsCommand); + _scaleSeatsCommand, + _orgDeleteTokenDataFactory); } public void Dispose() From b2693913bfacb004b18bbb7c2627e6c9f72f149d Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Thu, 23 May 2024 09:15:12 +1000 Subject: [PATCH 35/36] [AC-2521] Remove FlexibleCollectionsSignUp feature flag (#4109) * Remove FlexibleCollectionsSignUp feature flag * Always set Organization.FlexibleCollections to true * Remove explicit assignment of LimitCollectionCreationDeletion so it defaults to false --- .../Controllers/ProvidersController.cs | 3 +- .../Models/OrganizationEditModel.cs | 15 ++++---- .../AdminConsole/Entities/Organization.cs | 16 ++++---- .../Implementations/OrganizationService.cs | 35 ++++++------------ src/Core/Constants.cs | 7 +--- .../Services/OrganizationServiceTests.cs | 37 +++---------------- 6 files changed, 33 insertions(+), 80 deletions(-) diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index d58d132bbc..160af7893e 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -305,9 +305,8 @@ public class ProvidersController : Controller return RedirectToAction("Index"); } - var flexibleCollectionsSignupEnabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup); var flexibleCollectionsV1Enabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1); - var organization = model.CreateOrganization(provider, flexibleCollectionsSignupEnabled, flexibleCollectionsV1Enabled); + var organization = model.CreateOrganization(provider, flexibleCollectionsV1Enabled); await _organizationService.CreatePendingOrganization(organization, model.Owners, User, _userService, model.SalesAssistedTrialStarted); await _providerService.AddOrganization(providerId, organization.Id, null); diff --git a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs index abb7bdaa6c..54d13d8196 100644 --- a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs @@ -162,19 +162,18 @@ public class OrganizationEditModel : OrganizationViewModel { "baseServiceAccount", p.SecretsManager.BaseServiceAccount } }); - public Organization CreateOrganization(Provider provider, bool flexibleCollectionsSignupEnabled, bool flexibleCollectionsV1Enabled) + public Organization CreateOrganization(Provider provider, bool flexibleCollectionsV1Enabled) { BillingEmail = provider.BillingEmail; var newOrg = new Organization { - // 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, + // Flexible Collections MVP is fully released and all organizations must always have this setting enabled. + // AC-1714 will remove this flag after all old code has been removed. + FlexibleCollections = true, + + // This is a transitional setting that defaults to ON until Flexible Collections v1 is released + // (to preserve existing behavior) and defaults to OFF after release (enabling new behavior) AllowAdminAccessToAllCollectionItems = !flexibleCollectionsV1Enabled }; return ToOrganization(newOrg); diff --git a/src/Core/AdminConsole/Entities/Organization.cs b/src/Core/AdminConsole/Entities/Organization.cs index fc8e515bd9..f12baf5729 100644 --- a/src/Core/AdminConsole/Entities/Organization.cs +++ b/src/Core/AdminConsole/Entities/Organization.cs @@ -86,20 +86,20 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable, public int? MaxAutoscaleSmSeats { get; set; } public int? MaxAutoscaleSmServiceAccounts { get; set; } /// - /// Refers to the ability for an organization to limit collection creation and deletion to owners and admins only + /// If set to true, only owners, admins, and some custom users can create and delete collections. + /// If set to false, any organization member can create a collection, and any member can delete a collection that + /// they have Can Manage permissions for. /// public bool LimitCollectionCreationDeletion { get; set; } /// - /// Refers to the ability for an organization to limit owner/admin access to all collection items - /// - /// True: Owner/admins can access all items belonging to any collections - /// False: Owner/admins can only access items for collections they are assigned - /// + /// If set to true, admins, owners, and some custom users can read/write all collections and items in the Admin Console. + /// If set to false, users generally need collection-level permissions to read/write a collection or its items. /// public bool AllowAdminAccessToAllCollectionItems { get; set; } /// - /// True if the organization is using the Flexible Collections permission changes, false otherwise. - /// For existing organizations, this must only be set to true once data migrations have been run for this organization. + /// This is an organization-level feature flag (not controlled via LaunchDarkly) to onboard organizations to the + /// Flexible Collections MVP changes. This has been fully released and must always be set to TRUE for all organizations. + /// AC-1714 will remove this flag after all old code has been removed. /// public bool FlexibleCollections { get; set; } diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index f32b29d83e..e71b9c1beb 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -438,9 +438,6 @@ public class OrganizationService : IOrganizationService ValidatePlan(plan, signup.AdditionalSeats, "Password Manager"); - var flexibleCollectionsSignupEnabled = - _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup); - var flexibleCollectionsV1Enabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1); @@ -482,14 +479,12 @@ public class OrganizationService : IOrganizationService // Secrets Manager not available for purchase with Consolidated Billing. UseSecretsManager = false, - // This feature flag indicates that new organizations should be automatically onboarded to - // Flexible Collections enhancements - FlexibleCollections = flexibleCollectionsSignupEnabled, + // Flexible Collections MVP is fully released and all organizations must always have this setting enabled. + // AC-1714 will remove this flag after all old code has been removed. + FlexibleCollections = true, - // 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, + // This is a transitional setting that defaults to ON until Flexible Collections v1 is released + // (to preserve existing behavior) and defaults to OFF after release (enabling new behavior) AllowAdminAccessToAllCollectionItems = !flexibleCollectionsV1Enabled }; @@ -533,9 +528,6 @@ public class OrganizationService : IOrganizationService await ValidateSignUpPoliciesAsync(signup.Owner.Id); } - var flexibleCollectionsSignupEnabled = - _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup); - var flexibleCollectionsV1IsEnabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1); @@ -577,14 +569,12 @@ public class OrganizationService : IOrganizationService UsePasswordManager = true, UseSecretsManager = signup.UseSecretsManager, - // This feature flag indicates that new organizations should be automatically onboarded to - // Flexible Collections enhancements - FlexibleCollections = flexibleCollectionsSignupEnabled, + // Flexible Collections MVP is fully released and all organizations must always have this setting enabled. + // AC-1714 will remove this flag after all old code has been removed. + FlexibleCollections = true, - // 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, + // This is a transitional setting that defaults to ON until Flexible Collections v1 is released + // (to preserve existing behavior) and defaults to OFF after release (enabling new behavior) AllowAdminAccessToAllCollectionItems = !flexibleCollectionsV1IsEnabled }; @@ -665,9 +655,6 @@ public class OrganizationService : IOrganizationService await ValidateSignUpPoliciesAsync(owner.Id); - var flexibleCollectionsSignupEnabled = - _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup); - var organization = new Organization { Name = license.Name, @@ -713,7 +700,7 @@ public class OrganizationService : IOrganizationService // This feature flag indicates that new organizations should be automatically onboarded to // Flexible Collections enhancements - FlexibleCollections = flexibleCollectionsSignupEnabled, + FlexibleCollections = true, }; var result = await SignUpAsync(organization, owner.Id, ownerKey, collectionName, false); diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index a95d0290c0..6804a9a223 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -115,10 +115,6 @@ public static class FeatureFlagKeys public const string KeyRotationImprovements = "key-rotation-improvements"; public const string DuoRedirect = "duo-redirect"; /// - /// 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 /// @@ -151,8 +147,7 @@ public static class FeatureFlagKeys return new Dictionary() { { DuoRedirect, "true" }, - { UnassignedItemsBanner, "true"}, - { FlexibleCollectionsSignup, "true" } + { UnassignedItemsBanner, "true"} }; } } diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index db6805c097..48a66dff0e 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -252,7 +252,7 @@ public class OrganizationServiceTests [Theory] [BitAutoData(PlanType.FamiliesAnnually)] - public async Task SignUp_WithFlexibleCollections_SetsAccessAllToFalse + public async Task SignUp_EnablesFlexibleCollectionsFeatures (PlanType planType, OrganizationSignup signup, SutProvider sutProvider) { signup.Plan = planType; @@ -261,10 +261,6 @@ public class OrganizationServiceTests signup.PremiumAccessAddon = false; signup.UseSecretsManager = false; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup) - .Returns(true); - // Extract orgUserId when created Guid? orgUserId = null; await sutProvider.GetDependency() @@ -272,6 +268,10 @@ public class OrganizationServiceTests var result = await sutProvider.Sut.SignUpAsync(signup); + // Assert: Organization.FlexibleCollections is enabled + await sutProvider.GetDependency().Received(1) + .CreateAsync(Arg.Is(o => o.FlexibleCollections)); + // Assert: AccessAll is not used await sutProvider.GetDependency().Received(1).CreateAsync( Arg.Is(o => @@ -295,33 +295,6 @@ public class OrganizationServiceTests Assert.NotNull(result.Item2); } - [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.Item1); - Assert.NotNull(result.Item2); - } - [Theory] [BitAutoData(PlanType.EnterpriseAnnually)] [BitAutoData(PlanType.EnterpriseMonthly)] From a9ab894893ecf36c36221158d0796dbd87134721 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Thu, 23 May 2024 11:40:51 +0100 Subject: [PATCH 36/36] Send upcoming invoice to provider billing email (#4112) Signed-off-by: Cy Okeke --- src/Billing/Controllers/StripeController.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index 278d7c5f84..9e298b6865 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -715,6 +715,23 @@ public class StripeController : Controller await SendEmails(new List { user.Email }); } } + else if (providerId.HasValue) + { + var provider = await _providerRepository.GetByIdAsync(providerId.Value); + + if (provider == null) + { + _logger.LogError( + "Received invoice.Upcoming webhook ({EventID}) for Provider ({ProviderID}) that does not exist", + parsedEvent.Id, + providerId.Value); + + return; + } + + await SendEmails(new List { provider.BillingEmail }); + + } return;