diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 989ca47d..81e97628 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -8,7 +8,7 @@ use crate::{ CONFIG, api::admin::FAKE_ADMIN_UUID, api::{ - EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, + ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor}, }, auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite}, @@ -17,7 +17,8 @@ use crate::{ models::{ Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, - OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, UserId, + OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, SendControlsPolicyData, + SendOptionsPolicyData, SendWhoCanAccessType, User, UserId, }, }, mail, @@ -2160,6 +2161,10 @@ async fn put_policy( } } + if pol_type_enum == OrgPolicyType::SendControls && data.enabled { + validate_send_controls(&parse_send_controls(data.data.as_ref())?)?; + } + let mut policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await { Some(p) => p, None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_owned()), @@ -2169,6 +2174,8 @@ async fn put_policy( policy.data = serde_json::to_string(&data.data)?; policy.save(&conn).await?; + sync_send_policies(pol_type_enum, &policy, &org_id, &conn).await?; + log_event( EventType::PolicyUpdated as i32, policy.uuid.as_ref(), @@ -2183,6 +2190,124 @@ async fn put_policy( Ok(Json(policy.to_json())) } +fn parse_send_controls(data: Option<&Value>) -> ApiResult { + match data { + None | Some(Value::Null) => Ok(SendControlsPolicyData::default()), + Some(value) => match serde_json::from_value::(value.clone()) { + Ok(parsed) => Ok(parsed), + Err(e) => err!(format!("Invalid Send controls policy data: {e}")), + }, + } +} + +fn validate_send_controls(data: &SendControlsPolicyData) -> EmptyResult { + // Vaultwarden rejects Sends that carry recipient emails, so requiring email verification would + // leave the members of this organization unable to create any Send at all. + if data.required_access_type() == Some(SendWhoCanAccessType::SpecificPeople) { + err!("Sends with email verification are not supported, so that access type cannot be required") + } + + // Upstream only accepts allowed domains together with the specific people access type, which + // the check above already rules out. + if data.allowed_domains.is_some() { + err!("Allowed domains can only be set when the required access type is set to specific people") + } + + // A non positive value would mean no Send could ever satisfy the policy. + if data.deletion_hours.is_some_and(|hours| hours < 1) { + err!("The maximum lifetime of a Send has to be at least one hour") + } + + Ok(()) +} + +/// The `Send controls` policy is a container that absorbs the older `DisableSend` and +/// `Send Options` policies. Upstream mirrors changes in both directions with dedicated policy event +/// handlers, so that clients which only know the legacy policies keep enforcing the same rules and +/// so that a rollback stays safe. We do the same, which also keeps the existing enforcement in +/// `sends.rs` authoritative for those two flags. +/// +/// Ref: https://github.com/bitwarden/server/blob/main/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyEventHandlers/SendControlsSyncPolicyEvent.cs +async fn sync_send_policies( + pol_type: OrgPolicyType, + saved: &OrgPolicy, + org_id: &OrganizationId, + conn: &DbConn, +) -> EmptyResult { + match pol_type { + OrgPolicyType::SendControls => { + let data = saved.send_controls_data(); + // Upstream leaves the data of the DisableSend policy untouched, it carries no options. + upsert_mirrored_policy(org_id, OrgPolicyType::DisableSend, saved.enabled && data.disable_send, None, conn) + .await?; + + let send_options = SendOptionsPolicyData { + disable_hide_email: data.disable_hide_email, + }; + upsert_mirrored_policy( + org_id, + OrgPolicyType::SendOptions, + saved.enabled && data.disable_hide_email, + Some(serde_json::to_string(&send_options)?), + conn, + ) + .await?; + } + OrgPolicyType::DisableSend | OrgPolicyType::SendOptions => { + // Keep every restriction that only exists on the Send controls policy. + let mut data = match OrgPolicy::find_by_org_and_type(org_id, OrgPolicyType::SendControls, conn).await { + Some(p) => p.send_controls_data(), + None => SendControlsPolicyData::default(), + }; + + let disable_send = OrgPolicy::find_by_org_and_type(org_id, OrgPolicyType::DisableSend, conn) + .await + .is_some_and(|p| p.enabled); + let send_options = OrgPolicy::find_by_org_and_type(org_id, OrgPolicyType::SendOptions, conn).await; + + data.disable_send = disable_send; + // Upstream reads this out of the data of the legacy policy regardless of whether that + // policy is enabled, the enabled flag is only folded into the container below. + data.disable_hide_email = send_options + .as_ref() + .and_then(|p| serde_json::from_str::(&p.data).ok()) + .is_some_and(|d| d.disable_hide_email); + + let enabled = disable_send || send_options.is_some_and(|p| p.enabled); + upsert_mirrored_policy( + org_id, + OrgPolicyType::SendControls, + enabled, + Some(serde_json::to_string(&data)?), + conn, + ) + .await?; + } + _ => (), + } + + Ok(()) +} + +async fn upsert_mirrored_policy( + org_id: &OrganizationId, + pol_type: OrgPolicyType, + enabled: bool, + data: Option, + conn: &DbConn, +) -> EmptyResult { + let mut policy = match OrgPolicy::find_by_org_and_type(org_id, pol_type, conn).await { + Some(p) => p, + None => OrgPolicy::new(org_id.clone(), pol_type, false, "null".to_owned()), + }; + + policy.enabled = enabled; + if let Some(data) = data { + policy.data = data; + } + policy.save(conn).await +} + // Deprecated with client v2026.5.0 #[put("/organizations//policies//vnext", data = "")] async fn put_policy_vnext( @@ -3251,3 +3376,30 @@ async fn rotate_api_key( ) -> JsonResult { api_key(&org_id, data, true, headers, conn).await } + +#[cfg(test)] +mod tests { + use super::*; + + fn send_controls(json: &str) -> SendControlsPolicyData { + serde_json::from_str(json).expect("valid Send controls payload") + } + + #[test] + fn send_controls_rejects_restrictions_vaultwarden_cannot_satisfy() { + // Requiring recipient emails would lock every member out of creating Sends. + assert!(validate_send_controls(&send_controls(r#"{"whoCanAccess":2}"#)).is_err()); + assert!(validate_send_controls(&send_controls(r#"{"allowedDomains":"example.com"}"#)).is_err()); + assert!(validate_send_controls(&send_controls(r#"{"deletionHours":0}"#)).is_err()); + } + + #[test] + fn send_controls_accepts_the_supported_restrictions() { + assert!(validate_send_controls(&send_controls(r#"{"disableSend":true,"disableHideEmail":true}"#)).is_ok()); + assert!( + validate_send_controls(&send_controls(r#"{"whoCanAccess":1,"deletionHours":24,"allowedSendTypes":[0]}"#)) + .is_ok() + ); + assert!(validate_send_controls(&SendControlsPolicyData::default()).is_ok()); + } +} diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs index 042ce95b..06fb3d97 100644 --- a/src/api/core/sends.rs +++ b/src/api/core/sends.rs @@ -16,7 +16,7 @@ use crate::{ config::PathType, db::{ DbConn, DbPool, - models::{Device, OrgPolicy, OrgPolicyType, Send, SendFileId, SendId, SendType, UserId}, + models::{Device, OrgPolicy, OrgPolicyType, Send, SendFileId, SendId, SendType, SendWhoCanAccessType, UserId}, }, util::{NumberOrString, save_temp_file}, }; @@ -129,6 +129,60 @@ async fn enforce_disable_hide_email_policy(data: &SendData, headers: &Headers, c Ok(()) } +/// Enforces the restrictions that only exist on the `Send controls` policy. +/// +/// `DisableSend` and `DisableHideEmail` are deliberately not checked here. Upstream keeps enforcing +/// those two through the legacy `DisableSend` and `Send Options` policies and mirrors them into +/// `Send controls`, which `put_policy` does as well, so the two functions above stay authoritative. +/// +/// `has_password` and `creation_date` describe the Send as it will look after the request: on an +/// update the client may leave the password out to keep the existing one. +/// +/// Ref: https://github.com/bitwarden/server/blob/main/src/Core/Tools/SendFeatures/Services/SendValidationService.cs +async fn enforce_send_controls_policy( + data: &SendData, + has_password: bool, + creation_date: DateTime, + headers: &Headers, + conn: &DbConn, +) -> EmptyResult { + let controls = OrgPolicy::send_controls_for_user(&headers.user.uuid, conn).await; + + match controls.required_access_type() { + Some(SendWhoCanAccessType::PasswordProtected) if !has_password => { + err!("Due to an Enterprise Policy, your Sends have to be protected by a password.") + } + // Vaultwarden rejects Sends that carry recipient emails, so this can never be satisfied. + // `put_policy` already refuses to store such a policy, this only guards rows that were + // written before or outside of that check. + Some(SendWhoCanAccessType::SpecificPeople) => { + err!( + "Due to an Enterprise Policy, your Sends have to be protected by email verification, \ + which is not supported." + ) + } + _ => (), + } + + if let Some(allowed_types) = &controls.allowed_send_types + && !allowed_types.contains(&data.r#type) + { + err!("Due to an Enterprise Policy, your Sends have to be of a type the organization allows.") + } + + if let Some(hours) = controls.deletion_hours { + // Upstream allows for up to a minute of skew between the deletion and the creation date. + let lifetime = data.deletion_date - creation_date - TimeDelta::minutes(1); + if lifetime.num_minutes() > i64::from(hours) * 60 { + err!(format!( + "Due to an Enterprise Policy, the deletion date of your Sends has to be within {hours} hours of their creation date." + )) + } + } + + Ok(()) +} + fn create_send(data: SendData, user_id: UserId) -> ApiResult { let data_val = if data.r#type == SendType::Text as i32 { data.text @@ -199,6 +253,7 @@ async fn post_send(data: Json, headers: Headers, conn: DbConn, nt: Not let data: SendData = data.into_inner(); enforce_disable_hide_email_policy(&data, &headers, &conn).await?; + enforce_send_controls_policy(&data, data.password.is_some(), Utc::now(), &headers, &conn).await?; if data.r#type == SendType::File as i32 { err!("File sends should use /api/sends/file") @@ -252,6 +307,7 @@ async fn post_send_file(data: Form>, headers: Headers, conn: DbCo } enforce_disable_hide_email_policy(&model, &headers, &conn).await?; + enforce_send_controls_policy(&model, model.password.is_some(), Utc::now(), &headers, &conn).await?; let size_limit = match CONFIG.user_send_limit() { Some(0) => err!("File uploads are disabled"), @@ -317,6 +373,7 @@ async fn post_send_file_v2(data: Json, headers: Headers, conn: DbConn) } enforce_disable_hide_email_policy(&data, &headers, &conn).await?; + enforce_send_controls_policy(&data, data.password.is_some(), Utc::now(), &headers, &conn).await?; let file_length = if let Some(m) = &data.file_length { m.into_i64()? @@ -637,6 +694,11 @@ async fn put_send(send_id: SendId, data: Json, headers: Headers, conn: err!("Sends with email verification is not supported"); } + // Leaving out the password keeps the existing one, and the deletion date is measured against + // the date the Send was originally created on. + let has_password = data.password.is_some() || send.password_hash.is_some(); + enforce_send_controls_policy(&data, has_password, send.creation_date.and_utc(), &headers, &conn).await?; + update_send_from_data(&mut send, data, &headers, &conn, &nt, UpdateType::SyncSendUpdate).await?; Ok(Json(send.to_json())) @@ -723,6 +785,13 @@ async fn delete_send(send_id: SendId, headers: Headers, conn: DbConn, nt: Notify async fn put_remove_password(send_id: SendId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { enforce_disable_send_policy(&headers, &conn).await?; + // Removing the password would leave the Send non compliant with a policy that requires one. + if OrgPolicy::send_controls_for_user(&headers.user.uuid, &conn).await.required_access_type() + == Some(SendWhoCanAccessType::PasswordProtected) + { + err!("Due to an Enterprise Policy, your Sends have to be protected by a password.") + } + let Some(mut send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await else { err!("Send not found", "Invalid send uuid, or does not belong to user") }; diff --git a/src/config.rs b/src/config.rs index d5b50146..1db8ff0c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1441,6 +1441,8 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[ "cxp-export-mobile", // Platform Team "pm-30529-webauthn-related-origins", + // Tools Team + "pm-31885-send-controls", ]; impl Config { diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 0ed8ef91..565abc94 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -29,7 +29,9 @@ pub use self::event::{Event, EventType}; pub use self::favorite::Favorite; pub use self::folder::{Folder, FolderCipher, FolderId}; pub use self::group::{CollectionGroup, Group, GroupId, GroupUser}; -pub use self::org_policy::{OrgPolicy, OrgPolicyId, OrgPolicyType}; +pub use self::org_policy::{ + OrgPolicy, OrgPolicyId, OrgPolicyType, SendControlsPolicyData, SendOptionsPolicyData, SendWhoCanAccessType, +}; 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 88b7872c..87d9bb09 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -49,16 +49,54 @@ pub enum OrgPolicyType { // AutotypeDefaultSetting = 17, // Not supported yet // AutoConfirm = 18, // Not supported (not implemented yet) // BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed) + // OrganizationUserNotification = 20, // Not supported (not implemented yet) + SendControls = 21, } // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendOptionsPolicyData.cs#L5 -#[derive(Deserialize)] +#[derive(Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct SendOptionsPolicyData { #[serde(rename = "disableHideEmail", alias = "DisableHideEmail")] pub disable_hide_email: bool, } +// https://github.com/bitwarden/server/blob/main/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendControlsAllowedAccessControl.cs +#[derive(Copy, Clone, Eq, PartialEq, num_derive::FromPrimitive)] +pub enum SendWhoCanAccessType { + Any = 0, + PasswordProtected = 1, + SpecificPeople = 2, +} + +// https://github.com/bitwarden/server/blob/main/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendControlsPolicyData.cs +// +// The `Send controls` policy absorbs `DisableSend` and `SendOptions` and adds restrictions of its +// own. The web vault we ship only renders the first four fields so far, the last two are already +// part of the data model upstream and are parsed and enforced here as well. +#[derive(Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SendControlsPolicyData { + #[serde(rename = "disableSend", alias = "DisableSend", default)] + pub disable_send: bool, + #[serde(rename = "disableHideEmail", alias = "DisableHideEmail", default)] + pub disable_hide_email: bool, + #[serde(rename = "whoCanAccess", alias = "WhoCanAccess", default)] + pub who_can_access: Option, + #[serde(rename = "allowedDomains", alias = "AllowedDomains", default)] + pub allowed_domains: Option, + #[serde(rename = "deletionHours", alias = "DeletionHours", default)] + pub deletion_hours: Option, + #[serde(rename = "allowedSendTypes", alias = "AllowedSendTypes", default)] + pub allowed_send_types: Option>, +} + +impl SendControlsPolicyData { + pub fn required_access_type(&self) -> Option { + self.who_can_access.and_then(num_traits::FromPrimitive::from_i32) + } +} + // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/ResetPasswordDataModel.cs #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -352,6 +390,43 @@ impl OrgPolicy { false } + /// Reads the `Send controls` data of this policy, falling back to the defaults when the stored + /// data is missing or unreadable. A policy row without data means "no restrictions", so a + /// broken payload must not accidentally lock users out of creating Sends. + pub fn send_controls_data(&self) -> SendControlsPolicyData { + if let Ok(data) = serde_json::from_str::(&self.data) { + return data; + } + + if self.data != "null" && !self.data.is_empty() { + error!("Failed to deserialize SendControlsPolicyData: {}", self.data); + } + SendControlsPolicyData::default() + } + + /// Combines the `Send controls` policies of every organization the user is a plain member of. + /// Mirrors upstreams `SendControlsPolicyRequirementFactory`: the two toggles are ORed, the + /// remaining restrictions are taken from the first organization that sets them. + pub async fn send_controls_for_user(user_uuid: &UserId, conn: &DbConn) -> SendControlsPolicyData { + let mut result = SendControlsPolicyData::default(); + for policy in + OrgPolicy::find_confirmed_by_user_and_active_policy(user_uuid, OrgPolicyType::SendControls, conn).await + { + if let Some(user) = Membership::find_confirmed_by_user_and_org(user_uuid, &policy.org_uuid, conn).await + && user.atype < MembershipType::Admin + { + let data = policy.send_controls_data(); + result.disable_send |= data.disable_send; + result.disable_hide_email |= data.disable_hide_email; + result.who_can_access = result.who_can_access.or(data.who_can_access); + result.allowed_domains = result.allowed_domains.or(data.allowed_domains); + result.deletion_hours = result.deletion_hours.or(data.deletion_hours); + result.allowed_send_types = result.allowed_send_types.or(data.allowed_send_types); + } + } + result + } + pub async fn is_enabled_for_member(member_uuid: &MembershipId, policy_type: OrgPolicyType, conn: &DbConn) -> bool { if let Some(member) = Membership::find_by_uuid(member_uuid, conn).await && let Some(policy) = OrgPolicy::find_by_org_and_type(&member.org_uuid, policy_type, conn).await @@ -364,3 +439,44 @@ impl OrgPolicy { #[derive(Clone, Debug, AsRef, DieselNewType, From, FromForm, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct OrgPolicyId(String); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn send_controls_data_parses_the_payload_the_clients_send() { + let data: SendControlsPolicyData = serde_json::from_str( + r#"{"disableSend":true,"disableHideEmail":true,"whoCanAccess":1,"allowedDomains":null}"#, + ) + .unwrap(); + + assert!(data.disable_send); + assert!(data.disable_hide_email); + assert!(data.required_access_type() == Some(SendWhoCanAccessType::PasswordProtected)); + assert!(data.allowed_domains.is_none()); + assert!(data.deletion_hours.is_none()); + assert!(data.allowed_send_types.is_none()); + } + + #[test] + fn send_controls_data_accepts_the_pascal_case_aliases_and_fills_in_defaults() { + let data: SendControlsPolicyData = serde_json::from_str(r#"{"DisableSend":true}"#).unwrap(); + + assert!(data.disable_send); + assert!(!data.disable_hide_email); + assert!(data.required_access_type().is_none()); + } + + #[test] + fn a_policy_without_readable_data_restricts_nothing() { + let org_uuid = OrganizationId::from(String::from("00000000-0000-0000-0000-000000000000")); + let policy = OrgPolicy::new(org_uuid, OrgPolicyType::SendControls, true, "null".to_owned()); + let data = policy.send_controls_data(); + + assert!(!data.disable_send); + assert!(!data.disable_hide_email); + assert!(data.required_access_type().is_none()); + assert!(data.deletion_hours.is_none()); + } +}