diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 046be793..95b06dfc 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -17,8 +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, TwoFactor, TwoFactorType, User, - UserId, + OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, SendControlsPolicyData, + SendOptionsPolicyData, SendWhoCanAccessType, TwoFactor, TwoFactorType, User, UserId, }, }, mail, @@ -2178,6 +2178,10 @@ async fn put_policy( } } + if pol_type_enum == OrgPolicyType::SendControls && data.enabled { + validate_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()), @@ -2187,6 +2191,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, policy.uuid.as_ref(), @@ -2201,6 +2207,90 @@ async fn put_policy( Ok(Json(policy.to_json())) } +fn validate_send_controls(data: Option<&Value>) -> EmptyResult { + let data = match serde_json::from_value::>(data.cloned().unwrap_or_default()) { + Ok(data) => data.unwrap_or_default(), + Err(e) => err!(format!("Invalid Send controls policy data: {e}")), + }; + + // Vaultwarden rejects Sends carrying recipient emails, so members could not create any Send. + 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 allows domains together with the specific people type, ruled out above. + 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(()) +} + +/// `Send controls` absorbs the legacy `DisableSend` and `Send Options` policies. Like upstream we +/// mirror changes in both directions so older clients keep enforcing the same rules and a rollback +/// stays safe; enforcement in `sends.rs` therefore stays 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. + let mut disable_send = find_or_new_policy(org_id, OrgPolicyType::DisableSend, conn).await; + disable_send.enabled = saved.enabled && data.disable_send; + disable_send.save(conn).await?; + + let mut send_options = find_or_new_policy(org_id, OrgPolicyType::SendOptions, conn).await; + send_options.enabled = saved.enabled && data.disable_hide_email; + send_options.data = serde_json::to_string(&SendOptionsPolicyData { + disable_hide_email: data.disable_hide_email, + })?; + send_options.save(conn).await?; + } + OrgPolicyType::DisableSend | OrgPolicyType::SendOptions => { + 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; + + // Keep every restriction that only exists on the Send controls policy. + let mut controls = find_or_new_policy(org_id, OrgPolicyType::SendControls, conn).await; + let mut data = controls.send_controls_data(); + 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); + + controls.enabled = disable_send || send_options.is_some_and(|p| p.enabled); + controls.data = serde_json::to_string(&data)?; + controls.save(conn).await?; + } + _ => (), + } + + Ok(()) +} + +async fn find_or_new_policy(org_id: &OrganizationId, pol_type: OrgPolicyType, conn: &DbConn) -> OrgPolicy { + 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()), + } +} + // Deprecated with client v2026.5.0 #[put("/organizations//policies//vnext", data = "")] async fn put_policy_vnext( @@ -3291,3 +3381,25 @@ async fn rotate_api_key( ) -> JsonResult { api_key(&org_id, data, true, headers, conn).await } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn send_controls_validation_rejects_only_the_unsupported_restrictions() { + let validate = |json: &str| validate_send_controls(Some(&serde_json::from_str(json).unwrap())); + + for invalid in [r#"{"whoCanAccess":2}"#, r#"{"allowedDomains":"a.b"}"#, r#"{"deletionHours":0}"#, "1"] { + assert!(validate(invalid).is_err(), "should reject {invalid}"); + } + // What the web vault sends, and the two fields only reachable through the API. + for valid in [ + r#"{"disableSend":true,"disableHideEmail":true,"whoCanAccess":1,"allowedDomains":null}"#, + r#"{"deletionHours":1,"allowedSendTypes":[0]}"#, + "null", + ] { + assert!(validate(valid).is_ok(), "should accept {valid}"); + } + } +} diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs index 042ce95b..dfe28fa1 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,56 @@ 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` stay with the two functions above, which are mirrored into `Send controls`. +/// `existing` is the Send being updated: the client may leave the password out to keep the existing +/// one, and the deletion date is measured against the date the Send was originally created on. +/// +/// Ref: https://github.com/bitwarden/server/blob/main/src/Core/Tools/SendFeatures/Services/SendValidationService.cs +async fn enforce_send_controls_policy( + data: &SendData, + existing: Option<&Send>, + headers: &Headers, + conn: &DbConn, +) -> EmptyResult { + let has_password = data.password.is_some() || existing.is_some_and(|s| s.password_hash.is_some()); + let creation_date = existing.map_or_else(Utc::now, |s| s.creation_date.and_utc()); + 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 carrying recipient emails, so this can never be satisfied. + // `put_policy` refuses to store such a policy; this only guards rows written outside it. + 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 +249,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, None, &headers, &conn).await?; if data.r#type == SendType::File as i32 { err!("File sends should use /api/sends/file") @@ -252,6 +303,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, None, &headers, &conn).await?; let size_limit = match CONFIG.user_send_limit() { Some(0) => err!("File uploads are disabled"), @@ -317,6 +369,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, None, &headers, &conn).await?; let file_length = if let Some(m) = &data.file_length { m.into_i64()? @@ -637,6 +690,8 @@ async fn put_send(send_id: SendId, data: Json, headers: Headers, conn: err!("Sends with email verification is not supported"); } + enforce_send_controls_policy(&data, Some(&send), &headers, &conn).await?; + update_send_from_data(&mut send, data, &headers, &conn, &nt, UpdateType::SyncSendUpdate).await?; Ok(Json(send.to_json())) @@ -723,6 +778,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 9f0ae2e1..b5760b71 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1443,6 +1443,8 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[ "pm-34171-card-scanner", // Platform Team "pm-30529-webauthn-related-origins", + // Tools Team + "pm-31885-send-controls", // Vault Team "pm-32009-new-item-types", ]; diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 0e4073a5..0409a3b2 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 94e53cbd..633edcd5 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -50,16 +50,52 @@ pub enum OrgPolicyType { // AutoConfirm = 18, // Not supported (not implemented yet) // BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed) OrganizationUserNotification = 20, + 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 shipped web vault only renders the first four fields; the last two already exist 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")] @@ -381,6 +417,39 @@ impl OrgPolicy { false } + /// Reads the `Send controls` data, falling back to the defaults when the stored data is missing + /// or unreadable: 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. Only + /// the restrictions enforced in `sends.rs` are merged, each from the first organization that sets + /// it like upstreams `SendControlsPolicyRequirementFactory`. + 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.who_can_access = result.who_can_access.or(data.who_can_access); + 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 @@ -393,3 +462,30 @@ impl OrgPolicy { #[derive(Clone, Debug, AsRef, DieselNewType, From, FromForm, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct OrgPolicyId(String); + +#[cfg(test)] +mod tests { + use super::*; + + fn send_controls(data: &str) -> SendControlsPolicyData { + OrgPolicy::new(OrganizationId::from(String::new()), OrgPolicyType::SendControls, true, data.to_owned()) + .send_controls_data() + } + + #[test] + fn send_controls_data_parses_client_payloads_and_pascal_case_aliases() { + let data = send_controls(r#"{"disableSend":true,"disableHideEmail":true,"whoCanAccess":1,"deletionHours":2}"#); + assert!(data.disable_send && data.disable_hide_email && data.deletion_hours == Some(2)); + assert!(data.required_access_type() == Some(SendWhoCanAccessType::PasswordProtected)); + let data = send_controls(r#"{"DisableSend":true,"allowedSendTypes":[1]}"#); + assert!(data.disable_send && !data.disable_hide_email && data.allowed_send_types == Some(vec![1])); + } + + #[test] + fn a_policy_without_readable_data_restricts_nothing() { + let unrestricted = serde_json::to_value(SendControlsPolicyData::default()).unwrap(); + for unreadable in ["null", "", r#"{"whoCanAccess":"1"}"#] { + assert_eq!(serde_json::to_value(send_controls(unreadable)).unwrap(), unrestricted, "{unreadable:?}"); + } + } +}