diff --git a/.env.template b/.env.template index 9fc29989..51d9da53 100644 --- a/.env.template +++ b/.env.template @@ -491,6 +491,13 @@ ## KNOW WHAT YOU ARE DOING! # ORG_GROUPS_ENABLED=false +## Automatic user confirmation (Know the risks!) +## Allows organizations to enable the automatic user confirmation policy. +## Members which accepted an invitation are then confirmed unattended by the browser extension +## of an unlocked admin, without any human reviewing the invitation. +## KNOW WHAT YOU ARE DOING! +# ORG_AUTO_CONFIRM_ENABLED=false + ## Increase secure note size limit (Know the risks!) ## Sets the secure note size limit to 100_000 instead of the default 10_000. ## WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers! diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 0cb4d3c0..d4e2efeb 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -12,7 +12,7 @@ use crate::{ CONFIG, api::{ AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, - core::{accept_org_invite, log_user_event, two_factor::email}, + core::{accept_org_invite, accept_user_invitations, log_user_event, two_factor::email}, master_password_policy, register_push_device, unregister_push_device, }, auth::{ClientHeaders, ClientIp, Headers, decode_delete, decode_invite, decode_verify_email}, @@ -255,7 +255,7 @@ async fn is_email_2fa_required(member_id: Option, conn: &DbConn) - false } -pub async fn register(data: Json, email_verification: bool, conn: DbConn) -> JsonResult { +pub async fn register(data: Json, email_verification: bool, conn: DbConn, nt: Notify<'_>) -> JsonResult { let mut data: RegisterData = data.into_inner(); let email = data.email.to_lowercase(); @@ -357,7 +357,7 @@ pub async fn register(data: Json, email_verification: bool, conn: err!("Registration email does not match invite email") } } else if Invitation::take(&email, &conn).await { - Membership::accept_user_invitations(&user.uuid, &conn).await?; + accept_user_invitations(&user.uuid, &conn, &nt).await?; user } else if CONFIG.is_signup_allowed(&email) || (CONFIG.emergency_access_allowed() @@ -436,7 +436,7 @@ pub async fn register(data: Json, email_verification: bool, conn: } #[post("/accounts/set-password", data = "")] -async fn post_set_password(data: Json, headers: Headers, conn: DbConn) -> JsonResult { +async fn post_set_password(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { let data: SetPasswordData = data.into_inner(); let mut user = headers.user; @@ -478,13 +478,13 @@ async fn post_set_password(data: Json, headers: Headers, conn: err!("Failed to retrieve the invitation") }; - accept_org_invite(&user, membership, None, &conn).await?; + accept_org_invite(&user, membership, None, &conn, &nt).await?; } if CONFIG.mail_enabled() { mail::send_welcome(&user.email.to_lowercase()).await?; } else { - Membership::accept_user_invitations(&user.uuid, &conn).await?; + accept_user_invitations(&user.uuid, &conn, &nt).await?; } log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) diff --git a/src/api/core/emergency_access.rs b/src/api/core/emergency_access.rs index 2eb95502..d263cd54 100644 --- a/src/api/core/emergency_access.rs +++ b/src/api/core/emergency_access.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use chrono::{TimeDelta, Utc}; use rocket::{Route, serde::json::Json}; use serde_json::Value; @@ -20,6 +22,58 @@ use crate::{ util::NumberOrString, }; +/// Drops every emergency access of `user_id`, the ones it granted as well as the ones it holds, and tells +/// the grantors which contacts they lost. The grantor of the second group is somebody else, so without a +/// mail that user would silently lose a part of its account setup. +/// Bitwarden notifies the same way, one mail per grantor listing all of its removed contacts. +/// https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/Auth/UserFeatures/EmergencyAccess/Commands/DeleteEmergencyAccessCommand.cs +pub async fn delete_all_emergency_access_of_user(user_id: &UserId, conn: &DbConn) -> EmptyResult { + // Read before deleting, afterwards the rows are gone. + let mut removed = EmergencyAccess::find_all_by_grantor_uuid(user_id, conn).await; + removed.extend(EmergencyAccess::find_all_by_grantee_uuid(user_id, conn).await); + + EmergencyAccess::delete_all_by_user(user_id, conn).await?; + + if !CONFIG.mail_enabled() || removed.is_empty() { + return Ok(()); + } + + // Group by grantor so that each of them gets a single mail listing all of its removed contacts. + let mut by_grantor: HashMap> = HashMap::new(); + for emergency_access in removed { + // An invitation that was never accepted only carries the address, the uuid is stored on accept. + let grantee_email = match &emergency_access.grantee_uuid { + Some(grantee_uuid) => User::find_by_uuid(grantee_uuid, conn).await.map(|u| u.email), + None => emergency_access.email.clone(), + }; + let Some(grantee_email) = grantee_email else { + warn!( + "Not naming the grantee of emergency access {} in the removal notification, it has neither a known user nor an address", + emergency_access.uuid + ); + continue; + }; + by_grantor.entry(emergency_access.grantor_uuid).or_default().push(grantee_email); + } + + for (grantor_uuid, mut grantee_emails) in by_grantor { + let Some(grantor) = User::find_by_uuid(&grantor_uuid, conn).await else { + warn!("Skipping the emergency access removal notification for {grantor_uuid}, the account is gone"); + continue; + }; + + grantee_emails.sort_unstable(); + grantee_emails.dedup(); + + // The rows are already gone, so a failing mail must not take the whole request down with it. + if let Err(e) = mail::send_emergency_access_grantees_removed(&grantor.email, &grantee_emails).await { + error!("Failed to notify {} about its removed emergency access contacts: {e:?}", grantor.email); + } + } + + Ok(()) +} + pub fn routes() -> Vec { routes![ get_contacts, @@ -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 OrgPolicy::is_user_in_auto_confirm_org(&grantor_user.uuid, &conn).await { + err!("You are a member of an organization which does not allow emergency access.") + } + let (grantee_user, new_user) = match User::find_by_mail(&email, &conn).await { None => { if !CONFIG.invitations_allowed() { @@ -354,6 +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 OrgPolicy::is_user_in_auto_confirm_org(&grantee_user.uuid, &conn).await { + err!("You are a member of an organization which does not allow emergency access.") + } + // We need to search for the uuid in combination with the email, since we do not yet store the uuid of the grantee in the database. // The uuid of the grantee gets stored once accepted. let Some(mut emergency_access) = diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index e6a184dd..fb7ed126 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,10 @@ fn config() -> Json { &FeatureFlagFilter::ValidOnly, ); feature_states.insert("pm-19148-innovation-archive".to_owned(), true); + // Web vaults up to 2026.4.x only offer the automatic user confirmation policy when this flag is on: + // `display$(org, config) => config.getFeatureFlag$(FeatureFlag.AutoConfirm).pipe(map(f => f && org.useAutomaticUserConfirmation))` + // Newer clients dropped the flag and look at `useAutomaticUserConfirmation` alone, so sending it stays harmless. + feature_states.insert("pm-19934-auto-confirm-organization-users".to_owned(), CONFIG.org_auto_confirm_enabled()); Json(json!({ // Note: The clients use this version to handle backwards compatibility concerns @@ -277,11 +281,54 @@ fn api_not_found() -> Json { })) } +/// Tells everybody who is able to confirm this member that it accepted its invitation and is waiting. +/// Only the browser extension of an unlocked admin acts upon this, it holds the organization key which +/// is needed to confirm a member and which the server never has. +/// Call this wherever a membership reaches the accepted state. +pub async fn notify_pending_auto_confirm(member: &Membership, conn: &DbConn, nt: &Notify<'_>) { + if member.status != MembershipStatus::Accepted as i32 + || member.atype != MembershipType::User + || !OrgPolicy::is_auto_confirm_enabled(&member.org_uuid, conn).await + { + return; + } + + // Confirming requires `AdminHeaders`, so skip the managers this also returns. They could not act on + // the notification anyway and would only run into a rejected request. + for admin in Membership::find_confirmed_and_manage_all_by_org(&member.org_uuid, conn) + .await + .into_iter() + .filter(|m| m.atype >= MembershipType::Admin) + { + nt.send_auto_confirm_member(&admin.user_uuid, &member.org_uuid, &member.uuid, &member.user_uuid).await; + } +} + +/// Accepts every open invitation of a user at once, as done when mail is disabled, and notifies for each +/// of them. See [`notify_pending_auto_confirm`]. +pub async fn accept_user_invitations(user_id: &UserId, conn: &DbConn, nt: &Notify<'_>) -> EmptyResult { + let invited: Vec = Membership::find_any_state_by_user(user_id, conn) + .await + .into_iter() + .filter(|m| m.status == MembershipStatus::Invited as i32) + .collect(); + + Membership::accept_user_invitations(user_id, conn).await?; + + for mut member in invited { + member.status = MembershipStatus::Accepted as i32; + notify_pending_auto_confirm(&member, conn, nt).await; + } + + Ok(()) +} + async fn accept_org_invite( user: &User, mut member: Membership, reset_password_key: Option, conn: &DbConn, + nt: &Notify<'_>, ) -> EmptyResult { if member.status != MembershipStatus::Invited as i32 { err!("User already accepted the invitation"); @@ -295,6 +342,8 @@ async fn accept_org_invite( member.save(conn).await?; + notify_pending_auto_confirm(&member, conn, nt).await; + if CONFIG.mail_enabled() { let Some(org) = Organization::find_by_uuid(&member.org_uuid, conn).await else { err!("Organization not found.") diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 989ca47d..d7f5fc4d 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -9,7 +9,10 @@ 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::{ @@ -55,6 +58,9 @@ pub fn routes() -> Vec { bulk_reinvite_members, confirm_invite, bulk_confirm_invite, + get_pending_auto_confirm_members, + auto_confirm_member, + bulk_auto_confirm_members, accept_invite, get_org_user_mini_details, get_user, @@ -1045,6 +1051,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"); @@ -1185,6 +1192,11 @@ async fn send_invite( let mut group_entry = GroupUser::new(group_id.clone(), new_member.uuid.clone()); group_entry.save(&conn).await?; } + + // With mail disabled an existing user is accepted right away, so there is no accept request later on. + // This is the last step on purpose: an admin client may confirm the member the moment it is told + // about it, and by then the collections and groups of the invite have to be in place. + notify_pending_auto_confirm(&new_member, &conn, &nt).await; } Ok(()) @@ -1196,6 +1208,7 @@ async fn bulk_reinvite_members( data: Json, headers: AdminHeaders, conn: DbConn, + nt: Notify<'_>, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); @@ -1204,7 +1217,7 @@ async fn bulk_reinvite_members( let mut bulk_response = Vec::new(); for member_id in data.ids { - let err_msg = match reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await { + let err_msg = match reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn, &nt).await { Ok(()) => String::new(), Err(e) => format!("{e:?}"), }; @@ -1231,11 +1244,12 @@ async fn reinvite_member( member_id: MembershipId, headers: AdminHeaders, conn: DbConn, + nt: Notify<'_>, ) -> EmptyResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } - reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await + reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn, &nt).await } async fn reinvite_member_impl( @@ -1243,6 +1257,7 @@ async fn reinvite_member_impl( member_id: &MembershipId, invited_by_email: &str, conn: &DbConn, + nt: &Notify<'_>, ) -> EmptyResult { let Some(member) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { err!("The user hasn't been invited to the organization.") @@ -1276,6 +1291,7 @@ async fn reinvite_member_impl( let mut member = member; member.status = MembershipStatus::Accepted as i32; member.save(conn).await?; + notify_pending_auto_confirm(&member, conn, nt).await; } Ok(()) @@ -1295,6 +1311,7 @@ async fn accept_invite( data: Json, headers: Headers, conn: DbConn, + nt: Notify<'_>, ) -> EmptyResult { // The web-vault passes org_id and member_id in the URL, but we are just reading them from the JWT instead let data: AcceptData = data.into_inner(); @@ -1333,7 +1350,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(); @@ -1373,7 +1390,11 @@ async fn bulk_confirm_invite( match data.keys { Some(keys) => { for invite in keys { - let member_id = invite.id.unwrap(); + // Never unwrap the id, this is client supplied and a missing one must not take the request down + let Some(member_id) = invite.id else { + error!("Ignoring a bulk confirm entry without a member id"); + continue; + }; let user_key = invite.key.unwrap_or_default(); let err_msg = match confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await { Ok(()) => String::new(), @@ -1428,7 +1449,7 @@ async fn confirm_invite_impl( err!("Key or UserId is not set, unable to process request"); } - let Some(mut member_to_confirm) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { + let Some(member_to_confirm) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { err!("The specified user isn't a member of the organization") }; @@ -1436,6 +1457,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") } @@ -1446,10 +1481,20 @@ 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?; + // An organization which confirms its members automatically does not tolerate emergency access: the + // grantee could take over the account of a member that nobody ever vetted and reach the organization + // vault through it. Enabling the policy drops the grants of the members present at that time, this + // covers the member which brings one along when it joins afterwards. Bitwarden does the same, and + // like there it applies to the manual confirmation as well. + // https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs + if OrgPolicy::is_auto_confirm_enabled(&org_id, conn).await { + delete_all_emergency_access_of_user(&member_to_confirm.user_uuid, conn).await?; + } + log_event( EventType::OrganizationUserConfirmed as i32, &member_to_confirm.uuid, - org_id, + &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, @@ -1458,7 +1503,7 @@ async fn confirm_invite_impl( .await; if CONFIG.mail_enabled() { - let org_name = if let Some(org) = Organization::find_by_uuid(org_id, conn).await { + let org_name = if let Some(org) = Organization::find_by_uuid(&org_id, conn).await { org.name } else { err!("Error looking up organization.") @@ -1480,6 +1525,142 @@ async fn confirm_invite_impl( save_result } +// Automatic user confirmation. The server can never confirm a member by itself, confirming means +// encrypting the organization key with the public key of the member and the server does not have the +// organization key. So all we do here is telling an admin client which members are waiting, the client +// does the actual work in the background. +// https://bitwarden.com/help/automatic-confirmation/ + +/// Only a member which accepted its invitation and holds the plain User role is ever confirmed without +/// a human looking at it. Every elevated role keeps needing a manual confirmation by an Owner, and an +/// Owner can not lift that restriction here like it can for the manual confirmation. +fn may_be_confirmed_automatically(member: &Membership) -> bool { + member.status == MembershipStatus::Accepted as i32 && member.atype == MembershipType::User +} + +#[get("/organizations//users/pending-auto-confirm")] +async fn get_pending_auto_confirm_members(org_id: OrganizationId, headers: AdminHeaders, conn: DbConn) -> JsonResult { + if org_id != headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } + + // Bitwarden responds with an empty list instead of an error when the feature or the policy is off. + let members = if OrgPolicy::is_auto_confirm_enabled(&org_id, &conn).await { + Membership::find_by_org(&org_id, &conn) + .await + .into_iter() + .filter(may_be_confirmed_automatically) + .map(|m| { + json!({ + "object": "organizationUserPendingAutoConfirm", + "id": m.uuid, + "userId": m.user_uuid, + }) + }) + .collect() + } else { + Vec::new() + }; + + Ok(Json(json!({ + "data": members, + "object": "list", + "continuationToken": null + }))) +} + +#[post("/organizations//users//auto-confirm", data = "")] +async fn auto_confirm_member( + org_id: OrganizationId, + member_id: MembershipId, + data: Json, + headers: AdminHeaders, + conn: DbConn, + nt: Notify<'_>, +) -> EmptyResult { + let data = data.into_inner(); + let user_key = data.key.unwrap_or_default(); + auto_confirm_member_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await +} + +#[post("/organizations//users/bulk-auto-confirm", data = "")] +async fn bulk_auto_confirm_members( + org_id: OrganizationId, + data: Json, + headers: AdminHeaders, + conn: DbConn, + nt: Notify<'_>, +) -> JsonResult { + if org_id != headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } + let data = data.into_inner(); + + let mut bulk_response = Vec::new(); + match data.keys { + Some(keys) => { + for member in keys { + // Never unwrap the id, this is client supplied and a missing one must not take the request down + let Some(member_id) = member.id else { + error!("Ignoring a bulk auto confirm entry without a member id"); + continue; + }; + let user_key = member.key.unwrap_or_default(); + let err_msg = match auto_confirm_member_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await + { + Ok(()) => String::new(), + Err(e) => format!("{e:?}"), + }; + + bulk_response.push(json!( + { + "object": "OrganizationBulkConfirmResponseModel", + "id": member_id, + "error": err_msg + } + )); + } + } + None => error!("No keys to confirm"), + } + + Ok(Json(json!({ + "data": bulk_response, + "object": "list", + "continuationToken": null + }))) +} + +async fn auto_confirm_member_impl( + org_id: &OrganizationId, + member_id: &MembershipId, + key: &str, + headers: &AdminHeaders, + conn: &DbConn, + nt: &Notify<'_>, +) -> EmptyResult { + if org_id != &headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } + if key.is_empty() || member_id.is_empty() { + err!("Key or UserId is not set, unable to process request"); + } + + if !OrgPolicy::is_auto_confirm_enabled(org_id, conn).await { + err!("Automatic user confirmation is not enabled for this organization") + } + + let Some(member_to_confirm) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { + err!("The specified user isn't a member of the organization") + }; + + if !may_be_confirmed_automatically(&member_to_confirm) { + err!("This member can not be confirmed automatically") + } + + confirm_member(member_to_confirm, key, headers, conn, nt).await +} + #[get("/organizations//users/mini-details", rank = 1)] async fn get_org_user_mini_details(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { if org_id != headers.membership.org_uuid { @@ -2113,6 +2294,54 @@ async fn put_policy( } } + // The automatic user confirmation policy hands out organization access without anybody looking at it, + // so it needs to be allowed by the server first and it requires the Single Org policy on top. + // https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyEventHandlers/AutomaticUserConfirmationPolicyEventHandler.cs + let auto_confirm_turned_on = if pol_type_enum == OrgPolicyType::AutomaticUserConfirmation + && data.enabled + // Only the step from disabled to enabled validates and has side effects. The web vault saves a + // policy on every edit, and re-running the below on an already enabled policy would keep wiping + // emergency access that members created in the meantime. Bitwarden guards this the same way. + && !OrgPolicy::is_auto_confirm_enabled(&org_id, &conn).await + { + if !CONFIG.org_auto_confirm_enabled() { + err!("Automatic user confirmation is not enabled on this server.") + } + + let single_org_policy_enabled = + match OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::SingleOrg, &conn).await { + Some(p) => p.enabled, + None => false, + }; + + if !single_org_policy_enabled { + err!("Single Organization policy is not enabled. It is mandatory for this policy to be enabled.") + } + + // Every member has to be compliant already. Contrary to the Single Org policy below we do not revoke + // the members that are not, because this policy also applies to owners and admins and revoking those + // could lock the organization out of itself. + for member in Membership::find_by_org(&org_id, &conn).await { + if member.status != MembershipStatus::Invited as i32 + && Membership::count_accepted_and_confirmed_by_user(&member.user_uuid, &org_id, &conn).await > 0 + { + err!("This policy forbids members to be part of other organizations, but at least one member still is.") + } + } + + true + } else { + false + }; + + // Also prevent the Single Org policy to be disabled while automatic user confirmation depends on it + if pol_type_enum == OrgPolicyType::SingleOrg + && !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( @@ -2169,6 +2398,25 @@ async fn put_policy( policy.data = serde_json::to_string(&data.data)?; policy.save(&conn).await?; + // Emergency access would hand the account of a member to somebody outside of the control of this + // organization, which defeats the point of vetting members. Bitwarden drops these grants when the + // policy is turned on, and blocks new ones while it is on (see `emergency_access.rs`). + // This runs after the policy is stored so that a failed save can not destroy data for nothing, and it + // skips invited members on purpose: an invitation is created by an admin without any consent of the + // invited user, so it must never be able to delete data of an account that never joined. + if auto_confirm_turned_on { + for member in Membership::find_by_org(&org_id, &conn).await { + if member.status == MembershipStatus::Invited as i32 { + continue; + } + info!( + "Removing emergency access of {} because automatic user confirmation was enabled for {org_id}", + member.user_uuid + ); + delete_all_emergency_access_of_user(&member.user_uuid, &conn).await?; + } + } + log_event( EventType::PolicyUpdated as i32, policy.uuid.as_ref(), @@ -3251,3 +3499,32 @@ async fn rotate_api_key( ) -> JsonResult { api_key(&org_id, data, true, headers, conn).await } + +#[cfg(test)] +mod tests { + use super::*; + + /// Automatic confirmation hands out access to the organization vault without anybody looking at it, + /// so it must stay limited to plain members which actually accepted their invitation. + #[test] + fn only_accepted_plain_members_are_confirmed_automatically() { + let mut member = + Membership::new(UserId::from(String::from("user")), OrganizationId::from(String::from("org")), None); + + for status in + [MembershipStatus::Revoked as i32, MembershipStatus::Invited as i32, MembershipStatus::Confirmed as i32] + { + member.status = status; + assert!(!may_be_confirmed_automatically(&member), "status {status} must not qualify"); + } + + member.status = MembershipStatus::Accepted as i32; + for atype in [MembershipType::Owner as i32, MembershipType::Admin as i32, MembershipType::Manager as i32] { + member.atype = atype; + assert!(!may_be_confirmed_automatically(&member), "type {atype} must not qualify"); + } + + member.atype = MembershipType::User as i32; + assert!(may_be_confirmed_automatically(&member)); + } +} diff --git a/src/api/identity.rs b/src/api/identity.rs index 23411dc7..a24c1f2d 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -12,7 +12,7 @@ use serde_json::Value; use crate::{ CONFIG, api::{ - ApiResult, EmptyResult, JsonResult, + ApiResult, EmptyResult, JsonResult, Notify, core::{ accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register}, log_user_event, @@ -1034,8 +1034,8 @@ async fn prelogin_password(data: Json, conn: DbConn) -> Json, conn: DbConn) -> JsonResult { - register(data, false, conn).await +async fn identity_register(data: Json, conn: DbConn, nt: Notify<'_>) -> JsonResult { + register(data, false, conn, nt).await } #[derive(Debug, Deserialize)] @@ -1098,8 +1098,8 @@ async fn register_verification_email( } #[post("/accounts/register/finish", data = "")] -async fn register_finish(data: Json, conn: DbConn) -> JsonResult { - register(data, true, conn).await +async fn register_finish(data: Json, conn: DbConn, nt: Notify<'_>) -> JsonResult { + register(data, true, conn, nt).await } // https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts diff --git a/src/api/notifications.rs b/src/api/notifications.rs index 8bfcd518..d545fd02 100644 --- a/src/api/notifications.rs +++ b/src/api/notifications.rs @@ -15,7 +15,10 @@ use crate::{ auth::{ClientIp, WsAccessTokenHeader}, db::{ DbConn, - models::{AuthRequestId, Cipher, CollectionId, Device, DeviceId, Folder, PushId, Send as DbSend, User, UserId}, + models::{ + AuthRequestId, Cipher, CollectionId, Device, DeviceId, Folder, MembershipId, OrganizationId, PushId, + Send as DbSend, User, UserId, + }, }, }; @@ -510,6 +513,33 @@ impl WebSocketUsers { } } + /// Tells the clients of `recipient_id` that `member_id` accepted an invitation and is waiting to be + /// confirmed. Only the browser extension acts upon this, it holds the organization key needed to + /// confirm the member, which the server never has. Because of that this is WebSocket only. + /// https://github.com/bitwarden/clients/blob/main/libs/auto-confirm/README.md + pub async fn send_auto_confirm_member( + &self, + recipient_id: &UserId, + org_id: &OrganizationId, + member_id: &MembershipId, + member_user_id: &UserId, + ) { + if !CONFIG.enable_websocket() { + return; + } + let data = create_update( + vec![ + ("UserId".into(), recipient_id.to_string().into()), + ("OrganizationId".into(), org_id.to_string().into()), + ("TargetUserId".into(), member_user_id.to_string().into()), + ("TargetOrganizationUserId".into(), member_id.to_string().into()), + ], + UpdateType::AutoConfirmMember, + None, + ); + self.send_update(recipient_id, &data).await; + } + pub async fn send_auth_request(&self, user_id: &UserId, auth_request_uuid: &str, device: &Device, conn: &DbConn) { // Skip any processing if both WebSockets and Push are not active if *NOTIFICATIONS_DISABLED { @@ -700,6 +730,13 @@ pub enum UpdateType { // NotificationStatus = 21, // Not supported // RefreshSecurityTasks = 22, // Not supported + + // OrganizationBankAccountVerified = 23, // Not supported (Not AGPLv3 Licensed) + // ProviderBankAccountVerified = 24, // Not supported (Not AGPLv3 Licensed) + + // SyncPolicy = 25, // Not supported + AutoConfirmMember = 26, + // PremiumStatusChanged = 27, // Not supported None = 100, } diff --git a/src/config.rs b/src/config.rs index d5b50146..7c746b1e 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; @@ -1739,6 +1744,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"); @@ -1858,3 +1864,33 @@ handlebars::handlebars_helper!(webver: | web_vault_version: String | handlebars::handlebars_helper!(vwver: | vw_version: String | semver::VersionReq::parse(&vw_version).expect("Invalid Vaultwarden version compare string").matches(&VW_VERSION) ); + +#[cfg(test)] +mod tests { + use super::*; + + /// The registry runs in strict mode, so a placeholder which the sender does not fill only blows up + /// when the mail is actually sent. Render both templates of the emergency access removal notification + /// with the data `mail::send_emergency_access_grantees_removed` passes. + #[test] + fn emergency_access_grantees_removed_renders() { + let hb = load_templates(std::env::temp_dir()); + let data = serde_json::json!({ + "url": "https://vault.example.com", + "img_src": "https://vault.example.com/mail/", + "grantee_emails": ["first@example.com", "second@example.com"], + }); + + for name in ["email/emergency_access_grantees_removed", "email/emergency_access_grantees_removed.html"] { + let rendered = match hb.render(name, &data) { + Ok(rendered) => rendered, + Err(e) => panic!("{name} failed to render: {e:?}"), + }; + + let (subject, body) = rendered.split_once("").expect("no subject separator"); + assert_eq!(subject.trim(), "Emergency contacts removed"); + assert!(body.contains("first@example.com"), "{name} does not list every removed contact"); + assert!(body.contains("second@example.com"), "{name} does not list every removed contact"); + } + } +} diff --git a/src/db/models/org_policy.rs b/src/db/models/org_policy.rs index 88b7872c..a9781323 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -47,7 +47,7 @@ pub enum OrgPolicyType { RestrictedItemTypes = 15, UriMatchDefaults = 16, // AutotypeDefaultSetting = 17, // Not supported yet - // AutoConfirm = 18, // Not supported (not implemented yet) + AutomaticUserConfirmation = 18, // BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed) } @@ -280,6 +280,50 @@ impl OrgPolicy { false } + /// Returns true if the user is a member of an organization other than `exclude_org_uuid` which has the + /// automatic user confirmation policy enabled. Contrary to `is_applicable_to_user` this does not exempt + /// owners and admins, the policy applies to every role and every status. + /// https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs + pub async fn auto_confirm_enabled_for_other_org( + user_uuid: &UserId, + exclude_org_uuid: &OrganizationId, + conn: &DbConn, + ) -> bool { + CONFIG.org_auto_confirm_enabled() + && Self::find_accepted_and_confirmed_by_user_and_active_policy( + user_uuid, + OrgPolicyType::AutomaticUserConfirmation, + conn, + ) + .await + .iter() + .any(|policy| &policy.org_uuid != exclude_org_uuid) + } + + /// Returns true if the user is a member of an organization which confirms its members automatically. + /// Such a membership also restricts what the user may do outside of that organization. + pub async fn is_user_in_auto_confirm_org(user_uuid: &UserId, conn: &DbConn) -> bool { + CONFIG.org_auto_confirm_enabled() + && !Self::find_accepted_and_confirmed_by_user_and_active_policy( + user_uuid, + OrgPolicyType::AutomaticUserConfirmation, + conn, + ) + .await + .is_empty() + } + + /// Returns true if members of this organization may be confirmed automatically. This requires both the + /// server wide config option and the policy of this organization to be enabled, which mirrors Bitwarden + /// where the organization needs the feature enabled by support on top of the policy. + pub async fn is_auto_confirm_enabled(org_uuid: &OrganizationId, conn: &DbConn) -> bool { + CONFIG.org_auto_confirm_enabled() + && match Self::find_by_org_and_type(org_uuid, OrgPolicyType::AutomaticUserConfirmation, conn).await { + Some(p) => p.enabled, + None => false, + } + } + pub async fn check_user_allowed(m: &Membership, action: &str, conn: &DbConn) -> EmptyResult { if m.atype < MembershipType::Admin && m.status > (MembershipStatus::Invited as i32) { // Enforce TwoFactor/TwoStep login @@ -313,6 +357,26 @@ impl OrgPolicy { } } + // The automatic user confirmation policy is a stricter variant of the SingleOrg policy, it does not + // exempt owners and admins and it applies to every status. Therefore it is checked outside of the + // block above. + // https://github.com/bitwarden/server/blob/b3d1eb9a7854322f106efa55c191c1a4da9f8645/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementHandler.cs + if Self::auto_confirm_enabled_for_other_org(&m.user_uuid, &m.org_uuid, conn).await { + err!(format!( + "Cannot {} because another organization confirms its members automatically and forbids other memberships (membership {})", + action, m.uuid + )); + } + + if Self::is_auto_confirm_enabled(&m.org_uuid, conn).await + && Membership::count_accepted_and_confirmed_by_user(&m.user_uuid, &m.org_uuid, conn).await > 0 + { + err!(format!( + "Cannot {} because the organization confirms its members automatically and forbids being part of other organizations (membership {})", + action, m.uuid + )); + } + Ok(()) } diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index bdb69864..df7473ea 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -204,6 +204,7 @@ impl Organization { "maxCollections": null, "maxStorageGb": i16::MAX, // The value doesn't matter, we don't check server-side "use2fa": true, + "useAutomaticUserConfirmation": CONFIG.org_auto_confirm_enabled(), "useCustomPermissions": true, "useDirectory": false, // Is supported, but this value isn't checked anywhere (yet) "useEvents": CONFIG.org_events_enabled(), @@ -498,6 +499,7 @@ impl Membership { "useKeyConnector": false, "useSecretsManager": false, // Not supported (Not AGPLv3 Licensed) "usePasswordManager": true, + "useAutomaticUserConfirmation": CONFIG.org_auto_confirm_enabled(), "useCustomPermissions": true, "useActivateAutofillPolicy": false, "useAdminSponsoredFamilies": false, diff --git a/src/mail.rs b/src/mail.rs index a7e5e5ae..eaf8ec35 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -394,6 +394,22 @@ pub async fn send_emergency_access_invite_accepted(address: &str, grantee_email: send_email(address, &subject, body_html, body_text).await } +/// Tells a grantor which of its emergency access contacts were dropped. Deliberately does not name the +/// reason: the recipient can be somebody outside of the organization which triggered this, and it has no +/// business learning about the memberships of others. Bitwarden keeps this generic as well. +pub async fn send_emergency_access_grantees_removed(address: &str, grantee_emails: &[String]) -> EmptyResult { + let (subject, body_html, body_text) = get_text( + "email/emergency_access_grantees_removed", + json!({ + "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), + "grantee_emails": grantee_emails, + }), + )?; + + send_email(address, &subject, body_html, body_text).await +} + pub async fn send_emergency_access_invite_confirmed(address: &str, grantor_name: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/emergency_access_invite_confirmed", diff --git a/src/static/templates/email/emergency_access_grantees_removed.hbs b/src/static/templates/email/emergency_access_grantees_removed.hbs new file mode 100644 index 00000000..bc1c8046 --- /dev/null +++ b/src/static/templates/email/emergency_access_grantees_removed.hbs @@ -0,0 +1,10 @@ +Emergency contacts removed + +The following emergency contacts have been removed from your account: + +{{#each grantee_emails}} +* {{this}} +{{/each}} + +You can set up emergency access again from the web vault ({{url}}). +{{> email/email_footer_text }} diff --git a/src/static/templates/email/emergency_access_grantees_removed.html.hbs b/src/static/templates/email/emergency_access_grantees_removed.html.hbs new file mode 100644 index 00000000..1d0db8fc --- /dev/null +++ b/src/static/templates/email/emergency_access_grantees_removed.html.hbs @@ -0,0 +1,21 @@ +Emergency contacts removed + +{{> email/email_header }} + + + + + + + +
+ The following emergency contacts have been removed from your account: +
    +{{#each grantee_emails}} +
  • {{this}}
  • +{{/each}} +
+
+ You can set up emergency access again from the web vault. +
+{{> email/email_footer }}