From f1886466a91d677570f3f902fe8fa7be8ca473ef Mon Sep 17 00:00:00 2001 From: tom27052006 <83423411+tom27052006@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:37:03 +0000 Subject: [PATCH] Add My Items (default user collections) --- .env.template | 1 + .../down.sql | 0 .../up.sql | 6 + .../down.sql | 0 .../up.sql | 6 + .../down.sql | 0 .../up.sql | 6 + src/api/core/ciphers.rs | 584 ++++++++++++------ src/api/core/events.rs | 7 +- src/api/core/organizations.rs | 418 ++++++++++--- src/config.rs | 1 + src/db/models/cipher.rs | 287 +++++++-- src/db/models/collection.rs | 472 +++++++++++++- src/db/models/event.rs | 2 +- src/db/models/mod.rs | 2 +- src/db/models/org_policy.rs | 10 + src/db/models/organization.rs | 79 ++- src/db/models/user.rs | 28 +- src/db/schema.rs | 2 + src/util.rs | 42 ++ 20 files changed, 1622 insertions(+), 331 deletions(-) create mode 100644 migrations/mysql/2026-09-23-120000_add_default_user_collection/down.sql create mode 100644 migrations/mysql/2026-09-23-120000_add_default_user_collection/up.sql create mode 100644 migrations/postgresql/2026-09-23-120000_add_default_user_collection/down.sql create mode 100644 migrations/postgresql/2026-09-23-120000_add_default_user_collection/up.sql create mode 100644 migrations/sqlite/2026-09-23-120000_add_default_user_collection/down.sql create mode 100644 migrations/sqlite/2026-09-23-120000_add_default_user_collection/up.sql diff --git a/.env.template b/.env.template index 62231776..750ce72d 100644 --- a/.env.template +++ b/.env.template @@ -404,6 +404,7 @@ ## - "pm-30529-webauthn-related-origins": ## - "pm-32009-new-item-types": Enable new item types: Bank Account, Driver's License, and Passport (Clients >= 2026.4.0) ## - "pm-34171-card-scanner": Enable the new card scanner feature on mobile (Android >= 2026.4.1, iOS >= 2026.4.1) +## - "pm-20558-migrate-myvault-to-myitems": On mobile, ask members that the organization data ownership policy applies to, and that still have personal items, to transfer them to their My Items or to leave the organization (Android, iOS) ## - "desktop-ui-migration-milestone-1": Special feature flag for desktop UI (Desktop >= 2026.2.0) ## - "desktop-ui-migration-milestone-2": Special feature flag for desktop UI (Desktop >= 2026.2.0) ## - "desktop-ui-migration-milestone-3": Special feature flag for desktop UI (Desktop >= 2026.2.0) diff --git a/migrations/mysql/2026-09-23-120000_add_default_user_collection/down.sql b/migrations/mysql/2026-09-23-120000_add_default_user_collection/down.sql new file mode 100644 index 00000000..e69de29b diff --git a/migrations/mysql/2026-09-23-120000_add_default_user_collection/up.sql b/migrations/mysql/2026-09-23-120000_add_default_user_collection/up.sql new file mode 100644 index 00000000..4184ea04 --- /dev/null +++ b/migrations/mysql/2026-09-23-120000_add_default_user_collection/up.sql @@ -0,0 +1,6 @@ +-- Older releases ignore both columns after a downgrade: a My Items collection becomes a regular one there, which +-- Owners, Admins and members with access to all collections see with its items. +ALTER TABLE collections ADD COLUMN default_user_uuid CHAR(36); +ALTER TABLE collections ADD COLUMN default_user_collection_email TEXT; +-- A member has at most one My Items collection per organization. NULL (a shared collection) never conflicts. +CREATE UNIQUE INDEX collections_default_user_uuid ON collections (org_uuid, default_user_uuid); diff --git a/migrations/postgresql/2026-09-23-120000_add_default_user_collection/down.sql b/migrations/postgresql/2026-09-23-120000_add_default_user_collection/down.sql new file mode 100644 index 00000000..e69de29b diff --git a/migrations/postgresql/2026-09-23-120000_add_default_user_collection/up.sql b/migrations/postgresql/2026-09-23-120000_add_default_user_collection/up.sql new file mode 100644 index 00000000..7c80ffe1 --- /dev/null +++ b/migrations/postgresql/2026-09-23-120000_add_default_user_collection/up.sql @@ -0,0 +1,6 @@ +-- Older releases ignore both columns after a downgrade: a My Items collection becomes a regular one there, which +-- Owners, Admins and members with access to all collections see with its items. +ALTER TABLE collections ADD COLUMN default_user_uuid TEXT; +ALTER TABLE collections ADD COLUMN default_user_collection_email TEXT; +-- A member has at most one My Items collection per organization. NULL (a shared collection) never conflicts. +CREATE UNIQUE INDEX collections_default_user_uuid ON collections (org_uuid, default_user_uuid); diff --git a/migrations/sqlite/2026-09-23-120000_add_default_user_collection/down.sql b/migrations/sqlite/2026-09-23-120000_add_default_user_collection/down.sql new file mode 100644 index 00000000..e69de29b diff --git a/migrations/sqlite/2026-09-23-120000_add_default_user_collection/up.sql b/migrations/sqlite/2026-09-23-120000_add_default_user_collection/up.sql new file mode 100644 index 00000000..7c80ffe1 --- /dev/null +++ b/migrations/sqlite/2026-09-23-120000_add_default_user_collection/up.sql @@ -0,0 +1,6 @@ +-- Older releases ignore both columns after a downgrade: a My Items collection becomes a regular one there, which +-- Owners, Admins and members with access to all collections see with its items. +ALTER TABLE collections ADD COLUMN default_user_uuid TEXT; +ALTER TABLE collections ADD COLUMN default_user_collection_email TEXT; +-- A member has at most one My Items collection per organization. NULL (a shared collection) never conflicts. +CREATE UNIQUE INDEX collections_default_user_uuid ON collections (org_uuid, default_user_uuid); diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index a8c6aea0..fa48b98d 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -21,9 +21,9 @@ use crate::{ db::{ DbConn, DbPool, models::{ - Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, - CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId, - Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId, + Archive, Attachment, AttachmentId, Cipher, CipherAccessScope, CipherId, Collection, CollectionCipher, + CollectionGroup, CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, + KeyId, Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId, }, }, util::{NumberOrString, deser_opt_nonempty_str, save_temp_file}, @@ -54,6 +54,7 @@ pub fn routes() -> Vec { post_ciphers_create, post_ciphers_import, get_attachment, + get_attachment_admin, post_attachment_v2, post_attachment_v2_data, post_attachment, // legacy @@ -231,23 +232,62 @@ async fn get_ciphers(headers: Headers, conn: DbConn) -> JsonResult { }))) } +// Like upstream, this doesn't return the items stored only in another member's My Items. The clients also +// fetch updated ciphers from here when notified, which keeps those out of an admin's vault. #[get("/ciphers/")] async fn get_cipher(cipher_id: CipherId, headers: Headers, conn: DbConn) -> JsonResult { - let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { + get_cipher_impl(&cipher_id, &headers, CipherAccessScope::User, &conn).await +} + +// Also the items stored only in a member's My Items, which upstream's organization reports open from here. +#[get("/ciphers//admin")] +async fn get_cipher_admin(cipher_id: CipherId, headers: Headers, conn: DbConn) -> JsonResult { + get_cipher_impl(&cipher_id, &headers, CipherAccessScope::OrganizationAdmin, &conn).await +} + +/// The cipher as a route of the given scope answers with. Like upstream's administrative cipher responses, those to +/// members with organization-wide cipher authority also list the members' My Items collections the cipher is in, +/// which they don't see otherwise. +async fn cipher_response( + cipher: &Cipher, + headers: &Headers, + scope: CipherAccessScope, + sync_type: CipherSyncType, + conn: &DbConn, +) -> Result { + let mut response = cipher.to_json(&headers.host, &headers.user.uuid, None, sync_type, conn).await?; + if scope == CipherAccessScope::OrganizationAdmin + && let Some(org_uuid) = &cipher.organization_uuid + && CipherAccessScope::for_member( + Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_uuid, conn).await.as_ref(), + ) == CipherAccessScope::OrganizationAdmin + && let Some(collection_ids) = response["collectionIds"].as_array_mut() + { + for my_items in CollectionCipher::find_my_items_of_cipher(&cipher.uuid, conn).await { + let my_items = json!(my_items); + if !collection_ids.contains(&my_items) { + collection_ids.push(my_items); + } + } + } + Ok(response) +} + +async fn get_cipher_impl( + cipher_id: &CipherId, + headers: &Headers, + scope: CipherAccessScope, + conn: &DbConn, +) -> JsonResult { + let Some(cipher) = Cipher::find_by_uuid(cipher_id, conn).await else { err!("Cipher doesn't exist") }; - if !cipher.is_accessible_to_user(&headers.user.uuid, &conn).await { + if !cipher.is_accessible_to_user(&headers.user.uuid, scope, conn).await { err!("Cipher is not owned by user") } - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) -} - -#[get("/ciphers//admin")] -async fn get_cipher_admin(cipher_id: CipherId, headers: Headers, conn: DbConn) -> JsonResult { - // TODO: Implement this correctly - get_cipher(cipher_id, headers, conn).await + Ok(Json(cipher_response(&cipher, headers, scope, CipherSyncType::User, conn).await?)) } #[get("/ciphers//details")] @@ -426,11 +466,33 @@ async fn enforce_personal_ownership_policy(data: Option<&CipherData>, headers: & Ok(()) } +/// Collections the caller of `update_cipher_from_data()` already validated the user's write access to. +pub enum ValidatedCollections { + /// The caller links the cipher to them itself, like the organization import does. + Checked(Vec), + /// `update_cipher_from_data()` links the cipher to them once every one of its own checks passed. + Link(Vec), +} + +impl ValidatedCollections { + fn ids(&self) -> &[CollectionId] { + match self { + Self::Checked(ids) | Self::Link(ids) => ids, + } + } + + fn into_ids(self) -> Vec { + match self { + Self::Checked(ids) | Self::Link(ids) => ids, + } + } +} + pub async fn update_cipher_from_data( cipher: &mut Cipher, data: CipherData, headers: &Headers, - shared_to_collections: Option>, + shared_to_collections: Option, conn: &DbConn, nt: &Notify<'_>, ut: UpdateType, @@ -486,9 +548,9 @@ pub async fn update_cipher_from_data( Some(member) => { // A non-empty list of collections implies the caller already validated the user's write // access to them, so we can move the cipher into the organization on that basis. - if shared_to_collections.as_ref().is_some_and(|cols| !cols.is_empty()) + if shared_to_collections.as_ref().is_some_and(|cols| !cols.ids().is_empty()) || member.has_full_access() - || cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await + || cipher.is_write_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, conn).await { cipher.organization_uuid = Some(org_id); // After some discussion in PR #1329 re-added the user_uuid = None again. @@ -570,7 +632,11 @@ pub async fn update_cipher_from_data( cipher.password_history = data.password_history.map(|f| f.to_string()); cipher.reprompt = data.reprompt.filter(|r| *r == RepromptType::None as i32 || *r == RepromptType::Password as i32); - cipher.save(conn).await?; + match &shared_to_collections { + // Linked only now, after every check passed, and together with the cipher + Some(ValidatedCollections::Link(collection_ids)) => cipher.save_with_collections(collection_ids, conn).await?, + _ => cipher.save(conn).await?, + } cipher.move_to_folder(data.folder_id, &headers.user.uuid, conn).await?; cipher.set_favorite(data.favorite, &headers.user.uuid, conn).await?; @@ -599,7 +665,7 @@ pub async fn update_cipher_from_data( cipher, &cipher.update_users_revision(conn).await, &headers.device, - shared_to_collections, + shared_to_collections.map(ValidatedCollections::into_ids), conn, ) .await; @@ -636,33 +702,12 @@ async fn post_ciphers_import(data: Json, headers: Headers, conn: DbC // TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks. Cipher::validate_cipher_data(&data.ciphers)?; - // Read and create the folders - let existing_folders: HashSet> = - Folder::find_by_user(&headers.user.uuid, &conn).await.into_iter().map(|f| Some(f.uuid)).collect(); - let mut folders: Vec = Vec::with_capacity(data.folders.len()); - for folder in data.folders { - let folder_id = if existing_folders.contains(&folder.id) { - folder.id.unwrap() - } else { - let mut new_folder = Folder::new(headers.user.uuid.clone(), folder.name); - new_folder.save(&conn).await?; - new_folder.uuid - }; - - folders.push(folder_id); - } - - // Read the relations between folders and ciphers - // Ciphers can only be in one folder at the same time - let mut relations_map = HashMap::with_capacity(data.folder_relationships.len()); - for relation in data.folder_relationships { - relations_map.insert(relation.key, relation.value); - } + let relations = data.folder_relationships.into_iter().map(|r| (r.key, r.value)); + let cipher_folders = import_folders(data.folders, relations, &headers.user.uuid, &conn).await?; // Read and create the ciphers for (index, mut cipher_data) in data.ciphers.into_iter().enumerate() { - let folder_id = relations_map.get(&index).and_then(|i| folders.get(*i).cloned()); - cipher_data.folder_id = folder_id; + cipher_data.folder_id = cipher_folders.get(&index).cloned(); let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?; @@ -675,6 +720,32 @@ async fn post_ciphers_import(data: Json, headers: Headers, conn: DbC Ok(()) } +/// Creates the imported folders the user doesn't have yet and returns the folder of each imported cipher, by index. +/// `relations` are the (cipher index, folder index) pairs of the import, a cipher can only be in one folder. +pub async fn import_folders( + folders: Vec, + relations: impl Iterator, + user_id: &UserId, + conn: &DbConn, +) -> Result, crate::Error> { + let existing_folders: HashSet> = + Folder::find_by_user(user_id, conn).await.into_iter().map(|f| Some(f.uuid)).collect(); + let mut folder_ids: Vec = Vec::with_capacity(folders.len()); + for folder in folders { + let folder_id = if existing_folders.contains(&folder.id) { + folder.id.unwrap() + } else { + let mut new_folder = Folder::new(user_id.clone(), folder.name); + new_folder.save(conn).await?; + new_folder.uuid + }; + + folder_ids.push(folder_id); + } + + Ok(relations.filter_map(|(cipher, folder)| Some((cipher, folder_ids.get(folder)?.clone()))).collect()) +} + /// Called when an org admin modifies an existing org cipher. #[put("/ciphers//admin", data = "")] async fn put_cipher_admin( @@ -684,7 +755,7 @@ async fn put_cipher_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - put_cipher(cipher_id, data, headers, conn, nt).await + put_cipher_impl(&cipher_id, data.into_inner(), &headers, CipherAccessScope::OrganizationAdmin, &conn, &nt).await } #[post("/ciphers//admin", data = "")] @@ -695,7 +766,7 @@ async fn post_cipher_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - post_cipher(cipher_id, data, headers, conn, nt).await + put_cipher_impl(&cipher_id, data.into_inner(), &headers, CipherAccessScope::OrganizationAdmin, &conn, &nt).await } #[post("/ciphers/", data = "")] @@ -706,7 +777,7 @@ async fn post_cipher( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - put_cipher(cipher_id, data, headers, conn, nt).await + put_cipher_impl(&cipher_id, data.into_inner(), &headers, CipherAccessScope::User, &conn, &nt).await } #[put("/ciphers/", data = "")] @@ -717,9 +788,18 @@ async fn put_cipher( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - let data: CipherData = data.into_inner(); + put_cipher_impl(&cipher_id, data.into_inner(), &headers, CipherAccessScope::User, &conn, &nt).await +} - let Some(mut cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { +async fn put_cipher_impl( + cipher_id: &CipherId, + data: CipherData, + headers: &Headers, + scope: CipherAccessScope, + conn: &DbConn, + nt: &Notify<'_>, +) -> JsonResult { + let Some(mut cipher) = Cipher::find_by_uuid(cipher_id, conn).await else { err!("Cipher doesn't exist") }; @@ -728,13 +808,13 @@ async fn put_cipher( // cipher itself, so the user shouldn't need write access to change these. // Interestingly, upstream Bitwarden doesn't properly handle this either. - if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await { + if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, conn).await { err!("Cipher is not write accessible") } - update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate).await?; + update_cipher_from_data(&mut cipher, data, headers, None, conn, nt, UpdateType::SyncCipherUpdate).await?; - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) + Ok(Json(cipher_response(&cipher, headers, scope, CipherSyncType::User, conn).await?)) } #[post("/ciphers//partial", data = "")] @@ -761,7 +841,7 @@ async fn put_cipher_partial( err!("Cipher does not exist") }; - if !cipher.is_accessible_to_user(&headers.user.uuid, &conn).await { + if !cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, &conn).await { err!("Cipher does not exist", "Cipher is not accessible for the current user") } @@ -832,13 +912,22 @@ async fn post_collections_update( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - let data: CollectionsAdminData = data.into_inner(); + update_cipher_collections(&cipher_id, data.into_inner(), &headers, CipherAccessScope::User, &conn, &nt).await +} - let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { +async fn update_cipher_collections( + cipher_id: &CipherId, + data: CollectionsAdminData, + headers: &Headers, + scope: CipherAccessScope, + conn: &DbConn, + nt: &Notify<'_>, +) -> JsonResult { + let Some(cipher) = Cipher::find_by_uuid(cipher_id, conn).await else { err!("Cipher doesn't exist") }; - if !cipher.is_in_editable_collection_by_user(&headers.user.uuid, &conn).await { + if !cipher.is_in_editable_collection_by_user(&headers.user.uuid, scope, conn).await { err!("Collection cannot be changed") } @@ -846,36 +935,59 @@ async fn post_collections_update( err!("Cipher is not owned by an organization") }; + // Only the collections the user can write to change, so the posted ones are compared with those: the clients + // leave out the read-only ones. The My Items rule looks at every collection the user sees the cipher in. let posted_collections = HashSet::::from_iter(data.collection_ids); - let current_collections = - HashSet::::from_iter(cipher.get_collections(headers.user.uuid.clone(), &conn).await); - - for collection in posted_collections.symmetric_difference(¤t_collections) { - match Collection::find_by_uuid_and_org(collection, org_uuid, &conn).await { - None => err!("Invalid collection ID provided"), - Some(collection) => { - if collection.is_writable_by_user(&headers.user.uuid, &conn).await { - if posted_collections.contains(&collection.uuid) { - // Add to collection - CollectionCipher::save(&cipher.uuid, &collection.uuid, &conn).await?; - } else { - // Remove from collection - CollectionCipher::delete(&cipher.uuid, &collection.uuid, &conn).await?; - } - } else { - err!("No rights to modify the collection") - } + let current_collections = HashSet::::from_iter(match scope { + CipherAccessScope::User => cipher.get_collections(headers.user.uuid.clone(), conn).await, + CipherAccessScope::OrganizationAdmin => cipher.get_admin_collections(headers.user.uuid.clone(), conn).await, + }); + let visible_collections = + HashSet::::from_iter(cipher.get_accessible_collections(headers.user.uuid.clone(), conn).await); + let org_collections: HashMap = + Collection::find_by_organization(org_uuid, conn).await.into_iter().map(|c| (c.uuid.clone(), c)).collect(); + // Only looked up when the cipher is to be added to a My Items collection + let mut cipher_my_items = None; + + // Check every change before making any + let (mut add, mut remove) = (Vec::new(), Vec::new()); + for collection_uuid in posted_collections.symmetric_difference(¤t_collections) { + let Some(collection) = org_collections.get(collection_uuid) else { + err!("Invalid collection ID provided") + }; + // Like upstream, the administrative route never adds to or removes from a My Items collection. The admin + // console doesn't list them, so it never sends the one of the acting member either. + if scope == CipherAccessScope::OrganizationAdmin && collection.is_default_user_collection() { + continue; + } + if !collection.is_writable_by_user(&headers.user.uuid, conn).await { + err!("No rights to modify the collection") + } + if posted_collections.contains(collection_uuid) { + if collection.is_default_user_collection() && cipher_my_items.is_none() { + cipher_my_items = Some(CollectionCipher::find_my_items_of_cipher(&cipher.uuid, conn).await); } + collection + .check_cipher_assignment(&visible_collections, cipher_my_items.as_ref().unwrap_or(&HashSet::new()))?; + add.push(collection_uuid.clone()); + } else { + remove.push(collection_uuid.clone()); } } + // Moved into a My Items collection, an unassigned cipher is taken away from the members with organization-wide + // access, which the revisions of the changed collections don't cover, so they sync as well + if add.iter().any(|c| org_collections.get(c).is_some_and(Collection::is_default_user_collection)) { + cipher.update_users_revision(conn).await; + } + CollectionCipher::update_for_cipher(&cipher.uuid, add, remove, conn).await?; nt.send_cipher_update( UpdateType::SyncCipherUpdate, &cipher, - &cipher.update_users_revision(&conn).await, + &cipher.update_users_revision(conn).await, &headers.device, Some(Vec::from_iter(posted_collections)), - &conn, + conn, ) .await; @@ -886,11 +998,15 @@ async fn post_collections_update( &headers.user.uuid, headers.device.atype, &headers.ip.ip, - &conn, + conn, ) .await; - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) + let sync_type = match scope { + CipherAccessScope::User => CipherSyncType::User, + CipherAccessScope::OrganizationAdmin => CipherSyncType::Organization, + }; + Ok(Json(cipher_response(&cipher, headers, scope, sync_type, conn).await?)) } #[put("/ciphers//collections-admin", data = "")] @@ -912,65 +1028,8 @@ async fn post_collections_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - let data: CollectionsAdminData = data.into_inner(); - - let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { - err!("Cipher doesn't exist") - }; - - if !cipher.is_in_editable_collection_by_user(&headers.user.uuid, &conn).await { - err!("Collection cannot be changed") - } - - let Some(ref org_uuid) = cipher.organization_uuid else { - err!("Cipher is not owned by an organization") - }; - - let posted_collections = HashSet::::from_iter(data.collection_ids); - let current_collections = - HashSet::::from_iter(cipher.get_admin_collections(headers.user.uuid.clone(), &conn).await); - - for collection in posted_collections.symmetric_difference(¤t_collections) { - match Collection::find_by_uuid_and_org(collection, org_uuid, &conn).await { - None => err!("Invalid collection ID provided"), - Some(collection) => { - if collection.is_writable_by_user(&headers.user.uuid, &conn).await { - if posted_collections.contains(&collection.uuid) { - // Add to collection - CollectionCipher::save(&cipher.uuid, &collection.uuid, &conn).await?; - } else { - // Remove from collection - CollectionCipher::delete(&cipher.uuid, &collection.uuid, &conn).await?; - } - } else { - err!("No rights to modify the collection") - } - } - } - } - - nt.send_cipher_update( - UpdateType::SyncCipherUpdate, - &cipher, - &cipher.update_users_revision(&conn).await, - &headers.device, - Some(Vec::from_iter(posted_collections)), - &conn, - ) - .await; - - log_event( - EventType::CipherUpdatedCollections, - &cipher.uuid, - org_uuid, - &headers.user.uuid, - headers.device.atype, - &headers.ip.ip, - &conn, - ) - .await; - - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::Organization, &conn).await?)) + update_cipher_collections(&cipher_id, data.into_inner(), &headers, CipherAccessScope::OrganizationAdmin, &conn, &nt) + .await } #[derive(Deserialize)] @@ -1021,7 +1080,7 @@ async fn put_cipher_share_selected( headers: Headers, conn: DbConn, nt: Notify<'_>, -) -> EmptyResult { +) -> JsonResult { let mut data: ShareSelectedCipherData = data.into_inner(); if data.ciphers.is_empty() { @@ -1038,6 +1097,8 @@ async fn put_cipher_share_selected( } } + // Like upstream, answer with the shared ciphers: the mobile clients update their local copies from them + let mut shared_ciphers = Vec::with_capacity(data.ciphers.len()); while let Some(cipher) = data.ciphers.pop() { let mut shared_cipher_data = ShareCipherData { cipher, @@ -1045,16 +1106,22 @@ async fn put_cipher_share_selected( }; if let Some(id) = shared_cipher_data.cipher.id.take() { - share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await? + let shared = + share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await?; + shared_ciphers.push(shared.into_inner()); } else { err!("Request missing ids field") - }; + } } // Multi share actions do not send out a push for each cipher, we need to send a general sync here nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), &conn).await; - Ok(()) + Ok(Json(json!({ + "data": shared_ciphers, + "object": "list", + "continuationToken": null, + }))) } async fn share_cipher_by_uuid( @@ -1066,7 +1133,7 @@ async fn share_cipher_by_uuid( override_ut: Option, ) -> JsonResult { let mut cipher = if let Some(cipher) = Cipher::find_by_uuid(cipher_id, conn).await { - if cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { + if cipher.is_write_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, conn).await { cipher } else { err!("Cipher is not write accessible") @@ -1075,22 +1142,31 @@ async fn share_cipher_by_uuid( err!("Cipher doesn't exist") }; - // `update_cipher_from_data()` rejects this too, but only after the collections below were - // already linked. There are no transactions, so that would leave the cipher linked to a - // collection of another organization. + // `update_cipher_from_data()` rejects this too, but the collections below are validated against the + // requested organization, so report the mismatch first. if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.cipher.organization_id { err!("Organization mismatch. Please resync the client before updating the cipher") } + // Only validated here. `update_cipher_from_data()` links the cipher to them once every one of its own checks + // passed as well, so a rejected request leaves no collection assignment behind. let mut shared_to_collections = vec![]; if let Some(organization_id) = &data.cipher.organization_id { + let (current_collections, cipher_my_items) = if cipher.organization_uuid.is_some() { + ( + HashSet::from_iter(cipher.get_accessible_collections(headers.user.uuid.clone(), conn).await), + CollectionCipher::find_my_items_of_cipher(&cipher.uuid, conn).await, + ) + } else { + (HashSet::new(), HashSet::new()) + }; for col_id in &data.collection_ids { match Collection::find_by_uuid_and_org(col_id, organization_id, conn).await { None => err!("Invalid collection ID provided"), Some(collection) => { if collection.is_writable_by_user(&headers.user.uuid, conn).await { - CollectionCipher::save(&cipher.uuid, &collection.uuid, conn).await?; + collection.check_cipher_assignment(¤t_collections, &cipher_my_items)?; shared_to_collections.push(collection.uuid); } else { err!("No rights to modify the collection") @@ -1110,7 +1186,8 @@ async fn share_cipher_by_uuid( UpdateType::SyncCipherCreate }; - update_cipher_from_data(&mut cipher, data.cipher, headers, Some(shared_to_collections), conn, nt, ut).await?; + let shared_to_collections = Some(ValidatedCollections::Link(shared_to_collections)); + update_cipher_from_data(&mut cipher, data.cipher, headers, shared_to_collections, conn, nt, ut).await?; Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?)) } @@ -1128,16 +1205,37 @@ async fn get_attachment( headers: Headers, conn: DbConn, ) -> JsonResult { - let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { + get_attachment_impl(&cipher_id, &attachment_id, &headers, CipherAccessScope::User, &conn).await +} + +// Upstream's `GetAttachmentDataAdmin`, which the clients use from the organization's views +#[get("/ciphers//attachment//admin")] +async fn get_attachment_admin( + cipher_id: CipherId, + attachment_id: AttachmentId, + headers: Headers, + conn: DbConn, +) -> JsonResult { + get_attachment_impl(&cipher_id, &attachment_id, &headers, CipherAccessScope::OrganizationAdmin, &conn).await +} + +async fn get_attachment_impl( + cipher_id: &CipherId, + attachment_id: &AttachmentId, + headers: &Headers, + scope: CipherAccessScope, + conn: &DbConn, +) -> JsonResult { + let Some(cipher) = Cipher::find_by_uuid(cipher_id, conn).await else { err!("Cipher doesn't exist") }; - if !cipher.is_accessible_to_user(&headers.user.uuid, &conn).await { + if !cipher.is_accessible_to_user(&headers.user.uuid, scope, conn).await { err!("Cipher is not accessible") } - match Attachment::find_by_id(&attachment_id, &conn).await { - Some(attachment) if cipher_id == attachment.cipher_uuid => Ok(Json(attachment.to_json(&headers.host).await?)), + match Attachment::find_by_id(attachment_id, conn).await { + Some(attachment) if *cipher_id == attachment.cipher_uuid => Ok(Json(attachment.to_json(&headers.host).await?)), Some(_) => err!("Attachment doesn't belong to cipher"), None => err!("Attachment doesn't exist"), } @@ -1172,11 +1270,14 @@ async fn post_attachment_v2( err!("Cipher doesn't exist") }; - if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await { + let data: AttachmentRequestData = data.into_inner(); + if !cipher + .is_write_accessible_to_user(&headers.user.uuid, CipherAccessScope::requested(data.admin_request), &conn) + .await + { err!("Cipher is not write accessible") } - let data: AttachmentRequestData = data.into_inner(); let file_size = data.file_size.into_i64()?; if file_size < 0 { @@ -1221,6 +1322,7 @@ async fn save_attachment( cipher_id: CipherId, data: Form>, headers: &Headers, + scope: Option, conn: DbConn, nt: Notify<'_>, ) -> Result<(Cipher, DbConn), crate::error::Error> { @@ -1237,7 +1339,18 @@ async fn save_attachment( err!("Cipher doesn't exist") }; - if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await { + // Without a scope, like upstream's `PostFileForExistingAttachment`, from the caller's own membership + let scope = match scope { + Some(scope) => scope, + None => CipherAccessScope::for_member( + match &cipher.organization_uuid { + Some(org_uuid) => Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_uuid, &conn).await, + None => None, + } + .as_ref(), + ), + }; + if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, &conn).await { err!("Cipher is not write accessible") } @@ -1398,7 +1511,7 @@ async fn post_attachment_v2_data( None => err!("Attachment doesn't exist"), }; - save_attachment(attachment, cipher_id, data, &headers, conn, nt).await?; + save_attachment(attachment, cipher_id, data, &headers, None, conn, nt).await?; Ok(()) } @@ -1411,14 +1524,25 @@ async fn post_attachment( headers: Headers, conn: DbConn, nt: Notify<'_>, +) -> JsonResult { + post_attachment_impl(cipher_id, data, &headers, CipherAccessScope::User, conn, nt).await +} + +async fn post_attachment_impl( + cipher_id: CipherId, + data: Form>, + headers: &Headers, + scope: CipherAccessScope, + conn: DbConn, + nt: Notify<'_>, ) -> JsonResult { // Setting this as None signifies to save_attachment() that it should create // the attachment database record as well as saving the data to disk. let attachment = None; - let (cipher, conn) = save_attachment(attachment, cipher_id, data, &headers, conn, nt).await?; + let (cipher, conn) = save_attachment(attachment, cipher_id, data, headers, Some(scope), conn, nt).await?; - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) + Ok(Json(cipher_response(&cipher, headers, scope, CipherSyncType::User, &conn).await?)) } #[post("/ciphers//attachment-admin", format = "multipart/form-data", data = "")] @@ -1429,7 +1553,7 @@ async fn post_attachment_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - post_attachment(cipher_id, data, headers, conn, nt).await + post_attachment_impl(cipher_id, data, &headers, CipherAccessScope::OrganizationAdmin, conn, nt).await } #[post("/ciphers//attachment//share", format = "multipart/form-data", data = "")] @@ -1441,8 +1565,8 @@ async fn post_attachment_share( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await?; - post_attachment(cipher_id, data, headers, conn, nt).await + delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, CipherAccessScope::User, &conn, &nt).await?; + post_attachment_impl(cipher_id, data, &headers, CipherAccessScope::User, conn, nt).await } #[post("/ciphers//attachment//delete-admin")] @@ -1453,7 +1577,15 @@ async fn delete_attachment_post_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - delete_attachment(cipher_id, attachment_id, headers, conn, nt).await + delete_cipher_attachment_by_id( + &cipher_id, + &attachment_id, + &headers, + CipherAccessScope::OrganizationAdmin, + &conn, + &nt, + ) + .await } #[post("/ciphers//attachment//delete")] @@ -1475,7 +1607,7 @@ async fn delete_attachment( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await + delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, CipherAccessScope::User, &conn, &nt).await } #[delete("/ciphers//attachment//admin")] @@ -1486,42 +1618,77 @@ async fn delete_attachment_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await + delete_cipher_attachment_by_id( + &cipher_id, + &attachment_id, + &headers, + CipherAccessScope::OrganizationAdmin, + &conn, + &nt, + ) + .await } #[post("/ciphers//delete")] async fn delete_cipher_post(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await + delete_cipher_by_uuid(&cipher_id, &headers, CipherAccessScope::User, &conn, &CipherDeleteOptions::HardSingle, &nt) + .await // permanent delete } #[post("/ciphers//delete-admin")] async fn delete_cipher_post_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await + delete_cipher_by_uuid( + &cipher_id, + &headers, + CipherAccessScope::OrganizationAdmin, + &conn, + &CipherDeleteOptions::HardSingle, + &nt, + ) + .await // permanent delete } #[put("/ciphers//delete")] async fn delete_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::SoftSingle, &nt).await + delete_cipher_by_uuid(&cipher_id, &headers, CipherAccessScope::User, &conn, &CipherDeleteOptions::SoftSingle, &nt) + .await // soft delete } #[put("/ciphers//delete-admin")] async fn delete_cipher_put_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::SoftSingle, &nt).await + delete_cipher_by_uuid( + &cipher_id, + &headers, + CipherAccessScope::OrganizationAdmin, + &conn, + &CipherDeleteOptions::SoftSingle, + &nt, + ) + .await // soft delete } #[delete("/ciphers/")] async fn delete_cipher(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await + delete_cipher_by_uuid(&cipher_id, &headers, CipherAccessScope::User, &conn, &CipherDeleteOptions::HardSingle, &nt) + .await // permanent delete } #[delete("/ciphers//admin")] async fn delete_cipher_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await + delete_cipher_by_uuid( + &cipher_id, + &headers, + CipherAccessScope::OrganizationAdmin, + &conn, + &CipherDeleteOptions::HardSingle, + &nt, + ) + .await // permanent delete } @@ -1532,7 +1699,7 @@ async fn delete_cipher_selected( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await + delete_multiple_ciphers(data, headers, CipherAccessScope::User, conn, CipherDeleteOptions::HardMulti, nt).await // permanent delete } @@ -1543,7 +1710,7 @@ async fn delete_cipher_selected_post( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await + delete_multiple_ciphers(data, headers, CipherAccessScope::User, conn, CipherDeleteOptions::HardMulti, nt).await // permanent delete } @@ -1554,7 +1721,7 @@ async fn delete_cipher_selected_put( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await + delete_multiple_ciphers(data, headers, CipherAccessScope::User, conn, CipherDeleteOptions::SoftMulti, nt).await // soft delete } @@ -1565,7 +1732,15 @@ async fn delete_cipher_selected_admin( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await + delete_multiple_ciphers( + data, + headers, + CipherAccessScope::OrganizationAdmin, + conn, + CipherDeleteOptions::HardMulti, + nt, + ) + .await // permanent delete } @@ -1576,7 +1751,15 @@ async fn delete_cipher_selected_post_admin( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await + delete_multiple_ciphers( + data, + headers, + CipherAccessScope::OrganizationAdmin, + conn, + CipherDeleteOptions::HardMulti, + nt, + ) + .await // permanent delete } @@ -1587,18 +1770,26 @@ async fn delete_cipher_selected_put_admin( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await + delete_multiple_ciphers( + data, + headers, + CipherAccessScope::OrganizationAdmin, + conn, + CipherDeleteOptions::SoftMulti, + nt, + ) + .await // soft delete } #[put("/ciphers//restore")] async fn restore_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { - restore_cipher_by_uuid(&cipher_id, &headers, false, &conn, &nt).await + restore_cipher_by_uuid(&cipher_id, &headers, false, CipherAccessScope::User, &conn, &nt).await } #[put("/ciphers//restore-admin")] async fn restore_cipher_put_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { - restore_cipher_by_uuid(&cipher_id, &headers, false, &conn, &nt).await + restore_cipher_by_uuid(&cipher_id, &headers, false, CipherAccessScope::OrganizationAdmin, &conn, &nt).await } #[put("/ciphers/restore-admin", data = "")] @@ -1608,7 +1799,7 @@ async fn restore_cipher_selected_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - restore_multiple_ciphers(data, &headers, &conn, &nt).await + restore_multiple_ciphers(data, &headers, CipherAccessScope::OrganizationAdmin, &conn, &nt).await } #[put("/ciphers/restore", data = "")] @@ -1618,7 +1809,7 @@ async fn restore_cipher_selected( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - restore_multiple_ciphers(data, &headers, &conn, &nt).await + restore_multiple_ciphers(data, &headers, CipherAccessScope::User, &conn, &nt).await } #[derive(Deserialize)] @@ -1722,7 +1913,7 @@ async fn purge_org_vault( match Membership::find_confirmed_by_user_and_org(&user.uuid, &organization.org_id, &conn).await { Some(member) if member.atype == MembershipType::Owner => { - Cipher::delete_all_by_organization(&organization.org_id, &conn).await?; + Cipher::purge_organization(&organization.org_id, &conn).await?; nt.send_user_update(UpdateType::SyncVault, &user, headers.device.push_uuid.as_ref(), &conn).await; log_event( @@ -1809,6 +2000,7 @@ pub enum CipherDeleteOptions { async fn delete_cipher_by_uuid( cipher_id: &CipherId, headers: &Headers, + scope: CipherAccessScope, conn: &DbConn, delete_options: &CipherDeleteOptions, nt: &Notify<'_>, @@ -1817,7 +2009,7 @@ async fn delete_cipher_by_uuid( err!("Cipher doesn't exist") }; - if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { + if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, conn).await { err!("Cipher can't be deleted by user") } @@ -1875,6 +2067,7 @@ struct CipherIdsData { async fn delete_multiple_ciphers( data: Json, headers: Headers, + scope: CipherAccessScope, conn: DbConn, delete_options: CipherDeleteOptions, nt: Notify<'_>, @@ -1882,7 +2075,7 @@ async fn delete_multiple_ciphers( let data = data.into_inner(); for cipher_id in data.ids { - if let error @ Err(_) = delete_cipher_by_uuid(&cipher_id, &headers, &conn, &delete_options, &nt).await { + if let error @ Err(_) = delete_cipher_by_uuid(&cipher_id, &headers, scope, &conn, &delete_options, &nt).await { return error; } } @@ -1897,6 +2090,7 @@ async fn restore_cipher_by_uuid( cipher_id: &CipherId, headers: &Headers, multi_restore: bool, + scope: CipherAccessScope, conn: &DbConn, nt: &Notify<'_>, ) -> JsonResult { @@ -1904,7 +2098,7 @@ async fn restore_cipher_by_uuid( err!("Cipher doesn't exist") }; - if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { + if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, conn).await { err!("Cipher can't be restored by user") } @@ -1936,12 +2130,13 @@ async fn restore_cipher_by_uuid( .await; } - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?)) + Ok(Json(cipher_response(&cipher, headers, scope, CipherSyncType::User, conn).await?)) } async fn restore_multiple_ciphers( data: Json, headers: &Headers, + scope: CipherAccessScope, conn: &DbConn, nt: &Notify<'_>, ) -> JsonResult { @@ -1949,7 +2144,7 @@ async fn restore_multiple_ciphers( let mut ciphers: Vec = Vec::new(); for cipher_id in data.ids { - match restore_cipher_by_uuid(&cipher_id, headers, true, conn, nt).await { + match restore_cipher_by_uuid(&cipher_id, headers, true, scope, conn, nt).await { Ok(json) => ciphers.push(json.into_inner()), err => return err, } @@ -1969,6 +2164,7 @@ async fn delete_cipher_attachment_by_id( cipher_id: &CipherId, attachment_id: &AttachmentId, headers: &Headers, + scope: CipherAccessScope, conn: &DbConn, nt: &Notify<'_>, ) -> JsonResult { @@ -1984,7 +2180,7 @@ async fn delete_cipher_attachment_by_id( err!("Cipher doesn't exist") }; - if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { + if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, conn).await { err!("Cipher cannot be deleted by user") } @@ -2027,7 +2223,7 @@ async fn archive_cipher( err!("Cipher doesn't exist") }; - if !cipher.is_accessible_to_user(&headers.user.uuid, conn).await { + if !cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, conn).await { err!("Cipher is not accessible for the current user") } @@ -2059,7 +2255,7 @@ async fn unarchive_cipher( err!("Cipher doesn't exist") }; - if !cipher.is_accessible_to_user(&headers.user.uuid, conn).await { + if !cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, conn).await { err!("Cipher is not accessible for the current user") } @@ -2146,6 +2342,7 @@ pub struct CipherSyncData { pub user_collections: HashMap, pub user_collections_groups: HashMap, pub user_group_full_access_for_organizations: HashSet, + pub my_items_only_ciphers: HashSet, } #[derive(Eq, PartialEq)] @@ -2196,7 +2393,7 @@ impl CipherSyncData { } // Generate a HashMap with the Cipher UUID as key and one or more Collection UUID's - let user_cipher_collections = Cipher::get_collections_with_cipher_by_user(user_id.clone(), conn).await; + let user_cipher_collections = Cipher::get_collections_with_cipher_by_user(user_id.clone(), None, conn).await; let mut cipher_collections: HashMap> = HashMap::with_capacity(user_cipher_collections.len()); for (cipher, collection) in user_cipher_collections { @@ -2238,6 +2435,20 @@ impl CipherSyncData { HashSet::new() }; + // Only organization-wide access has to leave out the items stored only in a member's My Items, and only + // the user's vault checks the access restrictions at all + let my_items_only_ciphers = match sync_type { + CipherSyncType::User => { + let full_access_orgs = members + .values() + .filter(|m| m.has_full_access() || user_group_full_access_for_organizations.contains(&m.org_uuid)) + .map(|m| m.org_uuid.clone()) + .collect(); + CollectionCipher::find_my_items_only_by_orgs(full_access_orgs, conn).await + } + CipherSyncType::Organization => HashSet::new(), + }; + Self { cipher_attachments, cipher_folders, @@ -2248,6 +2459,7 @@ impl CipherSyncData { user_collections, user_collections_groups, user_group_full_access_for_organizations, + my_items_only_ciphers, } } } diff --git a/src/api/core/events.rs b/src/api/core/events.rs index a5b5b6b1..6c552348 100644 --- a/src/api/core/events.rs +++ b/src/api/core/events.rs @@ -10,7 +10,9 @@ use crate::{ auth::{AdminHeaders, Headers}, db::{ DbConn, DbPool, - models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId}, + models::{ + Cipher, CipherAccessScope, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId, + }, }, util::parse_date, }; @@ -224,7 +226,8 @@ async fn post_events_collect(data: Json>, headers: Headers, // user can actually access it instead of trusting the provided cipher uuid. if let Some(cipher_uuid) = &event.cipher_id && let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await - && cipher.is_accessible_to_user(&headers.user.uuid, &conn).await + // Like upstream, also for the members' My Items an admin opened from a report + && cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::OrganizationAdmin, &conn).await && let Some(org_id) = cipher.organization_uuid { log_event_impl( diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 046be793..9f2fa04c 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -15,15 +15,15 @@ use crate::{ db::{ DbConn, 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, + Cipher, CipherAccessScope, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, + CollectionUser, EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, + MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, + OrganizationId, TwoFactor, TwoFactorType, User, UserId, }, }, mail, sso::FAKE_SSO_IDENTIFIER, - util::{NumberOrString, convert_json_key_lcase_first}, + util::{NumberOrString, convert_json_key_lcase_first, is_valid_enc_string}, }; pub fn routes() -> Vec { @@ -77,6 +77,7 @@ pub fn routes() -> Vec { get_organization_public_key, bulk_public_keys, revoke_member, + revoke_self, bulk_revoke_members, restore_member, restore_member_vnext, @@ -130,7 +131,8 @@ struct OrganizationUpdateData { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct FullCollectionData { - name: String, + // Optional on updates only: the clients send none for a former My Items collection, like upstream + name: Option, groups: Vec, users: Vec, external_id: Option, @@ -439,7 +441,10 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea .collect(); let mut data = Vec::new(); - for col in Collection::find_by_organization(&org_id, &conn).await { + // Like upstream, the members' My Items collections are not managed here + for col in + Collection::find_by_organization(&org_id, &conn).await.into_iter().filter(|c| !c.is_default_user_collection()) + { // check whether the current user has access to the given collection let assigned = has_full_access_to_org || CollectionUser::has_access_to_collection_by_user(&col.uuid, &member.user_uuid, &conn).await @@ -490,8 +495,14 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea }))) } +// The shared collections only, like upstream async fn get_org_collections_impl(org_id: &OrganizationId, conn: &DbConn) -> Value { - Collection::find_by_organization(org_id, conn).await.iter().map(Collection::to_json).collect::() + Collection::find_by_organization(org_id, conn) + .await + .iter() + .filter(|c| !c.is_default_user_collection()) + .map(Collection::to_json) + .collect::() } #[post("/organizations//collections", data = "")] @@ -511,7 +522,10 @@ async fn post_organization_collections( err!("You don't have permission to create collections") } - let collection = Collection::new(org_id.clone(), data.name, data.external_id); + let Some(name) = data.name else { + err!("The Name field is required.") + }; + let collection = Collection::new(org_id.clone(), name, data.external_id); collection.save(&conn).await?; log_event( @@ -578,15 +592,26 @@ async fn post_bulk_access_collections( err!("Can't find organization details") } - // The collections and members are checked below, the groups only here. + // Like upstream, check the groups, members and collections before changing any collection let org_groups = Group::find_by_organization(&org_id, &conn).await; let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect(); if let Some(g) = data.groups.iter().find(|g| !org_group_ids.contains(&g.id)) { err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id, org_id)) } - for col_id in data.collection_ids { - let Some(collection) = Collection::find_by_uuid_and_org(&col_id, &org_id, &conn).await else { + let mut members = Vec::with_capacity(data.users.len()); + for user in &data.users { + let Some(member) = Membership::find_by_uuid_and_org(&user.id, &org_id, &conn).await else { + err!("User is not part of organization") + }; + if !member.access_all { + members.push((member, user)); + } + } + + let mut collections = Vec::with_capacity(data.collection_ids.len()); + for col_id in &data.collection_ids { + let Some(collection) = Collection::find_by_uuid_and_org(col_id, &org_id, &conn).await else { err!("Collection not found") }; @@ -594,6 +619,16 @@ async fn post_bulk_access_collections( err!("Collection not found", "The current user isn't a manager for this collection") } + if collection.is_default_user_collection() { + err!("You cannot add access to collections with the type as DefaultUserCollection.") + } + + collections.push(collection); + } + + for collection in collections { + let col_id = collection.uuid.clone(); + // update collection modification date collection.save(&conn).await?; @@ -616,15 +651,7 @@ async fn post_bulk_access_collections( } CollectionUser::delete_all_by_collection(&col_id, &conn).await?; - for user in &data.users { - let Some(member) = Membership::find_by_uuid_and_org(&user.id, &org_id, &conn).await else { - err!("User is not part of organization") - }; - - if member.access_all { - continue; - } - + for (member, user) in &members { CollectionUser::save(&member.user_uuid, &col_id, user.read_only, user.hide_passwords, user.manage, &conn) .await?; } @@ -666,7 +693,16 @@ async fn post_organization_collection_update( err!("Collection not found") }; - collection.name = data.name; + if collection.is_default_user_collection() { + err!("You cannot edit a collection with the type as DefaultUserCollection.") + } + + // A former My Items collection is shown by its former owner's email address, its name stays as it is + if collection.default_user_collection_email.is_none() + && let Some(name) = data.name.filter(|n| !n.trim().is_empty()) + { + collection.name = name; + } collection.external_id = match data.external_id { Some(external_id) if !external_id.trim().is_empty() => Some(external_id), _ => None, @@ -723,6 +759,9 @@ async fn delete_organization_collection_impl( let Some(collection) = Collection::find_by_uuid_and_org(col_id, org_id, conn).await else { err!("Collection not found", "Collection does not exist or does not belong to this organization") }; + if collection.is_default_user_collection() { + err!("You cannot delete a collection with the type as DefaultUserCollection.") + } log_event( EventType::CollectionDeleted, &collection.uuid, @@ -778,6 +817,16 @@ async fn bulk_delete_organization_collections( let headers = ManagerHeaders::from_loose(headers, &collections, &conn).await?; + // Like upstream, reject them all before deleting any + for col_id in &collections { + if Collection::find_by_uuid_and_org(col_id, &org_id, &conn) + .await + .is_some_and(|c| c.is_default_user_collection()) + { + err!("You cannot delete collections with the type as DefaultUserCollection.") + } + } + for col_id in collections { delete_organization_collection_impl(&org_id, &col_id, &headers, &conn).await?; } @@ -883,18 +932,29 @@ struct OrgIdData { organization_id: OrganizationId, } +#[derive(FromForm)] +struct OrgDetailsData { + #[field(name = "organizationId")] + organization_id: OrganizationId, + // Set by the organization reports, which also cover the members' My Items + #[field(name = "includeMemberItems", default = false)] + include_member_items: bool, +} + #[get("/ciphers/organization-details?")] -async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { +async fn get_org_details(data: OrgDetailsData, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { if data.organization_id != headers.membership.org_uuid { err_code!("Resource not found.", "Organization id's do not match", Status::NotFound.code); } + // Together with the Manager guard, this is the organization-wide cipher authority upstream requires to include + // the members' My Items as well if !headers.membership.has_full_access() { err_code!("Resource not found.", "User does not have full access", Status::NotFound.code); } Ok(Json(json!({ - "data": get_org_details_impl(&data.organization_id, &headers.host, &headers.user.uuid, &conn).await?, + "data": get_org_details_impl(&data.organization_id, &headers.host, &headers.user.uuid, data.include_member_items, &conn).await?, "object": "list", "continuationToken": null, }))) @@ -904,14 +964,32 @@ async fn get_org_details_impl( org_id: &OrganizationId, host: &str, user_id: &UserId, + include_member_items: bool, conn: &DbConn, ) -> Result { - let ciphers = Cipher::find_by_org(org_id, conn).await; + let mut ciphers = Cipher::find_by_org(org_id, conn).await; + if !include_member_items { + // Like upstream, leave out the items that are only in the members' My Items collections + let my_items_only = CollectionCipher::find_my_items_only_by_orgs(vec![org_id.clone()], conn).await; + ciphers.retain(|c| !my_items_only.contains(&c.uuid)); + } + let my_items = CollectionCipher::find_my_items_by_org(org_id, conn).await; let cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::Organization, conn).await; let mut ciphers_json = Vec::with_capacity(ciphers.len()); for c in ciphers { - ciphers_json.push(c.to_json(host, user_id, Some(&cipher_sync_data), CipherSyncType::Organization, conn).await?); + let mut details = c.to_json(host, user_id, Some(&cipher_sync_data), CipherSyncType::Organization, conn).await?; + // Like upstream, the members' items list all their collections, including the My Items ones, and the + // organization's items none of those + if let Some(cipher_my_items) = my_items.get(&c.uuid) + && let Some(collection_ids) = details["collectionIds"].as_array_mut() + { + collection_ids.retain(|id| id.as_str().is_none_or(|id| !cipher_my_items.iter().any(|c| c.as_ref() == id))); + if include_member_items { + collection_ids.extend(cipher_my_items.iter().map(|c| json!(c))); + } + } + ciphers_json.push(details); } Ok(json!(ciphers_json)) } @@ -1023,8 +1101,10 @@ struct InviteData { impl InviteData { async fn validate(&self, org_id: &OrganizationId, conn: &DbConn) -> EmptyResult { + // A My Items collection can't be assigned, it fails like a collection of another organization, like upstream let org_collections = Collection::find_by_organization(org_id, conn).await; - let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect(); + let org_collection_ids: HashSet<&CollectionId> = + org_collections.iter().filter(|c| !c.is_default_user_collection()).map(|c| &c.uuid).collect(); if let Some(e) = self.collections.iter().flatten().find(|c| !org_collection_ids.contains(&c.id)) { err!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id)) } @@ -1349,12 +1429,30 @@ async fn accept_invite( struct ConfirmData { id: Option, key: Option, + // The encrypted name of the member's My Items collection, sent by the clients whenever they confirm or restore + // a member, and when they demote one from Owner or Admin + default_user_collection_name: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct BulkConfirmData { keys: Option>, + default_user_collection_name: Option, +} + +/// Like upstream's `[EncryptedString]` and `[EncryptedStringLength(1000)]`, the name of a My Items collection has to +/// be an encrypted string: the collection can't be renamed afterwards. +fn check_default_user_collection_name(name: Option<&str>) -> EmptyResult { + if let Some(name) = name { + if name.len() > 1000 { + err!("The field DefaultUserCollectionName exceeds the maximum encrypted value length of 1000 characters.") + } + if !is_valid_enc_string(name) { + err!("DefaultUserCollectionName is not a valid encrypted string.") + } + } + Ok(()) } #[post("/organizations//users/confirm", data = "")] @@ -1369,6 +1467,7 @@ async fn bulk_confirm_invite( err!("Organization not found", "Organization id's do not match"); } let data = data.into_inner(); + check_default_user_collection_name(data.default_user_collection_name.as_deref())?; let mut bulk_response = Vec::new(); match data.keys { @@ -1376,10 +1475,14 @@ async fn bulk_confirm_invite( for invite in keys { let member_id = invite.id.unwrap(); let user_key = invite.key.unwrap_or_default(); - let err_msg = match confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await { - Ok(()) => String::new(), - Err(e) => format!("{e:?}"), - }; + let collection_name = data.default_user_collection_name.as_deref(); + let err_msg = + match confirm_invite_impl(&org_id, &member_id, &user_key, collection_name, &headers, &conn, &nt) + .await + { + Ok(()) => String::new(), + Err(e) => format!("{e:?}"), + }; bulk_response.push(json!( { @@ -1410,14 +1513,25 @@ async fn confirm_invite( nt: Notify<'_>, ) -> EmptyResult { let data = data.into_inner(); + check_default_user_collection_name(data.default_user_collection_name.as_deref())?; let user_key = data.key.unwrap_or_default(); - confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await + confirm_invite_impl( + &org_id, + &member_id, + &user_key, + data.default_user_collection_name.as_deref(), + &headers, + &conn, + &nt, + ) + .await } async fn confirm_invite_impl( org_id: &OrganizationId, member_id: &MembershipId, key: &str, + default_collection_name: Option<&str>, headers: &AdminHeaders, conn: &DbConn, nt: &Notify<'_>, @@ -1472,13 +1586,18 @@ async fn confirm_invite_impl( mail::send_invite_confirmed(&address, &org_name).await?; } - let save_result = member_to_confirm.save(conn).await; + let mut result = member_to_confirm.save(conn).await; + // Only once the membership is stored as confirmed, like upstream: a concurrent policy update then either + // finds this member confirmed or this check finds the policy enabled, so the member always gets one. + if result.is_ok() { + result = Collection::create_default_user_collection(&member_to_confirm, default_collection_name, conn).await; + } if let Some(user) = User::find_by_uuid(&member_to_confirm.user_uuid, conn).await { nt.send_user_update(UpdateType::SyncOrgKeys, &user, headers.device.push_uuid.as_ref(), conn).await; } - save_result + result } #[get("/organizations//users/mini-details", rank = 1)] @@ -1529,6 +1648,7 @@ struct EditUserData { groups: Option>, #[serde(default)] permissions: HashMap, + default_user_collection_name: Option, } #[put("/organizations//users/", data = "", rank = 1)] @@ -1554,6 +1674,7 @@ async fn edit_member( err!("Organization not found", "Organization id's do not match"); } let data: EditUserData = data.into_inner(); + check_default_user_collection_name(data.default_user_collection_name.as_deref())?; // HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission // The from_str() will convert the custom role type into a manager role type @@ -1597,6 +1718,8 @@ async fn edit_member( } } + // Upstream creates the My Items collection of a member demoted from Owner or Admin + let demoted = member_to_edit.atype >= MembershipType::Admin && new_type < MembershipType::Admin; member_to_edit.access_all = access_all; member_to_edit.atype = new_type as i32; @@ -1604,15 +1727,26 @@ async fn edit_member( // We need to perform the check after changing the type since `admin` is exempt. OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?; - // Delete all the odd collections - for c in CollectionUser::find_by_organization_and_user_uuid(&org_id, &member_to_edit.user_uuid, &conn).await { - c.delete(&conn).await?; + // Like upstream, reject a My Items collection before changing anything + let org_collections: HashMap = + Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| (c.uuid.clone(), c)).collect(); + if !access_all + && data + .collections + .iter() + .flatten() + .any(|col| org_collections.get(&col.id).is_some_and(Collection::is_default_user_collection)) + { + err!("Default collections cannot be assigned to a member.") } + // Delete all the odd collections, but keep the member's My Items + CollectionUser::delete_all_but_my_items_by_user_and_org(&member_to_edit.user_uuid, &org_id, &conn).await?; + // If no accessAll, add the collections received if !access_all { for col in data.collections.iter().flatten() { - match Collection::find_by_uuid_and_org(&col.id, &org_id, &conn).await { + match org_collections.get(&col.id) { None => err!("Collection not found in Organization"), Some(collection) => { CollectionUser::save( @@ -1650,7 +1784,17 @@ async fn edit_member( ) .await; - member_to_edit.save(&conn).await + member_to_edit.save(&conn).await?; + // Only once the demotion is stored, like upstream, see `confirm_invite_impl()` + if demoted { + Collection::create_default_user_collection( + &member_to_edit, + data.default_user_collection_name.as_deref(), + &conn, + ) + .await?; + } + Ok(()) } #[delete("/organizations//users", data = "")] @@ -1794,7 +1938,8 @@ async fn bulk_public_keys( } use super::ciphers::CipherData; -use super::ciphers::update_cipher_from_data; +use super::ciphers::{ValidatedCollections, import_folders, update_cipher_from_data}; +use super::folders::FolderData; // The import endpoint only ever uses the name/id/external_id of a collection. // Bitwarden's own server ignores `groups`/`users` here too, so do not make them @@ -1813,6 +1958,11 @@ struct ImportData { ciphers: Vec, collections: Vec, collection_relationships: Vec, + // The clients keep the folders of an import into My Items + #[serde(default)] + folders: Vec, + #[serde(default)] + folder_relationships: Vec, } #[derive(Deserialize)] @@ -1851,23 +2001,29 @@ async fn post_org_import( let existing_collections: HashMap = Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| (c.uuid.clone(), c)).collect(); - let mut collections: Vec = Vec::with_capacity(data.collections.len()); - for col in data.collections { - let existing = col.id.as_ref().and_then(|col_id| existing_collections.get(col_id)); - let collection_uuid = if let Some(collection) = existing { + // Check every collection before creating any + for col in &data.collections { + if let Some(collection) = col.id.as_ref().and_then(|col_id| existing_collections.get(col_id)) { // When not an Owner or Admin, check if the member is allowed to write to the collection. - if headers.membership.atype < MembershipType::Admin + // Only its owner can import into a My Items collection. + if (headers.membership.atype < MembershipType::Admin || collection.is_default_user_collection()) && !collection.is_writable_by_user(&headers.membership.user_uuid, &conn).await { err!(Compact, "The current user isn't allowed to manage this collection") } - collection.uuid.clone() - } else { + } else if headers.membership.atype <= MembershipType::Manager && !headers.membership.has_full_access() { // We do not allow users or managers which can not manage all collections to create new collections // If there is any collection other than an existing import collection, abort the import. - if headers.membership.atype <= MembershipType::Manager && !headers.membership.has_full_access() { - err!(Compact, "The current user isn't allowed to create new collections") - } + err!(Compact, "The current user isn't allowed to create new collections") + } + } + + let mut collections: Vec = Vec::with_capacity(data.collections.len()); + for col in data.collections { + let existing = col.id.as_ref().and_then(|col_id| existing_collections.get(col_id)); + let collection_uuid = if let Some(collection) = existing { + collection.uuid.clone() + } else { let new_collection = Collection::new(org_id.clone(), col.name, col.external_id); new_collection.save(&conn).await?; new_collection.uuid @@ -1885,10 +2041,13 @@ async fn post_org_import( let headers: Headers = headers.into(); + let folder_relations = data.folder_relationships.into_iter().map(|r| (r.key, r.value)); + let cipher_folders = import_folders(data.folders, folder_relations, &headers.user.uuid, &conn).await?; + let mut ciphers: Vec = Vec::with_capacity(data.ciphers.len()); - for mut cipher_data in data.ciphers { - // Always clear folder_id's via an organization import - cipher_data.folder_id = None; + for (index, mut cipher_data) in data.ciphers.into_iter().enumerate() { + // Only the folder relationships of the import set a folder, never the client-provided folderId + cipher_data.folder_id = cipher_folders.get(&index).cloned(); // Replace the client-provided, unvalidated organizationId with the real target org cipher_data.organization_id = Some(org_id.clone()); let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); @@ -1896,7 +2055,7 @@ async fn post_org_import( &mut cipher, cipher_data, &headers, - Some(collections.clone()), + Some(ValidatedCollections::Checked(collections.clone())), &conn, &nt, UpdateType::None, @@ -1960,22 +2119,44 @@ async fn post_bulk_collections(data: Json, headers: Headers } } + let mut ciphers = Vec::with_capacity(data.cipher_ids.len()); for cipher_id in &data.cipher_ids { // Only act on existing cipher uuid's // Do not abort the operation just ignore it, it could be a cipher was just deleted for example if let Some(cipher) = Cipher::find_by_uuid_and_org(cipher_id, &data.organization_id, &conn).await - && cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await + && cipher.is_write_accessible_to_user(&headers.user.uuid, CipherAccessScope::OrganizationAdmin, &conn).await { - // When selecting a specific collection from the left filter list, and use the bulk option, you can remove an item from that collection - // In these cases the client will call this endpoint twice, once for adding the new collections and a second for deleting. - if data.remove_collections { - for collection in &data.collection_ids { - CollectionCipher::delete(&cipher.uuid, collection, &conn).await?; - } - } else { - for collection in &data.collection_ids { - CollectionCipher::save(&cipher.uuid, collection, &conn).await?; - } + ciphers.push(cipher); + } + } + + // Only the user's own My Items collection can be among them, check all ciphers before changing any + let my_items = + user_collections.values().find(|c| c.is_default_user_collection()).filter(|_| !data.remove_collections); + if let Some(my_items) = my_items { + for cipher in &ciphers { + my_items.check_cipher_assignment( + &HashSet::from_iter(cipher.get_accessible_collections(headers.user.uuid.clone(), &conn).await), + &CollectionCipher::find_my_items_of_cipher(&cipher.uuid, &conn).await, + )?; + } + } + + for cipher in ciphers { + // When selecting a specific collection from the left filter list, and use the bulk option, you can remove an item from that collection + // In these cases the client will call this endpoint twice, once for adding the new collections and a second for deleting. + if data.remove_collections { + for collection in &data.collection_ids { + CollectionCipher::delete(&cipher.uuid, collection, &conn).await?; + } + } else { + // Moved into My Items, an unassigned cipher is taken away from the members with organization-wide access, + // so they sync as well + if my_items.is_some() { + cipher.update_users_revision(&conn).await; + } + for collection in &data.collection_ids { + CollectionCipher::save(&cipher.uuid, collection, &conn).await?; } } } @@ -2076,10 +2257,14 @@ struct PolicyData { #[derive(Deserialize)] struct PutPolicy { policy: PolicyData, - // Ignore metadata for now as we do not yet support this - // "metadata": { - // "defaultUserCollectionName": "2.xx|xx==|xx=" - // } + metadata: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PolicyMetadata { + // Sent with the organization data ownership policy, see `ConfirmData` + default_user_collection_name: Option, } #[put("/organizations//policies/", data = "")] @@ -2093,11 +2278,17 @@ async fn put_policy( if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } - let data: PolicyData = data.into_inner().policy; + let PutPolicy { + policy: data, + metadata, + } = data.into_inner(); let Some(pol_type_enum) = OrgPolicyType::from_i32(pol_type) else { err!("Invalid or unsupported policy type") }; + if pol_type_enum == OrgPolicyType::PersonalOwnership { + check_default_user_collection_name(metadata.as_ref().and_then(|m| m.default_user_collection_name.as_deref()))?; + } // 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 @@ -2198,6 +2389,18 @@ async fn put_policy( ) .await; + // Only once the policy is stored, like upstream: a concurrent confirm, restore or demotion then either finds + // the policy enabled or is among the confirmed members loaded here. + // Unlike upstream, this runs on every enabled update, not only when the policy gets enabled. The clients send + // the name on every save, so saving the policy again creates the ones still missing: those of the members of + // organizations that enabled the policy before upgrading, of members confirmed by clients that sent no name, + // and of a failed earlier attempt. + if pol_type_enum == OrgPolicyType::PersonalOwnership && data.enabled { + let collection_name = metadata.as_ref().and_then(|m| m.default_user_collection_name.as_deref()); + let members = Membership::find_confirmed_by_org(&org_id, &conn).await; + Collection::create_default_user_collections(&members, collection_name, &conn).await?; + } + Ok(Json(policy.to_json())) } @@ -2290,6 +2493,39 @@ async fn revoke_member( revoke_member_impl(&org_id, &member_id, &headers, &conn).await } +// Called by the clients when a member declines to transfer their personal items into their My Items collection, +// as the organization data ownership policy asks of them. +#[put("/organizations//users/revoke-self")] +async fn revoke_self(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn, nt: Notify<'_>) -> EmptyResult { + if org_id != headers.membership.org_uuid { + err!("Organization not found", "Organization id's do not match"); + } + let mut member = headers.membership; + + if !OrgPolicy::is_personal_ownership_enforced_for(&member, &conn).await { + err!( + "User is not eligible for self-revocation. The organization data ownership policy must be enabled and the user must be a confirmed member." + ) + } + + member.revoke(); + member.save(&conn).await?; + + log_event( + EventType::OrganizationUserSelfRevoked, + &member.uuid, + &org_id, + &headers.user.uuid, + headers.device.atype, + &headers.ip.ip, + &conn, + ) + .await; + + nt.send_user_update(UpdateType::SyncOrgKeys, &headers.user, headers.device.push_uuid.as_ref(), &conn).await; + Ok(()) +} + #[put("/organizations//users/revoke", data = "")] async fn bulk_revoke_members( org_id: OrganizationId, @@ -2373,16 +2609,23 @@ async fn revoke_member_impl( Ok(()) } -#[put("/organizations//users//restore/vnext")] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RestoreMemberData { + default_user_collection_name: Option, +} + +#[put("/organizations//users//restore/vnext", data = "")] async fn restore_member_vnext( org_id: OrganizationId, member_id: MembershipId, + data: Json, headers: AdminHeaders, conn: DbConn, ) -> EmptyResult { - // Vaultwarden does not (yet) support the per User Collection linked to the `Enforce organization data ownership` policy. - // Therefor we ignore the `defaultUserCollectionName` data sent and just call restore_member - restore_member_impl(&org_id, &member_id, &headers, &conn).await + let collection_name = data.into_inner().default_user_collection_name; + check_default_user_collection_name(collection_name.as_deref())?; + restore_member_impl(&org_id, &member_id, collection_name.as_deref(), &headers, &conn).await } #[put("/organizations//users//restore")] @@ -2392,13 +2635,20 @@ async fn restore_member( headers: AdminHeaders, conn: DbConn, ) -> EmptyResult { - restore_member_impl(&org_id, &member_id, &headers, &conn).await + restore_member_impl(&org_id, &member_id, None, &headers, &conn).await +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct BulkRestoreMemberData { + ids: Vec, + default_user_collection_name: Option, } #[put("/organizations//users/restore", data = "")] async fn bulk_restore_members( org_id: OrganizationId, - data: Json, + data: Json, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { @@ -2406,10 +2656,12 @@ async fn bulk_restore_members( err!("Organization not found", "Organization id's do not match"); } let data = data.into_inner(); + let collection_name = data.default_user_collection_name.as_deref(); + check_default_user_collection_name(collection_name)?; let mut bulk_response = Vec::new(); for member_id in data.ids { - let err_msg = match restore_member_impl(&org_id, &member_id, &headers, &conn).await { + let err_msg = match restore_member_impl(&org_id, &member_id, collection_name, &headers, &conn).await { Ok(()) => String::new(), Err(e) => format!("{e:?}"), }; @@ -2433,6 +2685,7 @@ async fn bulk_restore_members( async fn restore_member_impl( org_id: &OrganizationId, member_id: &MembershipId, + default_collection_name: Option<&str>, headers: &AdminHeaders, conn: &DbConn, ) -> EmptyResult { @@ -2464,6 +2717,9 @@ async fn restore_member_impl( conn, ) .await; + + // Only once the restore is stored, like upstream, see `confirm_invite_impl()` + Collection::create_default_user_collection(&member, default_collection_name, conn).await?; } Some(_) => err!("User is already active"), None => err!("User not found in organization"), @@ -2566,6 +2822,12 @@ impl GroupRequest { if let Some(e) = self.collections.iter().find(|c| !org_collection_ids.contains(&c.id)) { err!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id)) } + if org_collections + .iter() + .any(|c| c.is_default_user_collection() && self.collections.iter().any(|g| g.id == c.uuid)) + { + err!("You cannot modify group access for collections with the type as DefaultUserCollection.") + } let org_memberships = Membership::find_by_org(org_id, conn).await; let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect(); @@ -3231,7 +3493,7 @@ async fn get_org_export(org_id: OrganizationId, headers: AdminHeaders, conn: DbC Ok(Json(json!({ "collections": convert_json_key_lcase_first(get_org_collections_impl(&org_id, &conn).await), - "ciphers": convert_json_key_lcase_first(get_org_details_impl(&org_id, &headers.host, &headers.user.uuid, &conn).await?), + "ciphers": convert_json_key_lcase_first(get_org_details_impl(&org_id, &headers.host, &headers.user.uuid, false, &conn).await?), }))) } diff --git a/src/config.rs b/src/config.rs index 9f0ae2e1..99487725 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1445,6 +1445,7 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[ "pm-30529-webauthn-related-origins", // Vault Team "pm-32009-new-item-types", + "pm-20558-migrate-myvault-to-myitems", ]; impl Config { diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index 4a8ba1c1..fcd620ec 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -28,6 +28,49 @@ use super::{ MembershipStatus, MembershipType, OrganizationId, User, UserId, }; +/// Which routes a cipher operation is authorized for. Vaultwarden serves the organization's administrative cipher +/// routes (`/ciphers//admin` and friends) from the same handlers as the regular vault routes, so the handlers +/// state it explicitly. +/// +/// It only makes a difference for the items stored only in a member's My Items collection: like upstream, members +/// with organization-wide cipher authority reach those through the administrative routes, never in their vault. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CipherAccessScope { + User, + OrganizationAdmin, +} + +impl CipherAccessScope { + /// The scope of the v2 attachment create, the one route that states it (`adminRequest`), like upstream's + /// `PostAttachment`. It only selects which check runs, never what it answers. + pub fn requested(admin_request: Option) -> Self { + if admin_request == Some(true) { + Self::OrganizationAdmin + } else { + Self::User + } + } + + /// The scope of a route that isn't told which one to use, the second leg of the v2 attachment upload. Like + /// upstream's `PostFileForExistingAttachment`, it follows the caller's own membership, not the request. + pub fn for_member(membership: Option<&Membership>) -> Self { + if membership.is_some_and(may_administer_org_ciphers) { + Self::OrganizationAdmin + } else { + Self::User + } + } +} + +/// Upstream's organization-wide cipher authority (`ViewAllCollections`, `CanEditAllCiphersAsync`): a confirmed Owner +/// or Admin, or a Manager with access to all collections, which the clients show as a Custom member holding +/// `Edit any collection`. +fn may_administer_org_ciphers(membership: &Membership) -> bool { + membership.has_status(MembershipStatus::Confirmed) + && (membership.atype >= MembershipType::Admin + || (membership.atype == MembershipType::Manager && membership.access_all)) +} + #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = ciphers)] #[diesel(treat_none_as_null = true)] @@ -179,7 +222,15 @@ impl Cipher { // We don't need these values at all for Organizational syncs // Skip any other database calls if this is the case and just return false. let (read_only, hide_passwords, _) = if sync_type == CipherSyncType::User { - if let Some((ro, hp, mn)) = self.get_access_restrictions(user_uuid, cipher_sync_data, conn).await { + let mut restrictions = + self.get_access_restrictions(user_uuid, CipherAccessScope::User, cipher_sync_data, conn).await; + if restrictions.is_none() { + // A cipher that only the administrative routes open, answered from one of them + restrictions = self + .get_access_restrictions(user_uuid, CipherAccessScope::OrganizationAdmin, cipher_sync_data, conn) + .await; + } + if let Some((ro, hp, mn)) = restrictions { (ro, hp, mn) } else { error!("Cipher ownership assertion failure"); @@ -417,6 +468,18 @@ impl Cipher { None => { // Belongs to Organization, need to update affected users if let Some(ref org_uuid) = self.organization_uuid { + // My Items-only ciphers belong in the owner's vault, not in the vault/revision stream of + // members that merely have organization- or group-wide access. + if let Some(owners) = CollectionCipher::find_my_items_owners_if_only(&self.uuid, conn).await { + for owner in owners { + if Membership::find_confirmed_by_user_and_org(&owner, org_uuid, conn).await.is_some() { + User::update_uuid_revision(&owner, conn).await; + user_uuids.push(owner); + } + } + return user_uuids; + } + // users having access to the collection let mut collection_users = Membership::find_by_cipher_and_org(&self.uuid, org_uuid, conn).await; if CONFIG.org_groups_enabled() { @@ -461,6 +524,64 @@ impl Cipher { } } + /// Saves the cipher and adds it to the collections in one transaction, so it can't end up in the organization + /// without them. + pub async fn save_with_collections(&mut self, collection_uuids: &[CollectionId], conn: &DbConn) -> EmptyResult { + self.update_users_revision(conn).await; + self.updated_at = Utc::now().naive_utc(); + + let rows: Vec<_> = collection_uuids + .iter() + .map(|c| (ciphers_collections::cipher_uuid.eq(&self.uuid), ciphers_collections::collection_uuid.eq(c))) + .collect(); + let cipher = &*self; + db_run! { conn: + mysql { + conn.transaction::<_, diesel::result::Error, _>(|conn| { + diesel::insert_into(ciphers::table) + .values(cipher) + .on_conflict(diesel::dsl::DuplicatedKeys) + .do_update() + .set(cipher) + .execute(conn)?; + if !rows.is_empty() { + diesel::insert_into(ciphers_collections::table) + .values(&rows) + .on_conflict(diesel::dsl::DuplicatedKeys) + .do_nothing() + .execute(conn)?; + } + Ok(()) + }) + .map_res("Error saving cipher") + } + postgresql, sqlite { + conn.transaction::<_, diesel::result::Error, _>(|conn| { + diesel::insert_into(ciphers::table) + .values(cipher) + .on_conflict(ciphers::uuid) + .do_update() + .set(cipher) + .execute(conn)?; + if !rows.is_empty() { + diesel::insert_into(ciphers_collections::table) + .values(&rows) + .on_conflict((ciphers_collections::cipher_uuid, ciphers_collections::collection_uuid)) + .do_nothing() + .execute(conn)?; + } + Ok(()) + }) + .map_res("Error saving cipher") + } + }?; + + for collection_uuid in collection_uuids { + CollectionCipher::update_users_revision(collection_uuid, conn).await; + } + Ok(()) + } + pub async fn delete(&self, conn: &DbConn) -> EmptyResult { self.update_users_revision(conn).await; @@ -485,6 +606,24 @@ impl Cipher { Ok(()) } + /// Purges the organization vault like upstream's `Cipher_DeleteByOrganizationId`: the items stored in a + /// member's My Items collection are kept, they are only removed from the shared collections. + pub async fn purge_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { + let my_items = CollectionCipher::find_my_items_by_org(org_uuid, conn).await; + for cipher in Self::find_by_org(org_uuid, conn).await { + if !my_items.contains_key(&cipher.uuid) { + cipher.delete(conn).await?; + } + } + + if !my_items.is_empty() { + CollectionCipher::delete_all_shared_by_organization(org_uuid, conn).await?; + let members = Membership::find_by_org(org_uuid, conn).await; + User::update_uuid_revisions(members.into_iter().map(|m| m.user_uuid).collect(), conn).await; + } + Ok(()) + } + pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { for cipher in Self::find_owned_by_user(user_uuid, conn).await { cipher.delete(conn).await?; @@ -543,23 +682,36 @@ impl Cipher { self.user_uuid.is_some() && self.user_uuid.as_ref().unwrap() == user_uuid } - /// Returns whether this cipher is owned by an org in which the user has full access. - async fn is_in_full_access_org( + /// What the user's membership in the cipher's organization grants over all of its ciphers: whether it has + /// full access, and whether it holds organization-wide cipher authority. Both are false without a confirmed + /// membership. + async fn org_wide_access( &self, user_uuid: &UserId, cipher_sync_data: Option<&CipherSyncData>, conn: &DbConn, - ) -> bool { - if let Some(ref org_uuid) = self.organization_uuid { - if let Some(cipher_sync_data) = cipher_sync_data { - if let Some(cached_member) = cipher_sync_data.members.get(org_uuid) { - return cached_member.has_full_access(); - } - } else if let Some(member) = Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn).await { - return member.has_full_access(); - } + ) -> (bool, bool) { + let Some(ref org_uuid) = self.organization_uuid else { + return (false, false); + }; + let access = |member: &Membership| (member.has_full_access(), may_administer_org_ciphers(member)); + if let Some(cipher_sync_data) = cipher_sync_data { + cipher_sync_data.members.get(org_uuid).map_or((false, false), access) + } else { + Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn) + .await + .as_ref() + .map_or((false, false), access) + } + } + + /// Whether the cipher is assigned to My Items collections only. + async fn is_my_items_only(&self, cipher_sync_data: Option<&CipherSyncData>, conn: &DbConn) -> bool { + if let Some(cipher_sync_data) = cipher_sync_data { + cipher_sync_data.my_items_only_ciphers.contains(&self.uuid) + } else { + CollectionCipher::find_my_items_owners_if_only(&self.uuid, conn).await.is_some() } - false } /// Returns whether this cipher is owned by an group in which the user has full access. @@ -589,15 +741,31 @@ impl Cipher { pub async fn get_access_restrictions( &self, user_uuid: &UserId, + scope: CipherAccessScope, cipher_sync_data: Option<&CipherSyncData>, conn: &DbConn, ) -> Option<(bool, bool, bool)> { + if let Some(org_uuid) = self.organization_uuid.as_ref() + && let Some(cipher_sync_data) = cipher_sync_data + && !cipher_sync_data.members.contains_key(org_uuid) + { + // Cached collection rows can outlive a revoked membership. They are not effective without a confirmed + // membership in the same organization. + return None; + } + // Check whether this cipher is directly owned by the user, or is in // a collection that the user has full access to. If so, there are no // access restrictions. - if self.is_owned_by_user(user_uuid) - || self.is_in_full_access_org(user_uuid, cipher_sync_data, conn).await - || self.is_in_full_access_group(user_uuid, cipher_sync_data, conn).await + if self.is_owned_by_user(user_uuid) { + return Some((false, false, true)); + } + let (full_access, administers) = self.org_wide_access(user_uuid, cipher_sync_data, conn).await; + // Organization-wide access doesn't reach the items stored only in a member's My Items collection. Like + // upstream, only the administrative routes open those, for members with organization-wide cipher authority. + if (full_access || self.is_in_full_access_group(user_uuid, cipher_sync_data, conn).await) + && ((scope == CipherAccessScope::OrganizationAdmin && administers) + || !self.is_my_items_only(cipher_sync_data, conn).await) { return Some((false, false, true)); } @@ -718,8 +886,13 @@ impl Cipher { .await } - pub async fn is_write_accessible_to_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { - match self.get_access_restrictions(user_uuid, None, conn).await { + pub async fn is_write_accessible_to_user( + &self, + user_uuid: &UserId, + scope: CipherAccessScope, + conn: &DbConn, + ) -> bool { + match self.get_access_restrictions(user_uuid, scope, None, conn).await { Some((read_only, _hide_passwords, manage)) => !read_only || manage, None => false, } @@ -727,15 +900,20 @@ impl Cipher { // used for checking if collection can be edited (only if user has access to a collection they // can write to and also passwords are not hidden to prevent privilege escalation) - pub async fn is_in_editable_collection_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { - match self.get_access_restrictions(user_uuid, None, conn).await { + pub async fn is_in_editable_collection_by_user( + &self, + user_uuid: &UserId, + scope: CipherAccessScope, + conn: &DbConn, + ) -> bool { + match self.get_access_restrictions(user_uuid, scope, None, conn).await { Some((read_only, hide_passwords, manage)) => (!read_only && !hide_passwords) || manage, None => false, } } - pub async fn is_accessible_to_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { - self.get_access_restrictions(user_uuid, None, conn).await.is_some() + pub async fn is_accessible_to_user(&self, user_uuid: &UserId, scope: CipherAccessScope, conn: &DbConn) -> bool { + self.get_access_restrictions(user_uuid, scope, None, conn).await.is_some() } // Returns whether this cipher is a favorite of the specified user. @@ -813,10 +991,12 @@ impl Cipher { cipher_uuids: &Vec, conn: &DbConn, ) -> Vec { + // access_all and admin rights don't reach the items stored only in another member's My Items collection if CONFIG.org_groups_enabled() { conn.run(move |conn| { let mut query = ciphers::table .left_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid))) + .left_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid))) .left_join( users_organizations::table.on(ciphers::organization_uuid .eq(users_organizations::org_uuid.nullable()) @@ -844,15 +1024,17 @@ impl Cipher { .and(collections_groups::groups_uuid.eq(groups::uuid))), ) .filter(ciphers::user_uuid.eq(user_uuid)) // Cipher owner - .or_filter(users_organizations::access_all.eq(true)) // access_all in org + .or_filter(users_organizations::access_all.eq(true).and(collections::default_user_uuid.is_null())) // access_all in org .or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection - .or_filter(groups::access_all.eq(true)) // Access via groups + .or_filter(groups::access_all.eq(true).and(collections::default_user_uuid.is_null())) // Access via groups .or_filter(collections_groups::collections_uuid.is_not_null()) // Access via groups .into_boxed(); if !visible_only { query = query.or_filter( - users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner + users_organizations::atype + .le(MembershipType::Admin as i32) // Org admin/owner + .and(collections::default_user_uuid.is_null()), ); } @@ -868,6 +1050,7 @@ impl Cipher { conn.run(move |conn| { let mut query = ciphers::table .left_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid))) + .left_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid))) .left_join( users_organizations::table.on(ciphers::organization_uuid .eq(users_organizations::org_uuid.nullable()) @@ -881,13 +1064,15 @@ impl Cipher { .and(users_organizations::user_uuid.eq(users_collections::user_uuid))), ) .filter(ciphers::user_uuid.eq(user_uuid)) // Cipher owner - .or_filter(users_organizations::access_all.eq(true)) // access_all in org + .or_filter(users_organizations::access_all.eq(true).and(collections::default_user_uuid.is_null())) // access_all in org .or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection .into_boxed(); if !visible_only { query = query.or_filter( - users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner + users_organizations::atype + .le(MembershipType::Admin as i32) // Org admin/owner + .and(collections::default_user_uuid.is_null()), ); } @@ -1007,10 +1192,11 @@ impl Cipher { .filter( users_organizations::access_all .eq(true) // User has access all + .and(collections::default_user_uuid.is_null()) .or(users_collections::user_uuid .eq(user_uuid) // User has access to collection .and(users_collections::read_only.eq(false))) - .or(groups::access_all.eq(true)) // Access via groups + .or(groups::access_all.eq(true).and(collections::default_user_uuid.is_null())) // Access via groups .or(collections_groups::collections_uuid .is_not_null() // Access via groups .and(collections_groups::read_only.eq(false))), @@ -1039,6 +1225,7 @@ impl Cipher { .filter( users_organizations::access_all .eq(true) // User has access all + .and(collections::default_user_uuid.is_null()) .or(users_collections::user_uuid .eq(user_uuid) // User has access to collection .and(users_collections::read_only.eq(false))), @@ -1051,6 +1238,17 @@ impl Cipher { } } + /// The cipher's collections the user sees it in, whatever they may do there: its `collectionIds` in the user's + /// sync, and upstream's `GetManyByUserIdCipherIdAsync`. Unlike `get_collections()`, whose callers need the + /// writable assignments. + pub async fn get_accessible_collections(&self, user_uuid: UserId, conn: &DbConn) -> Vec { + Self::get_collections_with_cipher_by_user(user_uuid, Some(self.uuid.clone()), conn) + .await + .into_iter() + .map(|(_, collection_uuid)| collection_uuid) + .collect() + } + pub async fn get_admin_collections(&self, user_uuid: UserId, conn: &DbConn) -> Vec { if CONFIG.org_groups_enabled() { conn.run(move |conn| { @@ -1084,14 +1282,17 @@ impl Cipher { .filter( users_organizations::access_all .eq(true) // User has access all + .and(collections::default_user_uuid.is_null()) .or(users_collections::user_uuid .eq(user_uuid) // User has access to collection .and(users_collections::read_only.eq(false))) - .or(groups::access_all.eq(true)) // Access via groups + .or(groups::access_all.eq(true).and(collections::default_user_uuid.is_null())) // Access via groups .or(collections_groups::collections_uuid .is_not_null() // Access via groups .and(collections_groups::read_only.eq(false))) - .or(users_organizations::atype.le(MembershipType::Admin as i32)), // User is admin or owner + .or(users_organizations::atype + .le(MembershipType::Admin as i32) // User is admin or owner + .and(collections::default_user_uuid.is_null())), ) .select(ciphers_collections::collection_uuid) .load::(conn) @@ -1117,10 +1318,13 @@ impl Cipher { .filter( users_organizations::access_all .eq(true) // User has access all + .and(collections::default_user_uuid.is_null()) .or(users_collections::user_uuid .eq(user_uuid) // User has access to collection .and(users_collections::read_only.eq(false))) - .or(users_organizations::atype.le(MembershipType::Admin as i32)), // User is admin or owner + .or(users_organizations::atype + .le(MembershipType::Admin as i32) // User is admin or owner + .and(collections::default_user_uuid.is_null())), ) .select(ciphers_collections::collection_uuid) .load::(conn) @@ -1132,12 +1336,14 @@ impl Cipher { /// Return a Vec with (cipher_uuid, collection_uuid) /// This is used during a full sync so we only need one query for all collections accessible. + /// With `cipher_uuid`, only the ones of that cipher. pub async fn get_collections_with_cipher_by_user( user_uuid: UserId, + cipher_uuid: Option, conn: &DbConn, ) -> Vec<(CipherId, CollectionId)> { conn.run(move |conn| { - ciphers_collections::table + let mut query = ciphers_collections::table .inner_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid))) .inner_join( users_organizations::table.on(users_organizations::org_uuid @@ -1160,12 +1366,23 @@ impl Cipher { .eq(ciphers_collections::collection_uuid) .and(collections_groups::groups_uuid.eq(groups::uuid))), ) + // access_all and admin rights don't reach another member's My Items collection .or_filter(users_collections::user_uuid.eq(user_uuid)) // User has access to collection - .or_filter(users_organizations::access_all.eq(true)) // User has access all - .or_filter(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner - .or_filter(groups::access_all.eq(true)) //Access via group + .or_filter(users_organizations::access_all.eq(true).and(collections::default_user_uuid.is_null())) // User has access all + .or_filter( + users_organizations::atype + .le(MembershipType::Admin as i32) // User is admin or owner + .and(collections::default_user_uuid.is_null()), + ) + .or_filter(groups::access_all.eq(true).and(collections::default_user_uuid.is_null())) //Access via group .or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) + .into_boxed(); + // After the alternatives above, so it narrows all of them + if let Some(cipher_uuid) = cipher_uuid { + query = query.filter(ciphers_collections::cipher_uuid.eq(cipher_uuid)); + } + query .select(ciphers_collections::all_columns) .distinct() .load::<(CipherId, CollectionId)>(conn) diff --git a/src/db/models/collection.rs b/src/db/models/collection.rs index 21387e4d..69f8e275 100644 --- a/src/db/models/collection.rs +++ b/src/db/models/collection.rs @@ -1,6 +1,13 @@ +use std::collections::{HashMap, HashSet}; + use derive_more::{AsRef, Deref, Display, From}; -use diesel::prelude::*; +use diesel::{ + dsl::{count, count_star}, + prelude::*, + result::{DatabaseErrorKind, Error as DieselError}, +}; use serde_json::Value; +use tokio::time::{Duration, sleep}; use crate::{ CONFIG, @@ -17,8 +24,8 @@ use crate::{ use macros::UuidFromParam; use super::{ - CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, - User, UserId, + CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, + OrganizationId, User, UserId, }; // See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs @@ -31,6 +38,10 @@ pub struct Collection { pub org_uuid: OrganizationId, pub name: String, pub external_id: Option, + /// The member whose "My Items" collection (upstream's `DefaultUserCollection`) this is, `None` for a shared one. + pub default_user_uuid: Option, + /// The email address of the former owner of a My Items collection that was turned into a shared one. + pub default_user_collection_email: Option, } #[derive(Identifiable, Queryable, Insertable)] @@ -60,23 +71,54 @@ impl Collection { org_uuid, name, external_id: None, + default_user_uuid: None, + default_user_collection_email: None, }; new_model.set_external_id(external_id); new_model } + pub fn is_default_user_collection(&self) -> bool { + self.default_user_uuid.is_some() + } + + /// Like upstream, an item that is only in shared collections can't be put (back) into a My Items collection. + /// `current_collections` are the item's collections the acting user sees, before the change. Neither can an + /// item of another member's My Items, which the acting user may administer but doesn't see: + /// `cipher_my_items` are all the My Items collections the item is in. + pub fn check_cipher_assignment( + &self, + current_collections: &HashSet, + cipher_my_items: &HashSet, + ) -> EmptyResult { + if !self.is_default_user_collection() { + return Ok(()); + } + if !current_collections.is_empty() && !current_collections.contains(&self.uuid) { + err!( + "The cipher(s) cannot be assigned to a default collection when only assigned to non-default collections." + ) + } + if cipher_my_items.iter().any(|c| *c != self.uuid) { + err!( + "The cipher(s) cannot be assigned to a default collection when in another member's default collection." + ) + } + Ok(()) + } + pub fn to_json(&self) -> Value { json!({ "externalId": self.external_id, "id": self.uuid, "organizationId": self.org_uuid, "name": self.name, - // Collection types are either 0: SharedCollection or 1: DefaultUserCollection, of which we do not yet support DefaultUserCollection. + // Collection types are either 0: SharedCollection or 1: DefaultUserCollection ("My Items"). // See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Enums/CollectionType.cs - "type": 0, - // This is only used together with MyItems/DefaultUserCollection, which we do not yet support. - "defaultUserCollectionEmail": null, + "type": i32::from(self.is_default_user_collection()), + // Set when a My Items collection outlived its owner's membership, the clients show it as the name. + "defaultUserCollectionEmail": self.default_user_collection_email, "object": "collection", }) } @@ -108,8 +150,9 @@ impl Collection { // Owners and Admins always have true. Users are not able to have full access Some(m) if m.has_full_access() => (false, false, m.atype >= MembershipType::Manager), Some(m) => { - // Only let a manager manage collections when the have full read/write access - let is_manager = m.atype == MembershipType::Manager; + // Only let a manager manage collections when the have full read/write access. + // The owner of a My Items collection, its only member, manages it whatever their type, like upstream. + let is_manager = m.atype == MembershipType::Manager || self.is_default_user_collection(); if let Some(cu) = cipher_sync_data.user_collections.get(&self.uuid) { ( cu.read_only, @@ -135,7 +178,7 @@ impl Collection { (false, false, true) } Some(m) => { - let is_manager = m.atype == MembershipType::Manager; + let is_manager = m.atype == MembershipType::Manager || self.is_default_user_collection(); let read_only = !self.is_writable_by_user(user_uuid, conn).await; let hide_passwords = self.hide_passwords_for_user(user_uuid, conn).await; (read_only, hide_passwords, is_manager && !read_only && !hide_passwords) @@ -167,6 +210,20 @@ impl Collection { pub async fn save(&self, conn: &DbConn) -> EmptyResult { self.update_users_revision(conn).await; + if self.is_default_user_collection() { + // Only ever updated here. They are created by `create_default_user_collections()`: on MySQL, the upsert + // below also fires on the unique `(org_uuid, default_user_uuid)` index and would overwrite the member's + // existing My Items collection. + return conn + .run(move |conn| { + diesel::update(collections::table.filter(collections::uuid.eq(&self.uuid))) + .set(self) + .execute(conn) + .map_res("Error saving collection") + }) + .await; + } + db_run! { conn: mysql { diesel::insert_into(collections::table) @@ -211,6 +268,12 @@ impl Collection { } pub async fn update_users_revision(&self, conn: &DbConn) { + if let Some(owner) = &self.default_user_uuid { + if Membership::find_confirmed_by_user_and_org(owner, &self.org_uuid, conn).await.is_some() { + User::update_uuid_revision(owner, conn).await; + } + return; + } for member in &Membership::find_by_collection_and_org(&self.uuid, &self.org_uuid, conn).await { User::update_uuid_revision(&member.user_uuid, conn).await; } @@ -251,12 +314,13 @@ impl Collection { .filter( users_collections::user_uuid .eq(user_uuid) + // access_all never reaches another member's My Items collection .or( // Directly accessed collection - users_organizations::access_all.eq(true), // access_all in Organization + users_organizations::access_all.eq(true).and(collections::default_user_uuid.is_null()), // access_all in Organization ) .or( - groups::access_all.eq(true), // access_all in groups + groups::access_all.eq(true).and(collections::default_user_uuid.is_null()), // access_all in groups ) .or( // access via groups @@ -287,7 +351,8 @@ impl Collection { .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter(users_collections::user_uuid.eq(user_uuid).or( // Directly accessed collection - users_organizations::access_all.eq(true), // access_all in Organization + // access_all in Organization, which never reaches another member's My Items collection + users_organizations::access_all.eq(true).and(collections::default_user_uuid.is_null()), )) .select(collections::all_columns) .distinct() @@ -339,6 +404,199 @@ impl Collection { .await } + pub async fn find_default_by_user_and_org( + user_uuid: &UserId, + org_uuid: &OrganizationId, + conn: &DbConn, + ) -> Option { + conn.run(move |conn| { + collections::table + .filter(collections::org_uuid.eq(org_uuid)) + .filter(collections::default_user_uuid.eq(user_uuid)) + .first::(conn) + .ok() + }) + .await + } + + /// Creates the member's My Items collection, with the name the client encrypted for it, when upstream does: + /// the member is confirmed, not an Owner or Admin, and the organization data ownership policy is enabled. + /// Without a name nothing is created, like upstream. + pub async fn create_default_user_collection(member: &Membership, name: Option<&str>, conn: &DbConn) -> EmptyResult { + if !OrgPolicy::is_personal_ownership_enforced_for(member, conn).await { + return Ok(()); + } + Self::create_default_user_collections(std::slice::from_ref(member), name, conn).await + } + + /// Creates all missing My Items collections for an enabled organization data ownership policy in one + /// transaction, and gives the members a missing access to their existing one back, which a member update racing + /// the creation could remove in earlier versions. The caller is responsible for checking that the policy is + /// enabled. Re-running this is safe and repairs incomplete provisioning from an earlier request. The unique index + /// on `(org_uuid, default_user_uuid)` makes sure a member never gets a second one, also when requests race. + pub async fn create_default_user_collections( + members: &[Membership], + name: Option<&str>, + conn: &DbConn, + ) -> EmptyResult { + let Some(name) = name.filter(|n| !n.trim().is_empty()) else { + return Ok(()); + }; + + let eligible: Vec<(OrganizationId, UserId)> = members + .iter() + .filter(|member| member.has_status(MembershipStatus::Confirmed) && member.atype < MembershipType::Admin) + .map(|member| (member.org_uuid.clone(), member.user_uuid.clone())) + .collect(); + let Some((org_uuid, _)) = eligible.first() else { + return Ok(()); + }; + if eligible.iter().any(|(member_org_uuid, _)| member_org_uuid != org_uuid) { + err!("Cannot create My Items collections for members of different organizations") + } + let org_uuid = org_uuid.clone(); + let user_uuids: Vec = eligible.into_iter().map(|(_, user_uuid)| user_uuid).collect(); + + // The body of the transaction below, for the members it found still eligible: gives them back a missing + // access to their existing My Items collection and creates the missing ones. Returns the members whose + // access changed. + macro_rules! provision { + ($conn:ident, $org_uuid:ident, $members:ident, $name:ident) => {{ + let missing_access = collections::table + .left_join( + users_collections::table.on(users_collections::collection_uuid + .eq(collections::uuid) + .and(users_collections::user_uuid.nullable().eq(collections::default_user_uuid))), + ) + .filter(collections::org_uuid.eq(&$org_uuid)) + .filter(collections::default_user_uuid.eq_any(&$members)) + .filter(users_collections::user_uuid.is_null()) + .select(( + collections::default_user_uuid.assume_not_null(), + collections::uuid, + false.into_sql::(), + false.into_sql::(), + true.into_sql::(), + )); + let restored = diesel::insert_into(users_collections::table) + .values(missing_access) + .into_columns(( + users_collections::user_uuid, + users_collections::collection_uuid, + users_collections::read_only, + users_collections::hide_passwords, + users_collections::manage, + )) + .execute($conn)?; + let existing: HashSet = collections::table + .filter(collections::org_uuid.eq(&$org_uuid)) + .filter(collections::default_user_uuid.eq_any(&$members)) + .select(collections::default_user_uuid) + .load::>($conn)? + .into_iter() + .flatten() + .collect(); + + let mut created = Vec::new(); + for user_uuid in $members.iter().filter(|user_uuid| !existing.contains(*user_uuid)) { + let mut collection = Self::new($org_uuid.clone(), $name.clone(), None); + collection.default_user_uuid = Some(user_uuid.clone()); + let owner_access = CollectionUser { + user_uuid: user_uuid.clone(), + collection_uuid: collection.uuid.clone(), + read_only: false, + hide_passwords: false, + manage: true, + }; + diesel::insert_into(collections::table).values(&collection).execute($conn)?; + diesel::insert_into(users_collections::table).values(&owner_access).execute($conn)?; + created.push(user_uuid.clone()); + } + Ok::<_, DieselError>(if restored > 0 { + $members + } else { + created + }) + }}; + } + + // A concurrent request can create one of these between the read and insert. Retry when the unique index + // rejects the insert, which MySQL and MariaDB often report as a deadlock instead of a unique violation; the + // next read omits the collections created by the racer. + for attempt in 0..3 { + if attempt > 0 { + // Give the concurrent request time to commit, so the next read sees what it created + sleep(Duration::from_millis(100)).await; + } + let (org_uuid, name) = (org_uuid.clone(), name.to_owned()); + // Only the members that are still confirmed, and neither an Owner nor an Admin, when the transaction runs + // get one. The members were loaded before, and a removal converting their My Items collection could have + // run meanwhile or run concurrently: see `Membership::delete()`, which removes the membership first. + let eligible = users_organizations::table + .filter(users_organizations::org_uuid.eq(org_uuid.clone())) + .filter(users_organizations::user_uuid.eq_any(user_uuids.clone())) + .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) + .filter( + users_organizations::atype.eq_any([MembershipType::User as i32, MembershipType::Manager as i32]), + ) + .select(users_organizations::user_uuid); + let result = db_run! { conn: + mysql, postgresql { + // Locking the memberships makes a concurrent removal wait until the collections are created, so + // it converts them, or makes this wait until the removal is done, so it doesn't find the member. + conn.transaction(|conn| { + let members = eligible.for_update().load::(conn)?; + provision!(conn, org_uuid, members, name) + }) + } + sqlite { + // No row locks: taking the database write lock before reading serializes this with the removal. + conn.immediate_transaction(|conn| { + let members = eligible.load::(conn)?; + provision!(conn, org_uuid, members, name) + }) + } + }; + + match result { + Ok(changed) => { + User::update_uuid_revisions(changed, conn).await; + return Ok(()); + } + Err(DieselError::DatabaseError( + DatabaseErrorKind::UniqueViolation | DatabaseErrorKind::SerializationFailure, + _, + )) => {} + Err(e) => return Err::<(), _>(e).map_res("Error creating My Items collections"), + } + } + + err!("A concurrent request kept changing the My Items collections; please retry") + } + + /// Offboards what remains of a deleted account in the organizations, once its memberships are removed, like + /// upstream's `User_DeleteById`: turns the My Items collections still attributed to the user into shared ones + /// named by their email address, and removes their collection access, which would otherwise block the deletion. + /// A member removal racing the creation of a My Items collection could leave both behind in earlier versions. + pub async fn release_all_by_user(user_uuid: &UserId, email: &str, conn: &DbConn) -> EmptyResult { + let email = email.to_owned(); + conn.run(move |conn| { + conn.transaction::<_, DieselError, _>(|conn| { + diesel::update(collections::table.filter(collections::default_user_uuid.eq(user_uuid))) + .set(( + collections::default_user_uuid.eq(None::), + collections::default_user_collection_email.eq(email), + )) + .execute(conn)?; + diesel::delete(users_collections::table.filter(users_collections::user_uuid.eq(user_uuid))) + .execute(conn)?; + Ok(()) + }) + .map_res("Error removing the user's collection access") + }) + .await + } + pub async fn find_by_uuid_and_user(uuid: &CollectionId, user_uuid: UserId, conn: &DbConn) -> Option { if CONFIG.org_groups_enabled() { conn.run(move |conn| { @@ -424,6 +682,12 @@ impl Collection { } pub async fn is_writable_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { + // Only its owner can put items into a My Items collection, admin rights and access_all don't reach into it. + // Like any collection access upstream, only while the owner is a confirmed member of the organization. + if let Some(owner) = &self.default_user_uuid { + return owner == user_uuid + && Membership::find_confirmed_by_user_and_org(user_uuid, &self.org_uuid, conn).await.is_some(); + } let user_uuid = user_uuid.to_string(); if CONFIG.org_groups_enabled() { conn.run(move |conn| { @@ -656,6 +920,8 @@ impl Collection { .and(collections_groups::collections_uuid.eq(collections::uuid))), ) .filter(collections::org_uuid.eq(&org_uuid)) + // Like upstream, only shared collections count: every member manages their own My Items collection + .filter(collections::default_user_uuid.is_null()) .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter( // Manage permission on a collection assigned directly or via a group. @@ -882,6 +1148,38 @@ impl CollectionUser { .await } + /// Removes the user from all the organization's collections except their own My Items collection. In one + /// statement, like upstream's `OrganizationUser_UpdateWithCollections`, so it also keeps a My Items + /// collection another request creates meanwhile. + pub async fn delete_all_but_my_items_by_user_and_org( + user_uuid: &UserId, + org_uuid: &OrganizationId, + conn: &DbConn, + ) -> EmptyResult { + User::update_uuid_revision(user_uuid, conn).await; + + conn.run(move |conn| { + diesel::delete( + users_collections::table.filter(users_collections::user_uuid.eq(user_uuid)).filter( + users_collections::collection_uuid.eq_any( + collections::table + .filter(collections::org_uuid.eq(org_uuid)) + .filter( + collections::default_user_uuid + .is_null() + .or(collections::default_user_uuid.ne(user_uuid)), + ) + .select(collections::uuid), + ), + ), + ) + .execute(conn) + .map(|_| ()) + .map_res("Error removing user from collections") + }) + .await + } + pub async fn has_access_to_collection_by_user(col_id: &CollectionId, user_uuid: &UserId, conn: &DbConn) -> bool { Self::find_by_collection_and_user(col_id, user_uuid, conn).await.is_some() } @@ -956,6 +1254,154 @@ impl CollectionCipher { collection.update_users_revision(conn).await; } } + + /// Changes the cipher's collections in one transaction: adds it to `add` and removes it from `remove`. + pub async fn update_for_cipher( + cipher_uuid: &CipherId, + add: Vec, + remove: Vec, + conn: &DbConn, + ) -> EmptyResult { + // Before the change, like `save()` and `delete()`, so the members that lose the cipher sync as well + for collection_uuid in add.iter().chain(&remove) { + Self::update_users_revision(collection_uuid, conn).await; + } + let rows: Vec<_> = add + .iter() + .map(|c| (ciphers_collections::cipher_uuid.eq(cipher_uuid), ciphers_collections::collection_uuid.eq(c))) + .collect(); + let remove_rows = ciphers_collections::table + .filter(ciphers_collections::cipher_uuid.eq(cipher_uuid)) + .filter(ciphers_collections::collection_uuid.eq_any(&remove)); + db_run! { conn: + mysql { + conn.transaction::<_, DieselError, _>(|conn| { + if !rows.is_empty() { + diesel::insert_into(ciphers_collections::table) + .values(&rows) + .on_conflict(diesel::dsl::DuplicatedKeys) + .do_nothing() + .execute(conn)?; + } + diesel::delete(remove_rows).execute(conn) + }) + .map(|_| ()) + .map_res("Error updating the cipher's collections") + } + postgresql, sqlite { + conn.transaction::<_, DieselError, _>(|conn| { + if !rows.is_empty() { + diesel::insert_into(ciphers_collections::table) + .values(&rows) + .on_conflict((ciphers_collections::cipher_uuid, ciphers_collections::collection_uuid)) + .do_nothing() + .execute(conn)?; + } + diesel::delete(remove_rows).execute(conn) + }) + .map(|_| ()) + .map_res("Error updating the cipher's collections") + } + } + } + + /// The organizations' ciphers that are in My Items collections only. + pub async fn find_my_items_only_by_orgs(org_uuids: Vec, conn: &DbConn) -> HashSet { + if org_uuids.is_empty() { + return HashSet::new(); + } + conn.run(move |conn| { + ciphers_collections::table + .inner_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid))) + .filter(collections::org_uuid.eq_any(org_uuids)) + .group_by(ciphers_collections::cipher_uuid) + .select((ciphers_collections::cipher_uuid, count(collections::default_user_uuid), count_star())) + .load::<(CipherId, i64, i64)>(conn) + .expect("Error loading My Items ciphers") + .into_iter() + .filter(|(_, in_my_items, in_total)| in_my_items == in_total) + .map(|(cipher_uuid, _, _)| cipher_uuid) + .collect() + }) + .await + } + + /// The organization's ciphers that are in a My Items collection, each with those My Items collections. + pub async fn find_my_items_by_org( + org_uuid: &OrganizationId, + conn: &DbConn, + ) -> HashMap> { + conn.run(move |conn| { + ciphers_collections::table + .inner_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid))) + .filter(collections::org_uuid.eq(org_uuid)) + .filter(collections::default_user_uuid.is_not_null()) + .select(ciphers_collections::all_columns) + .load::<(CipherId, CollectionId)>(conn) + .expect("Error loading My Items ciphers") + .into_iter() + .fold(HashMap::new(), |mut map: HashMap>, (cipher, collection)| { + map.entry(cipher).or_default().push(collection); + map + }) + }) + .await + } + + /// The My Items collections the cipher is in, whoever's they are. + pub async fn find_my_items_of_cipher(cipher_uuid: &CipherId, conn: &DbConn) -> HashSet { + conn.run(move |conn| { + ciphers_collections::table + .inner_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid))) + .filter(ciphers_collections::cipher_uuid.eq(cipher_uuid)) + .filter(collections::default_user_uuid.is_not_null()) + .select(ciphers_collections::collection_uuid) + .load::(conn) + .expect("Error loading the cipher's My Items collections") + .into_iter() + .collect() + }) + .await + } + + /// Returns the owners when the cipher is assigned exclusively to My Items collections. `None` means that the + /// cipher is unassigned or has at least one shared collection assignment. + pub async fn find_my_items_owners_if_only(cipher_uuid: &CipherId, conn: &DbConn) -> Option> { + let owners = conn + .run(move |conn| { + ciphers_collections::table + .filter(ciphers_collections::cipher_uuid.eq(cipher_uuid)) + .inner_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid))) + .select(collections::default_user_uuid) + .load::>(conn) + .expect("Error loading My Items owners") + }) + .await; + + if owners.is_empty() || owners.iter().any(Option::is_none) { + None + } else { + Some(owners.into_iter().flatten().collect()) + } + } + + pub async fn delete_all_shared_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { + conn.run(move |conn| { + diesel::delete( + ciphers_collections::table.filter( + ciphers_collections::collection_uuid.eq_any( + collections::table + .filter(collections::org_uuid.eq(org_uuid)) + .filter(collections::default_user_uuid.is_null()) + .select(collections::uuid), + ), + ), + ) + .execute(conn) + .map_res("Error removing ciphers from the shared collections") + }) + .await + } } // Added in case we need the membership_uuid instead of the user_uuid diff --git a/src/db/models/event.rs b/src/db/models/event.rs index cc0eb504..936437ca 100644 --- a/src/db/models/event.rs +++ b/src/db/models/event.rs @@ -109,7 +109,7 @@ pub enum EventType { OrganizationUserDeleted = 1515, // Both user and organization user data were deleted OrganizationUserLeft = 1516, // User voluntarily left the organization // OrganizationUserAutomaticallyConfirmed = 1517, - // OrganizationUserSelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy + OrganizationUserSelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy OrganizationUserAdminResetTwoFactor = 1519, // OrganizationUserRevoked_TwoFactorNonCompliance = 1520, // OrganizationUserRevoked_SingleOrganizationNonCompliance = 1521, diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 0e4073a5..5e5ebdda 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -21,7 +21,7 @@ mod user; pub use self::archive::Archive; pub use self::attachment::{Attachment, AttachmentId}; pub use self::auth_request::{AuthRequest, AuthRequestId}; -pub use self::cipher::{Cipher, CipherId, RepromptType}; +pub use self::cipher::{Cipher, CipherAccessScope, CipherId, RepromptType}; pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser}; pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId}; pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType}; diff --git a/src/db/models/org_policy.rs b/src/db/models/org_policy.rs index 94e53cbd..b1add839 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -381,6 +381,16 @@ impl OrgPolicy { false } + /// Whether the personal ownership (upstream: organization data ownership) policy of the member's organization + /// applies to them. Like upstream, it exempts Owners, Admins and members that are not confirmed. + pub async fn is_personal_ownership_enforced_for(member: &Membership, conn: &DbConn) -> bool { + member.has_status(MembershipStatus::Confirmed) + && member.atype < MembershipType::Admin + && Self::find_by_org_and_type(&member.org_uuid, OrgPolicyType::PersonalOwnership, conn) + .await + .is_some_and(|p| p.enabled) + } + 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 diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 353a406e..bddbd3de 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -12,8 +12,8 @@ use crate::{ db::{ DbConn, schema::{ - ciphers, ciphers_collections, collections_groups, groups, groups_users, org_policies, organization_api_key, - organizations, users, users_collections, users_organizations, + ciphers, ciphers_collections, collections, collections_groups, groups, groups_users, org_policies, + organization_api_key, organizations, users, users_collections, users_organizations, }, }, error::MapResult, @@ -21,8 +21,8 @@ use crate::{ use macros::UuidFromParam; use super::{ - Cipher, CipherId, Collection, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, OrgPolicyType, - TwoFactor, User, UserId, + Cipher, CipherId, Collection, CollectionCipher, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, + OrgPolicyType, TwoFactor, User, UserId, }; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -216,7 +216,7 @@ impl Organization { "useApi": true, "useDisableSMAdsForUsers": true, // Hide Secrets Manager ads "useInviteLinks": false, // Not (yet) supported - "useMyItems": false, // Not (yet) supported + "useMyItems": true, "useOrganizationDomains": false, // Not supported (Linked to SSO) "usePam": false, // Not supported "usePhishingBlocker": false, @@ -492,7 +492,7 @@ impl Membership { "useRiskInsights": false, // Not supported (Not AGPLv3 Licensed) "useDisableSMAdsForUsers": true, // Hide Secrets Manager ads "useInviteLinks": false, // Not (yet) supported - "useMyItems": false, // Not (yet) supported + "useMyItems": true, "useOrganizationDomains": false, // Not supported (Linked to SSO) "usePam": false, // Not supported "usePhishingBlocker": false, @@ -569,9 +569,12 @@ impl Membership { // If collections are to be included, only include them if the user does not have full access via a group or defined to the user it self // Only the collections assigned directly are returned, the ones assigned via a group are returned via a special group endpoint let collections: Vec = if include_collections && !(full_access_group || self.access_all) { + // Like upstream, a member's My Items collection is not part of their collection access + let my_items = Collection::find_default_by_user_and_org(&self.user_uuid, &self.org_uuid, conn).await; CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn) .await .into_iter() + .filter(|cu| my_items.as_ref().is_none_or(|c| c.uuid != cu.collection_uuid)) .map(|cu| { json!({ "id": cu.collection_uuid, @@ -742,15 +745,60 @@ impl Membership { pub async fn delete(self, conn: &DbConn) -> EmptyResult { User::update_uuid_revision(&self.user_uuid, conn).await; - CollectionUser::delete_all_by_user_and_org(&self.user_uuid, &self.org_uuid, conn).await?; - GroupUser::delete_all_by_member(&self.uuid, conn).await?; - - conn.run(move |conn| { - diesel::delete(users_organizations::table.filter(users_organizations::uuid.eq(self.uuid))) - .execute(conn) + // Offboarding, like upstream's `OrganizationUser_DeleteById`: the member's My Items collection and its items + // stay in the organization as a shared collection, named by the former member's email address. Converted in + // the same transaction as the removal, so a failure can't convert it while the member stays, and by the + // member rather than by a collection looked up beforehand. + let (member_uuid, user_uuid, org_uuid) = (self.uuid, self.user_uuid, self.org_uuid); + let converted = conn + .run(move |conn| { + conn.transaction::<_, diesel::result::Error, _>(|conn| { + // Writes first: SQLite can't turn a transaction that started with a read into a writing one + // once another connection wrote meanwhile, and fails without waiting ("database is locked"). + diesel::delete(groups_users::table.filter(groups_users::users_organizations_uuid.eq(&member_uuid))) + .execute(conn)?; + // The membership goes before the conversion: its row lock (the write lock on SQLite) makes a + // concurrent creation of the member's My Items collection either finish first, so the conversion + // below finds it, or wait and then find the member gone. See + // `Collection::create_default_user_collections()`. + diesel::delete(users_organizations::table.filter(users_organizations::uuid.eq(&member_uuid))) + .execute(conn)?; + let my_items = collections::table + .filter(collections::org_uuid.eq(&org_uuid)) + .filter(collections::default_user_uuid.eq(&user_uuid)); + let converted = my_items.select(collections::uuid).load::(conn)?; + let email = users::table + .filter(users::uuid.eq(&user_uuid)) + .select(users::email) + .first::(conn) + .optional()?; + diesel::update(my_items) + .set(( + collections::default_user_uuid.eq(None::), + collections::default_user_collection_email.eq(email), + )) + .execute(conn)?; + diesel::delete( + users_collections::table.filter(users_collections::user_uuid.eq(&user_uuid)).filter( + users_collections::collection_uuid.eq_any( + collections::table + .filter(collections::org_uuid.eq(&org_uuid)) + .select(collections::uuid), + ), + ), + ) + .execute(conn)?; + Ok(converted) + }) .map_res("Error removing user from organization") - }) - .await + }) + .await?; + + // The members that now reach the former My Items collection sync it + for collection_uuid in &converted { + CollectionCipher::update_users_revision(collection_uuid, conn).await; + } + Ok(()) } pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { @@ -1028,6 +1076,7 @@ impl Membership { conn.run(move |conn| { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) + .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .left_join(users_collections::table.on(users_collections::user_uuid.eq(users_organizations::user_uuid))) .left_join( ciphers_collections::table.on(ciphers_collections::collection_uuid @@ -1054,6 +1103,7 @@ impl Membership { conn.run(move |conn| { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) + .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .inner_join( groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)), ) @@ -1110,6 +1160,7 @@ impl Membership { conn.run(move |conn| { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) + .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .left_join(users_collections::table.on(users_collections::user_uuid.eq(users_organizations::user_uuid))) .filter(users_organizations::access_all.eq(true).or( // AccessAll.. diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 3412b142..129183b7 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -19,7 +19,8 @@ use crate::{ use macros::UuidFromParam; use super::{ - Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, TwoFactorIncomplete, + Cipher, Collection, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, + TwoFactorIncomplete, }; #[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] @@ -361,6 +362,7 @@ impl User { EmergencyAccess::delete_all_by_user(&self.uuid, conn).await?; EmergencyAccess::delete_all_by_grantee_email(&self.email, conn).await?; Membership::delete_all_by_user(&self.uuid, conn).await?; + Collection::release_all_by_user(&self.uuid, &self.email, conn).await?; Cipher::delete_all_by_user(&self.uuid, conn).await?; Favorite::delete_all_by_user(&self.uuid, conn).await?; Folder::delete_all_by_user(&self.uuid, conn).await?; @@ -381,6 +383,30 @@ impl User { } } + pub async fn update_uuid_revisions(uuids: Vec, conn: &DbConn) { + if uuids.is_empty() { + return; + } + let updated_at = Utc::now().naive_utc(); + if let Err(e) = conn + .run(move |conn| { + retry( + || { + diesel::update(users::table.filter(users::uuid.eq_any(&uuids))) + .set(users::updated_at.eq(updated_at)) + .execute(conn) + }, + 10, + ) + .map(|_| ()) + .map_res("Error updating user revisions") + }) + .await + { + warn!("Failed to update user revisions: {e:#?}"); + } + } + pub async fn update_all_revisions(conn: &DbConn) -> EmptyResult { let updated_at = Utc::now().naive_utc(); diff --git a/src/db/schema.rs b/src/db/schema.rs index 98b1eda6..6f423c96 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -40,6 +40,8 @@ table! { org_uuid -> Text, name -> Text, external_id -> Nullable, + default_user_uuid -> Nullable, + default_user_collection_email -> Nullable, } } diff --git a/src/util.rs b/src/util.rs index 6de2d803..14cbd342 100644 --- a/src/util.rs +++ b/src/util.rs @@ -540,6 +540,48 @@ pub fn is_valid_email(email: &str) -> bool { email_url.domain().is_some() && email_url.path() == "/" && email_url.query().is_none() } +/// Returns whether the value has the form of an encrypted string, like upstream's `EncryptedStringAttribute`: +/// `.|...` with a known encryption type, its number of base64 pieces, and the fixed decoded +/// lengths of the IV (16 bytes) and the MAC (32 bytes). Without a type it is the legacy `iv|ct[|mac]` format. +pub fn is_valid_enc_string(value: &str) -> bool { + const IV: Option = Some(16); + const MAC: Option = Some(32); + const ANY: Option = None; + + let (pieces, rest): (&[Option], &str) = match value.split_once('.') { + Some((header, rest)) => match header.parse::() { + // AesCbc256_B64 + Ok(0) => (&[IV, ANY], rest), + // AesCbc256_HmacSha256_B64 + Ok(2) => (&[IV, ANY, MAC], rest), + // Rsa2048_OaepSha256_B64, Rsa2048_OaepSha1_B64, and type 7: a CBOR encoded Encrypt0 message + Ok(3 | 4 | 7) => (&[ANY], rest), + // Rsa2048_OaepSha256_HmacSha256_B64, Rsa2048_OaepSha1_HmacSha256_B64 + Ok(5 | 6) => (&[ANY, ANY], rest), + _ => return false, + }, + None if value.matches('|').count() == 2 => (&[IV, ANY, MAC], value), + None => (&[IV, ANY], value), + }; + + let parts: Vec<&str> = rest.split('|').collect(); + parts.len() == pieces.len() && parts.iter().zip(pieces).all(|(part, len)| is_valid_base64_piece(part, *len)) +} + +/// Base64 with padding and, like upstream, any value for the unused bits of the last character. +fn is_valid_base64_piece(piece: &str, decoded_len: Option) -> bool { + let bytes = piece.as_bytes(); + if bytes.is_empty() || !bytes.len().is_multiple_of(4) { + return false; + } + let padding = bytes.iter().rev().take_while(|&&b| b == b'=').count(); + if padding > 2 { + return false; + } + bytes[..bytes.len() - padding].iter().all(|b| b.is_ascii_alphanumeric() || *b == b'+' || *b == b'/') + && decoded_len.is_none_or(|len| bytes.len() / 4 * 3 - padding == len) +} + // // Deployment environment methods //