1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-21 12:05:42 +01:00

[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 <cokeke@bitwarden.com>

* revert unnecessary changes

* Add a model

* add the delete token endpoint

* add a unit test for delete provider

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

* add the delete provider method

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

* resolve the failing test

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

* resolve the delete request redirect issue

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

* changes to correct the json issue

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

* resolve errors

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

* resolve pr comment

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

* move ProviderDeleteTokenable to the adminConsole

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

* Add feature flag

* resolve pr comments

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

* add some unit test

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

* resolve the failing test

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

* resolve test

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

* add the remove feature flag

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

* [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 <cokeke@bitwarden.com>

---------

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>
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>
This commit is contained in:
cyprain-okeke 2024-04-17 10:09:53 +01:00 committed by GitHub
parent ddbb031bcb
commit 6672019122
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 567 additions and 11 deletions

View File

@ -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<ProviderDeleteTokenable> _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<ProviderDeleteTokenable> 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<Provider> 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);

View File

@ -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<ProviderService> sutProvider)
{
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
var applicationCacheService = sutProvider.GetDependency<IApplicationCacheService>();
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<ProviderService> sutProvider)
{
var provider = new Provider { Name = "" };
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.InitiateDeleteAsync(provider, providerAdminEmail));
}
[Theory, BitAutoData]
public async Task InitiateDeleteAsync_ThrowsBadRequestException_WhenProviderAdminNotFound(Provider provider, SutProvider<ProviderService> sutProvider)
{
var providerAdminEmail = "nonexistent@example.com";
var userRepository = sutProvider.GetDependency<IUserRepository>();
userRepository.GetByEmailAsync(providerAdminEmail).Returns(Task.FromResult<User>(null));
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.InitiateDeleteAsync(provider, providerAdminEmail));
}
[Theory, BitAutoData]
public async Task InitiateDeleteAsync_ThrowsBadRequestException_WhenProviderAdminStatusIsNotConfirmed(
Provider provider
, User providerAdmin
, ProviderUser providerUser
, SutProvider<ProviderService> sutProvider)
{
var providerAdminEmail = "nonexistent@example.com";
providerUser.Status = ProviderUserStatusType.Confirmed;
providerUser.Type = ProviderUserType.ServiceUser;
var userRepository = sutProvider.GetDependency<IUserRepository>();
userRepository.GetByEmailAsync(providerAdminEmail).Returns(Task.FromResult<User>(providerAdmin));
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
providerUserRepository.GetByProviderUserAsync(provider.Id, providerAdmin.Id).Returns(providerUser);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => 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<ProviderService> sutProvider)
{
var providerAdminEmail = providerAdmin.Email;
providerUser.Status = ProviderUserStatusType.Confirmed;
providerUser.Type = ProviderUserType.ProviderAdmin;
var userRepository = sutProvider.GetDependency<IUserRepository>();
userRepository.GetByEmailAsync(providerAdminEmail).Returns(Task.FromResult<User>(providerAdmin));
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
providerUserRepository.GetByProviderUserAsync(provider.Id, providerAdmin.Id).Returns(providerUser);
var mailService = sutProvider.GetDependency<IMailService>();
await sutProvider.Sut.InitiateDeleteAsync(provider, providerAdminEmail);
await mailService.Received().SendInitiateDeletProviderEmailAsync(providerAdminEmail, provider, Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task DeleteAsync_ThrowsBadRequestException_WhenInvalidToken(Provider provider, string invalidToken
, SutProvider<ProviderService> sutProvider)
{
var providerDeleteTokenDataFactory = sutProvider.GetDependency<IDataProtectorTokenFactory<ProviderDeleteTokenable>>();
providerDeleteTokenDataFactory.TryUnprotect(invalidToken, out Arg.Any<ProviderDeleteTokenable>()).Returns(false);
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.DeleteAsync(provider, invalidToken));
}
[Theory, BitAutoData]
public async Task DeleteAsync_ThrowsBadRequestException_WhenInvalidTokenData(Provider provider, string validToken
, SutProvider<ProviderService> sutProvider)
{
var validTokenData = new ProviderDeleteTokenable();
var providerDeleteTokenDataFactory = sutProvider.GetDependency<IDataProtectorTokenFactory<ProviderDeleteTokenable>>();
providerDeleteTokenDataFactory.TryUnprotect(validToken, out validTokenData).Returns(false);
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.DeleteAsync(provider, validToken));
}
private static SubscriptionUpdateOptions SubscriptionUpdateRequest(string expectedPlanId, Subscription subscriptionItem) =>
new()
{

View File

@ -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<IActionResult> 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<IActionResult> 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();
}
}

View File

@ -6,7 +6,6 @@
@model ProviderEditModel
@{
ViewData["Title"] = "Provider: " + Model.Provider.DisplayName();
var canEdit = AccessControlService.UserHasPermission(Permission.Provider_Edit);
}
@ -62,10 +61,89 @@
}
</form>
@await Html.PartialAsync("Organizations", Model)
@if (canEdit)
{
<div class="d-flex mt-4">
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
</div>
}
@if (canEdit)
{
<!-- Modals -->
<div class="modal fade rounded" id="requestDeletionModal" tabindex="-1" aria-labelledby="requestDeletionModal" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content rounded">
<div class="p-3">
<h3 class="font-weight-bolder" id="exampleModalLabel">Request provider deletion</h3>
</div>
<div class="modal-body">
<span class="font-weight-light">
Enter the email of the provider admin that will receive the request to delete the provider portal.
</span>
<form>
<div class="form-group">
<label for="provider-email" class="col-form-label">Provider email</label>
<input type="email" class="form-control" id="provider-email">
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-primary btn-pill" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger btn-pill" onclick="initiateDeleteProvider('@Model.Provider.Id')">Send email request</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="DeleteModal" tabindex="-1" aria-labelledby="DeleteModal" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content rounded">
<div class="p-3">
<h3 class="font-weight-bolder" id="exampleModalLabel">Delete provider</h3>
</div>
<div class="modal-body">
<span class="font-weight-light">
This action is permanent and irreversible. Enter the provide name to complete deletion of the provider and associated data.
</span>
<form>
<div class="form-group">
<label for="provider-name" class="col-form-label">Provider name</label>
<input type="text" class="form-control" id="provider-name">
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-primary btn-pill" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger btn-pill" onclick="deleteProvider('@Model.Provider.Id');">Delete provider</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="linkedWarningModal" tabindex="-1" role="dialog" aria-labelledby="linkedWarningModal" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content rounded">
<div class="modal-body">
<h4 class="font-weight-bolder">Cannot Delete @Model.Name</h4>
<p class="font-weight-lighter">you must unlink all clients before deleting @Model.Name</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-primary btn-pill" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary btn-pill" data-dismiss="modal">Ok</button>
</div>
</div>
</div>
</div>
<!-- End of Modal Section -->
<div class="d-flex mt-4">
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableDeleteProvider))
{
<div class="ml-auto d-flex">
<button class="btn btn-danger" onclick="openRequestDeleteModal(@Model.ProviderOrganizations.Count())">Request Delete</button>
<button id="requestDeletionBtn" hidden="hidden" data-toggle="modal" data-target="#requestDeletionModal"></button>
<button class="btn btn-outline-danger ml-2" onclick="openDeleteModal(@Model.ProviderOrganizations.Count())">Delete</button>
<button id="deleteBtn" hidden="hidden" data-toggle="modal" data-target="#DeleteModal"></button>
<button id="linkAccWarningBtn" hidden="hidden" data-toggle="modal" data-target="#linkedWarningModal"></button>
</div>
}
</div>
}

View File

@ -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()
}
}
</script>

View File

@ -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);
}
}

View File

@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Api.AdminConsole.Models.Request.Providers;
public class ProviderVerifyDeleteRecoverRequestModel
{
[Required]
public string Token { get; set; }
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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();
}

View File

@ -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<string> GetAllKeys()
{

View File

@ -3,5 +3,6 @@
public enum ApplicationCacheMessageType : byte
{
UpsertOrganizationAbility = 0,
DeleteOrganizationAbility = 1
DeleteOrganizationAbility = 1,
DeleteProviderAbility = 2,
}

View File

@ -0,0 +1,37 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: left;" valign="top" align="center">
We recently received your request to permanently delete the following Bitwarden provider:
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
<b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">Name:</b> {{ProviderName}}<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
<b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">ID:</b> {{ProviderId}}<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
<b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">Created:</b> {{ProviderCreationDate}} at {{ProviderCreationTime}} {{TimeZone}}<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
<b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">Billing email address:</b> {{ProviderBillingEmail}}
</td>
</tr>
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: left;" valign="top" align="center">
Click the link below to delete your Bitwarden provider.
</td>
</tr>
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none; text-align: left;" valign="top" align="center">
If you did not request this email to delete your Bitwarden provider, you can safely ignore it.
<br style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
<br style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
</td>
</tr>
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
<a href="{{{Url}}}" clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #175DDC; border-color: #175DDC; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
Delete Your Provider
</a>
<br style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

@ -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}}

View File

@ -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; }
}

View File

@ -15,4 +15,5 @@ public interface IApplicationCacheService
Task UpsertOrganizationAbilityAsync(Organization organization);
Task UpsertProviderAbilityAsync(Provider provider);
Task DeleteOrganizationAbilityAsync(Guid organizationId);
Task DeleteProviderAbilityAsync(Guid providerId);
}

View File

@ -78,5 +78,6 @@ public interface IMailService
Task SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable<string> ownerEmails);
Task SendTrustedDeviceAdminApprovalEmailAsync(string email, DateTime utcNow, string ip, string deviceTypeAndIdentifier);
Task SendTrialInitiationEmailAsync(string email);
Task SendInitiateDeletProviderEmailAsync(string email, Provider provider, string token);
}

View File

@ -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);

View File

@ -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;

View File

@ -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);
}
}

View File

@ -268,5 +268,7 @@ public class NoopMailService : IMailService
{
return Task.FromResult(0);
}
public Task SendInitiateDeletProviderEmailAsync(string email, Provider provider, string token) => throw new NotImplementedException();
}

View File

@ -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<ILogger<DataProtectorTokenFactory<DuoUserStateTokenable>>>()));
services.AddSingleton<IDataProtectorTokenFactory<ProviderDeleteTokenable>>(serviceProvider =>
new DataProtectorTokenFactory<ProviderDeleteTokenable>(
ProviderDeleteTokenable.ClearTextPrefix,
ProviderDeleteTokenable.DataProtectorPurpose,
serviceProvider.GetDataProtectionProvider(),
serviceProvider.GetRequiredService<ILogger<DataProtectorTokenFactory<ProviderDeleteTokenable>>>())
);
}
public static void AddDefaultServices(this IServiceCollection services, GlobalSettings globalSettings)