diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index d9dfbafc7..193077dc1 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -580,6 +580,13 @@ public class AccountsController : Controller } else { + // If Account Deprovisioning is enabled, we need to check if the user is managed by any organization. + if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) + && await _userService.IsManagedByAnyOrganizationAsync(user.Id)) + { + throw new BadRequestException("Cannot delete accounts owned by an organization. Contact your organization administrator for additional details."); + } + var result = await _userService.DeleteAsync(user); if (result.Succeeded) { diff --git a/src/Core/Auth/Models/Mail/CannotDeleteManagedAccountViewModel.cs b/src/Core/Auth/Models/Mail/CannotDeleteManagedAccountViewModel.cs new file mode 100644 index 000000000..02549a959 --- /dev/null +++ b/src/Core/Auth/Models/Mail/CannotDeleteManagedAccountViewModel.cs @@ -0,0 +1,7 @@ +using Bit.Core.Models.Mail; + +namespace Bit.Core.Auth.Models.Mail; + +public class CannotDeleteManagedAccountViewModel : BaseMailModel +{ +} diff --git a/src/Core/MailTemplates/Handlebars/AdminConsole/CannotDeleteManagedAccount.html.hbs b/src/Core/MailTemplates/Handlebars/AdminConsole/CannotDeleteManagedAccount.html.hbs new file mode 100644 index 000000000..e867bf4f1 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/AdminConsole/CannotDeleteManagedAccount.html.hbs @@ -0,0 +1,15 @@ +{{#>FullHtmlLayout}} + + + + + + + +
+ You have requested to delete your account. This action cannot be completed because your account is owned by an organization. +
+ Please contact your organization administrator for additional details. +
+
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/AdminConsole/CannotDeleteManagedAccount.text.hbs b/src/Core/MailTemplates/Handlebars/AdminConsole/CannotDeleteManagedAccount.text.hbs new file mode 100644 index 000000000..3b71a1b1f --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/AdminConsole/CannotDeleteManagedAccount.text.hbs @@ -0,0 +1,6 @@ +{{#>BasicTextLayout}} +You have requested to delete your account. This action cannot be completed because your account is owned by an organization. + +Please contact your organization administrator for additional details. + +{{/BasicTextLayout}} diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 9ae275719..fe801af81 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -18,6 +18,7 @@ public interface IMailService ProductTierType productTier, IEnumerable products); Task SendVerifyDeleteEmailAsync(string email, Guid userId, string token); + Task SendCannotDeleteManagedAccountEmailAsync(string email); Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail); Task SendChangeEmailEmailAsync(string newEmailAddress, string token); Task SendTwoFactorEmailAsync(string email, string token); diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index b22be0622..007231299 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -112,6 +112,19 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendCannotDeleteManagedAccountEmailAsync(string email) + { + var message = CreateDefaultMessage("Delete Your Account", email); + var model = new CannotDeleteManagedAccountViewModel + { + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName, + }; + await AddMessageContentAsync(message, "AdminConsole.CannotDeleteManagedAccount", model); + message.Category = "CannotDeleteManagedAccount"; + await _mailDeliveryService.SendEmailAsync(message); + } + public async Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail) { var message = CreateDefaultMessage("Your Email Change", toEmail); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index f2e1d183d..2199d0a7a 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -297,6 +297,12 @@ public class UserService : UserManager, IUserService, IDisposable return; } + if (await IsManagedByAnyOrganizationAsync(user.Id)) + { + await _mailService.SendCannotDeleteManagedAccountEmailAsync(user.Email); + return; + } + var token = await base.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "DeleteAccount"); await _mailService.SendVerifyDeleteEmailAsync(user.Email, user.Id, token); } diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index 469673057..edada01da 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -94,6 +94,11 @@ public class NoopMailService : IMailService return Task.FromResult(0); } + public Task SendCannotDeleteManagedAccountEmailAsync(string email) + { + return Task.FromResult(0); + } + public Task SendPasswordlessSignInAsync(string returnUrl, string token, string email) { return Task.FromResult(0); diff --git a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs index 4127c92ee..13c80f856 100644 --- a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs @@ -534,6 +534,34 @@ public class AccountsControllerTests : IDisposable await Assert.ThrowsAsync(() => _sut.PostSetPasswordAsync(model)); } + [Fact] + public async Task Delete_WhenAccountDeprovisioningIsEnabled_WithUserManagedByAnOrganization_ThrowsBadRequestException() + { + var user = GenerateExampleUser(); + ConfigureUserServiceToReturnValidPrincipalFor(user); + ConfigureUserServiceToAcceptPasswordFor(user); + _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); + _userService.IsManagedByAnyOrganizationAsync(user.Id).Returns(true); + + var result = await Assert.ThrowsAsync(() => _sut.Delete(new SecretVerificationRequestModel())); + + Assert.Equal("Cannot delete accounts owned by an organization. Contact your organization administrator for additional details.", result.Message); + } + + [Fact] + public async Task Delete_WhenAccountDeprovisioningIsEnabled_WithUserNotManagedByAnOrganization_ShouldSucceed() + { + var user = GenerateExampleUser(); + ConfigureUserServiceToReturnValidPrincipalFor(user); + ConfigureUserServiceToAcceptPasswordFor(user); + _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); + _userService.IsManagedByAnyOrganizationAsync(user.Id).Returns(false); + _userService.DeleteAsync(user).Returns(IdentityResult.Success); + + await _sut.Delete(new SecretVerificationRequestModel()); + + await _userService.Received(1).DeleteAsync(user); + } // Below are helper functions that currently belong to this // test class, but ultimately may need to be split out into