Browse Source

Add the Send controls policy

pull/7519/head
tom27052006 3 days ago
parent
commit
66e13c966d
  1. 116
      src/api/core/organizations.rs
  2. 64
      src/api/core/sends.rs
  3. 2
      src/config.rs
  4. 4
      src/db/models/mod.rs
  5. 98
      src/db/models/org_policy.rs

116
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,
@ -2172,6 +2172,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()),
@ -2181,6 +2185,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(),
@ -2195,6 +2201,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::<Option<SendControlsPolicyData>>(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::<SendOptionsPolicyData>(&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/<org_id>/policies/<pol_type>/vnext", data = "<data>")]
async fn put_policy_vnext(
@ -3285,3 +3375,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}");
}
}
}

64
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<Send> {
let data_val = if data.r#type == SendType::Text as i32 {
data.text
@ -199,6 +249,7 @@ async fn post_send(data: Json<SendData>, 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<UploadData<'_>>, 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<SendData>, 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<SendData>, 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")
};

2
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",
];

4
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,

98
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<i32>,
#[serde(rename = "allowedDomains", alias = "AllowedDomains", default)]
pub allowed_domains: Option<String>,
#[serde(rename = "deletionHours", alias = "DeletionHours", default)]
pub deletion_hours: Option<i32>,
#[serde(rename = "allowedSendTypes", alias = "AllowedSendTypes", default)]
pub allowed_send_types: Option<Vec<i32>>,
}
impl SendControlsPolicyData {
pub fn required_access_type(&self) -> Option<SendWhoCanAccessType> {
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::<SendControlsPolicyData>(&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:?}");
}
}
}

Loading…
Cancel
Save