diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index 5a0ae68631..3300b05531 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -152,7 +152,15 @@ public class ProviderService : IProviderService throw new ArgumentException("Cannot create provider this way."); } + var existingProvider = await _providerRepository.GetByIdAsync(provider.Id); + var enabledStatusChanged = existingProvider != null && existingProvider.Enabled != provider.Enabled; + await _providerRepository.ReplaceAsync(provider); + + if (enabledStatusChanged && (provider.Type == ProviderType.Msp || provider.Type == ProviderType.BusinessUnit)) + { + await UpdateClientOrganizationsEnabledStatusAsync(provider.Id, provider.Enabled); + } } public async Task> InviteUserAsync(ProviderUserInvite invite) @@ -728,4 +736,20 @@ public class ProviderService : IProviderService throw new BadRequestException($"Unsupported provider type {providerType}."); } } + + private async Task UpdateClientOrganizationsEnabledStatusAsync(Guid providerId, bool enabled) + { + var providerOrganizations = await _providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId); + + foreach (var providerOrganization in providerOrganizations) + { + var organization = await _organizationRepository.GetByIdAsync(providerOrganization.OrganizationId); + if (organization != null && organization.Enabled != enabled) + { + organization.Enabled = enabled; + await _organizationRepository.ReplaceAsync(organization); + await _applicationCacheService.UpsertOrganizationAbilityAsync(organization); + } + } + } } diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs index cb8a9e8c69..608b4b3034 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs @@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Business.Provider; using Bit.Core.AdminConsole.Models.Business.Tokenables; +using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; @@ -188,6 +189,262 @@ public class ProviderServiceTests await sutProvider.Sut.UpdateAsync(provider); } + [Theory, BitAutoData] + public async Task UpdateAsync_ExistingProviderIsNull_DoesNotCallUpdateClientOrganizationsEnabledStatus( + Provider provider, SutProvider sutProvider) + { + // Arrange + var providerRepository = sutProvider.GetDependency(); + var providerOrganizationRepository = sutProvider.GetDependency(); + + providerRepository.GetByIdAsync(provider.Id).Returns((Provider)null); + + // Act + await sutProvider.Sut.UpdateAsync(provider); + + // Assert + await providerRepository.Received(1).ReplaceAsync(provider); + await providerOrganizationRepository.DidNotReceive().GetManyDetailsByProviderAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_EnabledStatusNotChanged_DoesNotCallUpdateClientOrganizationsEnabledStatus( + Provider provider, Provider existingProvider, SutProvider sutProvider) + { + // Arrange + var providerRepository = sutProvider.GetDependency(); + var providerOrganizationRepository = sutProvider.GetDependency(); + + existingProvider.Id = provider.Id; + existingProvider.Enabled = provider.Enabled; // Same enabled status + provider.Type = ProviderType.Msp; // Set to a type that would trigger update if status changed + + providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider); + + // Act + await sutProvider.Sut.UpdateAsync(provider); + + // Assert + await providerRepository.Received(1).ReplaceAsync(provider); + await providerOrganizationRepository.DidNotReceive().GetManyDetailsByProviderAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_EnabledStatusChangedButProviderTypeIsReseller_DoesNotCallUpdateClientOrganizationsEnabledStatus( + Provider provider, Provider existingProvider, SutProvider sutProvider) + { + // Arrange + var providerRepository = sutProvider.GetDependency(); + var providerOrganizationRepository = sutProvider.GetDependency(); + + existingProvider.Id = provider.Id; + existingProvider.Enabled = !provider.Enabled; // Different enabled status + provider.Type = ProviderType.Reseller; // Type that should not trigger update + + providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider); + + // Act + await sutProvider.Sut.UpdateAsync(provider); + + // Assert + await providerRepository.Received(1).ReplaceAsync(provider); + await providerOrganizationRepository.DidNotReceive().GetManyDetailsByProviderAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_EnabledStatusChangedAndProviderTypeIsMsp_CallsUpdateClientOrganizationsEnabledStatus( + Provider provider, Provider existingProvider, SutProvider sutProvider) + { + // Arrange + var providerRepository = sutProvider.GetDependency(); + var providerOrganizationRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var applicationCacheService = sutProvider.GetDependency(); + + existingProvider.Id = provider.Id; + existingProvider.Enabled = !provider.Enabled; // Different enabled status + provider.Type = ProviderType.Msp; // Type that should trigger update + + // Create test provider organization details + var providerOrganizationDetails = new List + { + new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() }, + new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() } + }; + + // Create test organizations with different enabled status than what we're setting + var organizations = providerOrganizationDetails.Select(po => + { + var org = new Organization { Id = po.OrganizationId, Enabled = !provider.Enabled }; + return org; + }).ToList(); + + providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider); + providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id).Returns(providerOrganizationDetails); + + foreach (var org in organizations) + { + organizationRepository.GetByIdAsync(org.Id).Returns(org); + } + + // Act + await sutProvider.Sut.UpdateAsync(provider); + + // Assert + await providerRepository.Received(1).ReplaceAsync(provider); + await providerOrganizationRepository.Received(1).GetManyDetailsByProviderAsync(provider.Id); + + foreach (var org in organizations) + { + await organizationRepository.Received(1).ReplaceAsync(Arg.Is(o => + o.Id == org.Id && o.Enabled == provider.Enabled)); + await applicationCacheService.Received(1).UpsertOrganizationAbilityAsync(Arg.Is(o => + o.Id == org.Id && o.Enabled == provider.Enabled)); + } + } + + [Theory, BitAutoData] + public async Task UpdateAsync_EnabledStatusChangedAndProviderTypeIsBusinessUnit_CallsUpdateClientOrganizationsEnabledStatus( + Provider provider, Provider existingProvider, SutProvider sutProvider) + { + // Arrange + var providerRepository = sutProvider.GetDependency(); + var providerOrganizationRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var applicationCacheService = sutProvider.GetDependency(); + + existingProvider.Id = provider.Id; + existingProvider.Enabled = !provider.Enabled; // Different enabled status + provider.Type = ProviderType.BusinessUnit; // Type that should trigger update + + // Create test provider organization details + var providerOrganizationDetails = new List + { + new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() }, + new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() } + }; + + // Create test organizations with different enabled status than what we're setting + var organizations = providerOrganizationDetails.Select(po => + { + var org = new Organization { Id = po.OrganizationId, Enabled = !provider.Enabled }; + return org; + }).ToList(); + + providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider); + providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id).Returns(providerOrganizationDetails); + + foreach (var org in organizations) + { + organizationRepository.GetByIdAsync(org.Id).Returns(org); + } + + // Act + await sutProvider.Sut.UpdateAsync(provider); + + // Assert + await providerRepository.Received(1).ReplaceAsync(provider); + await providerOrganizationRepository.Received(1).GetManyDetailsByProviderAsync(provider.Id); + + foreach (var org in organizations) + { + await organizationRepository.Received(1).ReplaceAsync(Arg.Is(o => + o.Id == org.Id && o.Enabled == provider.Enabled)); + await applicationCacheService.Received(1).UpsertOrganizationAbilityAsync(Arg.Is(o => + o.Id == org.Id && o.Enabled == provider.Enabled)); + } + } + + [Theory, BitAutoData] + public async Task UpdateAsync_OrganizationEnabledStatusAlreadyMatches_DoesNotUpdateOrganization( + Provider provider, Provider existingProvider, SutProvider sutProvider) + { + // Arrange + var providerRepository = sutProvider.GetDependency(); + var providerOrganizationRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var applicationCacheService = sutProvider.GetDependency(); + + existingProvider.Id = provider.Id; + existingProvider.Enabled = !provider.Enabled; // Different enabled status + provider.Type = ProviderType.Msp; // Type that should trigger update + + // Create test provider organization details + var providerOrganizationDetails = new List + { + new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() }, + new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() } + }; + + // Create test organizations with SAME enabled status as what we're setting + var organizations = providerOrganizationDetails.Select(po => + { + var org = new Organization { Id = po.OrganizationId, Enabled = provider.Enabled }; + return org; + }).ToList(); + + providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider); + providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id).Returns(providerOrganizationDetails); + + foreach (var org in organizations) + { + organizationRepository.GetByIdAsync(org.Id).Returns(org); + } + + // Act + await sutProvider.Sut.UpdateAsync(provider); + + // Assert + await providerRepository.Received(1).ReplaceAsync(provider); + await providerOrganizationRepository.Received(1).GetManyDetailsByProviderAsync(provider.Id); + + // Organizations should not be updated since their enabled status already matches + foreach (var org in organizations) + { + await organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any()); + await applicationCacheService.DidNotReceive().UpsertOrganizationAbilityAsync(Arg.Any()); + } + } + + [Theory, BitAutoData] + public async Task UpdateAsync_OrganizationIsNull_SkipsNullOrganization( + Provider provider, Provider existingProvider, SutProvider sutProvider) + { + // Arrange + var providerRepository = sutProvider.GetDependency(); + var providerOrganizationRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var applicationCacheService = sutProvider.GetDependency(); + + existingProvider.Id = provider.Id; + existingProvider.Enabled = !provider.Enabled; // Different enabled status + provider.Type = ProviderType.Msp; // Type that should trigger update + + // Create test provider organization details + var providerOrganizationDetails = new List + { + new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() }, + new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() } + }; + + providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider); + providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id).Returns(providerOrganizationDetails); + + // Return null for all organizations + organizationRepository.GetByIdAsync(Arg.Any()).Returns((Organization)null); + + // Act + await sutProvider.Sut.UpdateAsync(provider); + + // Assert + await providerRepository.Received(1).ReplaceAsync(provider); + await providerOrganizationRepository.Received(1).GetManyDetailsByProviderAsync(provider.Id); + + // No organizations should be updated since they're all null + await organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any()); + await applicationCacheService.DidNotReceive().UpsertOrganizationAbilityAsync(Arg.Any()); + } + [Theory, BitAutoData] public async Task InviteUserAsync_ProviderIdIsInvalid_Throws(ProviderUserInvite invite, SutProvider sutProvider) { diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index 4fc9556a66..df333d5d4e 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -5,6 +5,7 @@ using System.ComponentModel.DataAnnotations; using System.Net; using Bit.Admin.AdminConsole.Models; using Bit.Admin.Enums; +using Bit.Admin.Services; using Bit.Admin.Utilities; using Bit.Core; using Bit.Core.AdminConsole.Entities.Provider; @@ -51,6 +52,7 @@ public class ProvidersController : Controller private readonly IProviderBillingService _providerBillingService; private readonly IPricingClient _pricingClient; private readonly IStripeAdapter _stripeAdapter; + private readonly IAccessControlService _accessControlService; private readonly string _stripeUrl; private readonly string _braintreeMerchantUrl; private readonly string _braintreeMerchantId; @@ -70,7 +72,8 @@ public class ProvidersController : Controller IProviderBillingService providerBillingService, IWebHostEnvironment webHostEnvironment, IPricingClient pricingClient, - IStripeAdapter stripeAdapter) + IStripeAdapter stripeAdapter, + IAccessControlService accessControlService) { _organizationRepository = organizationRepository; _resellerClientOrganizationSignUpCommand = resellerClientOrganizationSignUpCommand; @@ -89,6 +92,7 @@ public class ProvidersController : Controller _stripeUrl = webHostEnvironment.GetStripeUrl(); _braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl(); _braintreeMerchantId = globalSettings.Braintree.MerchantId; + _accessControlService = accessControlService; } [RequirePermission(Permission.Provider_List_View)] @@ -291,9 +295,14 @@ public class ProvidersController : Controller return View(oldModel); } + var originalProviderStatus = provider.Enabled; + model.ToProvider(provider); - await _providerRepository.ReplaceAsync(provider); + provider.Enabled = _accessControlService.UserHasPermission(Permission.Provider_CheckEnabledBox) + ? model.Enabled : originalProviderStatus; + + await _providerService.UpdateAsync(provider); await _applicationCacheService.UpsertProviderAbilityAsync(provider); if (!provider.IsBillable()) diff --git a/src/Admin/AdminConsole/Models/ProviderEditModel.cs b/src/Admin/AdminConsole/Models/ProviderEditModel.cs index 51fe4bbe64..450dfbb2fc 100644 --- a/src/Admin/AdminConsole/Models/ProviderEditModel.cs +++ b/src/Admin/AdminConsole/Models/ProviderEditModel.cs @@ -38,6 +38,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject GatewaySubscriptionUrl = gatewaySubscriptionUrl; Type = provider.Type; PayByInvoice = payByInvoice; + Enabled = provider.Enabled; if (Type == ProviderType.BusinessUnit) { @@ -78,10 +79,14 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject [Display(Name = "Enterprise Seats Minimum")] public int? EnterpriseMinimumSeats { get; set; } + [Display(Name = "Enabled")] + public bool Enabled { get; set; } + public virtual Provider ToProvider(Provider existingProvider) { existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant().Trim(); existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant().Trim(); + existingProvider.Enabled = Enabled; switch (Type) { case ProviderType.Msp: diff --git a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml index d2a9ed1f62..ca4fa70ab5 100644 --- a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml +++ b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml @@ -11,6 +11,7 @@ @{ ViewData["Title"] = "Provider: " + Model.Provider.DisplayName(); var canEdit = AccessControlService.UserHasPermission(Permission.Provider_Edit); + var canCheckEnabled = AccessControlService.UserHasPermission(Permission.Provider_CheckEnabledBox); }

Provider @Model.Provider.DisplayName()

@@ -30,6 +31,13 @@
Name
@Model.Provider.DisplayName()
+ @if (canCheckEnabled && (Model.Provider.Type == ProviderType.Msp || Model.Provider.Type == ProviderType.BusinessUnit)) + { +
+ + +
+ }

Business Information

Business Name
diff --git a/src/Admin/AdminConsole/Views/Providers/_ViewInformation.cshtml b/src/Admin/AdminConsole/Views/Providers/_ViewInformation.cshtml index 5d18d7a651..81debddbeb 100644 --- a/src/Admin/AdminConsole/Views/Providers/_ViewInformation.cshtml +++ b/src/Admin/AdminConsole/Views/Providers/_ViewInformation.cshtml @@ -14,6 +14,12 @@
Provider Type
@(Model.Provider.Type.GetDisplayAttribute()?.GetName())
+ @if (Model.Provider.Type == ProviderType.Msp || Model.Provider.Type == ProviderType.BusinessUnit) + { +
Enabled
+
@(Model.Provider.Enabled ? "Yes" : "No")
+ } +
Created
@Model.Provider.CreationDate.ToString()
diff --git a/src/Admin/Enums/Permissions.cs b/src/Admin/Enums/Permissions.cs index 704fd770bb..14b255b2b6 100644 --- a/src/Admin/Enums/Permissions.cs +++ b/src/Admin/Enums/Permissions.cs @@ -45,6 +45,7 @@ public enum Permission Provider_Edit, Provider_View, Provider_ResendEmailInvite, + Provider_CheckEnabledBox, Tools_ChargeBrainTreeCustomer, Tools_PromoteAdmin, diff --git a/src/Admin/Utilities/RolePermissionMapping.cs b/src/Admin/Utilities/RolePermissionMapping.cs index f342dfce7c..b60cf895a1 100644 --- a/src/Admin/Utilities/RolePermissionMapping.cs +++ b/src/Admin/Utilities/RolePermissionMapping.cs @@ -47,6 +47,7 @@ public static class RolePermissionMapping Permission.Provider_Create, Permission.Provider_View, Permission.Provider_ResendEmailInvite, + Permission.Provider_CheckEnabledBox, Permission.Tools_ChargeBrainTreeCustomer, Permission.Tools_PromoteAdmin, Permission.Tools_PromoteProviderServiceUser, @@ -98,6 +99,7 @@ public static class RolePermissionMapping Permission.Provider_View, Permission.Provider_Edit, Permission.Provider_ResendEmailInvite, + Permission.Provider_CheckEnabledBox, Permission.Tools_ChargeBrainTreeCustomer, Permission.Tools_PromoteAdmin, Permission.Tools_PromoteProviderServiceUser, @@ -135,7 +137,8 @@ public static class RolePermissionMapping Permission.Org_Billing_LaunchGateway, Permission.Org_RequestDelete, Permission.Provider_List_View, - Permission.Provider_View + Permission.Provider_View, + Permission.Provider_CheckEnabledBox } }, { "billing", new List @@ -173,6 +176,7 @@ public static class RolePermissionMapping Permission.Provider_Edit, Permission.Provider_View, Permission.Provider_List_View, + Permission.Provider_CheckEnabledBox, Permission.Tools_ChargeBrainTreeCustomer, Permission.Tools_GenerateLicenseFile, Permission.Tools_ManageTaxRates,