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

[PM-7004] Org Admin Initiate Delete (#3905)

* 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 <rtome@bitwarden.com>
This commit is contained in:
Kyle Spearrin 2024-05-22 12:59:19 -04:00 committed by GitHub
parent 56c523f76f
commit 4264fc0729
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 334 additions and 28 deletions

View File

@ -269,6 +269,35 @@ public class OrganizationsController : Controller
return RedirectToAction("Index");
}
[HttpPost]
[ValidateAntiForgeryToken]
[RequirePermission(Permission.Org_Delete)]
public async Task<IActionResult> 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<IActionResult> TriggerBillingSync(Guid id)
{
var organization = await _organizationRepository.GetByIdAsync(id);

View File

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

View File

@ -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 @@
<script>
(() => {
document.getElementById('teams-trial').addEventListener('click', () => {
if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)PlanType.Free)') {
alert('Organization is not on a free plan.');
return;
}
setTrialDefaults('@((byte)PlanType.TeamsAnnually)');
togglePlanFeatures('@((byte)PlanType.TeamsAnnually)');
document.getElementById('@(nameof(Model.Plan))').value = 'Teams (Trial)';
});
document.getElementById('enterprise-trial').addEventListener('click', () => {
if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)PlanType.Free)') {
alert('Organization is not on a free plan.');
return;
}
setTrialDefaults('@((byte)PlanType.EnterpriseAnnually)');
togglePlanFeatures('@((byte)PlanType.EnterpriseAnnually)');
document.getElementById('@(nameof(Model.Plan))').value = 'Enterprise (Trial)';
});
const treamsTrialButton = document.getElementById('teams-trial');
if (treamsTrialButton != null) {
treamsTrialButton.addEventListener('click', () => {
if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)PlanType.Free)') {
alert('Organization is not on a free plan.');
return;
}
setTrialDefaults('@((byte)PlanType.TeamsAnnually)');
togglePlanFeatures('@((byte)PlanType.TeamsAnnually)');
document.getElementById('@(nameof(Model.Plan))').value = 'Teams (Trial)';
});
}
const entTrialButton = document.getElementById('enterprise-trial');
if (entTrialButton != null) {
entTrialButton.addEventListener('click', () => {
if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)PlanType.Free)') {
alert('Organization is not on a free plan.');
return;
}
setTrialDefaults('@((byte)PlanType.EnterpriseAnnually)');
togglePlanFeatures('@((byte)PlanType.EnterpriseAnnually)');
document.getElementById('@(nameof(Model.Plan))').value = 'Enterprise (Trial)';
});
}
const initDeleteButton = document.getElementById('initiate-delete-form');
if (initDeleteButton != null) {
initDeleteButton.addEventListener('submit', (e) => {
const email = prompt('Enter the email address of the owner/admin that your want to ' +
'request the organization delete verification process with.');
document.getElementById('AdminEmail').value = email;
if (email == null || email === '') {
e.preventDefault();
}
});
}
function setTrialDefaults(planType) {
// Plan
@ -76,7 +95,7 @@
{
<h2>Billing Information</h2>
@await Html.PartialAsync("_BillingInformation",
new BillingInformationModel { BillingInfo = Model.BillingInfo, OrganizationId = Model.Organization.Id, Entity = "Organization" })
new BillingInformationModel { BillingInfo = Model.BillingInfo, OrganizationId = Model.Organization.Id, Entity = "Organization" })
}
@await Html.PartialAsync("~/AdminConsole/Views/Shared/_OrganizationForm.cshtml", Model)
@ -95,18 +114,20 @@
}
@if (canUnlinkFromProvider && Model.Provider is not null)
{
<button
class="btn btn-outline-danger mr-2"
onclick="return unlinkProvider('@Model.Organization.Id');"
>
<button class="btn btn-outline-danger mr-2"
onclick="return unlinkProvider('@Model.Organization.Id');">
Unlink provider
</button>
}
@if (canDelete)
{
<form asp-action="DeleteInitiation" asp-route-id="@Model.Organization.Id" id="initiate-delete-form">
<input type="hidden" name="AdminEmail" id="AdminEmail" />
<button class="btn btn-danger mr-2" type="submit">Request Delete</button>
</form>
<form asp-action="Delete" asp-route-id="@Model.Organization.Id"
onsubmit="return confirm('Are you sure you want to delete this organization?')">
<button class="btn btn-danger" type="submit">Delete</button>
onsubmit="return confirm('Are you sure you want to hard delete this organization?')">
<button class="btn btn-outline-danger" type="submit">Delete</button>
</form>
}
</div>

View File

@ -174,6 +174,14 @@
});
</script>
}
@if (TempData["Success"] != null)
{
<script>
$(document).ready(function () {
toastr.success("@TempData["Success"]")
});
</script>
}
@RenderSection("Scripts", required: false)
</body>

View File

@ -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<OrgDeleteTokenable> _orgDeleteTokenDataFactory;
public OrganizationsController(
IOrganizationRepository organizationRepository,
@ -73,7 +76,8 @@ public class OrganizationsController : Controller
IPushNotificationService pushNotificationService,
IOrganizationEnableCollectionEnhancementsCommand organizationEnableCollectionEnhancementsCommand,
IProviderRepository providerRepository,
IScaleSeatsCommand scaleSeatsCommand)
IScaleSeatsCommand scaleSeatsCommand,
IDataProtectorTokenFactory<OrgDeleteTokenable> 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)
{

View File

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

View File

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

View File

@ -34,6 +34,7 @@ public interface IOrganizationService
/// </summary>
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);

View File

@ -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<OrgDeleteTokenable> _orgDeleteTokenDataFactory;
private readonly IProviderRepository _providerRepository;
private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory;
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
@ -94,6 +96,7 @@ public class OrganizationService : IOrganizationService
IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory,
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
IDataProtectorTokenFactory<OrgDeleteTokenable> 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);

View File

@ -0,0 +1,39 @@
{{#>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 organization:
</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> {{OrganizationName}}<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> {{OrganizationId}}<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> {{OrganizationCreationDate}} at {{OrganizationCreationTime}} {{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;">Plan:</b> {{OrganizationPlan}}<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;">Number of seats:</b> {{OrganizationSeats}}<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> {{OrganizationBillingEmail}}
</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 organization.
</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 organization, please contact us.
<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 Organization
</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,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}}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -150,6 +150,13 @@ public static class ServiceCollectionExtensions
public static void AddTokenizers(this IServiceCollection services)
{
services.AddSingleton<IDataProtectorTokenFactory<OrgDeleteTokenable>>(serviceProvider =>
new DataProtectorTokenFactory<OrgDeleteTokenable>(
OrgDeleteTokenable.ClearTextPrefix,
OrgDeleteTokenable.DataProtectorPurpose,
serviceProvider.GetDataProtectionProvider(),
serviceProvider.GetRequiredService<ILogger<DataProtectorTokenFactory<OrgDeleteTokenable>>>())
);
services.AddSingleton<IDataProtectorTokenFactory<EmergencyAccessInviteTokenable>>(serviceProvider =>
new DataProtectorTokenFactory<EmergencyAccessInviteTokenable>(
EmergencyAccessInviteTokenable.ClearTextPrefix,

View File

@ -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<OrgDeleteTokenable> _orgDeleteTokenDataFactory;
private readonly OrganizationsController _sut;
@ -70,6 +73,7 @@ public class OrganizationsControllerTests : IDisposable
_organizationEnableCollectionEnhancementsCommand = Substitute.For<IOrganizationEnableCollectionEnhancementsCommand>();
_providerRepository = Substitute.For<IProviderRepository>();
_scaleSeatsCommand = Substitute.For<IScaleSeatsCommand>();
_orgDeleteTokenDataFactory = Substitute.For<IDataProtectorTokenFactory<OrgDeleteTokenable>>();
_sut = new OrganizationsController(
_organizationRepository,
@ -89,7 +93,8 @@ public class OrganizationsControllerTests : IDisposable
_pushNotificationService,
_organizationEnableCollectionEnhancementsCommand,
_providerRepository,
_scaleSeatsCommand);
_scaleSeatsCommand,
_orgDeleteTokenDataFactory);
}
public void Dispose()