From 5482855dc4beabba53ba04db398455bc34bf644c Mon Sep 17 00:00:00 2001 From: tom27052006 <83423411+tom27052006@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:35:30 +0200 Subject: [PATCH] Notify the grantors whose emergency access the auto confirm policy drops Dropping an emergency access hits two people, and only one of them is the member the organization acts upon. The other one is the grantor of a grant the member merely held, who can be a complete outsider to that organization and would otherwise silently lose a part of its account setup. Every grantor now gets a single mail listing all of the contacts it lost, which is how Bitwarden notifies as well. The mail deliberately does not name a reason: a recipient can be outside of the organization which triggered this and has no business learning about the memberships of others. The rows are gone by the time the mail goes out, so a failing send is logged instead of failing the request, which would report a rollback that did not happen. --- src/api/core/emergency_access.rs | 54 +++++++++++++++++++ src/api/core/organizations.rs | 16 +++--- src/config.rs | 31 +++++++++++ src/mail.rs | 16 ++++++ .../emergency_access_grantees_removed.hbs | 10 ++++ ...emergency_access_grantees_removed.html.hbs | 21 ++++++++ 6 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 src/static/templates/email/emergency_access_grantees_removed.hbs create mode 100644 src/static/templates/email/emergency_access_grantees_removed.html.hbs 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 }}