diff --git a/src/api/core/emergency_access.rs b/src/api/core/emergency_access.rs index 915eb14e..d263cd54 100644 --- a/src/api/core/emergency_access.rs +++ b/src/api/core/emergency_access.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use chrono::{TimeDelta, Utc}; use rocket::{Route, serde::json::Json}; use serde_json::Value; @@ -20,6 +22,58 @@ use crate::{ util::NumberOrString, }; +/// Drops every emergency access of `user_id`, the ones it granted as well as the ones it holds, and tells +/// the grantors which contacts they lost. The grantor of the second group is somebody else, so without a +/// mail that user would silently lose a part of its account setup. +/// Bitwarden notifies the same way, one mail per grantor listing all of its removed contacts. +/// https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/Auth/UserFeatures/EmergencyAccess/Commands/DeleteEmergencyAccessCommand.cs +pub async fn delete_all_emergency_access_of_user(user_id: &UserId, conn: &DbConn) -> EmptyResult { + // Read before deleting, afterwards the rows are gone. + let mut removed = EmergencyAccess::find_all_by_grantor_uuid(user_id, conn).await; + removed.extend(EmergencyAccess::find_all_by_grantee_uuid(user_id, conn).await); + + EmergencyAccess::delete_all_by_user(user_id, conn).await?; + + if !CONFIG.mail_enabled() || removed.is_empty() { + return Ok(()); + } + + // Group by grantor so that each of them gets a single mail listing all of its removed contacts. + let mut by_grantor: HashMap> = HashMap::new(); + for emergency_access in removed { + // An invitation that was never accepted only carries the address, the uuid is stored on accept. + let grantee_email = match &emergency_access.grantee_uuid { + Some(grantee_uuid) => User::find_by_uuid(grantee_uuid, conn).await.map(|u| u.email), + None => emergency_access.email.clone(), + }; + let Some(grantee_email) = grantee_email else { + warn!( + "Not naming the grantee of emergency access {} in the removal notification, it has neither a known user nor an address", + emergency_access.uuid + ); + continue; + }; + by_grantor.entry(emergency_access.grantor_uuid).or_default().push(grantee_email); + } + + for (grantor_uuid, mut grantee_emails) in by_grantor { + let Some(grantor) = User::find_by_uuid(&grantor_uuid, conn).await else { + warn!("Skipping the emergency access removal notification for {grantor_uuid}, the account is gone"); + continue; + }; + + grantee_emails.sort_unstable(); + grantee_emails.dedup(); + + // The rows are already gone, so a failing mail must not take the whole request down with it. + if let Err(e) = mail::send_emergency_access_grantees_removed(&grantor.email, &grantee_emails).await { + error!("Failed to notify {} about its removed emergency access contacts: {e:?}", grantor.email); + } + } + + Ok(()) +} + pub fn routes() -> Vec { routes![ get_contacts, diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index c4dd97bc..d7f5fc4d 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -9,16 +9,18 @@ use crate::{ api::admin::FAKE_ADMIN_UUID, api::{ EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, - core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, notify_pending_auto_confirm, two_factor}, + core::{ + CipherSyncData, CipherSyncType, accept_org_invite, emergency_access::delete_all_emergency_access_of_user, + log_event, notify_pending_auto_confirm, two_factor, + }, }, auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite}, db::{ DbConn, models::{ - Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, - EmergencyAccess, EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, - MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, - OrganizationId, User, UserId, + Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, + Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, + OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, UserId, }, }, mail, @@ -1486,7 +1488,7 @@ async fn confirm_member( // like there it applies to the manual confirmation as well. // https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs if OrgPolicy::is_auto_confirm_enabled(&org_id, conn).await { - EmergencyAccess::delete_all_by_user(&member_to_confirm.user_uuid, conn).await?; + delete_all_emergency_access_of_user(&member_to_confirm.user_uuid, conn).await?; } log_event( @@ -2411,7 +2413,7 @@ async fn put_policy( "Removing emergency access of {} because automatic user confirmation was enabled for {org_id}", member.user_uuid ); - EmergencyAccess::delete_all_by_user(&member.user_uuid, &conn).await?; + delete_all_emergency_access_of_user(&member.user_uuid, &conn).await?; } } diff --git a/src/config.rs b/src/config.rs index 196f536f..7dcf1742 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1738,6 +1738,7 @@ where reg!("email/change_email_invited", ".html"); reg!("email/change_email", ".html"); reg!("email/delete_account", ".html"); + reg!("email/emergency_access_grantees_removed", ".html"); reg!("email/emergency_access_invite_accepted", ".html"); reg!("email/emergency_access_invite_confirmed", ".html"); reg!("email/emergency_access_recovery_approved", ".html"); @@ -1857,3 +1858,33 @@ handlebars::handlebars_helper!(webver: | web_vault_version: String | handlebars::handlebars_helper!(vwver: | vw_version: String | semver::VersionReq::parse(&vw_version).expect("Invalid Vaultwarden version compare string").matches(&VW_VERSION) ); + +#[cfg(test)] +mod tests { + use super::*; + + /// The registry runs in strict mode, so a placeholder which the sender does not fill only blows up + /// when the mail is actually sent. Render both templates of the emergency access removal notification + /// with the data `mail::send_emergency_access_grantees_removed` passes. + #[test] + fn emergency_access_grantees_removed_renders() { + let hb = load_templates(std::env::temp_dir()); + let data = serde_json::json!({ + "url": "https://vault.example.com", + "img_src": "https://vault.example.com/mail/", + "grantee_emails": ["first@example.com", "second@example.com"], + }); + + for name in ["email/emergency_access_grantees_removed", "email/emergency_access_grantees_removed.html"] { + let rendered = match hb.render(name, &data) { + Ok(rendered) => rendered, + Err(e) => panic!("{name} failed to render: {e:?}"), + }; + + let (subject, body) = rendered.split_once("").expect("no subject separator"); + assert_eq!(subject.trim(), "Emergency contacts removed"); + assert!(body.contains("first@example.com"), "{name} does not list every removed contact"); + assert!(body.contains("second@example.com"), "{name} does not list every removed contact"); + } + } +} diff --git a/src/mail.rs b/src/mail.rs index a7e5e5ae..eaf8ec35 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -394,6 +394,22 @@ pub async fn send_emergency_access_invite_accepted(address: &str, grantee_email: send_email(address, &subject, body_html, body_text).await } +/// Tells a grantor which of its emergency access contacts were dropped. Deliberately does not name the +/// reason: the recipient can be somebody outside of the organization which triggered this, and it has no +/// business learning about the memberships of others. Bitwarden keeps this generic as well. +pub async fn send_emergency_access_grantees_removed(address: &str, grantee_emails: &[String]) -> EmptyResult { + let (subject, body_html, body_text) = get_text( + "email/emergency_access_grantees_removed", + json!({ + "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), + "grantee_emails": grantee_emails, + }), + )?; + + send_email(address, &subject, body_html, body_text).await +} + pub async fn send_emergency_access_invite_confirmed(address: &str, grantor_name: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/emergency_access_invite_confirmed", diff --git a/src/static/templates/email/emergency_access_grantees_removed.hbs b/src/static/templates/email/emergency_access_grantees_removed.hbs new file mode 100644 index 00000000..bc1c8046 --- /dev/null +++ b/src/static/templates/email/emergency_access_grantees_removed.hbs @@ -0,0 +1,10 @@ +Emergency contacts removed + +The following emergency contacts have been removed from your account: + +{{#each grantee_emails}} +* {{this}} +{{/each}} + +You can set up emergency access again from the web vault ({{url}}). +{{> email/email_footer_text }} diff --git a/src/static/templates/email/emergency_access_grantees_removed.html.hbs b/src/static/templates/email/emergency_access_grantees_removed.html.hbs new file mode 100644 index 00000000..1d0db8fc --- /dev/null +++ b/src/static/templates/email/emergency_access_grantees_removed.html.hbs @@ -0,0 +1,21 @@ +Emergency contacts removed + +{{> email/email_header }} + + + + + + + +
+ The following emergency contacts have been removed from your account: +
    +{{#each grantee_emails}} +
  • {{this}}
  • +{{/each}} +
+
+ You can set up emergency access again from the web vault. +
+{{> email/email_footer }}