Skip to content

Commit

Permalink
[PM-10317] Email Users For Org Claiming Domain (#5094)
Browse files Browse the repository at this point in the history
* Revoking users when enabling single org and 2fa policies. Fixing tests.

* Added migration.

* Wrote tests and fixed bugs found.

* Patch build process

* Fixing tests.

* Added unit test around disabling the feature flag.

* Updated error message to be public and added test for validating the request.

* formatting

* Added some tests for single org policy validator.

* Fix issues from merge.

* Added sending emails to revoked non-compliant users.

* Fixing name. Adding two factor policy email.

* Send email when user has been revoked.

* Correcting migration name.

* Fixing templates and logic issue in Revoke command.

* Moving interface into its own file.

* Correcting namespaces for email templates.

* correcting logic that would not allow normal users to revoke non owners.

* Actually correcting the test and logic.

* dotnet format. Added exec to bottom of bulk sproc

* Update src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeNonCompliantOrganizationUserCommand.cs

Co-authored-by: Rui Tomé <[email protected]>

* Updated OrgIds to be a json string

* Fixing errors.

* Updating test

* Moving command result.

* Formatting and request rename

* Realized this would throw a null error from the system domain verification. Adding unknown type to event system user. Adding optional parameter to SaveAsync in policy service in order to pass in event system user.

* Code review changes

* Removing todos

* Corrected test name.

* Syncing filename to record name.

* Fixing up the tests.

* Added happy path test

* Naming corrections. And corrected EF query.

* added check against event service

* Code review changes.

* Fixing tests.

* splitting up tests

* Added templates and email side effect for claiming a domain.

* bringing changes from nc user changes.

* Switched to enqueue mail message.

* Filled in DomainClaimedByOrganization.html.hbs

* Added text document for domain claiming

* Fixing migration script.

* Remove old sproc

* Limiting sending of the email down to users who are a part of the domain being claimed.

* Added test for change

* Renames and fixed up email.

* Fixing up CSS

---------

Co-authored-by: Matt Bishop <[email protected]>
Co-authored-by: Rui Tomé <[email protected]>
Co-authored-by: Rui Tome <[email protected]>
  • Loading branch information
4 people authored Dec 5, 2024
1 parent 04f9d7d commit f471fff
Show file tree
Hide file tree
Showing 9 changed files with 145 additions and 9 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
Expand All @@ -22,11 +23,12 @@ public class VerifyOrganizationDomainCommand(
IFeatureService featureService,
ICurrentContext currentContext,
ISavePolicyCommand savePolicyCommand,
IMailService mailService,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
ILogger<VerifyOrganizationDomainCommand> logger)
: IVerifyOrganizationDomainCommand
{


public async Task<OrganizationDomain> UserVerifyOrganizationDomainAsync(OrganizationDomain organizationDomain)
{
if (currentContext.UserId is null)
Expand Down Expand Up @@ -109,7 +111,7 @@ private async Task<OrganizationDomain> VerifyOrganizationDomainAsync(Organizatio
{
domain.SetVerifiedDate();

await EnableSingleOrganizationPolicyAsync(domain.OrganizationId, actingUser);
await DomainVerificationSideEffectsAsync(domain, actingUser);
}
}
catch (Exception e)
Expand All @@ -121,19 +123,37 @@ private async Task<OrganizationDomain> VerifyOrganizationDomainAsync(Organizatio
return domain;
}

private async Task EnableSingleOrganizationPolicyAsync(Guid organizationId, IActingUser actingUser)
private async Task DomainVerificationSideEffectsAsync(OrganizationDomain domain, IActingUser actingUser)
{
if (featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
{
var policyUpdate = new PolicyUpdate
await EnableSingleOrganizationPolicyAsync(domain.OrganizationId, actingUser);
await SendVerifiedDomainUserEmailAsync(domain);
}
}

private async Task EnableSingleOrganizationPolicyAsync(Guid organizationId, IActingUser actingUser) =>
await savePolicyCommand.SaveAsync(
new PolicyUpdate
{
OrganizationId = organizationId,
Type = PolicyType.SingleOrg,
Enabled = true,
PerformedBy = actingUser
};
});

await savePolicyCommand.SaveAsync(policyUpdate);
}
private async Task SendVerifiedDomainUserEmailAsync(OrganizationDomain domain)
{
var orgUserUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(domain.OrganizationId);

var domainUserEmails = orgUserUsers
.Where(ou => ou.Email.ToLower().EndsWith($"@{domain.DomainName.ToLower()}") &&
ou.Status != OrganizationUserStatusType.Revoked &&
ou.Status != OrganizationUserStatusType.Invited)
.Select(ou => ou.Email);

var organization = await organizationRepository.GetByIdAsync(domain.OrganizationId);

await mailService.SendClaimedDomainUserEmailAsync(new ManagedUserDomainClaimedEmails(domainUserEmails, organization));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{{#>TitleContactUsHtmlLayout}}
<table width="100%" border="0" cellpadding="0" cellspacing="0" style="display: table; width:100%; padding: 30px; text-align: left;" align="center">
<tr>
<td display="display: table-cell">
As a member of {{OrganizationName}}, your Bitwarden account is claimed and owned by your organization.
</td>
</tr>
<tr>
<td style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif; font-style: normal; font-weight: 400; font-size: 16px; line-height: 24px; margin-top: 30px; margin-bottom: 25px; margin-left: 35px; margin-right: 35px;">
<b>Here's what that means:</b>
<ul>
<li>This account should only be used to store items related to {{OrganizationName}}</li>
<li>Admins managing your Bitwarden organization manage your email address and other account settings</li>
<li>Admins can also revoke or delete your account at any time</li>
</ul>
</td>
</tr>
<tr>
<td style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif; font-style: normal; font-weight: 400; font-size: 16px; line-height: 24px; margin-top: 30px; margin-bottom: 25px; margin-left: 35px; margin-right: 35px;">
For more information, please refer to the following help article: <a href="https://bitwarden.com/help/claimed-accounts">Claimed Accounts</a>
</td>
</tr>
</table>
{{/TitleContactUsHtmlLayout}}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
As a member of {{OrganizationName}}, your Bitwarden account is claimed and owned by your organization.

Here's what that means:
- This account should only be used to store items related to {{OrganizationName}}
- Your admins managing your Bitwarden organization manages your email address and other account settings
- Your admins can also revoke or delete your account at any time

For more information, please refer to the following help article: Claimed Accounts (https://bitwarden.com/help/claimed-accounts)
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
using Bit.Core.AdminConsole.Entities;

namespace Bit.Core.Models.Data.Organizations;

public record ManagedUserDomainClaimedEmails(IEnumerable<string> EmailList, Organization Organization);
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Bit.Core.Models.Mail;

public class ClaimedDomainUserNotificationViewModel : BaseTitleContactUsMailModel
{
public string OrganizationName { get; init; }
}
2 changes: 2 additions & 0 deletions src/Core/Services/IMailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Bit.Core.Auth.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Models.Mail;

namespace Bit.Core.Services;
Expand Down Expand Up @@ -93,5 +94,6 @@ Task SendProviderUpdatePaymentMethod(
Task SendRequestSMAccessToAdminEmailAsync(IEnumerable<string> adminEmails, string organizationName, string userRequestingAccess, string emailContent);
Task SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(string email, string offerAcceptanceDate, string organizationId,
string organizationName);
Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList);
}

17 changes: 17 additions & 0 deletions src/Core/Services/Implementations/HandlebarsMailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models.Mail;
using Bit.Core.Entities;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Models.Mail;
using Bit.Core.Models.Mail.FamiliesForEnterprise;
using Bit.Core.Models.Mail.Provider;
Expand Down Expand Up @@ -460,6 +461,22 @@ public async Task SendRequestSMAccessToAdminEmailAsync(IEnumerable<string> email
await _mailDeliveryService.SendEmailAsync(message);
}

public async Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList)
{
await EnqueueMailAsync(emailList.EmailList.Select(email =>
CreateMessage(email, emailList.Organization)));
return;

MailQueueMessage CreateMessage(string emailAddress, Organization org) =>
new(CreateDefaultMessage($"Your Bitwarden account is claimed by {org.DisplayName()}", emailAddress),
"AdminConsole.DomainClaimedByOrganization",
new ClaimedDomainUserNotificationViewModel
{
TitleFirst = $"Hey {emailAddress}, here is a heads up on your claimed account:",
OrganizationName = CoreHelpers.SanitizeForEmail(org.DisplayName(), false)
});
}

public async Task SendNewDeviceLoggedInEmail(string email, string deviceType, DateTime timestamp, string ip)
{
var message = CreateDefaultMessage($"New Device Logged In From {deviceType}", email);
Expand Down
2 changes: 2 additions & 0 deletions src/Core/Services/NoopImplementations/NoopMailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Bit.Core.Auth.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Models.Mail;

namespace Bit.Core.Services;
Expand Down Expand Up @@ -309,5 +310,6 @@ public Task SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(string email,
{
return Task.FromResult(0);
}
public Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList) => Task.CompletedTask;
}

Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
Expand All @@ -7,6 +8,8 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
Expand Down Expand Up @@ -269,4 +272,53 @@ await sutProvider.GetDependency<ISavePolicyCommand>()
.DidNotReceive()
.SaveAsync(Arg.Any<PolicyUpdate>());
}

[Theory, BitAutoData]
public async Task UserVerifyOrganizationDomainAsync_GivenOrganizationDomainWithAccountDeprovisioningEnabled_WhenDomainIsVerified_ThenEmailShouldBeSentToUsersWhoBelongToTheDomain(
ICollection<OrganizationUserUserDetails> organizationUsers,
OrganizationDomain domain,
Organization organization,
SutProvider<VerifyOrganizationDomainCommand> sutProvider)
{
foreach (var organizationUser in organizationUsers)
{
organizationUser.Email = $"{organizationUser.Name}@{domain.DomainName}";
}

var mockedUsers = organizationUsers
.Where(x => x.Status != OrganizationUserStatusType.Invited &&
x.Status != OrganizationUserStatusType.Revoked).ToList();

organization.Id = domain.OrganizationId;

sutProvider.GetDependency<IOrganizationDomainRepository>()
.GetClaimedDomainsByDomainNameAsync(domain.DomainName)
.Returns([]);

sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(domain.OrganizationId)
.Returns(organization);

sutProvider.GetDependency<IDnsResolverService>()
.ResolveAsync(domain.DomainName, domain.Txt)
.Returns(true);

sutProvider.GetDependency<ICurrentContext>()
.UserId.Returns(Guid.NewGuid());

sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
.Returns(true);

sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(domain.OrganizationId)
.Returns(mockedUsers);

_ = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(domain);

await sutProvider.GetDependency<IMailService>().Received().SendClaimedDomainUserEmailAsync(
Arg.Is<ManagedUserDomainClaimedEmails>(x =>
x.EmailList.Count(e => e.EndsWith(domain.DomainName)) == mockedUsers.Count &&
x.Organization.Id == organization.Id));
}
}

0 comments on commit f471fff

Please sign in to comment.