diff --git a/.env.template b/.env.template index 92362088..10378235 100644 --- a/.env.template +++ b/.env.template @@ -496,6 +496,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 cd280bc7..a3f44660 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, push_logout_to_relay, register_push_device, unregister_push_device, }, auth::{ClientHeaders, ClientIp, Headers, decode_delete, decode_invite, decode_verify_email}, @@ -267,7 +267,7 @@ async fn is_email_2fa_required(member_id: Option, conn: &DbConn) - false } -pub async fn register(data: Json, conn: DbConn) -> JsonResult { +pub async fn register(data: Json, conn: DbConn, nt: Notify<'_>) -> JsonResult { let data: RegisterData = data.into_inner(); let email = data.email.to_lowercase(); @@ -364,7 +364,7 @@ pub async fn register(data: Json, conn: DbConn) -> JsonResult { 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() @@ -442,7 +442,7 @@ pub async fn register(data: Json, conn: DbConn) -> JsonResult { } #[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; @@ -484,13 +484,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, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; diff --git a/src/api/core/emergency_access.rs b/src/api/core/emergency_access.rs index 773697e5..0e51a490 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::Utc; use rocket::{Route, serde::json::Json}; use serde_json::Value; @@ -15,14 +17,64 @@ use crate::{ db::{ DbConn, DbPool, models::{ - Cipher, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, - EmergencyAccessType, Invitation, Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId, + AutoConfirmRequirement, Cipher, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, + EmergencyAccessStatus, EmergencyAccessType, Invitation, Membership, MembershipType, OrgPolicy, TwoFactor, + User, UserId, }, }, mail, util::NumberOrString, }; +/// Deletes emergency access granted by or to `user_id` and sends each grantor one mail listing lost 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 { + // Keep the rows that `delete_all_by_user` would discard so notifications can name the contacts. + 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); + + // Collect before deletion and group into one mail per grantor. + 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, @@ -227,6 +279,11 @@ async fn send_invite(data: Json, headers: Headers, co err!("You can not set yourself as an emergency contact.") } + // Auto-confirm organizations forbid handing member accounts to unvetted emergency-access contacts. + 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() { @@ -361,6 +418,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 b7923982..16b216a2 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,8 @@ fn config() -> Json { &FeatureFlagFilter::ValidOnly, ); feature_states.insert("pm-19148-innovation-archive".to_owned(), true); + // Web vaults through 2026.4.x need this flag; newer clients use `useAutomaticUserConfirmation`. + 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 @@ -278,11 +280,43 @@ fn api_not_found() -> Json { })) } +/// Notifies eligible admins when a member reaches Accepted; their unlocked browser extension holds the +/// organization key and performs confirmation. Call this at every transition to Accepted. +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 all invitations at once and notifies for each; used when mail is disabled. +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"); @@ -296,6 +330,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 3328423e..300fb38c 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -9,15 +9,18 @@ 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, Device, - EventType, FillAssistPolicyData, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, - MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, + AutoConfirmRequirement, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, + CollectionUser, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser, Invitation, Membership, + MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId, }, }, @@ -54,6 +57,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, @@ -186,6 +192,13 @@ 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") } + // Unlike SingleOrg, auto-confirm forbids additional memberships for every role and status. + // 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." @@ -994,6 +1007,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"); @@ -1135,6 +1149,10 @@ 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 this is the only Accepted transition. Notify last so collections and groups + // exist before an admin client can confirm the member. + notify_pending_auto_confirm(&new_member, &conn, &nt).await; } Ok(()) @@ -1146,6 +1164,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"); @@ -1154,7 +1173,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) => { warn!("Unable to reinvite member {member_id}: {e:?}"); @@ -1184,11 +1203,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( @@ -1196,6 +1216,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.") @@ -1229,6 +1250,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(()) @@ -1248,6 +1270,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(); @@ -1286,7 +1309,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(); @@ -1317,10 +1340,21 @@ 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 manual and automatic bulk confirmation; their member checks remain separate. +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 { @@ -1330,7 +1364,12 @@ async fn bulk_confirm_invite( err!("Member id is required") }; 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) => { warn!("Unable to confirm member {member_id}: {e:?}"); @@ -1386,7 +1425,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") }; @@ -1394,6 +1433,20 @@ async fn confirm_invite_impl( err!("Only Owners can confirm Managers, Admins or Owners") } + confirm_member(member_to_confirm, key, EventType::OrganizationUserConfirmed, headers, conn, nt).await +} + +/// Shared confirmation after the manual or automatic checks have passed. +async fn confirm_member( + mut member_to_confirm: Membership, + key: &str, + event_type: EventType, + 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") } @@ -1404,10 +1457,17 @@ 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?; + // Remove emergency access created after policy activation; it could expose the organization vault. + // Bitwarden applies this 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, + event_type, &member_to_confirm.uuid, - org_id, + &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, @@ -1416,7 +1476,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.") @@ -1438,6 +1498,95 @@ async fn confirm_invite_impl( save_result } +// The server lacks the organization key; an admin client performs auto-confirmation 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, EventType::OrganizationUserAutomaticallyConfirmed, 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 { @@ -2103,6 +2252,33 @@ async fn put_policy( } } + let mut policy = match current_policy { + Some(p) => p, + None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_owned()), + }; + + // Unattended confirmation requires the server option and SingleOrg. + let enables_auto_confirm = pol_type_enum == OrgPolicyType::AutomaticUserConfirmation && data.enabled; + if enables_auto_confirm && !CONFIG.org_auto_confirm_enabled() { + err!("Automatic user confirmation is not enabled on this server.") + } + + // Only disabled -> enabled has side effects, and repeating them would remove new 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.") + } + + 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.") + } + } + } + if pol_type_enum == OrgPolicyType::FillAssist { let fill_assist_data = match data.data.as_ref() { None | Some(Value::Null) => FillAssistPolicyData { @@ -2134,6 +2310,14 @@ async fn put_policy( } } + // Keep SingleOrg enabled while automatic 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( @@ -2181,15 +2365,24 @@ async fn put_policy( } } - let mut policy = match current_policy { - 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?; + // Remove emergency access only after saving and only for joined members. A failed save must delete + // nothing, and an unaccepted invitation must not delete account data. New relationships are blocked elsewhere. + if auto_confirm_turned_on { + for member in Membership::find_by_org(&org_id, &conn).await { + if member.counts_for_auto_confirm() { + 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(), @@ -2435,6 +2628,14 @@ 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?; + + // Restore has no Accepted transition, so remove emergency access created while revoked. + // Invited remains exempt because that account 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 146cec4c..ca9c1c26 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, @@ -1124,9 +1124,9 @@ async fn register_verification_email_clicked( } #[post("/accounts/register/finish", data = "")] -async fn register_finish(data: Json, ip: ClientIp, conn: DbConn) -> JsonResult { +async fn register_finish(data: Json, ip: ClientIp, conn: DbConn, nt: Notify<'_>) -> JsonResult { crate::ratelimit::check_limit_unauthenticated(&ip.ip)?; - register(data, conn).await + register(data, 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 3bce7219..6ef66619 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, + }, }, }; @@ -539,6 +542,32 @@ impl WebSocketUsers { } } + /// Tells `recipient_id` that `member_id` awaits confirmation. The browser extension holds the + /// organization key, so 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 { @@ -725,6 +754,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 55d08259..66f1f1e6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -817,6 +817,10 @@ 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. + /// Accepted members can be confirmed unattended by an unlocked admin extension; disabled 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; @@ -1823,6 +1827,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"); @@ -1943,3 +1948,28 @@ 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::*; + + /// Render both strict templates with exactly the data supplied by the sender. + #[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"); + for email in ["first@example.com", "second@example.com"] { + assert!(body.contains(email), "{name} misses {email}"); + } + } + } +} diff --git a/src/db/models/event.rs b/src/db/models/event.rs index 8dc3a5ad..c49e064c 100644 --- a/src/db/models/event.rs +++ b/src/db/models/event.rs @@ -108,7 +108,7 @@ pub enum EventType { OrganizationUserRejectedAuthRequest = 1514, OrganizationUserDeleted = 1515, // Both user and organization user data were deleted OrganizationUserLeft = 1516, // User voluntarily left the organization - // OrganizationUserAutomaticallyConfirmed = 1517, + OrganizationUserAutomaticallyConfirmed = 1517, // OrganizationUserSelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy OrganizationUserAdminResetTwoFactor = 1519, // OrganizationUserRevoked_TwoFactorNonCompliance = 1520, diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index fda76df4..e355f9a4 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -31,7 +31,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::{FillAssistPolicyData, OrgPolicy, OrgPolicyId, OrgPolicyType}; +pub use self::org_policy::{AutoConfirmRequirement, FillAssistPolicyData, 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 554f51fc..4e78b780 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -50,7 +50,7 @@ pub enum OrgPolicyType { RestrictedItemTypes = 15, UriMatchDefaults = 16, AutotypeDefaultSetting = 17, - // AutoConfirm = 18, // Not supported (not implemented yet) + AutomaticUserConfirmation = 18, // BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed) OrganizationUserNotification = 20, // SendControls = 21, // Not supported yet @@ -318,6 +318,17 @@ impl OrgPolicy { false } + /// 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 option and this organization's 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 @@ -351,6 +362,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(()) } @@ -409,3 +438,89 @@ impl OrgPolicy { #[derive(Clone, Debug, AsRef, DieselNewType, From, FromForm, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct OrgPolicyId(String); + +/// Active auto-confirm policy memberships. No role or status is exempt; each check below decides which +/// statuses matter for its operation. +/// https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs +pub struct AutoConfirmRequirement(Vec); + +impl AutoConfirmRequirement { + /// Loads every role and status in organizations with this policy; empty when the server option is off. + /// https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/BasePolicyRequirementFactory.cs + pub async fn for_user(user_uuid: &UserId, conn: &DbConn) -> Self { + if !CONFIG.org_auto_confirm_enabled() { + return Self(Vec::new()); + } + + Self( + 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(OrgPolicyType::AutomaticUserConfirmation as i32)) + .filter(org_policies::enabled.eq(true)) + .select(users_organizations::all_columns) + .load::(conn) + .expect("Error loading memberships by org_policy") + }) + .await, + ) + } + + /// Any membership, including Invited and elevated roles, forbids creating another organization. + pub fn forbids_creating_organization(&self) -> bool { + !self.0.is_empty() + } + + /// Any policy membership outside `org_uuid` forbids membership in `org_uuid`. + pub fn forbids_membership_outside(&self, org_uuid: &OrganizationId) -> bool { + self.0.iter().any(|m| &m.org_uuid != org_uuid) + } + + /// Accepted, Confirmed, or Revoked members may neither grant nor accept emergency access; Invited is exempt. + 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("user".to_owned().into(), org_uuid.to_owned().into(), None); + member.atype = atype as i32; + member.status = status as i32; + member + } + + /// Every role and status is covered; only emergency access exempts Invited. + #[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()); + + // Revoked counts because restore has no second accept step; Invited alone does not restrict emergency access. + 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 25d03dfc..fb658b58 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -201,6 +201,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(), @@ -486,6 +487,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, @@ -886,6 +888,40 @@ impl Membership { .await } + /// Every role and status except Invited counts; Revoked counts because restore has no 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 + } + + /// Only an Accepted plain User may be auto-confirmed; elevated roles require manual confirmation. + pub fn can_be_auto_confirmed(&self) -> bool { + self.status == MembershipStatus::Accepted as i32 && self.atype == MembershipType::User + } + + /// Counts non-Invited memberships outside `excluded_org`, including Revoked; stricter than SingleOrg. + 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 @@ -1237,4 +1273,29 @@ mod tests { assert!(MembershipType::Manager > MembershipType::User); assert!(MembershipType::Manager == MembershipType::from_str("4").unwrap()); } + + /// All but Invited count for the policy; only an Accepted plain User may be auto-confirmed. + #[test] + fn auto_confirm_membership_rules() { + let mut member = Membership::new("user".to_owned().into(), "org".to_owned().into(), None); + for status in + [MembershipStatus::Invited as i32, MembershipStatus::Accepted as i32, MembershipStatus::Confirmed as i32] + { + member.status = status; + assert_eq!(member.counts_for_auto_confirm(), status != MembershipStatus::Invited as i32, "status {status}"); + assert_eq!(member.can_be_auto_confirmed(), status == MembershipStatus::Accepted as i32, "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"); + } + + member.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 14294213..8c0d56e2 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -394,6 +394,20 @@ pub async fn send_emergency_access_invite_accepted(address: &str, grantee_email: send_email(address, &subject, body_html, body_text).await } +/// Lists removed emergency-access contacts without leaking the triggering organization or memberships. +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 }}