From 6672019122bab7406b79bdae9c84b90fdac046fd Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Wed, 17 Apr 2024 10:09:53 +0100 Subject: [PATCH] [AC-1218] Add ability to delete Provider Portals (#3973) * add new classes * initial commit * revert the changes on this files Signed-off-by: Cy Okeke * revert unnecessary changes * Add a model * add the delete token endpoint * add a unit test for delete provider Signed-off-by: Cy Okeke * add the delete provider method Signed-off-by: Cy Okeke * resolve the failing test Signed-off-by: Cy Okeke * resolve the delete request redirect issue Signed-off-by: Cy Okeke * changes to correct the json issue Signed-off-by: Cy Okeke * resolve errors Signed-off-by: Cy Okeke * resolve pr comment Signed-off-by: Cy Okeke * move ProviderDeleteTokenable to the adminConsole Signed-off-by: Cy Okeke * Add feature flag * resolve pr comments Signed-off-by: Cy Okeke * add some unit test Signed-off-by: Cy Okeke * resolve the failing test Signed-off-by: Cy Okeke * resolve test Signed-off-by: Cy Okeke * add the remove feature flag Signed-off-by: Cy Okeke * [AC-2378] Added `ProviderId` to PayPal transaction model (#3995) * Added ProviderId to PayPal transaction model * Fixed issue with parsing provider id * [AC-1923] Add endpoint to create client organization (#3977) * Add new endpoint for creating client organizations in consolidated billing * Create empty org and then assign seats for code re-use * Fixes made from debugging client side * few more small fixes * Vincent's feedback * Bumped version to 2024.4.1 (#3997) * [AC-1923] Add endpoint to create client organization (#3977) * Add new endpoint for creating client organizations in consolidated billing * Create empty org and then assign seats for code re-use * Fixes made from debugging client side * few more small fixes * Vincent's feedback * [AC-1923] Add endpoint to create client organization (#3977) * Add new endpoint for creating client organizations in consolidated billing * Create empty org and then assign seats for code re-use * Fixes made from debugging client side * few more small fixes * Vincent's feedback * add changes after merge conflict Signed-off-by: Cy Okeke --------- Signed-off-by: Cy Okeke Co-authored-by: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Co-authored-by: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Co-authored-by: Bitwarden DevOps <106330231+bitwarden-devops-bot@users.noreply.github.com> --- .../AdminConsole/Services/ProviderService.cs | 48 +++++++++- .../Services/ProviderServiceTests.cs | 91 ++++++++++++++++++ .../Controllers/ProvidersController.cs | 64 ++++++++++++- .../AdminConsole/Views/Providers/Edit.cshtml | 92 +++++++++++++++++-- .../Views/Providers/_ProviderScripts.cshtml | 56 +++++++++++ .../Controllers/ProvidersController.cs | 36 ++++++++ ...ProviderVerifyDeleteRecoverRequestModel.cs | 9 ++ .../Tokenables/ProviderDeleteTokenable.cs | 37 ++++++++ .../AdminConsole/Services/IProviderService.cs | 3 + .../NoopProviderService.cs | 3 + src/Core/Constants.cs | 3 +- src/Core/Enums/ApplicationCacheMessageType.cs | 3 +- .../Provider/InitiateDeleteProvider.html.hbs | 37 ++++++++ .../Provider/InitiateDeleteProvider.text.hbs | 15 +++ .../Provider/ProviderInitiateDeleteModel.cs | 21 +++++ src/Core/Services/IApplicationCacheService.cs | 1 + src/Core/Services/IMailService.cs | 1 + .../Implementations/HandlebarsMailService.cs | 22 +++++ .../InMemoryApplicationCacheService.cs | 10 ++ ...MemoryServiceBusApplicationCacheService.cs | 15 +++ .../NoopImplementations/NoopMailService.cs | 2 + .../Utilities/ServiceCollectionExtensions.cs | 9 ++ 22 files changed, 567 insertions(+), 11 deletions(-) create mode 100644 src/Api/AdminConsole/Models/Request/Providers/ProviderVerifyDeleteRecoverRequestModel.cs create mode 100644 src/Core/AdminConsole/Models/Business/Tokenables/ProviderDeleteTokenable.cs create mode 100644 src/Core/MailTemplates/Handlebars/Provider/InitiateDeleteProvider.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/Provider/InitiateDeleteProvider.text.hbs create mode 100644 src/Core/Models/Mail/Provider/ProviderInitiateDeleteModel.cs diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index 7b14f3ed3..503d290f6 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Entities; 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.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Context; @@ -15,6 +16,7 @@ using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.Core.Tokens; using Bit.Core.Utilities; using Microsoft.AspNetCore.DataProtection; using Stripe; @@ -39,13 +41,17 @@ public class ProviderService : IProviderService private readonly ICurrentContext _currentContext; private readonly IStripeAdapter _stripeAdapter; private readonly IFeatureService _featureService; + private readonly IDataProtectorTokenFactory _providerDeleteTokenDataFactory; + private readonly IApplicationCacheService _applicationCacheService; public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository, IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository, IUserService userService, IOrganizationService organizationService, IMailService mailService, IDataProtectionProvider dataProtectionProvider, IEventService eventService, IOrganizationRepository organizationRepository, GlobalSettings globalSettings, - ICurrentContext currentContext, IStripeAdapter stripeAdapter, IFeatureService featureService) + ICurrentContext currentContext, IStripeAdapter stripeAdapter, IFeatureService featureService, + IDataProtectorTokenFactory providerDeleteTokenDataFactory, + IApplicationCacheService applicationCacheService) { _providerRepository = providerRepository; _providerUserRepository = providerUserRepository; @@ -61,6 +67,8 @@ public class ProviderService : IProviderService _currentContext = currentContext; _stripeAdapter = stripeAdapter; _featureService = featureService; + _providerDeleteTokenDataFactory = providerDeleteTokenDataFactory; + _applicationCacheService = applicationCacheService; } public async Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key) @@ -601,6 +609,44 @@ public class ProviderService : IProviderService } } + public async Task InitiateDeleteAsync(Provider provider, string providerAdminEmail) + { + if (string.IsNullOrWhiteSpace(provider.Name)) + { + throw new BadRequestException("Provider name not found."); + } + var providerAdmin = await _userRepository.GetByEmailAsync(providerAdminEmail); + if (providerAdmin == null) + { + throw new BadRequestException("Provider admin not found."); + } + + var providerAdminOrgUser = await _providerUserRepository.GetByProviderUserAsync(provider.Id, providerAdmin.Id); + if (providerAdminOrgUser == null || providerAdminOrgUser.Status != ProviderUserStatusType.Confirmed || + providerAdminOrgUser.Type != ProviderUserType.ProviderAdmin) + { + throw new BadRequestException("Org admin not found."); + } + + var token = _providerDeleteTokenDataFactory.Protect(new ProviderDeleteTokenable(provider, 1)); + await _mailService.SendInitiateDeletProviderEmailAsync(providerAdminEmail, provider, token); + } + + public async Task DeleteAsync(Provider provider, string token) + { + if (!_providerDeleteTokenDataFactory.TryUnprotect(token, out var data) || !data.IsValid(provider)) + { + throw new BadRequestException("Invalid token."); + } + await DeleteAsync(provider); + } + + public async Task DeleteAsync(Provider provider) + { + await _providerRepository.DeleteAsync(provider); + await _applicationCacheService.DeleteProviderAbilityAsync(provider.Id); + } + private async Task SendInviteAsync(ProviderUser providerUser, Provider provider) { var nowMillis = CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow); 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 22e8760cb..ac216ce8f 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs @@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Entities; 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.Repositories; using Bit.Core.Context; using Bit.Core.Entities; @@ -14,6 +15,7 @@ using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Test.AutoFixture.OrganizationFixtures; +using Bit.Core.Tokens; using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -745,6 +747,95 @@ public class ProviderServiceTests t.First().Item2 == null)); } + [Theory, BitAutoData] + public async Task Delete_Success(Provider provider, SutProvider sutProvider) + { + var providerRepository = sutProvider.GetDependency(); + var applicationCacheService = sutProvider.GetDependency(); + + await sutProvider.Sut.DeleteAsync(provider); + + await providerRepository.Received().DeleteAsync(provider); + await applicationCacheService.Received().DeleteProviderAbilityAsync(provider.Id); + } + + [Theory, BitAutoData] + public async Task InitiateDeleteAsync_ThrowsBadRequestException_WhenProviderNameIsEmpty(string providerAdminEmail, SutProvider sutProvider) + { + var provider = new Provider { Name = "" }; + await Assert.ThrowsAsync(() => sutProvider.Sut.InitiateDeleteAsync(provider, providerAdminEmail)); + } + + [Theory, BitAutoData] + public async Task InitiateDeleteAsync_ThrowsBadRequestException_WhenProviderAdminNotFound(Provider provider, SutProvider sutProvider) + { + var providerAdminEmail = "nonexistent@example.com"; + var userRepository = sutProvider.GetDependency(); + userRepository.GetByEmailAsync(providerAdminEmail).Returns(Task.FromResult(null)); + + await Assert.ThrowsAsync(() => sutProvider.Sut.InitiateDeleteAsync(provider, providerAdminEmail)); + } + + [Theory, BitAutoData] + public async Task InitiateDeleteAsync_ThrowsBadRequestException_WhenProviderAdminStatusIsNotConfirmed( + Provider provider + , User providerAdmin + , ProviderUser providerUser + , SutProvider sutProvider) + { + var providerAdminEmail = "nonexistent@example.com"; + providerUser.Status = ProviderUserStatusType.Confirmed; + providerUser.Type = ProviderUserType.ServiceUser; + + var userRepository = sutProvider.GetDependency(); + userRepository.GetByEmailAsync(providerAdminEmail).Returns(Task.FromResult(providerAdmin)); + var providerUserRepository = sutProvider.GetDependency(); + providerUserRepository.GetByProviderUserAsync(provider.Id, providerAdmin.Id).Returns(providerUser); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.InitiateDeleteAsync(provider, providerAdminEmail)); + Assert.Contains("Org admin not found.", exception.Message); + + } + + [Theory, BitAutoData] + public async Task InitiateDeleteAsync_SendsInitiateDeleteProviderEmail(Provider provider, User providerAdmin + , ProviderUser providerUser, SutProvider sutProvider) + { + var providerAdminEmail = providerAdmin.Email; + providerUser.Status = ProviderUserStatusType.Confirmed; + providerUser.Type = ProviderUserType.ProviderAdmin; + + var userRepository = sutProvider.GetDependency(); + userRepository.GetByEmailAsync(providerAdminEmail).Returns(Task.FromResult(providerAdmin)); + var providerUserRepository = sutProvider.GetDependency(); + providerUserRepository.GetByProviderUserAsync(provider.Id, providerAdmin.Id).Returns(providerUser); + var mailService = sutProvider.GetDependency(); + + await sutProvider.Sut.InitiateDeleteAsync(provider, providerAdminEmail); + await mailService.Received().SendInitiateDeletProviderEmailAsync(providerAdminEmail, provider, Arg.Any()); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_ThrowsBadRequestException_WhenInvalidToken(Provider provider, string invalidToken + , SutProvider sutProvider) + { + var providerDeleteTokenDataFactory = sutProvider.GetDependency>(); + providerDeleteTokenDataFactory.TryUnprotect(invalidToken, out Arg.Any()).Returns(false); + + await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteAsync(provider, invalidToken)); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_ThrowsBadRequestException_WhenInvalidTokenData(Provider provider, string validToken + , SutProvider sutProvider) + { + var validTokenData = new ProviderDeleteTokenable(); + var providerDeleteTokenDataFactory = sutProvider.GetDependency>(); + providerDeleteTokenDataFactory.TryUnprotect(validToken, out validTokenData).Returns(false); + + await Assert.ThrowsAsync(() => sutProvider.Sut.DeleteAsync(provider, validToken)); + } + private static SubscriptionUpdateOptions SubscriptionUpdateRequest(string expectedPlanId, Subscription subscriptionItem) => new() { diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index 59b4ef658..408fc5d31 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -1,4 +1,5 @@ -using System.Net; +using System.ComponentModel.DataAnnotations; +using System.Net; using Bit.Admin.AdminConsole.Models; using Bit.Admin.Enums; using Bit.Admin.Utilities; @@ -10,6 +11,7 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Entities; using Bit.Core.Billing.Repositories; +using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -275,4 +277,64 @@ public class ProvidersController : Controller return RedirectToAction("Edit", "Providers", new { id = providerId }); } + + [HttpPost] + [SelfHosted(NotSelfHostedOnly = true)] + [RequirePermission(Permission.Provider_Edit)] + public async Task Delete(Guid id, string providerName) + { + if (string.IsNullOrWhiteSpace(providerName)) + { + return BadRequest("Invalid provider name"); + } + + var providerOrganizations = await _providerOrganizationRepository.GetManyDetailsByProviderAsync(id); + + if (providerOrganizations.Count > 0) + { + return BadRequest("You must unlink all clients before you can delete a provider"); + } + + var provider = await _providerRepository.GetByIdAsync(id); + + if (provider is null) + { + return BadRequest("Provider does not exist"); + } + + if (!string.Equals(providerName.Trim(), provider.Name, StringComparison.OrdinalIgnoreCase)) + { + return BadRequest("Invalid provider name"); + } + + await _providerService.DeleteAsync(provider); + return NoContent(); + } + + [HttpPost] + [SelfHosted(NotSelfHostedOnly = true)] + [RequirePermission(Permission.Provider_Edit)] + public async Task DeleteInitiation(Guid id, string providerEmail) + { + var emailAttribute = new EmailAddressAttribute(); + if (!emailAttribute.IsValid(providerEmail)) + { + return BadRequest("Invalid provider admin email"); + } + + var provider = await _providerRepository.GetByIdAsync(id); + if (provider != null) + { + try + { + await _providerService.InitiateDeleteAsync(provider, providerEmail); + } + catch (BadRequestException ex) + { + return BadRequest(ex.Message); + } + } + + return NoContent(); + } } diff --git a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml index 2f652aaac..e8d4197ad 100644 --- a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml +++ b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml @@ -6,7 +6,6 @@ @model ProviderEditModel @{ ViewData["Title"] = "Provider: " + Model.Provider.DisplayName(); - var canEdit = AccessControlService.UserHasPermission(Permission.Provider_Edit); } @@ -62,10 +61,89 @@ } @await Html.PartialAsync("Organizations", Model) -@if (canEdit) -{ -
- -
-} + @if (canEdit) + { + + + + + + +
+ + @if (FeatureService.IsEnabled(FeatureFlagKeys.EnableDeleteProvider)) + { +
+ + + + + + + + +
+ } +
+ + } diff --git a/src/Admin/AdminConsole/Views/Providers/_ProviderScripts.cshtml b/src/Admin/AdminConsole/Views/Providers/_ProviderScripts.cshtml index 3ff2d9ae2..4fa1ed757 100644 --- a/src/Admin/AdminConsole/Views/Providers/_ProviderScripts.cshtml +++ b/src/Admin/AdminConsole/Views/Providers/_ProviderScripts.cshtml @@ -17,4 +17,60 @@ } return false; } + + function deleteProvider(id) { + const providerName = $('#DeleteModal input#provider-name').val(); + $.ajax({ + type: "POST", + url: `@Url.Action("Delete", "Providers")?id=${id}&providerName=${providerName}`, + dataType: 'json', + contentType: false, + processData: false, + success: function () { + $('#DeleteModal').modal('hide'); + window.location.href = `@Url.Action("Index", "Providers")`; + }, + error: function (response) { + alert("Error!: " + response.responseText); + } + }); + } + + function initiateDeleteProvider(id) { + const email = $('#requestDeletionModal input#provider-email').val(); + const providerEmail = encodeURIComponent(email); + $.ajax({ + type: "POST", + url: `@Url.Action("DeleteInitiation", "Providers")?id=${id}&providerEmail=${providerEmail}`, + dataType: 'json', + contentType: false, + processData: false, + success: function () { + $('#requestDeletionModal').modal('hide'); + window.location.href = `@Url.Action("Index", "Providers")`; + }, + error: function (response) { + alert("Error!: " + response.responseText); + } + }); + } + + function openDeleteModal(providerOrganizations) { + + if (providerOrganizations > 0){ + $('#linkAccWarningBtn').click() + } else { + $('#deleteBtn').click() + } + } + + function openRequestDeleteModal(providerOrganizations) { + + if (providerOrganizations > 0){ + $('#linkAccWarningBtn').click() + } else { + $('#requestDeletionBtn').click() + } + } + diff --git a/src/Api/AdminConsole/Controllers/ProvidersController.cs b/src/Api/AdminConsole/Controllers/ProvidersController.cs index 9039779f1..e1c908a1b 100644 --- a/src/Api/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Api/AdminConsole/Controllers/ProvidersController.cs @@ -123,4 +123,40 @@ public class ProvidersController : Controller return new ProviderResponseModel(response); } + + [HttpPost("{id}/delete-recover-token")] + [AllowAnonymous] + public async Task PostDeleteRecoverToken(Guid id, [FromBody] ProviderVerifyDeleteRecoverRequestModel model) + { + var provider = await _providerRepository.GetByIdAsync(id); + if (provider == null) + { + throw new NotFoundException(); + } + await _providerService.DeleteAsync(provider, model.Token); + } + + [HttpDelete("{id}")] + [HttpPost("{id}/delete")] + public async Task Delete(Guid id) + { + if (!_currentContext.ProviderProviderAdmin(id)) + { + throw new NotFoundException(); + } + + var provider = await _providerRepository.GetByIdAsync(id); + if (provider == null) + { + throw new NotFoundException(); + } + + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new UnauthorizedAccessException(); + } + + await _providerService.DeleteAsync(provider); + } } diff --git a/src/Api/AdminConsole/Models/Request/Providers/ProviderVerifyDeleteRecoverRequestModel.cs b/src/Api/AdminConsole/Models/Request/Providers/ProviderVerifyDeleteRecoverRequestModel.cs new file mode 100644 index 000000000..edb58c21b --- /dev/null +++ b/src/Api/AdminConsole/Models/Request/Providers/ProviderVerifyDeleteRecoverRequestModel.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.AdminConsole.Models.Request.Providers; + +public class ProviderVerifyDeleteRecoverRequestModel +{ + [Required] + public string Token { get; set; } +} diff --git a/src/Core/AdminConsole/Models/Business/Tokenables/ProviderDeleteTokenable.cs b/src/Core/AdminConsole/Models/Business/Tokenables/ProviderDeleteTokenable.cs new file mode 100644 index 000000000..2e84a275a --- /dev/null +++ b/src/Core/AdminConsole/Models/Business/Tokenables/ProviderDeleteTokenable.cs @@ -0,0 +1,37 @@ +using Newtonsoft.Json; + +namespace Bit.Core.AdminConsole.Models.Business.Tokenables; + +public class ProviderDeleteTokenable : Tokens.ExpiringTokenable +{ + public const string ClearTextPrefix = "BwProviderId"; + public const string DataProtectorPurpose = "ProviderDeleteDataProtector"; + public const string TokenIdentifier = "ProviderDelete"; + public string Identifier { get; set; } = TokenIdentifier; + public Guid Id { get; set; } + + [JsonConstructor] + public ProviderDeleteTokenable() + { + + } + + [JsonConstructor] + public ProviderDeleteTokenable(DateTime expirationDate) + { + ExpirationDate = expirationDate; + } + + public ProviderDeleteTokenable(Entities.Provider.Provider provider, int hoursTillExpiration) + { + Id = provider.Id; + ExpirationDate = DateTime.UtcNow.AddHours(hoursTillExpiration); + } + + public bool IsValid(Entities.Provider.Provider provider) + { + return Id == provider.Id; + } + + protected override bool TokenIsValid() => Identifier == TokenIdentifier && Id != default; +} diff --git a/src/Core/AdminConsole/Services/IProviderService.cs b/src/Core/AdminConsole/Services/IProviderService.cs index fdaef4c03..c12bda37d 100644 --- a/src/Core/AdminConsole/Services/IProviderService.cs +++ b/src/Core/AdminConsole/Services/IProviderService.cs @@ -26,5 +26,8 @@ public interface IProviderService Task LogProviderAccessToOrganizationAsync(Guid organizationId); Task ResendProviderSetupInviteEmailAsync(Guid providerId, Guid ownerId); Task SendProviderSetupInviteEmailAsync(Provider provider, string ownerEmail); + Task InitiateDeleteAsync(Provider provider, string providerAdminEmail); + Task DeleteAsync(Provider provider, string token); + Task DeleteAsync(Provider provider); } diff --git a/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs b/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs index b573764b3..26d8dae03 100644 --- a/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs +++ b/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs @@ -35,4 +35,7 @@ public class NoopProviderService : IProviderService public Task ResendProviderSetupInviteEmailAsync(Guid providerId, Guid userId) => throw new NotImplementedException(); public Task SendProviderSetupInviteEmailAsync(Provider provider, string ownerEmail) => throw new NotImplementedException(); + public Task InitiateDeleteAsync(Provider provider, string providerAdminEmail) => throw new NotImplementedException(); + public Task DeleteAsync(Provider provider, string token) => throw new NotImplementedException(); + public Task DeleteAsync(Provider provider) => throw new NotImplementedException(); } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index c523f1c83..55751c4df 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -128,11 +128,12 @@ public static class FeatureFlagKeys public const string FlexibleCollectionsMigration = "flexible-collections-migration"; public const string PM5766AutomaticTax = "PM-5766-automatic-tax"; public const string PM5864DollarThreshold = "PM-5864-dollar-threshold"; - public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email"; public const string ShowPaymentMethodWarningBanners = "show-payment-method-warning-banners"; + public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email"; public const string EnableConsolidatedBilling = "enable-consolidated-billing"; public const string AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section"; public const string UnassignedItemsBanner = "unassigned-items-banner"; + public const string EnableDeleteProvider = "AC-1218-delete-provider"; public static List GetAllKeys() { diff --git a/src/Core/Enums/ApplicationCacheMessageType.cs b/src/Core/Enums/ApplicationCacheMessageType.cs index 94889ed4e..0dd157ef8 100644 --- a/src/Core/Enums/ApplicationCacheMessageType.cs +++ b/src/Core/Enums/ApplicationCacheMessageType.cs @@ -3,5 +3,6 @@ public enum ApplicationCacheMessageType : byte { UpsertOrganizationAbility = 0, - DeleteOrganizationAbility = 1 + DeleteOrganizationAbility = 1, + DeleteProviderAbility = 2, } diff --git a/src/Core/MailTemplates/Handlebars/Provider/InitiateDeleteProvider.html.hbs b/src/Core/MailTemplates/Handlebars/Provider/InitiateDeleteProvider.html.hbs new file mode 100644 index 000000000..ec466ea74 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Provider/InitiateDeleteProvider.html.hbs @@ -0,0 +1,37 @@ +{{#>FullHtmlLayout}} + + + + + + + + + + + + + + + + +
+ We recently received your request to permanently delete the following Bitwarden provider: +
+ Name: {{ProviderName}}
+ ID: {{ProviderId}}
+ Created: {{ProviderCreationDate}} at {{ProviderCreationTime}} {{TimeZone}}
+ Billing email address: {{ProviderBillingEmail}} +
+ Click the link below to delete your Bitwarden provider. +
+ If you did not request this email to delete your Bitwarden provider, you can safely ignore it. +
+
+
+ + Delete Your Provider + +
+
+{{/FullHtmlLayout}} \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/Provider/InitiateDeleteProvider.text.hbs b/src/Core/MailTemplates/Handlebars/Provider/InitiateDeleteProvider.text.hbs new file mode 100644 index 000000000..5c092eb68 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Provider/InitiateDeleteProvider.text.hbs @@ -0,0 +1,15 @@ +{{#>BasicTextLayout}} +We recently received your request to permanently delete the following Bitwarden provider: + +- Name: {{ProviderName}} +- ID: {{ProviderId}} +- Created: {{ProviderCreationDate}} at {{ProviderCreationTime}} {{TimeZone}} +- Billing email address: {{ProviderBillingEmail}} + +Click the link below to complete the deletion of your provider. + +If you did not request this email to delete your Bitwarden provider, you can safely ignore it. + +{{{Url}}} + +{{/BasicTextLayout}} \ No newline at end of file diff --git a/src/Core/Models/Mail/Provider/ProviderInitiateDeleteModel.cs b/src/Core/Models/Mail/Provider/ProviderInitiateDeleteModel.cs new file mode 100644 index 000000000..196decb5e --- /dev/null +++ b/src/Core/Models/Mail/Provider/ProviderInitiateDeleteModel.cs @@ -0,0 +1,21 @@ +namespace Bit.Core.Models.Mail.Provider; + +public class ProviderInitiateDeleteModel : BaseMailModel +{ + public string Url => string.Format("{0}/verify-recover-delete-provider?providerId={1}&token={2}&name={3}", + WebVaultUrl, + ProviderId, + Token, + ProviderNameUrlEncoded); + + public string WebVaultUrl { get; set; } + public string Token { get; set; } + public Guid ProviderId { get; set; } + public string SiteName { get; set; } + public string ProviderName { get; set; } + public string ProviderNameUrlEncoded { get; set; } + public string ProviderBillingEmail { get; set; } + public string ProviderCreationDate { get; set; } + public string ProviderCreationTime { get; set; } + public string TimeZone { get; set; } +} diff --git a/src/Core/Services/IApplicationCacheService.cs b/src/Core/Services/IApplicationCacheService.cs index ee47cf29f..1236335d7 100644 --- a/src/Core/Services/IApplicationCacheService.cs +++ b/src/Core/Services/IApplicationCacheService.cs @@ -15,4 +15,5 @@ public interface IApplicationCacheService Task UpsertOrganizationAbilityAsync(Organization organization); Task UpsertProviderAbilityAsync(Provider provider); Task DeleteOrganizationAbilityAsync(Guid organizationId); + Task DeleteProviderAbilityAsync(Guid providerId); } diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 30c28ddd7..7943eca95 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -78,5 +78,6 @@ public interface IMailService Task SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable ownerEmails); Task SendTrustedDeviceAdminApprovalEmailAsync(string email, DateTime utcNow, string ip, string deviceTypeAndIdentifier); Task SendTrialInitiationEmailAsync(string email); + Task SendInitiateDeletProviderEmailAsync(string email, Provider provider, string token); } diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 64758c1e8..496d0ea0c 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -267,6 +267,28 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendInitiateDeletProviderEmailAsync(string email, Provider provider, string token) + { + var message = CreateDefaultMessage("Request to Delete Your Provider", email); + var model = new ProviderInitiateDeleteModel + { + Token = WebUtility.UrlEncode(token), + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName, + ProviderId = provider.Id, + ProviderName = CoreHelpers.SanitizeForEmail(provider.DisplayName(), false), + ProviderNameUrlEncoded = WebUtility.UrlEncode(provider.Name), + ProviderBillingEmail = provider.BillingEmail, + ProviderCreationDate = provider.CreationDate.ToLongDateString(), + ProviderCreationTime = provider.CreationDate.ToShortTimeString(), + TimeZone = _utcTimeZoneDisplay, + }; + await AddMessageContentAsync(message, "Provider.InitiateDeleteProvider", model); + message.MetaData.Add("SendGridBypassListManagement", true); + message.Category = "InitiateDeleteProvider"; + await _mailDeliveryService.SendEmailAsync(message); + } + public async Task SendPasswordlessSignInAsync(string returnUrl, string token, string email) { var message = CreateDefaultMessage("[Admin] Continue Logging In", email); diff --git a/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs b/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs index 256a9a08a..436e35495 100644 --- a/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs +++ b/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs @@ -85,6 +85,16 @@ public class InMemoryApplicationCacheService : IApplicationCacheService return Task.FromResult(0); } + public virtual Task DeleteProviderAbilityAsync(Guid providerId) + { + if (_providerAbilities != null && _providerAbilities.ContainsKey(providerId)) + { + _providerAbilities.Remove(providerId); + } + + return Task.FromResult(0); + } + private async Task InitOrganizationAbilitiesAsync() { var now = DateTime.UtcNow; diff --git a/src/Core/Services/Implementations/InMemoryServiceBusApplicationCacheService.cs b/src/Core/Services/Implementations/InMemoryServiceBusApplicationCacheService.cs index 677a18bc2..da70ccd2f 100644 --- a/src/Core/Services/Implementations/InMemoryServiceBusApplicationCacheService.cs +++ b/src/Core/Services/Implementations/InMemoryServiceBusApplicationCacheService.cs @@ -64,4 +64,19 @@ public class InMemoryServiceBusApplicationCacheService : InMemoryApplicationCach { await base.DeleteOrganizationAbilityAsync(organizationId); } + + public override async Task DeleteProviderAbilityAsync(Guid providerId) + { + await base.DeleteProviderAbilityAsync(providerId); + var message = new ServiceBusMessage + { + Subject = _subName, + ApplicationProperties = + { + { "type", (byte)ApplicationCacheMessageType.DeleteProviderAbility }, + { "id", providerId }, + } + }; + var task = _topicMessageSender.SendMessageAsync(message); + } } diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index 4bf15488c..f86d40a19 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -268,5 +268,7 @@ public class NoopMailService : IMailService { return Task.FromResult(0); } + + public Task SendInitiateDeletProviderEmailAsync(string email, Provider provider, string token) => throw new NotImplementedException(); } diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 7535aba88..bc3c68e87 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using System.Reflection; using System.Security.Claims; using System.Security.Cryptography.X509Certificates; using AspNetCoreRateLimit; +using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services.Implementations; using Bit.Core.AdminConsole.Services.NoopImplementations; @@ -203,6 +204,14 @@ public static class ServiceCollectionExtensions DuoUserStateTokenable.DataProtectorPurpose, serviceProvider.GetDataProtectionProvider(), serviceProvider.GetRequiredService>>())); + + services.AddSingleton>(serviceProvider => + new DataProtectorTokenFactory( + ProviderDeleteTokenable.ClearTextPrefix, + ProviderDeleteTokenable.DataProtectorPurpose, + serviceProvider.GetDataProtectionProvider(), + serviceProvider.GetRequiredService>>()) + ); } public static void AddDefaultServices(this IServiceCollection services, GlobalSettings globalSettings)