From 46670df4ba9a03b306a6546cd21e997a3c7b61a5 Mon Sep 17 00:00:00 2001 From: tom27052006 <83423411+tom27052006@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:12:52 +0200 Subject: [PATCH] Add sync policy notifications --- src/api/core/organizations.rs | 12 +++++- src/api/notifications.rs | 69 +++++++++++++++++++++++++++++++++-- src/api/push.rs | 29 ++++++++++++++- 3 files changed, 105 insertions(+), 5 deletions(-) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 36297d30..c2ae3880 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -2082,6 +2082,7 @@ async fn put_policy( pol_type: i32, data: Json, headers: AdminHeaders, + nt: Notify<'_>, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { @@ -2093,6 +2094,11 @@ async fn put_policy( err!("Invalid or unsupported policy type") }; + // Collect the members before applying the policy, since enabling the TwoFactorAuthentication or + // SingleOrg policy revokes members below, and those clients need to know about the change too. + let member_ids: Vec = + Membership::find_confirmed_by_org(&org_id, &conn).await.into_iter().map(|m| m.user_uuid).collect(); + // Bitwarden only allows the Reset Password policy when Single Org policy is enabled // Vaultwarden encouraged to use multiple orgs instead of groups because groups were not available in the past // Now that groups are available we can enforce this option when wanted. @@ -2192,6 +2198,9 @@ async fn put_policy( ) .await; + // Let the members sync, so the new policy applies without waiting for the next periodic sync + nt.send_policy_update(&policy, &member_ids, &conn).await; + Ok(Json(policy.to_json())) } @@ -2202,9 +2211,10 @@ async fn put_policy_vnext( pol_type: i32, data: Json, headers: AdminHeaders, + nt: Notify<'_>, conn: DbConn, ) -> JsonResult { - put_policy(org_id, pol_type, data, headers, conn).await + put_policy(org_id, pol_type, data, headers, nt, conn).await } #[get("/plans")] diff --git a/src/api/notifications.rs b/src/api/notifications.rs index 8bfcd518..58ffed93 100644 --- a/src/api/notifications.rs +++ b/src/api/notifications.rs @@ -15,13 +15,16 @@ 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, OrgPolicy, PushId, Send as DbSend, User, + UserId, + }, }, }; use super::{ - push::push_auth_request, push::push_auth_response, push_cipher_update, push_folder_update, push_logout, - push_send_update, push_user_update, + push::push_auth_request, push::push_auth_response, push::push_policy_update, push_cipher_update, + push_folder_update, push_logout, push_send_update, push_user_update, }; pub static WS_USERS: LazyLock> = LazyLock::new(|| { @@ -510,6 +513,33 @@ impl WebSocketUsers { } } + pub async fn send_policy_update(&self, policy: &OrgPolicy, user_ids: &[UserId], conn: &DbConn) { + // Skip any processing if both WebSockets and Push are not active + if *NOTIFICATIONS_DISABLED { + return; + } + debug!( + "Sending SyncPolicy ({}) for policy type {} of organization {} to {} member(s)", + UpdateType::SyncPolicy as i32, + policy.atype, + policy.org_uuid, + user_ids.len() + ); + if CONFIG.enable_websocket() { + let data = create_policy_update(policy); + + for user_id in user_ids { + self.send_update(user_id, &data).await; + } + } + + if CONFIG.push_enabled() { + for user_id in user_ids { + push_policy_update(policy, user_id, conn).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 { @@ -642,6 +672,32 @@ fn create_update(payload: Vec<(Value, Value)>, ut: UpdateType, acting_device_id: serialize(&value) } +// Follows upstream's `SyncPolicyPushNotification`, with the fields Vaultwarden stores. +// `Data` is sent as the raw string, like upstream's policy entity does, and there is no +// revision date because Vaultwarden does not keep one per policy. +// No acting device: upstream does not exclude it either, so the client which changed the policy syncs too. +fn create_policy_update(policy: &OrgPolicy) -> Vec { + use rmpv::Value as V; + + create_update( + vec![ + ("OrganizationId".into(), policy.org_uuid.to_string().into()), + ( + "Policy".into(), + V::Map(vec![ + ("Id".into(), policy.uuid.as_ref().as_str().into()), + ("OrganizationId".into(), policy.org_uuid.to_string().into()), + ("Type".into(), policy.atype.into()), + ("Data".into(), policy.data.as_str().into()), + ("Enabled".into(), policy.enabled.into()), + ]), + ), + ], + UpdateType::SyncPolicy, + None, + ) +} + fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id: &UserId) -> Vec { use rmpv::Value as V; @@ -700,6 +756,13 @@ pub enum UpdateType { // NotificationStatus = 21, // Not supported // RefreshSecurityTasks = 22, // Not supported + + // OrganizationBankAccountVerified = 23, // Not supported + // ProviderBankAccountVerified = 24, // Not supported + + // Upstream calls this `PolicyChanged`, the clients call it `SyncPolicy` + SyncPolicy = 25, + None = 100, } diff --git a/src/api/push.rs b/src/api/push.rs index e87a0985..67749bf1 100644 --- a/src/api/push.rs +++ b/src/api/push.rs @@ -15,7 +15,7 @@ use crate::{ api::{ApiResult, EmptyResult, UpdateType}, db::{ DbConn, - models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId}, + models::{AuthRequestId, Cipher, Device, Folder, OrgPolicy, PushId, Send, User, UserId}, }, http_client::make_http_request, util::{format_date, get_uuid}, @@ -264,6 +264,33 @@ pub async fn push_send_update(ut: UpdateType, send: &Send, device: &Device, conn } } +// Vaultwarden does not register the `organizationIds` of a device with the push relay (see +// `register_push_device`), so the members are notified one by one instead of the organization. +// The payload matches the one sent over the WebSocket, see `create_policy_update`. +pub async fn push_policy_update(policy: &OrgPolicy, user_id: &UserId, conn: &DbConn) { + if Device::check_user_has_push_device(user_id, conn).await { + tokio::task::spawn(send_to_push_relay(json!({ + "userId": user_id, + "organizationId": null, + "deviceId": null, // All devices of this user need to know about the change + "identifier": null, + "type": UpdateType::SyncPolicy as i32, + "payload": { + "organizationId": policy.org_uuid, + "policy": { + "id": policy.uuid, + "organizationId": policy.org_uuid, + "type": policy.atype, + "data": policy.data, + "enabled": policy.enabled, + } + }, + "clientType": null, + "installationId": null + }))); + } +} + async fn send_to_push_relay(notification_data: Value) { if !CONFIG.push_enabled() { return;