From f72f5014da8e98f29d6e76b23242139e48ebce7b Mon Sep 17 00:00:00 2001 From: tom27052006 <83423411+tom27052006@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:44:23 +0200 Subject: [PATCH] Add automatic user confirmation (policy 18) --- .env.template | 7 + src/api/core/accounts.rs | 12 +- src/api/core/emergency_access.rs | 69 ++++- src/api/core/mod.rs | 41 ++- src/api/core/organizations.rs | 261 ++++++++++++++++-- src/api/identity.rs | 10 +- src/api/notifications.rs | 39 ++- src/config.rs | 33 +++ src/db/models/mod.rs | 2 +- src/db/models/org_policy.rs | 147 +++++++++- src/db/models/organization.rs | 75 +++++ src/mail.rs | 16 ++ .../emergency_access_grantees_removed.hbs | 10 + ...emergency_access_grantees_removed.html.hbs | 21 ++ 14 files changed, 706 insertions(+), 37 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/.env.template b/.env.template index 62231776..70f0fcfd 100644 --- a/.env.template +++ b/.env.template @@ -495,6 +495,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 8cc5e55b..50e2e9d5 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}, @@ -257,7 +257,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(); @@ -359,7 +359,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() @@ -438,7 +438,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; @@ -480,13 +480,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..12dd2ac7 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; @@ -12,14 +14,66 @@ use crate::{ db::{ DbConn, DbPool, models::{ - Cipher, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation, - Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId, + AutoConfirmRequirement, Cipher, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, + EmergencyAccessType, Invitation, Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId, }, }, mail, util::NumberOrString, }; +/// Drops every emergency access of `user_id`, granted as well as held, and tells the grantors which +/// contacts they lost: those are other users which would otherwise silently lose part of their setup. +/// Like Bitwarden, 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 { + // The same rows `EmergencyAccess::delete_all_by_user` would load, loaded once and deleted below. + 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); + + // Group by grantor so that each of them gets a single mail listing all of its removed contacts. + // Collected before deleting, which consumes the rows. + let mut by_grantor: HashMap> = HashMap::new(); + if CONFIG.mail_enabled() { + 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.clone()).or_default().push(grantee_email); + } + } + + for emergency_access in removed { + emergency_access.delete(conn).await?; + } + + 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, @@ -222,6 +276,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 AutoConfirmRequirement::for_user(&grantor_user.uuid, &conn).await.forbids_emergency_access() { + 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 +414,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 AutoConfirmRequirement::for_user(&grantee_user.uuid, &conn).await.forbids_emergency_access() { + 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 e6a184dd..9eebb869 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, @@ -220,6 +220,9 @@ 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 policy when this flag is on; newer ones 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 @@ -277,11 +280,45 @@ fn api_not_found() -> Json { })) } +/// Tells everybody who can 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 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.can_be_auto_confirmed() || !OrgPolicy::is_auto_confirm_enabled(&member.org_uuid, conn).await { + return; + } + + // Confirming requires `AdminHeaders`, so skip the managers this also returns. + 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; + } +} + +/// 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 = Membership::find_invited_by_user(user_id, conn).await; + + 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 36297d30..fa23a6a7 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -9,16 +9,19 @@ 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, 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, EventType, - Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, - OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, - UserId, + AutoConfirmRequirement, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, + CollectionUser, EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, + MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, + OrganizationId, TwoFactor, TwoFactorType, User, UserId, }, }, mail, @@ -56,6 +59,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, @@ -196,6 +202,14 @@ async fn create_organization(headers: Headers, data: Json, conn: DbConn if !CONFIG.is_org_creation_allowed(&headers.user.email) { err!("User not allowed to create organizations") } + // Stricter than the SingleOrg policy below, which exempts owners and admins: an organization which + // confirms members automatically forbids every one of them, in any role and status, another membership. + // https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs + if AutoConfirmRequirement::for_user(&headers.user.uuid, &conn).await.forbids_creating_organization() { + err!( + "You may not create an organization. You belong to an organization which confirms its members automatically and prohibits you from being a member of any other organization." + ) + } if OrgPolicy::is_applicable_to_user(&headers.user.uuid, OrgPolicyType::SingleOrg, None, &conn).await { err!( "You may not create an organization. You belong to an organization which has a policy that prohibits you from being a member of any other organization." @@ -1045,6 +1059,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"); @@ -1186,6 +1201,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 no accept request follows. + // 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(()) @@ -1197,6 +1217,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"); @@ -1205,7 +1226,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:?}"), }; @@ -1232,11 +1253,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( @@ -1244,6 +1266,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.") @@ -1277,6 +1300,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(()) @@ -1296,6 +1320,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(); @@ -1334,7 +1359,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(); @@ -1365,18 +1390,39 @@ async fn bulk_confirm_invite( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - if org_id != headers.org_id { + bulk_confirm(&org_id, data.into_inner(), &headers, &conn, &nt, false).await +} + +/// Shared by the manual and the automatic bulk confirmation, which only differ in the checks a member +/// has to pass: see `confirm_invite_impl` and `auto_confirm_member_impl`. +async fn bulk_confirm( + org_id: &OrganizationId, + data: BulkConfirmData, + headers: &AdminHeaders, + conn: &DbConn, + nt: &Notify<'_>, + automatic: bool, +) -> 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 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 { + let result = if automatic { + auto_confirm_member_impl(org_id, &member_id, &user_key, headers, conn, nt).await + } else { + confirm_invite_impl(org_id, &member_id, &user_key, headers, conn, nt).await + }; + let err_msg = match result { Ok(()) => String::new(), Err(e) => format!("{e:?}"), }; @@ -1429,7 +1475,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") }; @@ -1437,6 +1483,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") } @@ -1447,10 +1507,18 @@ async fn confirm_invite_impl( // 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?; + // Emergency access would let a grantee take over the account of a member nobody vetted and reach + // the organization vault. Enabling the policy drops existing grants, this covers a member which + // brings one along afterwards. Like Bitwarden, this applies to 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 { + delete_all_emergency_access_of_user(&member_to_confirm.user_uuid, conn).await?; + } + log_event( EventType::OrganizationUserConfirmed, &member_to_confirm.uuid, - org_id, + &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, @@ -1459,7 +1527,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.") @@ -1481,6 +1549,97 @@ async fn confirm_invite_impl( save_result } +// Automatic user confirmation. The server can never confirm a member itself: that means encrypting the +// organization key with the public key of the member, and the server does not have the organization key. +// All we do is tell an admin client which members are waiting, the client does the work in the background. +// https://bitwarden.com/help/automatic-confirmation/ + +#[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(Membership::can_be_auto_confirmed) + .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 { + bulk_confirm(&org_id, data.into_inner(), &headers, &conn, &nt, true).await +} + +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 !member_to_confirm.can_be_auto_confirmed() { + 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 { @@ -2125,6 +2284,47 @@ async fn put_policy( } } + let mut policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await { + Some(p) => p, + None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_owned()), + }; + + // Automatic confirmation hands out organization access unattended, so it needs the server wide + // config option and the Single Org policy on top. + // https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyEventHandlers/AutomaticUserConfirmationPolicyEventHandler.cs + let enables_auto_confirm = pol_type_enum == OrgPolicyType::AutomaticUserConfirmation && data.enabled; + // Checked on every save, even when the policy is still stored as enabled from before the option was turned off. + if enables_auto_confirm && !CONFIG.org_auto_confirm_enabled() { + err!("Automatic user confirmation is not enabled on this server.") + } + + // Only the step from disabled to enabled validates and has side effects. The web vault saves on + // every edit, and re-running the below would keep wiping newly created emergency access. + let auto_confirm_turned_on = enables_auto_confirm && !policy.enabled; + if auto_confirm_turned_on { + if !OrgPolicy::is_enabled(&org_id, OrgPolicyType::SingleOrg, &conn).await { + 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 others: this policy also binds owners and admins, which could lock the org out. + for member in Membership::find_by_org(&org_id, &conn).await { + if member.counts_for_auto_confirm() + && Membership::count_accepted_confirmed_and_revoked_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.") + } + } + } + + // 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( @@ -2172,15 +2372,27 @@ async fn put_policy( } } - let mut policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await { - Some(p) => p, - None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_owned()), - }; - policy.enabled = data.enabled; policy.data = serde_json::to_string(&data.data)?; policy.save(&conn).await?; + // Emergency access would hand a member account to somebody outside this organization, which + // defeats vetting members; Bitwarden drops these on enable and blocks new ones (`emergency_access.rs`). + // Runs after the policy is stored so a failed save destroys nothing, and skips invited members: an + // invitation is created without their consent and must never 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.counts_for_auto_confirm() { + continue; + } + info!( + "Removing emergency access of {} because automatic user confirmation was enabled for {org_id}", + member.user_uuid + ); + delete_all_emergency_access_of_user(&member.user_uuid, &conn).await?; + } + } + log_event( EventType::PolicyUpdated, policy.uuid.as_ref(), @@ -2446,6 +2658,15 @@ async fn restore_member_impl( // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type // This check need to be done after restoring to work with the correct status OrgPolicy::check_user_allowed(&member, "restore", conn).await?; + + // A restore adds no second accept step, so emergency access created while revoked would + // outlive the revocation. Like enabling the policy this leaves a merely invited member alone, + // an invitation must never delete data of an account that never joined. + // https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs + if member.counts_for_auto_confirm() && OrgPolicy::is_auto_confirm_enabled(org_id, conn).await { + delete_all_emergency_access_of_user(&member.user_uuid, conn).await?; + } + member.save(conn).await?; log_event( diff --git a/src/api/identity.rs b/src/api/identity.rs index 6808ddde..d76815d6 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, @@ -1066,8 +1066,8 @@ async fn prelogin_password(data: Json, ip: ClientIp, conn: DbConn) } #[post("/accounts/register", data = "")] -async fn identity_register(data: 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)] @@ -1141,8 +1141,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..2e376031 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 the server + /// never has, which is why 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 9f0ae2e1..6736f8ce 100644 --- a/src/config.rs +++ b/src/config.rs @@ -795,6 +795,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; @@ -1754,6 +1759,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"); @@ -1873,3 +1879,30 @@ 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 the sender does not fill only blows up when the + /// mail is actually sent. Render both templates with the data the sender 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 = hb.render(name, &data).unwrap_or_else(|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") && body.contains("second@example.com"), + "{name} misses a contact" + ); + } + } +} diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 0e4073a5..ff41c228 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -29,7 +29,7 @@ pub use self::event::{Event, EventType}; pub use self::favorite::Favorite; pub use self::folder::{Folder, FolderCipher, FolderId}; pub use self::group::{CollectionGroup, Group, GroupId, GroupUser}; -pub use self::org_policy::{OrgPolicy, OrgPolicyId, OrgPolicyType}; +pub use self::org_policy::{AutoConfirmRequirement, OrgPolicy, OrgPolicyId, OrgPolicyType}; pub use self::organization::{ Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey, OrganizationId, diff --git a/src/db/models/org_policy.rs b/src/db/models/org_policy.rs index 94e53cbd..0afb91a8 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) OrganizationUserNotification = 20, } @@ -302,6 +302,43 @@ impl OrgPolicy { false } + /// Returns every membership of the user, in any status and of any role, in an organization which has + /// `policy_type` enabled. Contrary to the queries above this filters nothing away, the caller decides + /// which memberships it cares about, like Bitwarden does per policy. + /// https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/BasePolicyRequirementFactory.cs + pub async fn find_memberships_by_user_and_active_policy( + user_uuid: &UserId, + policy_type: OrgPolicyType, + conn: &DbConn, + ) -> Vec { + conn.run(move |conn| { + org_policies::table + .inner_join( + users_organizations::table.on(users_organizations::org_uuid + .eq(org_policies::org_uuid) + .and(users_organizations::user_uuid.eq(user_uuid))), + ) + .filter(org_policies::atype.eq(policy_type as i32)) + .filter(org_policies::enabled.eq(true)) + .select(users_organizations::all_columns) + .load::(conn) + .expect("Error loading memberships by org_policy") + }) + .await + } + + /// Whether the policy is stored as enabled for this organization, regardless of any config option. + pub async fn is_enabled(org_uuid: &OrganizationId, policy_type: OrgPolicyType, conn: &DbConn) -> bool { + Self::find_by_org_and_type(org_uuid, policy_type, conn).await.is_some_and(|p| p.enabled) + } + + /// Requires both the server wide config option and the policy of this organization, mirroring + /// Bitwarden where support has to enable the feature on top of the policy. + pub async fn is_auto_confirm_enabled(org_uuid: &OrganizationId, conn: &DbConn) -> bool { + CONFIG.org_auto_confirm_enabled() + && Self::is_enabled(org_uuid, OrgPolicyType::AutomaticUserConfirmation, conn).await + } + 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 @@ -335,6 +372,24 @@ impl OrgPolicy { } } + // Stricter than the SingleOrg block above: this policy exempts no role and no status. + // https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementHandler.cs + if AutoConfirmRequirement::for_user(&m.user_uuid, conn).await.forbids_membership_outside(&m.org_uuid) { + 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_confirmed_and_revoked_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(()) } @@ -393,3 +448,93 @@ impl OrgPolicy { #[derive(Clone, Debug, AsRef, DieselNewType, From, FromForm, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct OrgPolicyId(String); + +/// The memberships of a user in organizations which confirm their members automatically. +/// +/// Bitwarden models this as a policy requirement: a value answering what the policy forbids this user. +/// Contrary to every other policy this one exempts no role and no status, so an owner or an admin is +/// bound just like a plain member. Not every operation looks at every status though, which is why each +/// question below states which memberships it counts. +/// https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs +pub struct AutoConfirmRequirement(Vec); + +impl AutoConfirmRequirement { + /// Always empty while the server wide config option is off: the policy can not be enabled anywhere + /// then and so enforces nothing. + pub async fn for_user(user_uuid: &UserId, conn: &DbConn) -> Self { + if !CONFIG.org_auto_confirm_enabled() { + return Self(Vec::new()); + } + + Self( + OrgPolicy::find_memberships_by_user_and_active_policy( + user_uuid, + OrgPolicyType::AutomaticUserConfirmation, + conn, + ) + .await, + ) + } + + /// The user may not create another organization. Every membership counts, an open invitation and any + /// role included, which is what makes this stricter than SingleOrg. Mirrors `CannotCreateNewOrganization()`. + pub fn forbids_creating_organization(&self) -> bool { + !self.0.is_empty() + } + + /// A membership in an organization other than `org_uuid` forbids the user to be part of `org_uuid`. + /// Mirrors `IsEnabledForOrganizationsOtherThan(organizationId)`. + pub fn forbids_membership_outside(&self, org_uuid: &OrganizationId) -> bool { + self.0.iter().any(|m| &m.org_uuid != org_uuid) + } + + /// The user may neither grant nor accept emergency access, which would hand its account, and with + /// it the organization vault, to somebody the organization never vetted. Only an open invitation is + /// exempt, see [`Membership::counts_for_auto_confirm`]. + /// Mirrors `GrantorCannotInviteToEmergencyAccess()` and `GranteeCannotAcceptEmergencyAccess()`. + pub fn forbids_emergency_access(&self) -> bool { + self.0.iter().any(Membership::counts_for_auto_confirm) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn membership(org_uuid: &str, atype: MembershipType, status: MembershipStatus) -> Membership { + let mut member = + Membership::new(UserId::from(String::from("user")), OrganizationId::from(String::from(org_uuid)), None); + member.atype = atype as i32; + member.status = status as i32; + member + } + + /// Unlike SingleOrg, which lets owners, admins and open invitations through, this policy exempts no + /// role and no status. Only emergency access leaves an open invitation alone, that account did not join yet. + #[test] + fn auto_confirm_requirement() { + let auto_confirm_org = OrganizationId::from(String::from("auto-confirm")); + let other_org = OrganizationId::from(String::from("other")); + + let none = AutoConfirmRequirement(Vec::new()); + assert!(!none.forbids_creating_organization()); + assert!(!none.forbids_membership_outside(&other_org)); + assert!(!none.forbids_emergency_access()); + + let invited_owner = + AutoConfirmRequirement(vec![membership("auto-confirm", MembershipType::Owner, MembershipStatus::Invited)]); + assert!(invited_owner.forbids_creating_organization()); + assert!(invited_owner.forbids_membership_outside(&other_org)); + assert!(!invited_owner.forbids_membership_outside(&auto_confirm_org)); + assert!(!invited_owner.forbids_emergency_access()); + + // A revoked membership counts as well, a restore adds no second accept step. One is enough, next to + // an open invitation which does not restrict by itself. + let mut revoked_owner = membership("auto-confirm", MembershipType::Owner, MembershipStatus::Confirmed); + assert!(revoked_owner.revoke()); + let mut requirement = AutoConfirmRequirement(vec![revoked_owner]); + assert!(requirement.forbids_creating_organization()); + requirement.0.push(membership("invited-to", MembershipType::User, MembershipStatus::Invited)); + assert!(requirement.forbids_emergency_access()); + } +} diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index d615a3fc..a6237135 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(), @@ -489,6 +490,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, @@ -915,6 +917,46 @@ impl Membership { .await } + /// Whether this membership counts for the automatic user confirmation policy, which exempts no role + /// and only one status: an open invitation, because that account did not join yet and may still + /// decline. Every revoked membership counts, it is restored without another accept step. + /// https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementHandler.cs + pub fn counts_for_auto_confirm(&self) -> bool { + self.status != MembershipStatus::Invited as i32 + } + + /// Whether an admin client may confirm this membership without a human looking at it: only a member + /// which accepted its invitation and holds the plain User role. Every elevated role keeps needing a + /// manual confirmation by an Owner. + pub fn can_be_auto_confirmed(&self) -> bool { + self.status == MembershipStatus::Accepted as i32 && self.atype == MembershipType::User + } + + /// The same rule as [`Membership::counts_for_auto_confirm`] as a query: how many organizations besides + /// `excluded_org` the user belongs to. Contrary to `count_accepted_and_confirmed_by_user`, which the + /// SingleOrg policy uses, this counts revoked memberships, which are stored below `Invited`. + pub async fn count_accepted_confirmed_and_revoked_by_user( + user_uuid: &UserId, + excluded_org: &OrganizationId, + conn: &DbConn, + ) -> i64 { + conn.run(move |conn| { + users_organizations::table + .filter(users_organizations::user_uuid.eq(user_uuid)) + .filter(users_organizations::org_uuid.ne(excluded_org)) + .filter( + users_organizations::status + .eq(MembershipStatus::Accepted as i32) + .or(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) + .or(users_organizations::status.lt(MembershipStatus::Invited as i32)), + ) + .count() + .first::(conn) + .unwrap_or(0) + }) + .await + } + pub async fn find_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { conn.run(move |conn| { users_organizations::table @@ -1274,4 +1316,37 @@ mod tests { assert!(MembershipType::Manager > MembershipType::User); assert!(MembershipType::Manager == MembershipType::from_str("4").unwrap()); } + + fn member_with_status(status: i32) -> Membership { + let mut member = + Membership::new(UserId::from(String::from("user")), OrganizationId::from(String::from("org")), None); + member.status = status; + member + } + + /// Every membership but an open invitation counts for the auto confirm policy, revoked ones included, + /// while only an accepted plain member may be confirmed without a human looking at it. + #[test] + fn auto_confirm_membership_rules() { + for status in + [MembershipStatus::Invited as i32, MembershipStatus::Accepted as i32, MembershipStatus::Confirmed as i32] + { + let mut member = member_with_status(status); + let accepted = status == MembershipStatus::Accepted as i32; + assert_eq!(member.counts_for_auto_confirm(), status != MembershipStatus::Invited as i32, "status {status}"); + assert_eq!(member.can_be_auto_confirmed(), accepted, "status {status}"); + + assert!(member.revoke(), "status {status} can not be revoked"); + assert!(member.counts_for_auto_confirm(), "revoked {status} must count"); + assert!(!member.can_be_auto_confirmed(), "revoked {status} must not be confirmed automatically"); + // `count_accepted_confirmed_and_revoked_by_user` relies on this. + assert!(member.status < MembershipStatus::Invited as i32, "revoked {status} must stay below Invited"); + } + + let mut member = member_with_status(MembershipStatus::Accepted as i32); + for atype in [MembershipType::Owner, MembershipType::Admin, MembershipType::Manager] { + member.atype = atype as i32; + assert!(!member.can_be_auto_confirmed(), "type {} must not be confirmed automatically", atype as i32); + } + } } diff --git a/src/mail.rs b/src/mail.rs index b20f2853..eed0eac0 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 emergency access contacts were dropped. Deliberately does not name the reason: +/// the recipient can be outside the organization which triggered this and 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 }}