Browse Source

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.
pull/7499/head
tom27052006 1 week ago
parent
commit
5482855dc4
  1. 54
      src/api/core/emergency_access.rs
  2. 16
      src/api/core/organizations.rs
  3. 31
      src/config.rs
  4. 16
      src/mail.rs
  5. 10
      src/static/templates/email/emergency_access_grantees_removed.hbs
  6. 21
      src/static/templates/email/emergency_access_grantees_removed.html.hbs

54
src/api/core/emergency_access.rs

@ -1,3 +1,5 @@
use std::collections::HashMap;
use chrono::{TimeDelta, Utc}; use chrono::{TimeDelta, Utc};
use rocket::{Route, serde::json::Json}; use rocket::{Route, serde::json::Json};
use serde_json::Value; use serde_json::Value;
@ -20,6 +22,58 @@ use crate::{
util::NumberOrString, 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<UserId, Vec<String>> = 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<Route> { pub fn routes() -> Vec<Route> {
routes![ routes![
get_contacts, get_contacts,

16
src/api/core/organizations.rs

@ -9,16 +9,18 @@ use crate::{
api::admin::FAKE_ADMIN_UUID, api::admin::FAKE_ADMIN_UUID,
api::{ api::{
EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, 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}, auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite},
db::{ db::{
DbConn, DbConn,
models::{ models::{
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType,
EmergencyAccess, EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType,
MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, UserId,
OrganizationId, User, UserId,
}, },
}, },
mail, mail,
@ -1486,7 +1488,7 @@ async fn confirm_member(
// like there it applies to the manual confirmation as well. // like there it applies to the manual confirmation as well.
// https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs // https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs
if OrgPolicy::is_auto_confirm_enabled(&org_id, conn).await { 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( log_event(
@ -2411,7 +2413,7 @@ async fn put_policy(
"Removing emergency access of {} because automatic user confirmation was enabled for {org_id}", "Removing emergency access of {} because automatic user confirmation was enabled for {org_id}",
member.user_uuid member.user_uuid
); );
EmergencyAccess::delete_all_by_user(&member.user_uuid, &conn).await?; delete_all_emergency_access_of_user(&member.user_uuid, &conn).await?;
} }
} }

31
src/config.rs

@ -1738,6 +1738,7 @@ where
reg!("email/change_email_invited", ".html"); reg!("email/change_email_invited", ".html");
reg!("email/change_email", ".html"); reg!("email/change_email", ".html");
reg!("email/delete_account", ".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_accepted", ".html");
reg!("email/emergency_access_invite_confirmed", ".html"); reg!("email/emergency_access_invite_confirmed", ".html");
reg!("email/emergency_access_recovery_approved", ".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 | handlebars::handlebars_helper!(vwver: | vw_version: String |
semver::VersionReq::parse(&vw_version).expect("Invalid Vaultwarden version compare string").matches(&VW_VERSION) 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");
}
}
}

16
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 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 { pub async fn send_emergency_access_invite_confirmed(address: &str, grantor_name: &str) -> EmptyResult {
let (subject, body_html, body_text) = get_text( let (subject, body_html, body_text) = get_text(
"email/emergency_access_invite_confirmed", "email/emergency_access_invite_confirmed",

10
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 }}

21
src/static/templates/email/emergency_access_grantees_removed.html.hbs

@ -0,0 +1,21 @@
Emergency contacts removed
<!---------------->
{{> email/email_header }}
<table width="100%" cellpadding="0" cellspacing="0" 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;">
<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">
The following emergency contacts have been removed from your account:
<ul 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;">
{{#each grantee_emails}}
<li 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;">{{this}}</li>
{{/each}}
</ul>
</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 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;" valign="top">
You can set up emergency access again from the <a href="{{url}}/">web vault</a>.
</td>
</tr>
</table>
{{> email/email_footer }}
Loading…
Cancel
Save