diff --git a/.env.template b/.env.template index fd7c2fd2..9516a897 100644 --- a/.env.template +++ b/.env.template @@ -483,6 +483,13 @@ ## KNOW WHAT YOU ARE DOING! # ORG_GROUPS_ENABLED=false +## Automatic user confirmation (Know the risks!) +## Allows organizations to enable the automatic user confirmation policy. +## Members which accepted an invitation are then confirmed unattended by the browser extension +## of an unlocked admin, without any human reviewing the invitation. +## KNOW WHAT YOU ARE DOING! +# ORG_AUTO_CONFIRM_ENABLED=false + ## Increase secure note size limit (Know the risks!) ## Sets the secure note size limit to 100_000 instead of the default 10_000. ## WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers! diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 0cb4d3c0..d4e2efeb 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -12,7 +12,7 @@ use crate::{ CONFIG, api::{ AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, - core::{accept_org_invite, log_user_event, two_factor::email}, + core::{accept_org_invite, accept_user_invitations, log_user_event, two_factor::email}, master_password_policy, register_push_device, unregister_push_device, }, auth::{ClientHeaders, ClientIp, Headers, decode_delete, decode_invite, decode_verify_email}, @@ -255,7 +255,7 @@ async fn is_email_2fa_required(member_id: Option, conn: &DbConn) - false } -pub async fn register(data: Json, email_verification: bool, conn: DbConn) -> JsonResult { +pub async fn register(data: Json, email_verification: bool, conn: DbConn, nt: Notify<'_>) -> JsonResult { let mut data: RegisterData = data.into_inner(); let email = data.email.to_lowercase(); @@ -357,7 +357,7 @@ pub async fn register(data: Json, email_verification: bool, conn: err!("Registration email does not match invite email") } } else if Invitation::take(&email, &conn).await { - Membership::accept_user_invitations(&user.uuid, &conn).await?; + accept_user_invitations(&user.uuid, &conn, &nt).await?; user } else if CONFIG.is_signup_allowed(&email) || (CONFIG.emergency_access_allowed() @@ -436,7 +436,7 @@ pub async fn register(data: Json, email_verification: bool, conn: } #[post("/accounts/set-password", data = "")] -async fn post_set_password(data: Json, headers: Headers, conn: DbConn) -> JsonResult { +async fn post_set_password(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { let data: SetPasswordData = data.into_inner(); let mut user = headers.user; @@ -478,13 +478,13 @@ async fn post_set_password(data: Json, headers: Headers, conn: err!("Failed to retrieve the invitation") }; - accept_org_invite(&user, membership, None, &conn).await?; + accept_org_invite(&user, membership, None, &conn, &nt).await?; } if CONFIG.mail_enabled() { mail::send_welcome(&user.email.to_lowercase()).await?; } else { - Membership::accept_user_invitations(&user.uuid, &conn).await?; + accept_user_invitations(&user.uuid, &conn, &nt).await?; } log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) diff --git a/src/api/core/emergency_access.rs b/src/api/core/emergency_access.rs index 2eb95502..915eb14e 100644 --- a/src/api/core/emergency_access.rs +++ b/src/api/core/emergency_access.rs @@ -222,6 +222,12 @@ async fn send_invite(data: Json, headers: Headers, co err!("You can not set yourself as an emergency contact.") } + // Emergency access would hand this account to somebody the organization never vetted, which is why + // Bitwarden forbids it for members of an organization which confirms its members automatically. + if OrgPolicy::is_user_in_auto_confirm_org(&grantor_user.uuid, &conn).await { + err!("You are a member of an organization which does not allow emergency access.") + } + let (grantee_user, new_user) = match User::find_by_mail(&email, &conn).await { None => { if !CONFIG.invitations_allowed() { @@ -354,6 +360,11 @@ async fn accept_invite( err!("Invited user not found") }; + // See `send_invite`, the same restriction applies to the grantee side of an emergency access. + if OrgPolicy::is_user_in_auto_confirm_org(&grantee_user.uuid, &conn).await { + err!("You are a member of an organization which does not allow emergency access.") + } + // We need to search for the uuid in combination with the email, since we do not yet store the uuid of the grantee in the database. // The uuid of the grantee gets stored once accepted. let Some(mut emergency_access) = diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index a5ae50a4..d9b42555 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -24,7 +24,7 @@ use crate::{ auth::Headers, db::{ DbConn, - models::{Membership, MembershipStatus, OrgPolicy, Organization, User}, + models::{Membership, MembershipStatus, MembershipType, OrgPolicy, Organization, User, UserId}, }, error::Error, http_client::make_http_request, @@ -277,11 +277,48 @@ fn api_not_found() -> Json { })) } +/// Tells everybody who is able to confirm this member that it accepted its invitation and is waiting. +/// Only the browser extension of an unlocked admin acts upon this, it holds the organization key which +/// is needed to confirm a member and which the server never has. +/// Call this wherever a membership reaches the accepted state. +pub async fn notify_pending_auto_confirm(member: &Membership, conn: &DbConn, nt: &Notify<'_>) { + if member.status != MembershipStatus::Accepted as i32 + || member.atype != MembershipType::User + || !OrgPolicy::is_auto_confirm_enabled(&member.org_uuid, conn).await + { + return; + } + + for admin in Membership::find_confirmed_and_manage_all_by_org(&member.org_uuid, conn).await { + nt.send_auto_confirm_member(&admin.user_uuid, &member.org_uuid, &member.uuid, &member.user_uuid).await; + } +} + +/// Accepts every open invitation of a user at once, as done when mail is disabled, and notifies for each +/// of them. See [`notify_pending_auto_confirm`]. +pub async fn accept_user_invitations(user_id: &UserId, conn: &DbConn, nt: &Notify<'_>) -> EmptyResult { + let invited: Vec = Membership::find_any_state_by_user(user_id, conn) + .await + .into_iter() + .filter(|m| m.status == MembershipStatus::Invited as i32) + .collect(); + + Membership::accept_user_invitations(user_id, conn).await?; + + for mut member in invited { + member.status = MembershipStatus::Accepted as i32; + notify_pending_auto_confirm(&member, conn, nt).await; + } + + Ok(()) +} + async fn accept_org_invite( user: &User, mut member: Membership, reset_password_key: Option, conn: &DbConn, + nt: &Notify<'_>, ) -> EmptyResult { if member.status != MembershipStatus::Invited as i32 { err!("User already accepted the invitation"); @@ -295,6 +332,8 @@ async fn accept_org_invite( member.save(conn).await?; + notify_pending_auto_confirm(&member, conn, nt).await; + if CONFIG.mail_enabled() { let Some(org) = Organization::find_by_uuid(&member.org_uuid, conn).await else { err!("Organization not found.") diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 989ca47d..108e1d03 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -9,15 +9,16 @@ use crate::{ api::admin::FAKE_ADMIN_UUID, api::{ EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, - core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor}, + core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, notify_pending_auto_confirm, two_factor}, }, auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite}, db::{ DbConn, models::{ - Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, - Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, - OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, UserId, + Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, + EmergencyAccess, EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, + MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, + OrganizationId, User, UserId, }, }, mail, @@ -55,6 +56,9 @@ pub fn routes() -> Vec { bulk_reinvite_members, confirm_invite, bulk_confirm_invite, + get_pending_auto_confirm_members, + auto_confirm_member, + bulk_auto_confirm_members, accept_invite, get_org_user_mini_details, get_user, @@ -1045,6 +1049,7 @@ async fn send_invite( data: Json, headers: AdminHeaders, conn: DbConn, + nt: Notify<'_>, ) -> EmptyResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); @@ -1120,6 +1125,9 @@ async fn send_invite( new_member.status = member_status; new_member.save(&conn).await?; + // With mail disabled an existing user is accepted right away, so there is no accept request later on + notify_pending_auto_confirm(&new_member, &conn, &nt).await; + if CONFIG.mail_enabled() { let org_name = if let Some(org) = Organization::find_by_uuid(&org_id, &conn).await { org.name @@ -1196,6 +1204,7 @@ async fn bulk_reinvite_members( data: Json, headers: AdminHeaders, conn: DbConn, + nt: Notify<'_>, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); @@ -1204,7 +1213,7 @@ async fn bulk_reinvite_members( let mut bulk_response = Vec::new(); for member_id in data.ids { - let err_msg = match reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await { + let err_msg = match reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn, &nt).await { Ok(()) => String::new(), Err(e) => format!("{e:?}"), }; @@ -1231,11 +1240,12 @@ async fn reinvite_member( member_id: MembershipId, headers: AdminHeaders, conn: DbConn, + nt: Notify<'_>, ) -> EmptyResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } - reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await + reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn, &nt).await } async fn reinvite_member_impl( @@ -1243,6 +1253,7 @@ async fn reinvite_member_impl( member_id: &MembershipId, invited_by_email: &str, conn: &DbConn, + nt: &Notify<'_>, ) -> EmptyResult { let Some(member) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { err!("The user hasn't been invited to the organization.") @@ -1276,6 +1287,7 @@ async fn reinvite_member_impl( let mut member = member; member.status = MembershipStatus::Accepted as i32; member.save(conn).await?; + notify_pending_auto_confirm(&member, conn, nt).await; } Ok(()) @@ -1295,6 +1307,7 @@ async fn accept_invite( data: Json, headers: Headers, conn: DbConn, + nt: Notify<'_>, ) -> EmptyResult { // The web-vault passes org_id and member_id in the URL, but we are just reading them from the JWT instead let data: AcceptData = data.into_inner(); @@ -1333,7 +1346,7 @@ async fn accept_invite( // In case the user was invited before the mail was saved in db. membership.invited_by_email = membership.invited_by_email.or(claims.invited_by_email); - accept_org_invite(&headers.user, membership, reset_password_key, &conn).await?; + accept_org_invite(&headers.user, membership, reset_password_key, &conn, &nt).await?; } else if CONFIG.mail_enabled() { // User was invited from /admin, so they are automatically confirmed let org_name = CONFIG.invitation_org_name(); @@ -1428,7 +1441,7 @@ async fn confirm_invite_impl( err!("Key or UserId is not set, unable to process request"); } - let Some(mut member_to_confirm) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { + let Some(member_to_confirm) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { err!("The specified user isn't a member of the organization") }; @@ -1436,6 +1449,20 @@ async fn confirm_invite_impl( err!("Only Owners can confirm Managers, Admins or Owners") } + confirm_member(member_to_confirm, key, headers, conn, nt).await +} + +/// Shared by the manual and the automatic confirmation, both hand us the organization key encrypted +/// with the public key of the member to confirm. +async fn confirm_member( + mut member_to_confirm: Membership, + key: &str, + headers: &AdminHeaders, + conn: &DbConn, + nt: &Notify<'_>, +) -> EmptyResult { + let org_id = member_to_confirm.org_uuid.clone(); + if member_to_confirm.status != MembershipStatus::Accepted as i32 { err!("User in invalid state") } @@ -1449,7 +1476,7 @@ async fn confirm_invite_impl( log_event( EventType::OrganizationUserConfirmed as i32, &member_to_confirm.uuid, - org_id, + &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, @@ -1458,7 +1485,7 @@ async fn confirm_invite_impl( .await; if CONFIG.mail_enabled() { - let org_name = if let Some(org) = Organization::find_by_uuid(org_id, conn).await { + let org_name = if let Some(org) = Organization::find_by_uuid(&org_id, conn).await { org.name } else { err!("Error looking up organization.") @@ -1480,6 +1507,138 @@ async fn confirm_invite_impl( save_result } +// Automatic user confirmation. The server can never confirm a member by itself, confirming means +// encrypting the organization key with the public key of the member and the server does not have the +// organization key. So all we do here is telling an admin client which members are waiting, the client +// does the actual work in the background. +// https://bitwarden.com/help/automatic-confirmation/ + +/// Only a member which accepted its invitation and holds the plain User role is ever confirmed without +/// a human looking at it. Every elevated role keeps needing a manual confirmation by an Owner, and an +/// Owner can not lift that restriction here like it can for the manual confirmation. +fn may_be_confirmed_automatically(member: &Membership) -> bool { + member.status == MembershipStatus::Accepted as i32 && member.atype == MembershipType::User +} + +#[get("/organizations//users/pending-auto-confirm")] +async fn get_pending_auto_confirm_members(org_id: OrganizationId, headers: AdminHeaders, conn: DbConn) -> JsonResult { + if org_id != headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } + + // Bitwarden responds with an empty list instead of an error when the feature or the policy is off. + let members = if OrgPolicy::is_auto_confirm_enabled(&org_id, &conn).await { + Membership::find_by_org(&org_id, &conn) + .await + .into_iter() + .filter(may_be_confirmed_automatically) + .map(|m| { + json!({ + "object": "organizationUserPendingAutoConfirm", + "id": m.uuid, + "userId": m.user_uuid, + }) + }) + .collect() + } else { + Vec::new() + }; + + Ok(Json(json!({ + "data": members, + "object": "list", + "continuationToken": null + }))) +} + +#[post("/organizations//users//auto-confirm", data = "")] +async fn auto_confirm_member( + org_id: OrganizationId, + member_id: MembershipId, + data: Json, + headers: AdminHeaders, + conn: DbConn, + nt: Notify<'_>, +) -> EmptyResult { + let data = data.into_inner(); + let user_key = data.key.unwrap_or_default(); + auto_confirm_member_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await +} + +#[post("/organizations//users/bulk-auto-confirm", data = "")] +async fn bulk_auto_confirm_members( + org_id: OrganizationId, + data: Json, + headers: AdminHeaders, + conn: DbConn, + nt: Notify<'_>, +) -> JsonResult { + if org_id != headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } + let data = data.into_inner(); + + let mut bulk_response = Vec::new(); + match data.keys { + Some(keys) => { + for member in keys { + let member_id = member.id.unwrap(); + let user_key = member.key.unwrap_or_default(); + let err_msg = match auto_confirm_member_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await + { + Ok(()) => String::new(), + Err(e) => format!("{e:?}"), + }; + + bulk_response.push(json!( + { + "object": "OrganizationBulkConfirmResponseModel", + "id": member_id, + "error": err_msg + } + )); + } + } + None => error!("No keys to confirm"), + } + + Ok(Json(json!({ + "data": bulk_response, + "object": "list", + "continuationToken": null + }))) +} + +async fn auto_confirm_member_impl( + org_id: &OrganizationId, + member_id: &MembershipId, + key: &str, + headers: &AdminHeaders, + conn: &DbConn, + nt: &Notify<'_>, +) -> EmptyResult { + if org_id != &headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } + if key.is_empty() || member_id.is_empty() { + err!("Key or UserId is not set, unable to process request"); + } + + if !OrgPolicy::is_auto_confirm_enabled(org_id, conn).await { + err!("Automatic user confirmation is not enabled for this organization") + } + + let Some(member_to_confirm) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { + err!("The specified user isn't a member of the organization") + }; + + if !may_be_confirmed_automatically(&member_to_confirm) { + err!("This member can not be confirmed automatically") + } + + confirm_member(member_to_confirm, key, headers, conn, nt).await +} + #[get("/organizations//users/mini-details", rank = 1)] async fn get_org_user_mini_details(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { if org_id != headers.membership.org_uuid { @@ -2113,6 +2272,53 @@ async fn put_policy( } } + // The automatic user confirmation policy hands out organization access without anybody looking at it, + // so it needs to be allowed by the server first and it requires the Single Org policy on top. + // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyEventHandlers/AutomaticUserConfirmationPolicyEventHandler.cs + if pol_type_enum == OrgPolicyType::AutomaticUserConfirmation && data.enabled { + if !CONFIG.org_auto_confirm_enabled() { + err!("Automatic user confirmation is not enabled on this server.") + } + + let single_org_policy_enabled = + match OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::SingleOrg, &conn).await { + Some(p) => p.enabled, + None => false, + }; + + if !single_org_policy_enabled { + err!("Single Organization policy is not enabled. It is mandatory for this policy to be enabled.") + } + + // Every member has to be compliant already. Contrary to the Single Org policy below we do not revoke + // the members that are not, because this policy also applies to owners and admins and revoking those + // could lock the organization out of itself. + let members = Membership::find_by_org(&org_id, &conn).await; + for member in &members { + if member.status != MembershipStatus::Invited as i32 + && Membership::count_accepted_and_confirmed_by_user(&member.user_uuid, &org_id, &conn).await > 0 + { + err!("This policy forbids members to be part of other organizations, but at least one member still is.") + } + } + + // Emergency access would hand the account of a member to somebody outside of the control of this + // organization, which defeats the point of vetting members. Bitwarden drops these grants when the + // policy is turned on, and blocks new ones while it is on (see `emergency_access.rs`). + for member in &members { + info!("Removing emergency access of {} because automatic user confirmation was enabled", member.user_uuid); + EmergencyAccess::delete_all_by_user(&member.user_uuid, &conn).await?; + } + } + + // Also prevent the Single Org policy to be disabled while automatic user confirmation depends on it + if pol_type_enum == OrgPolicyType::SingleOrg + && !data.enabled + && OrgPolicy::is_auto_confirm_enabled(&org_id, &conn).await + { + err!("Automatic user confirmation is enabled. It is not allowed to disable this policy.") + } + // When enabling the TwoFactorAuthentication policy, revoke all members that do not have 2FA if pol_type_enum == OrgPolicyType::TwoFactorAuthentication && data.enabled { two_factor::enforce_2fa_policy_for_org( @@ -3251,3 +3457,32 @@ async fn rotate_api_key( ) -> JsonResult { api_key(&org_id, data, true, headers, conn).await } + +#[cfg(test)] +mod tests { + use super::*; + + /// Automatic confirmation hands out access to the organization vault without anybody looking at it, + /// so it must stay limited to plain members which actually accepted their invitation. + #[test] + fn only_accepted_plain_members_are_confirmed_automatically() { + let mut member = + Membership::new(UserId::from(String::from("user")), OrganizationId::from(String::from("org")), None); + + for status in + [MembershipStatus::Revoked as i32, MembershipStatus::Invited as i32, MembershipStatus::Confirmed as i32] + { + member.status = status; + assert!(!may_be_confirmed_automatically(&member), "status {status} must not qualify"); + } + + member.status = MembershipStatus::Accepted as i32; + for atype in [MembershipType::Owner as i32, MembershipType::Admin as i32, MembershipType::Manager as i32] { + member.atype = atype; + assert!(!may_be_confirmed_automatically(&member), "type {atype} must not qualify"); + } + + member.atype = MembershipType::User as i32; + assert!(may_be_confirmed_automatically(&member)); + } +} diff --git a/src/api/identity.rs b/src/api/identity.rs index 9212ed8d..91f660db 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -12,7 +12,7 @@ use serde_json::Value; use crate::{ CONFIG, api::{ - ApiResult, EmptyResult, JsonResult, + ApiResult, EmptyResult, JsonResult, Notify, core::{ accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register}, log_user_event, @@ -1034,8 +1034,8 @@ async fn prelogin_password(data: Json, conn: DbConn) -> Json, conn: DbConn) -> JsonResult { - register(data, false, conn).await +async fn identity_register(data: Json, conn: DbConn, nt: Notify<'_>) -> JsonResult { + register(data, false, conn, nt).await } #[derive(Debug, Deserialize)] @@ -1098,8 +1098,8 @@ async fn register_verification_email( } #[post("/accounts/register/finish", data = "")] -async fn register_finish(data: Json, conn: DbConn) -> JsonResult { - register(data, true, conn).await +async fn register_finish(data: Json, conn: DbConn, nt: Notify<'_>) -> JsonResult { + register(data, true, conn, nt).await } // https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts diff --git a/src/api/notifications.rs b/src/api/notifications.rs index 8bfcd518..d545fd02 100644 --- a/src/api/notifications.rs +++ b/src/api/notifications.rs @@ -15,7 +15,10 @@ use crate::{ auth::{ClientIp, WsAccessTokenHeader}, db::{ DbConn, - models::{AuthRequestId, Cipher, CollectionId, Device, DeviceId, Folder, PushId, Send as DbSend, User, UserId}, + models::{ + AuthRequestId, Cipher, CollectionId, Device, DeviceId, Folder, MembershipId, OrganizationId, PushId, + Send as DbSend, User, UserId, + }, }, }; @@ -510,6 +513,33 @@ impl WebSocketUsers { } } + /// Tells the clients of `recipient_id` that `member_id` accepted an invitation and is waiting to be + /// confirmed. Only the browser extension acts upon this, it holds the organization key needed to + /// confirm the member, which the server never has. Because of that this is WebSocket only. + /// https://github.com/bitwarden/clients/blob/main/libs/auto-confirm/README.md + pub async fn send_auto_confirm_member( + &self, + recipient_id: &UserId, + org_id: &OrganizationId, + member_id: &MembershipId, + member_user_id: &UserId, + ) { + if !CONFIG.enable_websocket() { + return; + } + let data = create_update( + vec![ + ("UserId".into(), recipient_id.to_string().into()), + ("OrganizationId".into(), org_id.to_string().into()), + ("TargetUserId".into(), member_user_id.to_string().into()), + ("TargetOrganizationUserId".into(), member_id.to_string().into()), + ], + UpdateType::AutoConfirmMember, + None, + ); + self.send_update(recipient_id, &data).await; + } + pub async fn send_auth_request(&self, user_id: &UserId, auth_request_uuid: &str, device: &Device, conn: &DbConn) { // Skip any processing if both WebSockets and Push are not active if *NOTIFICATIONS_DISABLED { @@ -700,6 +730,13 @@ pub enum UpdateType { // NotificationStatus = 21, // Not supported // RefreshSecurityTasks = 22, // Not supported + + // OrganizationBankAccountVerified = 23, // Not supported (Not AGPLv3 Licensed) + // ProviderBankAccountVerified = 24, // Not supported (Not AGPLv3 Licensed) + + // SyncPolicy = 25, // Not supported + AutoConfirmMember = 26, + // PremiumStatusChanged = 27, // Not supported None = 100, } diff --git a/src/config.rs b/src/config.rs index c4457478..54067adf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -790,6 +790,11 @@ make_config! { /// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!). org_groups_enabled: bool, false, def, false; + /// Enable automatic user confirmation (Know the risks!) |> Allows organizations to enable the automatic user confirmation policy. + /// Members which accepted an invitation are then confirmed unattended by the browser extension of an unlocked admin, + /// without any human reviewing the invitation. Bitwarden only enables this per organization on request, we keep it off by default. + org_auto_confirm_enabled: bool, false, def, false; + /// Increase note size limit (Know the risks!) |> Sets the secure note size limit to 100_000 instead of the default 10_000. /// WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers! increase_note_size_limit: bool, true, def, false; diff --git a/src/db/models/org_policy.rs b/src/db/models/org_policy.rs index 88b7872c..94e58591 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -47,7 +47,7 @@ pub enum OrgPolicyType { RestrictedItemTypes = 15, UriMatchDefaults = 16, // AutotypeDefaultSetting = 17, // Not supported yet - // AutoConfirm = 18, // Not supported (not implemented yet) + AutomaticUserConfirmation = 18, // BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed) } @@ -280,6 +280,50 @@ impl OrgPolicy { false } + /// Returns true if the user is a member of an organization other than `exclude_org_uuid` which has the + /// automatic user confirmation policy enabled. Contrary to `is_applicable_to_user` this does not exempt + /// owners and admins, the policy applies to every role and every status. + /// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs + pub async fn auto_confirm_enabled_for_other_org( + user_uuid: &UserId, + exclude_org_uuid: &OrganizationId, + conn: &DbConn, + ) -> bool { + CONFIG.org_auto_confirm_enabled() + && Self::find_accepted_and_confirmed_by_user_and_active_policy( + user_uuid, + OrgPolicyType::AutomaticUserConfirmation, + conn, + ) + .await + .iter() + .any(|policy| &policy.org_uuid != exclude_org_uuid) + } + + /// Returns true if the user is a member of an organization which confirms its members automatically. + /// Such a membership also restricts what the user may do outside of that organization. + pub async fn is_user_in_auto_confirm_org(user_uuid: &UserId, conn: &DbConn) -> bool { + CONFIG.org_auto_confirm_enabled() + && !Self::find_accepted_and_confirmed_by_user_and_active_policy( + user_uuid, + OrgPolicyType::AutomaticUserConfirmation, + conn, + ) + .await + .is_empty() + } + + /// Returns true if members of this organization may be confirmed automatically. This requires both the + /// server wide config option and the policy of this organization to be enabled, which mirrors Bitwarden + /// where the organization needs the feature enabled by support on top of the policy. + pub async fn is_auto_confirm_enabled(org_uuid: &OrganizationId, conn: &DbConn) -> bool { + CONFIG.org_auto_confirm_enabled() + && match Self::find_by_org_and_type(org_uuid, OrgPolicyType::AutomaticUserConfirmation, conn).await { + Some(p) => p.enabled, + None => false, + } + } + pub async fn check_user_allowed(m: &Membership, action: &str, conn: &DbConn) -> EmptyResult { if m.atype < MembershipType::Admin && m.status > (MembershipStatus::Invited as i32) { // Enforce TwoFactor/TwoStep login @@ -313,6 +357,26 @@ impl OrgPolicy { } } + // The automatic user confirmation policy is a stricter variant of the SingleOrg policy, it does not + // exempt owners and admins and it applies to every status. Therefore it is checked outside of the + // block above. + // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementHandler.cs + if Self::auto_confirm_enabled_for_other_org(&m.user_uuid, &m.org_uuid, conn).await { + err!(format!( + "Cannot {} because another organization confirms its members automatically and forbids other memberships (membership {})", + action, m.uuid + )); + } + + if Self::is_auto_confirm_enabled(&m.org_uuid, conn).await + && Membership::count_accepted_and_confirmed_by_user(&m.user_uuid, &m.org_uuid, conn).await > 0 + { + err!(format!( + "Cannot {} because the organization confirms its members automatically and forbids being part of other organizations (membership {})", + action, m.uuid + )); + } + Ok(()) } diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index bdb69864..df7473ea 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -204,6 +204,7 @@ impl Organization { "maxCollections": null, "maxStorageGb": i16::MAX, // The value doesn't matter, we don't check server-side "use2fa": true, + "useAutomaticUserConfirmation": CONFIG.org_auto_confirm_enabled(), "useCustomPermissions": true, "useDirectory": false, // Is supported, but this value isn't checked anywhere (yet) "useEvents": CONFIG.org_events_enabled(), @@ -498,6 +499,7 @@ impl Membership { "useKeyConnector": false, "useSecretsManager": false, // Not supported (Not AGPLv3 Licensed) "usePasswordManager": true, + "useAutomaticUserConfirmation": CONFIG.org_auto_confirm_enabled(), "useCustomPermissions": true, "useActivateAutofillPolicy": false, "useAdminSponsoredFamilies": false,