From cbea60cd594417b18f94891f7f1bc5490b15063f Mon Sep 17 00:00:00 2001 From: tom27052006 Date: Tue, 28 Jul 2026 16:12:38 +0200 Subject: [PATCH 1/5] Add automatic user confirmation (Bitwarden policy 18) Implements the server side of Bitwarden's "Automatic Confirmation", which automates the confirm step of the invite -> accept -> confirm flow. The server can never confirm a member itself: confirming means encrypting the organization key with the public key of the new member, and the server does not have that key. Bitwarden solves this by letting the unlocked browser extension of an admin do the work in the background, so what is added here is everything that extension needs: - `ORG_AUTO_CONFIRM_ENABLED` (off by default) and the matching `useAutomaticUserConfirmation` flag in both organization JSONs. This mirrors Bitwarden, where the feature is enabled per organization on request. - Policy type 18. Enabling it requires the Single Org policy, and every member has to be in this organization only. Contrary to the Single Org policy the non compliant members are not revoked, because this policy applies to owners and admins as well and revoking those could lock the organization out. Enabling it also drops the emergency access grants of all members, and new ones are refused while it is on. - `GET /organizations//users/pending-auto-confirm` `POST /organizations//users//auto-confirm` `POST /organizations//users/bulk-auto-confirm` Only members which accepted their invitation and hold the plain User role are ever confirmed this way, an elevated role always needs a human, and an owner can not lift that restriction like it can for the manual confirmation. - Notification type 26 (AutoConfirmMember) to everybody who can confirm, sent wherever a membership reaches the accepted state. WebSocket only, the extension is the only consumer. --- .env.template | 7 + src/api/core/accounts.rs | 12 +- src/api/core/emergency_access.rs | 11 ++ src/api/core/mod.rs | 41 ++++- src/api/core/organizations.rs | 255 +++++++++++++++++++++++++++++-- src/api/identity.rs | 10 +- src/api/notifications.rs | 39 ++++- src/config.rs | 5 + src/db/models/org_policy.rs | 66 +++++++- src/db/models/organization.rs | 2 + 10 files changed, 424 insertions(+), 24 deletions(-) diff --git a/.env.template b/.env.template index fd7c2fd2..9516a897 100644 --- a/.env.template +++ b/.env.template @@ -483,6 +483,13 @@ ## KNOW WHAT YOU ARE DOING! # ORG_GROUPS_ENABLED=false +## Automatic user confirmation (Know the risks!) +## Allows organizations to enable the automatic user confirmation policy. +## Members which accepted an invitation are then confirmed unattended by the browser extension +## of an unlocked admin, without any human reviewing the invitation. +## KNOW WHAT YOU ARE DOING! +# ORG_AUTO_CONFIRM_ENABLED=false + ## Increase secure note size limit (Know the risks!) ## Sets the secure note size limit to 100_000 instead of the default 10_000. ## WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers! diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 0cb4d3c0..d4e2efeb 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -12,7 +12,7 @@ use crate::{ CONFIG, api::{ AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, - core::{accept_org_invite, log_user_event, two_factor::email}, + core::{accept_org_invite, accept_user_invitations, log_user_event, two_factor::email}, master_password_policy, register_push_device, unregister_push_device, }, auth::{ClientHeaders, ClientIp, Headers, decode_delete, decode_invite, decode_verify_email}, @@ -255,7 +255,7 @@ async fn is_email_2fa_required(member_id: Option, conn: &DbConn) - false } -pub async fn register(data: Json, email_verification: bool, conn: DbConn) -> JsonResult { +pub async fn register(data: Json, email_verification: bool, conn: DbConn, nt: Notify<'_>) -> JsonResult { let mut data: RegisterData = data.into_inner(); let email = data.email.to_lowercase(); @@ -357,7 +357,7 @@ pub async fn register(data: Json, email_verification: bool, conn: err!("Registration email does not match invite email") } } else if Invitation::take(&email, &conn).await { - Membership::accept_user_invitations(&user.uuid, &conn).await?; + accept_user_invitations(&user.uuid, &conn, &nt).await?; user } else if CONFIG.is_signup_allowed(&email) || (CONFIG.emergency_access_allowed() @@ -436,7 +436,7 @@ pub async fn register(data: Json, email_verification: bool, conn: } #[post("/accounts/set-password", data = "")] -async fn post_set_password(data: Json, headers: Headers, conn: DbConn) -> JsonResult { +async fn post_set_password(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { let data: SetPasswordData = data.into_inner(); let mut user = headers.user; @@ -478,13 +478,13 @@ async fn post_set_password(data: Json, headers: Headers, conn: err!("Failed to retrieve the invitation") }; - accept_org_invite(&user, membership, None, &conn).await?; + accept_org_invite(&user, membership, None, &conn, &nt).await?; } if CONFIG.mail_enabled() { mail::send_welcome(&user.email.to_lowercase()).await?; } else { - Membership::accept_user_invitations(&user.uuid, &conn).await?; + accept_user_invitations(&user.uuid, &conn, &nt).await?; } log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) diff --git a/src/api/core/emergency_access.rs b/src/api/core/emergency_access.rs index 2eb95502..915eb14e 100644 --- a/src/api/core/emergency_access.rs +++ b/src/api/core/emergency_access.rs @@ -222,6 +222,12 @@ async fn send_invite(data: Json, headers: Headers, co err!("You can not set yourself as an emergency contact.") } + // Emergency access would hand this account to somebody the organization never vetted, which is why + // Bitwarden forbids it for members of an organization which confirms its members automatically. + if OrgPolicy::is_user_in_auto_confirm_org(&grantor_user.uuid, &conn).await { + err!("You are a member of an organization which does not allow emergency access.") + } + let (grantee_user, new_user) = match User::find_by_mail(&email, &conn).await { None => { if !CONFIG.invitations_allowed() { @@ -354,6 +360,11 @@ async fn accept_invite( err!("Invited user not found") }; + // See `send_invite`, the same restriction applies to the grantee side of an emergency access. + if OrgPolicy::is_user_in_auto_confirm_org(&grantee_user.uuid, &conn).await { + err!("You are a member of an organization which does not allow emergency access.") + } + // We need to search for the uuid in combination with the email, since we do not yet store the uuid of the grantee in the database. // The uuid of the grantee gets stored once accepted. let Some(mut emergency_access) = diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index a5ae50a4..d9b42555 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -24,7 +24,7 @@ use crate::{ auth::Headers, db::{ DbConn, - models::{Membership, MembershipStatus, OrgPolicy, Organization, User}, + models::{Membership, MembershipStatus, MembershipType, OrgPolicy, Organization, User, UserId}, }, error::Error, http_client::make_http_request, @@ -277,11 +277,48 @@ fn api_not_found() -> Json { })) } +/// Tells everybody who is able to confirm this member that it accepted its invitation and is waiting. +/// Only the browser extension of an unlocked admin acts upon this, it holds the organization key which +/// is needed to confirm a member and which the server never has. +/// Call this wherever a membership reaches the accepted state. +pub async fn notify_pending_auto_confirm(member: &Membership, conn: &DbConn, nt: &Notify<'_>) { + if member.status != MembershipStatus::Accepted as i32 + || member.atype != MembershipType::User + || !OrgPolicy::is_auto_confirm_enabled(&member.org_uuid, conn).await + { + return; + } + + for admin in Membership::find_confirmed_and_manage_all_by_org(&member.org_uuid, conn).await { + nt.send_auto_confirm_member(&admin.user_uuid, &member.org_uuid, &member.uuid, &member.user_uuid).await; + } +} + +/// Accepts every open invitation of a user at once, as done when mail is disabled, and notifies for each +/// of them. See [`notify_pending_auto_confirm`]. +pub async fn accept_user_invitations(user_id: &UserId, conn: &DbConn, nt: &Notify<'_>) -> EmptyResult { + let invited: Vec = Membership::find_any_state_by_user(user_id, conn) + .await + .into_iter() + .filter(|m| m.status == MembershipStatus::Invited as i32) + .collect(); + + Membership::accept_user_invitations(user_id, conn).await?; + + for mut member in invited { + member.status = MembershipStatus::Accepted as i32; + notify_pending_auto_confirm(&member, conn, nt).await; + } + + Ok(()) +} + async fn accept_org_invite( user: &User, mut member: Membership, reset_password_key: Option, conn: &DbConn, + nt: &Notify<'_>, ) -> EmptyResult { if member.status != MembershipStatus::Invited as i32 { err!("User already accepted the invitation"); @@ -295,6 +332,8 @@ async fn accept_org_invite( member.save(conn).await?; + notify_pending_auto_confirm(&member, conn, nt).await; + if CONFIG.mail_enabled() { let Some(org) = Organization::find_by_uuid(&member.org_uuid, conn).await else { err!("Organization not found.") diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 989ca47d..108e1d03 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -9,15 +9,16 @@ use crate::{ api::admin::FAKE_ADMIN_UUID, api::{ EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, - core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor}, + core::{CipherSyncData, CipherSyncType, accept_org_invite, 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, EventType, - Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, - OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, UserId, + Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, + EmergencyAccess, EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, + MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, + OrganizationId, User, UserId, }, }, mail, @@ -55,6 +56,9 @@ pub fn routes() -> Vec { bulk_reinvite_members, confirm_invite, bulk_confirm_invite, + get_pending_auto_confirm_members, + auto_confirm_member, + bulk_auto_confirm_members, accept_invite, get_org_user_mini_details, get_user, @@ -1045,6 +1049,7 @@ async fn send_invite( data: Json, headers: AdminHeaders, conn: DbConn, + nt: Notify<'_>, ) -> EmptyResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); @@ -1120,6 +1125,9 @@ async fn send_invite( new_member.status = member_status; new_member.save(&conn).await?; + // With mail disabled an existing user is accepted right away, so there is no accept request later on + notify_pending_auto_confirm(&new_member, &conn, &nt).await; + if CONFIG.mail_enabled() { let org_name = if let Some(org) = Organization::find_by_uuid(&org_id, &conn).await { org.name @@ -1196,6 +1204,7 @@ async fn bulk_reinvite_members( data: Json, headers: AdminHeaders, conn: DbConn, + nt: Notify<'_>, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); @@ -1204,7 +1213,7 @@ async fn bulk_reinvite_members( let mut bulk_response = Vec::new(); for member_id in data.ids { - let err_msg = match reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await { + let err_msg = match reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn, &nt).await { Ok(()) => String::new(), Err(e) => format!("{e:?}"), }; @@ -1231,11 +1240,12 @@ async fn reinvite_member( member_id: MembershipId, headers: AdminHeaders, conn: DbConn, + nt: Notify<'_>, ) -> EmptyResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } - reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await + reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn, &nt).await } async fn reinvite_member_impl( @@ -1243,6 +1253,7 @@ async fn reinvite_member_impl( member_id: &MembershipId, invited_by_email: &str, conn: &DbConn, + nt: &Notify<'_>, ) -> EmptyResult { let Some(member) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { err!("The user hasn't been invited to the organization.") @@ -1276,6 +1287,7 @@ async fn reinvite_member_impl( let mut member = member; member.status = MembershipStatus::Accepted as i32; member.save(conn).await?; + notify_pending_auto_confirm(&member, conn, nt).await; } Ok(()) @@ -1295,6 +1307,7 @@ async fn accept_invite( data: Json, headers: Headers, conn: DbConn, + nt: Notify<'_>, ) -> EmptyResult { // The web-vault passes org_id and member_id in the URL, but we are just reading them from the JWT instead let data: AcceptData = data.into_inner(); @@ -1333,7 +1346,7 @@ async fn accept_invite( // In case the user was invited before the mail was saved in db. membership.invited_by_email = membership.invited_by_email.or(claims.invited_by_email); - accept_org_invite(&headers.user, membership, reset_password_key, &conn).await?; + accept_org_invite(&headers.user, membership, reset_password_key, &conn, &nt).await?; } else if CONFIG.mail_enabled() { // User was invited from /admin, so they are automatically confirmed let org_name = CONFIG.invitation_org_name(); @@ -1428,7 +1441,7 @@ async fn confirm_invite_impl( err!("Key or UserId is not set, unable to process request"); } - let Some(mut member_to_confirm) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { + let Some(member_to_confirm) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { err!("The specified user isn't a member of the organization") }; @@ -1436,6 +1449,20 @@ async fn confirm_invite_impl( err!("Only Owners can confirm Managers, Admins or Owners") } + confirm_member(member_to_confirm, key, headers, conn, nt).await +} + +/// Shared by the manual and the automatic confirmation, both hand us the organization key encrypted +/// with the public key of the member to confirm. +async fn confirm_member( + mut member_to_confirm: Membership, + key: &str, + headers: &AdminHeaders, + conn: &DbConn, + nt: &Notify<'_>, +) -> EmptyResult { + let org_id = member_to_confirm.org_uuid.clone(); + if member_to_confirm.status != MembershipStatus::Accepted as i32 { err!("User in invalid state") } @@ -1449,7 +1476,7 @@ async fn confirm_invite_impl( log_event( EventType::OrganizationUserConfirmed as i32, &member_to_confirm.uuid, - org_id, + &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, @@ -1458,7 +1485,7 @@ async fn confirm_invite_impl( .await; if CONFIG.mail_enabled() { - let org_name = if let Some(org) = Organization::find_by_uuid(org_id, conn).await { + let org_name = if let Some(org) = Organization::find_by_uuid(&org_id, conn).await { org.name } else { err!("Error looking up organization.") @@ -1480,6 +1507,138 @@ async fn confirm_invite_impl( save_result } +// Automatic user confirmation. The server can never confirm a member by itself, confirming means +// encrypting the organization key with the public key of the member and the server does not have the +// organization key. So all we do here is telling an admin client which members are waiting, the client +// does the actual work in the background. +// https://bitwarden.com/help/automatic-confirmation/ + +/// Only a member which accepted its invitation and holds the plain User role is ever confirmed without +/// a human looking at it. Every elevated role keeps needing a manual confirmation by an Owner, and an +/// Owner can not lift that restriction here like it can for the manual confirmation. +fn may_be_confirmed_automatically(member: &Membership) -> bool { + member.status == MembershipStatus::Accepted as i32 && member.atype == MembershipType::User +} + +#[get("/organizations//users/pending-auto-confirm")] +async fn get_pending_auto_confirm_members(org_id: OrganizationId, headers: AdminHeaders, conn: DbConn) -> JsonResult { + if org_id != headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } + + // Bitwarden responds with an empty list instead of an error when the feature or the policy is off. + let members = if OrgPolicy::is_auto_confirm_enabled(&org_id, &conn).await { + Membership::find_by_org(&org_id, &conn) + .await + .into_iter() + .filter(may_be_confirmed_automatically) + .map(|m| { + json!({ + "object": "organizationUserPendingAutoConfirm", + "id": m.uuid, + "userId": m.user_uuid, + }) + }) + .collect() + } else { + Vec::new() + }; + + Ok(Json(json!({ + "data": members, + "object": "list", + "continuationToken": null + }))) +} + +#[post("/organizations//users//auto-confirm", data = "")] +async fn auto_confirm_member( + org_id: OrganizationId, + member_id: MembershipId, + data: Json, + headers: AdminHeaders, + conn: DbConn, + nt: Notify<'_>, +) -> EmptyResult { + let data = data.into_inner(); + let user_key = data.key.unwrap_or_default(); + auto_confirm_member_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await +} + +#[post("/organizations//users/bulk-auto-confirm", data = "")] +async fn bulk_auto_confirm_members( + org_id: OrganizationId, + data: Json, + headers: AdminHeaders, + conn: DbConn, + nt: Notify<'_>, +) -> JsonResult { + if org_id != headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } + let data = data.into_inner(); + + let mut bulk_response = Vec::new(); + match data.keys { + Some(keys) => { + for member in keys { + let member_id = member.id.unwrap(); + let user_key = member.key.unwrap_or_default(); + let err_msg = match auto_confirm_member_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await + { + Ok(()) => String::new(), + Err(e) => format!("{e:?}"), + }; + + bulk_response.push(json!( + { + "object": "OrganizationBulkConfirmResponseModel", + "id": member_id, + "error": err_msg + } + )); + } + } + None => error!("No keys to confirm"), + } + + Ok(Json(json!({ + "data": bulk_response, + "object": "list", + "continuationToken": null + }))) +} + +async fn auto_confirm_member_impl( + org_id: &OrganizationId, + member_id: &MembershipId, + key: &str, + headers: &AdminHeaders, + conn: &DbConn, + nt: &Notify<'_>, +) -> EmptyResult { + if org_id != &headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } + if key.is_empty() || member_id.is_empty() { + err!("Key or UserId is not set, unable to process request"); + } + + if !OrgPolicy::is_auto_confirm_enabled(org_id, conn).await { + err!("Automatic user confirmation is not enabled for this organization") + } + + let Some(member_to_confirm) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { + err!("The specified user isn't a member of the organization") + }; + + if !may_be_confirmed_automatically(&member_to_confirm) { + err!("This member can not be confirmed automatically") + } + + confirm_member(member_to_confirm, key, headers, conn, nt).await +} + #[get("/organizations//users/mini-details", rank = 1)] async fn get_org_user_mini_details(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { if org_id != headers.membership.org_uuid { @@ -2113,6 +2272,53 @@ async fn put_policy( } } + // The automatic user confirmation policy hands out organization access without anybody looking at it, + // so it needs to be allowed by the server first and it requires the Single Org policy on top. + // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyEventHandlers/AutomaticUserConfirmationPolicyEventHandler.cs + if pol_type_enum == OrgPolicyType::AutomaticUserConfirmation && data.enabled { + if !CONFIG.org_auto_confirm_enabled() { + err!("Automatic user confirmation is not enabled on this server.") + } + + let single_org_policy_enabled = + match OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::SingleOrg, &conn).await { + Some(p) => p.enabled, + None => false, + }; + + if !single_org_policy_enabled { + err!("Single Organization policy is not enabled. It is mandatory for this policy to be enabled.") + } + + // Every member has to be compliant already. Contrary to the Single Org policy below we do not revoke + // the members that are not, because this policy also applies to owners and admins and revoking those + // could lock the organization out of itself. + let members = Membership::find_by_org(&org_id, &conn).await; + for member in &members { + if member.status != MembershipStatus::Invited as i32 + && Membership::count_accepted_and_confirmed_by_user(&member.user_uuid, &org_id, &conn).await > 0 + { + err!("This policy forbids members to be part of other organizations, but at least one member still is.") + } + } + + // Emergency access would hand the account of a member to somebody outside of the control of this + // organization, which defeats the point of vetting members. Bitwarden drops these grants when the + // policy is turned on, and blocks new ones while it is on (see `emergency_access.rs`). + for member in &members { + info!("Removing emergency access of {} because automatic user confirmation was enabled", member.user_uuid); + EmergencyAccess::delete_all_by_user(&member.user_uuid, &conn).await?; + } + } + + // Also prevent the Single Org policy to be disabled while automatic user confirmation depends on it + if pol_type_enum == OrgPolicyType::SingleOrg + && !data.enabled + && OrgPolicy::is_auto_confirm_enabled(&org_id, &conn).await + { + err!("Automatic user confirmation is enabled. It is not allowed to disable this policy.") + } + // When enabling the TwoFactorAuthentication policy, revoke all members that do not have 2FA if pol_type_enum == OrgPolicyType::TwoFactorAuthentication && data.enabled { two_factor::enforce_2fa_policy_for_org( @@ -3251,3 +3457,32 @@ async fn rotate_api_key( ) -> JsonResult { api_key(&org_id, data, true, headers, conn).await } + +#[cfg(test)] +mod tests { + use super::*; + + /// Automatic confirmation hands out access to the organization vault without anybody looking at it, + /// so it must stay limited to plain members which actually accepted their invitation. + #[test] + fn only_accepted_plain_members_are_confirmed_automatically() { + let mut member = + Membership::new(UserId::from(String::from("user")), OrganizationId::from(String::from("org")), None); + + for status in + [MembershipStatus::Revoked as i32, MembershipStatus::Invited as i32, MembershipStatus::Confirmed as i32] + { + member.status = status; + assert!(!may_be_confirmed_automatically(&member), "status {status} must not qualify"); + } + + member.status = MembershipStatus::Accepted as i32; + for atype in [MembershipType::Owner as i32, MembershipType::Admin as i32, MembershipType::Manager as i32] { + member.atype = atype; + assert!(!may_be_confirmed_automatically(&member), "type {atype} must not qualify"); + } + + member.atype = MembershipType::User as i32; + assert!(may_be_confirmed_automatically(&member)); + } +} diff --git a/src/api/identity.rs b/src/api/identity.rs index 9212ed8d..91f660db 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -12,7 +12,7 @@ use serde_json::Value; use crate::{ CONFIG, api::{ - ApiResult, EmptyResult, JsonResult, + ApiResult, EmptyResult, JsonResult, Notify, core::{ accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register}, log_user_event, @@ -1034,8 +1034,8 @@ async fn prelogin_password(data: Json, conn: DbConn) -> Json, conn: DbConn) -> JsonResult { - register(data, false, conn).await +async fn identity_register(data: Json, conn: DbConn, nt: Notify<'_>) -> JsonResult { + register(data, false, conn, nt).await } #[derive(Debug, Deserialize)] @@ -1098,8 +1098,8 @@ async fn register_verification_email( } #[post("/accounts/register/finish", data = "")] -async fn register_finish(data: Json, conn: DbConn) -> JsonResult { - register(data, true, conn).await +async fn register_finish(data: Json, conn: DbConn, nt: Notify<'_>) -> JsonResult { + register(data, true, conn, nt).await } // https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts diff --git a/src/api/notifications.rs b/src/api/notifications.rs index 8bfcd518..d545fd02 100644 --- a/src/api/notifications.rs +++ b/src/api/notifications.rs @@ -15,7 +15,10 @@ use crate::{ auth::{ClientIp, WsAccessTokenHeader}, db::{ DbConn, - models::{AuthRequestId, Cipher, CollectionId, Device, DeviceId, Folder, PushId, Send as DbSend, User, UserId}, + models::{ + AuthRequestId, Cipher, CollectionId, Device, DeviceId, Folder, MembershipId, OrganizationId, PushId, + Send as DbSend, User, UserId, + }, }, }; @@ -510,6 +513,33 @@ impl WebSocketUsers { } } + /// Tells the clients of `recipient_id` that `member_id` accepted an invitation and is waiting to be + /// confirmed. Only the browser extension acts upon this, it holds the organization key needed to + /// confirm the member, which the server never has. Because of that this is WebSocket only. + /// https://github.com/bitwarden/clients/blob/main/libs/auto-confirm/README.md + pub async fn send_auto_confirm_member( + &self, + recipient_id: &UserId, + org_id: &OrganizationId, + member_id: &MembershipId, + member_user_id: &UserId, + ) { + if !CONFIG.enable_websocket() { + return; + } + let data = create_update( + vec![ + ("UserId".into(), recipient_id.to_string().into()), + ("OrganizationId".into(), org_id.to_string().into()), + ("TargetUserId".into(), member_user_id.to_string().into()), + ("TargetOrganizationUserId".into(), member_id.to_string().into()), + ], + UpdateType::AutoConfirmMember, + None, + ); + self.send_update(recipient_id, &data).await; + } + pub async fn send_auth_request(&self, user_id: &UserId, auth_request_uuid: &str, device: &Device, conn: &DbConn) { // Skip any processing if both WebSockets and Push are not active if *NOTIFICATIONS_DISABLED { @@ -700,6 +730,13 @@ pub enum UpdateType { // NotificationStatus = 21, // Not supported // RefreshSecurityTasks = 22, // Not supported + + // OrganizationBankAccountVerified = 23, // Not supported (Not AGPLv3 Licensed) + // ProviderBankAccountVerified = 24, // Not supported (Not AGPLv3 Licensed) + + // SyncPolicy = 25, // Not supported + AutoConfirmMember = 26, + // PremiumStatusChanged = 27, // Not supported None = 100, } diff --git a/src/config.rs b/src/config.rs index c4457478..54067adf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -790,6 +790,11 @@ make_config! { /// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!). org_groups_enabled: bool, false, def, false; + /// Enable automatic user confirmation (Know the risks!) |> Allows organizations to enable the automatic user confirmation policy. + /// Members which accepted an invitation are then confirmed unattended by the browser extension of an unlocked admin, + /// without any human reviewing the invitation. Bitwarden only enables this per organization on request, we keep it off by default. + org_auto_confirm_enabled: bool, false, def, false; + /// Increase note size limit (Know the risks!) |> Sets the secure note size limit to 100_000 instead of the default 10_000. /// WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers! increase_note_size_limit: bool, true, def, false; diff --git a/src/db/models/org_policy.rs b/src/db/models/org_policy.rs index 88b7872c..94e58591 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -47,7 +47,7 @@ pub enum OrgPolicyType { RestrictedItemTypes = 15, UriMatchDefaults = 16, // AutotypeDefaultSetting = 17, // Not supported yet - // AutoConfirm = 18, // Not supported (not implemented yet) + AutomaticUserConfirmation = 18, // BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed) } @@ -280,6 +280,50 @@ impl OrgPolicy { false } + /// Returns true if the user is a member of an organization other than `exclude_org_uuid` which has the + /// automatic user confirmation policy enabled. Contrary to `is_applicable_to_user` this does not exempt + /// owners and admins, the policy applies to every role and every status. + /// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs + pub async fn auto_confirm_enabled_for_other_org( + user_uuid: &UserId, + exclude_org_uuid: &OrganizationId, + conn: &DbConn, + ) -> bool { + CONFIG.org_auto_confirm_enabled() + && Self::find_accepted_and_confirmed_by_user_and_active_policy( + user_uuid, + OrgPolicyType::AutomaticUserConfirmation, + conn, + ) + .await + .iter() + .any(|policy| &policy.org_uuid != exclude_org_uuid) + } + + /// Returns true if the user is a member of an organization which confirms its members automatically. + /// Such a membership also restricts what the user may do outside of that organization. + pub async fn is_user_in_auto_confirm_org(user_uuid: &UserId, conn: &DbConn) -> bool { + CONFIG.org_auto_confirm_enabled() + && !Self::find_accepted_and_confirmed_by_user_and_active_policy( + user_uuid, + OrgPolicyType::AutomaticUserConfirmation, + conn, + ) + .await + .is_empty() + } + + /// Returns true if members of this organization may be confirmed automatically. This requires both the + /// server wide config option and the policy of this organization to be enabled, which mirrors Bitwarden + /// where the organization needs the feature enabled by support on top of the policy. + pub async fn is_auto_confirm_enabled(org_uuid: &OrganizationId, conn: &DbConn) -> bool { + CONFIG.org_auto_confirm_enabled() + && match Self::find_by_org_and_type(org_uuid, OrgPolicyType::AutomaticUserConfirmation, conn).await { + Some(p) => p.enabled, + None => false, + } + } + pub async fn check_user_allowed(m: &Membership, action: &str, conn: &DbConn) -> EmptyResult { if m.atype < MembershipType::Admin && m.status > (MembershipStatus::Invited as i32) { // Enforce TwoFactor/TwoStep login @@ -313,6 +357,26 @@ impl OrgPolicy { } } + // The automatic user confirmation policy is a stricter variant of the SingleOrg policy, it does not + // exempt owners and admins and it applies to every status. Therefore it is checked outside of the + // block above. + // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementHandler.cs + if Self::auto_confirm_enabled_for_other_org(&m.user_uuid, &m.org_uuid, conn).await { + err!(format!( + "Cannot {} because another organization confirms its members automatically and forbids other memberships (membership {})", + action, m.uuid + )); + } + + if Self::is_auto_confirm_enabled(&m.org_uuid, conn).await + && Membership::count_accepted_and_confirmed_by_user(&m.user_uuid, &m.org_uuid, conn).await > 0 + { + err!(format!( + "Cannot {} because the organization confirms its members automatically and forbids being part of other organizations (membership {})", + action, m.uuid + )); + } + Ok(()) } diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index bdb69864..df7473ea 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -204,6 +204,7 @@ impl Organization { "maxCollections": null, "maxStorageGb": i16::MAX, // The value doesn't matter, we don't check server-side "use2fa": true, + "useAutomaticUserConfirmation": CONFIG.org_auto_confirm_enabled(), "useCustomPermissions": true, "useDirectory": false, // Is supported, but this value isn't checked anywhere (yet) "useEvents": CONFIG.org_events_enabled(), @@ -498,6 +499,7 @@ impl Membership { "useKeyConnector": false, "useSecretsManager": false, // Not supported (Not AGPLv3 Licensed) "usePasswordManager": true, + "useAutomaticUserConfirmation": CONFIG.org_auto_confirm_enabled(), "useCustomPermissions": true, "useActivateAutofillPolicy": false, "useAdminSponsoredFamilies": false, From 6236b6fc2558a8056bf9109f35a1611fac3ba7b2 Mon Sep 17 00:00:00 2001 From: tom27052006 Date: Tue, 28 Jul 2026 16:54:42 +0200 Subject: [PATCH 2/5] Advertise the auto confirm feature flag to the clients Web vaults up to 2026.4.x do not show the automatic user confirmation policy based on the organization flag alone, they additionally require the server side feature flag `pm-19934-auto-confirm-organization-users`: display$(org, config) => config.getFeatureFlag$(FeatureFlag.AutoConfirm) .pipe(map(f => f && org.useAutomaticUserConfirmation)) Without it the policy is simply missing from Settings -> Policies. Newer clients dropped the flag again and look at `useAutomaticUserConfirmation` alone, so sending it stays harmless there. It follows `ORG_AUTO_CONFIRM_ENABLED`, which keeps it off unless the server really supports the feature. --- src/api/core/mod.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index d9b42555..7db34f45 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -220,6 +220,10 @@ fn config() -> Json { &FeatureFlagFilter::ValidOnly, ); feature_states.insert("pm-19148-innovation-archive".to_owned(), true); + // Web vaults up to 2026.4.x only offer the automatic user confirmation policy when this flag is on: + // `display$(org, config) => config.getFeatureFlag$(FeatureFlag.AutoConfirm).pipe(map(f => f && org.useAutomaticUserConfirmation))` + // Newer clients dropped the flag and look at `useAutomaticUserConfirmation` alone, so sending it stays harmless. + feature_states.insert("pm-19934-auto-confirm-organization-users".to_owned(), CONFIG.org_auto_confirm_enabled()); Json(json!({ // Note: The clients use this version to handle backwards compatibility concerns From 906c7b521566a1494391a3f1f268a84798875bcb Mon Sep 17 00:00:00 2001 From: tom27052006 Date: Tue, 28 Jul 2026 17:34:06 +0200 Subject: [PATCH 3/5] Harden the bulk auto confirm endpoint and the invite notification Two issues found while reviewing the previous commits: The bulk endpoint unwrapped the client supplied member id, so a single entry without an id took the whole request down with a 500 and silently dropped the confirmations of every other entry in the same call. Skip such an entry instead. The manual `bulk_confirm_invite` has the same unwrap, that one is left alone here. `send_invite` announced the pending member right after saving the membership, which is before the collections and groups of the invite are attached. An admin client acts on that notification immediately, so it could confirm the member while its access was still incomplete, and a failing collection or group assignment would leave a confirmed member behind. The notification now happens once the invite is fully applied. --- src/api/core/organizations.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 108e1d03..c6dde105 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -1125,9 +1125,6 @@ async fn send_invite( new_member.status = member_status; new_member.save(&conn).await?; - // With mail disabled an existing user is accepted right away, so there is no accept request later on - notify_pending_auto_confirm(&new_member, &conn, &nt).await; - if CONFIG.mail_enabled() { let org_name = if let Some(org) = Organization::find_by_uuid(&org_id, &conn).await { org.name @@ -1193,6 +1190,11 @@ async fn send_invite( let mut group_entry = GroupUser::new(group_id.clone(), new_member.uuid.clone()); group_entry.save(&conn).await?; } + + // With mail disabled an existing user is accepted right away, so there is no accept request later on. + // This is the last step on purpose: an admin client may confirm the member the moment it is told + // about it, and by then the collections and groups of the invite have to be in place. + notify_pending_auto_confirm(&new_member, &conn, &nt).await; } Ok(()) @@ -1582,7 +1584,11 @@ async fn bulk_auto_confirm_members( match data.keys { Some(keys) => { for member in keys { - let member_id = member.id.unwrap(); + // Never unwrap the id, this is client supplied and a missing one must not take the request down + let Some(member_id) = member.id else { + error!("Ignoring a bulk auto confirm entry without a member id"); + continue; + }; let user_key = member.key.unwrap_or_default(); let err_msg = match auto_confirm_member_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await { From 848efbebd63cbdb02684bdb653be2134b0e1f517 Mon Sep 17 00:00:00 2001 From: tom27052006 <83423411+tom27052006@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:14:33 +0200 Subject: [PATCH 4/5] Close the emergency access gaps of the auto confirm policy Confirming a member into an organization which confirms automatically now drops the emergency access of that member. Enabling the policy only cleaned up the members present at that time, so anybody who joined afterwards kept a grantee which could take over the account of a member nobody ever vetted and reach the organization vault through it. Bitwarden drops these on every confirmation, manual ones included, so this does the same. Enabling the policy no longer touches invited members. An invitation is created by an admin alone, without any consent of the invited user, so it must not be able to delete the emergency access of an account that never joined the organization. The validation and the cleanup now only run on the step from disabled to enabled. The web vault saves a policy on every edit and re-running the cleanup kept wiping emergency access that members created in the meantime. The cleanup also moved behind the save of the policy so that a failed save can no longer destroy data for nothing. While here: only notify members which may actually confirm, `AdminHeaders` rejects the managers that `find_confirmed_and_manage_all_by_org` also returns, and stop unwrapping the client supplied id in the manual bulk confirm. The referenced Bitwarden sources were pinned to a commit which predates the feature and returned 404, they now point at one which contains them. --- src/api/core/mod.rs | 8 ++++- src/api/core/organizations.rs | 60 +++++++++++++++++++++++++++-------- src/db/models/org_policy.rs | 4 +-- 3 files changed, 56 insertions(+), 16 deletions(-) diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index 7db34f45..fdf2132f 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -293,7 +293,13 @@ pub async fn notify_pending_auto_confirm(member: &Membership, conn: &DbConn, nt: return; } - for admin in Membership::find_confirmed_and_manage_all_by_org(&member.org_uuid, conn).await { + // Confirming requires `AdminHeaders`, so skip the managers this also returns. They could not act on + // the notification anyway and would only run into a rejected request. + for admin in Membership::find_confirmed_and_manage_all_by_org(&member.org_uuid, conn) + .await + .into_iter() + .filter(|m| m.atype >= MembershipType::Admin) + { nt.send_auto_confirm_member(&admin.user_uuid, &member.org_uuid, &member.uuid, &member.user_uuid).await; } } diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index c6dde105..c4dd97bc 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -1388,7 +1388,11 @@ async fn bulk_confirm_invite( match data.keys { Some(keys) => { for invite in keys { - let member_id = invite.id.unwrap(); + // Never unwrap the id, this is client supplied and a missing one must not take the request down + let Some(member_id) = invite.id else { + error!("Ignoring a bulk confirm entry without a member id"); + continue; + }; let user_key = invite.key.unwrap_or_default(); let err_msg = match confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await { Ok(()) => String::new(), @@ -1475,6 +1479,16 @@ async fn confirm_member( // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type OrgPolicy::check_user_allowed(&member_to_confirm, "confirm", conn).await?; + // An organization which confirms its members automatically does not tolerate emergency access: the + // grantee could take over the account of a member that nobody ever vetted and reach the organization + // vault through it. Enabling the policy drops the grants of the members present at that time, this + // covers the member which brings one along when it joins afterwards. Bitwarden does the same, and + // 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?; + } + log_event( EventType::OrganizationUserConfirmed as i32, &member_to_confirm.uuid, @@ -2280,8 +2294,14 @@ async fn put_policy( // The automatic user confirmation policy hands out organization access without anybody looking at it, // so it needs to be allowed by the server first and it requires the Single Org policy on top. - // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyEventHandlers/AutomaticUserConfirmationPolicyEventHandler.cs - if pol_type_enum == OrgPolicyType::AutomaticUserConfirmation && data.enabled { + // https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyEventHandlers/AutomaticUserConfirmationPolicyEventHandler.cs + let auto_confirm_turned_on = if pol_type_enum == OrgPolicyType::AutomaticUserConfirmation + && data.enabled + // Only the step from disabled to enabled validates and has side effects. The web vault saves a + // policy on every edit, and re-running the below on an already enabled policy would keep wiping + // emergency access that members created in the meantime. Bitwarden guards this the same way. + && !OrgPolicy::is_auto_confirm_enabled(&org_id, &conn).await + { if !CONFIG.org_auto_confirm_enabled() { err!("Automatic user confirmation is not enabled on this server.") } @@ -2299,8 +2319,7 @@ async fn put_policy( // Every member has to be compliant already. Contrary to the Single Org policy below we do not revoke // the members that are not, because this policy also applies to owners and admins and revoking those // could lock the organization out of itself. - let members = Membership::find_by_org(&org_id, &conn).await; - for member in &members { + for member in Membership::find_by_org(&org_id, &conn).await { if member.status != MembershipStatus::Invited as i32 && Membership::count_accepted_and_confirmed_by_user(&member.user_uuid, &org_id, &conn).await > 0 { @@ -2308,14 +2327,10 @@ async fn put_policy( } } - // Emergency access would hand the account of a member to somebody outside of the control of this - // organization, which defeats the point of vetting members. Bitwarden drops these grants when the - // policy is turned on, and blocks new ones while it is on (see `emergency_access.rs`). - for member in &members { - info!("Removing emergency access of {} because automatic user confirmation was enabled", member.user_uuid); - EmergencyAccess::delete_all_by_user(&member.user_uuid, &conn).await?; - } - } + true + } else { + false + }; // Also prevent the Single Org policy to be disabled while automatic user confirmation depends on it if pol_type_enum == OrgPolicyType::SingleOrg @@ -2381,6 +2396,25 @@ async fn put_policy( policy.data = serde_json::to_string(&data.data)?; policy.save(&conn).await?; + // Emergency access would hand the account of a member to somebody outside of the control of this + // organization, which defeats the point of vetting members. Bitwarden drops these grants when the + // policy is turned on, and blocks new ones while it is on (see `emergency_access.rs`). + // This runs after the policy is stored so that a failed save can not destroy data for nothing, and it + // skips invited members on purpose: an invitation is created by an admin without any consent of the + // invited user, so it must never be able to delete data of an account that never joined. + if auto_confirm_turned_on { + for member in Membership::find_by_org(&org_id, &conn).await { + if member.status == MembershipStatus::Invited as i32 { + continue; + } + info!( + "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?; + } + } + log_event( EventType::PolicyUpdated as i32, policy.uuid.as_ref(), diff --git a/src/db/models/org_policy.rs b/src/db/models/org_policy.rs index 94e58591..a9781323 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -283,7 +283,7 @@ impl OrgPolicy { /// Returns true if the user is a member of an organization other than `exclude_org_uuid` which has the /// automatic user confirmation policy enabled. Contrary to `is_applicable_to_user` this does not exempt /// owners and admins, the policy applies to every role and every status. - /// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs + /// https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs pub async fn auto_confirm_enabled_for_other_org( user_uuid: &UserId, exclude_org_uuid: &OrganizationId, @@ -360,7 +360,7 @@ impl OrgPolicy { // The automatic user confirmation policy is a stricter variant of the SingleOrg policy, it does not // exempt owners and admins and it applies to every status. Therefore it is checked outside of the // block above. - // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementHandler.cs + // https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementHandler.cs if Self::auto_confirm_enabled_for_other_org(&m.user_uuid, &m.org_uuid, conn).await { err!(format!( "Cannot {} because another organization confirms its members automatically and forbids other memberships (membership {})", 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 5/5] 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 }}