diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 69918212..417ec752 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -1037,7 +1037,8 @@ async fn post_rotatekey(data: Json, headers: Headers, conn: DbConn, nt: // Prevent triggering cipher updates via WebSockets by settings UpdateType::None // The user sessions are invalidated because all the ciphers were re-encrypted and thus triggering an update could cause issues. // We force the users to logout after the user has been saved to try and prevent these issues. - update_cipher_from_data(saved_cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?; + update_cipher_from_data(saved_cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None, true) + .await?; } } diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 97f5fd49..f33cf322 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -21,9 +21,10 @@ 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, User, 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, User, + UserId, }, }, util::{NumberOrString, deser_opt_nonempty_str, save_temp_file}, @@ -54,6 +55,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 @@ -233,21 +235,12 @@ async fn get_ciphers(headers: Headers, conn: DbConn) -> JsonResult { #[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 { - err!("Cipher doesn't exist") - }; - - if !cipher.is_accessible_to_user(&headers.user.uuid, &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_cipher_impl(cipher_id, &headers, CipherAccessScope::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 + get_cipher_impl(cipher_id, &headers, CipherAccessScope::OrganizationAdmin, &conn).await } #[get("/ciphers//details")] @@ -255,6 +248,38 @@ async fn get_cipher_details(cipher_id: CipherId, headers: Headers, conn: DbConn) get_cipher(cipher_id, headers, conn).await } +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, scope, conn).await { + err!("Cipher is not owned by user") + } + + Ok(Json(cipher_json_for_scope(&cipher, headers, scope, conn).await?)) +} + +/// The cipher, as answered to a route authorized with `scope`. +async fn cipher_json_for_scope( + cipher: &Cipher, + headers: &Headers, + scope: CipherAccessScope, + conn: &DbConn, +) -> Result { + match scope { + CipherAccessScope::User => { + cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await + } + CipherAccessScope::OrganizationAdmin => cipher.to_json_org_admin(&headers.host, &headers.user.uuid, conn).await, + } +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CipherData { @@ -337,7 +362,8 @@ pub struct Attachments2Data { /// Called when an org admin clones an org cipher. #[post("/ciphers/admin", data = "")] async fn post_ciphers_admin(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { - post_ciphers_create(data, headers, conn, nt).await + // The same as /ciphers/create, only answered like the other administrative routes + post_ciphers_create_impl(data, headers, CipherAccessScope::OrganizationAdmin, conn, nt).await } /// Called when creating a new org-owned cipher, or cloning a cipher (whether @@ -349,6 +375,16 @@ async fn post_ciphers_create( headers: Headers, conn: DbConn, nt: Notify<'_>, +) -> JsonResult { + post_ciphers_create_impl(data, headers, CipherAccessScope::User, conn, nt).await +} + +async fn post_ciphers_create_impl( + data: Json, + headers: Headers, + response_scope: CipherAccessScope, + conn: DbConn, + nt: Notify<'_>, ) -> JsonResult { let mut data: ShareCipherData = data.into_inner(); @@ -361,9 +397,20 @@ async fn post_ciphers_create( // cipher.save() below. enforce_personal_ownership_policy(Some(&data.cipher), &headers, &conn).await?; + if response_scope == CipherAccessScope::OrganizationAdmin { + let Some(org_id) = data.cipher.organization_id.as_ref() else { + err!("Cipher is not owned by an organization") + }; + if !Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn) + .await + .is_some_and(|member| member.can_edit_any_collection()) + { + err!("Cipher is not write accessible") + } + } + let mut cipher = Cipher::new(data.cipher.r#type, data.cipher.name.clone()); cipher.user_uuid = Some(headers.user.uuid.clone()); - cipher.save(&conn).await?; // When cloning a cipher, the Bitwarden clients seem to set this field // based on the cipher being cloned (when creating a new cipher, it's set @@ -373,11 +420,7 @@ async fn post_ciphers_create( // or otherwise), we can just ignore this field entirely. data.cipher.last_known_revision_date = None; - let res = share_cipher_by_uuid(&cipher.uuid, data, &headers, &conn, &nt, None).await; - if res.is_err() { - cipher.delete(&conn).await?; - } - res + share_cipher_by_uuid(&cipher.uuid.clone(), Some(cipher), data, &headers, response_scope, &conn, &nt, None).await } /// Called when creating a new user-owned cipher. @@ -403,7 +446,7 @@ async fn post_ciphers(data: Json, headers: Headers, conn: DbConn, nt data.last_known_revision_date = None; let mut cipher = Cipher::new(data.r#type, data.name.clone()); - update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherCreate).await?; + update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherCreate, true).await?; Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) } @@ -426,6 +469,10 @@ async fn enforce_personal_ownership_policy(data: Option<&CipherData>, headers: & Ok(()) } +#[expect( + clippy::too_many_arguments, + reason = "Cipher updates need separate access, notification and transaction controls" +)] pub async fn update_cipher_from_data( cipher: &mut Cipher, data: CipherData, @@ -434,6 +481,7 @@ pub async fn update_cipher_from_data( conn: &DbConn, nt: &Notify<'_>, ut: UpdateType, + emit_side_effects: bool, ) -> EmptyResult { // Cleanup cipher data, like removing the 'Response' key. // This key is somewhere generated during Javascript so no way for us this fix this. @@ -488,8 +536,9 @@ pub async fn update_cipher_from_data( // access to them, so we can move the cipher into the organization on that basis. // Write access to the cipher itself only counts when it already belongs to this organization. if shared_to_collections.as_ref().is_some_and(|cols| !cols.is_empty()) - || member.has_full_access() - || (!transfer_cipher && cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await) + || member.can_edit_any_collection() + || (!transfer_cipher + && 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. @@ -585,7 +634,7 @@ pub async fn update_cipher_from_data( None => cipher.unarchive(&headers.user.uuid, conn).await?, } - if ut != UpdateType::None { + if ut != UpdateType::None && emit_side_effects { // Only log events for organizational ciphers if let Some(org_id) = &cipher.organization_uuid { let event_type = match (&ut, transfer_cipher) { @@ -671,7 +720,7 @@ async fn post_ciphers_import(data: Json, headers: Headers, conn: DbC cipher_data.organization_id = None; 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?; + update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None, true).await?; } let mut user = headers.user; @@ -695,7 +744,7 @@ async fn put_cipher_admin( err_code!("Resource not found.", "Cipher is not an organization cipher", Status::NotFound.code); } - put_cipher(cipher_id, data, headers, conn, nt).await + put_cipher_impl(cipher_id, data, headers, CipherAccessScope::OrganizationAdmin, conn, nt).await } #[post("/ciphers//admin", data = "")] @@ -727,6 +776,17 @@ async fn put_cipher( headers: Headers, conn: DbConn, nt: Notify<'_>, +) -> JsonResult { + put_cipher_impl(cipher_id, data, headers, CipherAccessScope::User, conn, nt).await +} + +async fn put_cipher_impl( + cipher_id: CipherId, + data: Json, + headers: Headers, + scope: CipherAccessScope, + conn: DbConn, + nt: Notify<'_>, ) -> JsonResult { let data: CipherData = data.into_inner(); @@ -739,7 +799,7 @@ 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") } @@ -748,9 +808,9 @@ async fn put_cipher( err!("Organization mismatch. Please resync the client before updating the cipher") } - 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, true).await?; - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) + Ok(Json(cipher_json_for_scope(&cipher, &headers, scope, &conn).await?)) } #[post("/ciphers//partial", data = "")] @@ -777,7 +837,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") } @@ -848,65 +908,7 @@ async fn post_collections_update( 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_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::User, &conn).await?)) + update_collections_by_scope(cipher_id, data.into_inner(), headers, CipherAccessScope::User, conn, nt).await } #[put("/ciphers//collections-admin", data = "")] @@ -928,65 +930,94 @@ 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") - }; + update_collections_by_scope(cipher_id, data.into_inner(), headers, CipherAccessScope::OrganizationAdmin, conn, nt) + .await +} - 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 { +async fn update_collections_by_scope( + cipher_id: CipherId, + data: CollectionsAdminData, + headers: Headers, + scope: CipherAccessScope, + conn: DbConn, + nt: Notify<'_>, +) -> JsonResult { + let (cipher, posted_collections, org_uuid, affected_users) = conn + .transaction(async || { + 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, scope, &conn).await { + err!("Collection cannot be changed") + } + let Some(org_uuid) = cipher.organization_uuid.clone() else { + err!("Cipher is not owned by an organization") + }; + let posted = HashSet::::from_iter(data.collection_ids); + let current = 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 + } + }); + // The admin route accepts Edit Any for all collections of this cipher's organization. + // The personal route keeps the direct/group write check, even for that same member. + let admin_edit_any = scope == CipherAccessScope::OrganizationAdmin + && Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &org_uuid, &conn) + .await + .is_some_and(|member| member.can_edit_any_collection()); + for id in &posted { + if Collection::find_by_uuid_and_org(id, &org_uuid, &conn).await.is_none() { + err!("Invalid collection ID provided") + } + } + let mut changes = Vec::new(); + for id in posted.symmetric_difference(¤t) { + let Some(collection) = Collection::find_by_uuid_and_org(id, &org_uuid, &conn).await else { + err!("Invalid collection ID provided") + }; + if !admin_edit_any && !collection.is_writable_by_user(&headers.user.uuid, &conn).await { err!("No rights to modify the collection") } + changes.push((collection.uuid, posted.contains(id))); } - } - } + // Every ID and permission has been checked. A database error rolls back links and revisions. + for (id, add) in changes { + if add { + CollectionCipher::save(&cipher.uuid, &id, &conn).await?; + } else { + CollectionCipher::delete(&cipher.uuid, &id, &conn).await?; + } + } + let affected_users = cipher.update_users_revision(&conn).await; + Ok((cipher, posted, org_uuid, affected_users)) + }) + .await?; nt.send_cipher_update( UpdateType::SyncCipherUpdate, &cipher, - &cipher.update_users_revision(&conn).await, + &affected_users, &headers.device, Some(Vec::from_iter(posted_collections)), &conn, ) .await; - log_event( EventType::CipherUpdatedCollections, &cipher.uuid, - org_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?)) + let sync_type = match scope { + CipherAccessScope::User => CipherSyncType::User, + CipherAccessScope::OrganizationAdmin => CipherSyncType::Organization, + }; + Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, sync_type, &conn).await?)) } #[derive(Deserialize)] @@ -1027,7 +1058,7 @@ async fn put_cipher_share( err!("You must select at least one collection.") } - share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await + share_cipher_by_uuid(&cipher_id, None, data, &headers, CipherAccessScope::User, &conn, &nt, None).await } #[derive(Deserialize)] @@ -1110,14 +1141,23 @@ async fn put_cipher_share_selected( }; if let Some(id) = shared_cipher_data.cipher.id.take() { - let response = - share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await?; + let response = share_cipher_by_uuid( + &id, + None, + shared_cipher_data, + &headers, + CipherAccessScope::User, + &conn, + &nt, + Some(UpdateType::None), + ) + .await?; // Like upstream's VisibleToClient, withhold a cipher if sharing it // removed the user's access to it. Only then include its encrypted // mini response; the full single-share response stays internal. if let Some(shared_cipher) = Cipher::find_by_uuid(&id, &conn).await - && shared_cipher.is_accessible_to_user(&headers.user.uuid, &conn).await + && shared_cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, &conn).await { responses.push(cipher_mini_from_json(response.into_inner())); } @@ -1136,51 +1176,18 @@ async fn put_cipher_share_selected( }))) } +#[expect(clippy::too_many_arguments, reason = "New and existing ciphers share one atomic validation path")] async fn share_cipher_by_uuid( cipher_id: &CipherId, + new_cipher: Option, data: ShareCipherData, headers: &Headers, + // Only the admin creation route may use organization-wide collection authority. + response_scope: CipherAccessScope, conn: &DbConn, nt: &Notify<'_>, 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 { - cipher - } else { - err!("Cipher is not write accessible") - } - } else { - 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. - if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.cipher.organization_id { - err!("Organization mismatch. Please resync the client before updating the cipher") - } - - let mut shared_to_collections = vec![]; - - if let Some(organization_id) = &data.cipher.organization_id { - 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?; - shared_to_collections.push(collection.uuid); - } else { - err!("No rights to modify the collection") - } - } - } - } - } - - // When LastKnownRevisionDate is None, it is a new cipher, so send CipherCreate. - // If there is an override, like when handling multiple items, we want to prevent a push notification for every single item let ut = if let Some(ut) = override_ut { ut } else if data.cipher.last_known_revision_date.is_some() { @@ -1188,10 +1195,81 @@ async fn share_cipher_by_uuid( } else { UpdateType::SyncCipherCreate }; + let (cipher, shared_to_collections, transfer_cipher, affected_users) = conn + .transaction(async || { + let is_new = new_cipher.is_some(); + let mut cipher = if let Some(cipher) = new_cipher { + cipher + } else if let Some(cipher) = Cipher::find_by_uuid(cipher_id, conn).await { + cipher + } else { + err!("Cipher doesn't exist") + }; + if !cipher.is_write_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, conn).await { + err!("Cipher is not write accessible") + } + if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.cipher.organization_id { + err!("Organization mismatch. Please resync the client before updating the cipher") + } + let transfer_cipher = cipher.organization_uuid.is_none() && data.cipher.organization_id.is_some(); + let mut shared_to_collections = Vec::new(); + if let Some(organization_id) = &data.cipher.organization_id { + let admin_edit_any = response_scope == CipherAccessScope::OrganizationAdmin + && Membership::find_confirmed_by_user_and_org(&headers.user.uuid, organization_id, conn) + .await + .is_some_and(|member| member.can_edit_any_collection()); + if response_scope == CipherAccessScope::OrganizationAdmin && !admin_edit_any { + err!("Cipher is not write accessible") + } + for col_id in HashSet::::from_iter(data.collection_ids) { + let Some(collection) = Collection::find_by_uuid_and_org(&col_id, organization_id, conn).await + else { + err!("Invalid collection ID provided") + }; + if !admin_edit_any && !collection.is_writable_by_user(&headers.user.uuid, conn).await { + err!("No rights to modify the collection") + } + shared_to_collections.push(collection.uuid); + } + } + if is_new { + cipher.save(conn).await?; + } + // Keep the cipher, collection links and affected revisions in one transaction. + // The helper still validates stale writes, ownership, folders and cipher data. + update_cipher_from_data( + &mut cipher, + data.cipher, + headers, + Some(shared_to_collections.clone()), + conn, + nt, + ut, + false, + ) + .await?; + for col_id in &shared_to_collections { + CollectionCipher::save(&cipher.uuid, col_id, conn).await?; + } + // Bulk sharing skips per-cipher pushes, but new group-derived recipients still need a revision update. + let affected_users = cipher.update_users_revision(conn).await; + Ok((cipher, shared_to_collections, transfer_cipher, affected_users)) + }) + .await?; - update_cipher_from_data(&mut cipher, data.cipher, headers, Some(shared_to_collections), conn, nt, ut).await?; - - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?)) + if ut != UpdateType::None { + if let Some(org_id) = &cipher.organization_uuid { + let event_type = match (&ut, transfer_cipher) { + (UpdateType::SyncCipherCreate, true) => EventType::CipherCreated, + (UpdateType::SyncCipherUpdate, true) => EventType::CipherShared, + (_, _) => EventType::CipherUpdated, + }; + log_event(event_type, &cipher.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn) + .await; + } + nt.send_cipher_update(ut, &cipher, &affected_users, &headers.device, Some(shared_to_collections), conn).await; + } + Ok(Json(cipher_json_for_scope(&cipher, headers, response_scope, conn).await?)) } /// v2 API for downloading an attachment. This just redirects the client to @@ -1206,12 +1284,43 @@ async fn get_attachment( attachment_id: AttachmentId, headers: Headers, conn: DbConn, +) -> JsonResult { + get_attachment_data(cipher_id, attachment_id, headers, CipherAccessScope::User, conn).await +} + +#[get("/ciphers//attachment//admin")] +async fn get_attachment_admin( + cipher_id: CipherId, + attachment_id: AttachmentId, + headers: Headers, + conn: DbConn, +) -> JsonResult { + get_attachment_data(cipher_id, attachment_id, headers, CipherAccessScope::OrganizationAdmin, conn).await +} + +async fn get_attachment_data( + 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 scope == CipherAccessScope::OrganizationAdmin { + let Some(org_id) = cipher.organization_uuid.as_ref() else { + err!("Cipher is not owned by an organization") + }; + if !Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn) + .await + .is_some_and(|member| member.can_edit_any_collection()) + || !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, &conn).await + { + err!("Cipher is not accessible") + } + } else if !cipher.is_accessible_to_user(&headers.user.uuid, scope, &conn).await { err!("Cipher is not accessible") } @@ -1282,15 +1391,19 @@ async fn post_attachment_v2( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { + let data: AttachmentRequestData = data.into_inner(); + + // `adminRequest` picks the check, the administrative one still needs a member who may edit any collection + let scope = CipherAccessScope::requested(data.admin_request); + let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { 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 is not write accessible") } - let data: AttachmentRequestData = data.into_inner(); let file_size = data.file_size.into_i64()?; if file_size < 0 { @@ -1314,9 +1427,9 @@ async fn post_attachment_v2( notify_attachment_created(&cipher, &headers, &conn, &nt).await; let url = format!("/ciphers/{}/attachment/{attachment_id}", cipher.uuid); - let response_key = match data.admin_request { - Some(b) if b => "cipherMiniResponse", - _ => "cipherResponse", + let response_key = match scope { + CipherAccessScope::OrganizationAdmin => "cipherMiniResponse", + CipherAccessScope::User => "cipherResponse", }; Ok(Json(json!({ // AttachmentUploadDataResponseModel @@ -1324,7 +1437,7 @@ async fn post_attachment_v2( "attachmentId": attachment_id, "url": url, "fileUploadType": FileUploadType::Direct as i32, - response_key: cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?, + response_key: cipher_json_for_scope(&cipher, &headers, scope, &conn).await?, }))) } @@ -1347,6 +1460,7 @@ async fn save_attachment( cipher_id: CipherId, data: Form>, headers: &Headers, + scope: CipherAccessScope, conn: DbConn, nt: Notify<'_>, ) -> Result<(Cipher, DbConn), crate::error::Error> { @@ -1363,7 +1477,7 @@ async fn save_attachment( 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 is not write accessible") } @@ -1484,11 +1598,26 @@ async fn post_attachment_v2_data( None => err!("Attachment doesn't exist"), }; - save_attachment(attachment, cipher_id, data, &headers, conn, nt).await?; + // This request does not tell whether it is administrative, the member's own membership decides + let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { + err!("Cipher doesn't exist") + }; + let scope = cipher_scope_for_member(&cipher, &headers.user.uuid, &conn).await; + + save_attachment(attachment, cipher_id, data, &headers, scope, conn, nt).await?; Ok(()) } +/// The access for a route on `cipher` which does not tell which one it is. +async fn cipher_scope_for_member(cipher: &Cipher, user_id: &UserId, conn: &DbConn) -> CipherAccessScope { + let Some(org_id) = cipher.organization_uuid.as_ref() else { + return CipherAccessScope::User; + }; + + CipherAccessScope::for_member(Membership::find_confirmed_by_user_and_org(user_id, org_id, conn).await.as_ref()) +} + /// Legacy API for creating an attachment associated with a cipher. #[post("/ciphers//attachment", format = "multipart/form-data", data = "")] async fn post_attachment( @@ -1498,13 +1627,7 @@ async fn post_attachment( 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?; - - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) + post_attachment_impl(cipher_id, data, headers, CipherAccessScope::User, conn, nt).await } #[post("/ciphers//attachment-admin", format = "multipart/form-data", data = "")] @@ -1515,7 +1638,24 @@ 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 +} + +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, scope, conn, nt).await?; + + Ok(Json(cipher_json_for_scope(&cipher, &headers, scope, &conn).await?)) } #[post("/ciphers//attachment//share", format = "multipart/form-data", data = "")] @@ -1527,7 +1667,7 @@ async fn post_attachment_share( 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?; post_attachment(cipher_id, data, headers, conn, nt).await } @@ -1539,7 +1679,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")] @@ -1561,7 +1709,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")] @@ -1572,42 +1720,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 } @@ -1618,7 +1801,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 } @@ -1629,7 +1812,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 } @@ -1640,7 +1823,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 } @@ -1651,7 +1834,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 } @@ -1662,7 +1853,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 } @@ -1673,18 +1872,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 = "")] @@ -1694,7 +1901,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 = "")] @@ -1704,7 +1911,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)] @@ -1905,6 +2112,7 @@ pub enum CipherDeleteOptions { async fn delete_cipher_by_uuid( cipher_id: &CipherId, headers: &Headers, + scope: CipherAccessScope, conn: &DbConn, delete_options: &CipherDeleteOptions, nt: &Notify<'_>, @@ -1913,7 +2121,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") } @@ -1971,6 +2179,7 @@ struct CipherIdsData { async fn delete_multiple_ciphers( data: Json, headers: Headers, + scope: CipherAccessScope, conn: DbConn, delete_options: CipherDeleteOptions, nt: Notify<'_>, @@ -1978,7 +2187,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; } } @@ -1993,6 +2202,7 @@ async fn restore_cipher_by_uuid( cipher_id: &CipherId, headers: &Headers, multi_restore: bool, + scope: CipherAccessScope, conn: &DbConn, nt: &Notify<'_>, ) -> JsonResult { @@ -2000,7 +2210,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") } @@ -2032,12 +2242,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_json_for_scope(&cipher, headers, scope, conn).await?)) } async fn restore_multiple_ciphers( data: Json, headers: &Headers, + scope: CipherAccessScope, conn: &DbConn, nt: &Notify<'_>, ) -> JsonResult { @@ -2045,7 +2256,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, } @@ -2065,6 +2276,7 @@ async fn delete_cipher_attachment_by_id( cipher_id: &CipherId, attachment_id: &AttachmentId, headers: &Headers, + scope: CipherAccessScope, conn: &DbConn, nt: &Notify<'_>, ) -> JsonResult { @@ -2080,7 +2292,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") } @@ -2108,7 +2320,7 @@ async fn delete_cipher_attachment_by_id( ) .await; } - let cipher_json = cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?; + let cipher_json = cipher_json_for_scope(&cipher, headers, scope, conn).await?; Ok(Json(json!({"cipher":cipher_json}))) } @@ -2123,7 +2335,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") } @@ -2155,7 +2367,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") } @@ -2347,3 +2559,145 @@ impl CipherSyncData { } } } + +#[cfg(all(test, sqlite))] +mod custom_role_tests { + use super::*; + use crate::{ + api::notifications::{WS_USERS, WebSocketUsers}, + auth::ClientIp, + db::{ + models::{CustomPermission, Device, User}, + test_db, + }, + }; + use chrono::Datelike; + use diesel::connection::SimpleConnection; + use std::sync::Arc; + + #[test] + fn bulk_share_preserves_vault_scope_collection_ids_and_user_revisions() { + test_db::run(async |db| { + let conn = db.conn(); + let (org, user, mut member) = test_db::custom_member(&conn, CustomPermission::EditAnyCollection).await; + let (reader, _) = test_db::member(&conn, &org.uuid, MembershipType::User, &[]).await; + let collection = test_db::collection(&conn, &org.uuid).await; + let hidden = test_db::collection(&conn, &org.uuid).await; + CollectionUser::save(&reader.uuid, &collection.uuid, false, false, false, &conn).await.unwrap(); + let mut ids = Vec::new(); + for _ in 0..2 { + let mut cipher = Cipher::new(2, "Personal".into()); + cipher.user_uuid = Some(user.uuid.clone()); + cipher.save(&conn).await.unwrap(); + ids.push(cipher.uuid); + } + let rocket = rocket::build().manage(Arc::clone(&WS_USERS)); + let notify = rocket.state::>().unwrap(); + for assigned in [false, true] { + if assigned { + CollectionUser::save(&user.uuid, &collection.uuid, false, false, true, &conn).await.unwrap(); + } + conn.run(|db| db.batch_execute("UPDATE users SET updated_at='2000-01-01 00:00:00'")).await.unwrap(); + let ciphers = ids + .iter() + .map(|id| { + serde_json::from_value(json!({ + "id": id, "name": "Shared", "type": 2, "organizationId": org.uuid, + "encryptedFor": user.uuid, "secureNote": {"type": 0} + })) + .unwrap() + }) + .collect(); + let headers = Headers { + host: "http://localhost".into(), + device: Device::new("test".to_owned().into(), user.uuid.clone(), "test".into(), 1), + user: User::find_by_uuid(&user.uuid, &conn).await.unwrap(), + ip: ClientIp { + ip: "127.0.0.1".parse().unwrap(), + }, + }; + let result = put_cipher_share_selected( + Json(ShareSelectedCipherData { + ciphers, + collection_ids: vec![collection.uuid.clone()], + }), + headers, + db.conn(), + notify.into(), + ) + .await; + assert_eq!(result.is_ok(), assigned); + if assigned { + let response = result.unwrap().into_inner(); + assert_eq!(response["object"], "list"); + let ciphers = response["data"].as_array().unwrap(); + assert_eq!(ciphers.len(), ids.len()); + for cipher in ciphers { + assert_eq!(cipher["object"], "cipherMini"); + assert_eq!(cipher["organizationId"], json!(org.uuid)); + assert!(cipher.get("collectionIds").is_none()); + assert!(cipher.get("edit").is_none()); + } + } + for id in &ids { + let cipher = Cipher::find_by_uuid(id, &conn).await.unwrap(); + assert_eq!(cipher.organization_uuid.as_ref(), assigned.then_some(&org.uuid)); + assert_eq!(cipher.user_uuid.as_ref(), (!assigned).then_some(&user.uuid)); + } + let revision = User::find_by_uuid(&reader.uuid, &conn).await.unwrap().updated_at; + assert_eq!(revision.year() > 2000, assigned); + } + let mut cipher = Cipher::find_by_uuid(&ids[0], &conn).await.unwrap(); + CollectionCipher::save(&cipher.uuid, &hidden.uuid, &conn).await.unwrap(); + let personal = + cipher.to_json("http://localhost", &user.uuid, None, CipherSyncType::User, &conn).await.unwrap(); + let admin = cipher.to_json_org_admin("http://localhost", &member.user_uuid, &conn).await.unwrap(); + assert_eq!(personal["collectionIds"], json!([collection.uuid])); + let admin_ids = admin["collectionIds"].as_array().unwrap(); + assert_eq!(admin_ids.len(), 1); + assert!(admin_ids.contains(&json!(collection.uuid))); + // Exercise PR2's SQL authorization independently of Custom permission support added in PR3. + let sql = format!("DELETE FROM users_collections WHERE user_uuid='{}'", user.uuid); + conn.run(move |c| c.batch_execute(&sql)).await.unwrap(); + let foreign = test_db::organization(&conn).await; + let admin_scope = CipherAccessScope::requested(Some(true)); + assert_eq!(admin_scope, CipherAccessScope::OrganizationAdmin); + assert_eq!(CipherAccessScope::for_member(None), CipherAccessScope::User); + for request in [None, Some(false)] { + assert_eq!(CipherAccessScope::requested(request), CipherAccessScope::User); + } + for (role, status, all, allowed) in [ + (MembershipType::User, 2, false, false), + (MembershipType::Custom, 2, false, false), + (MembershipType::Manager, 2, false, false), + (MembershipType::Manager, 2, true, true), + (MembershipType::Owner, 2, false, true), + (MembershipType::Admin, 2, false, true), + (MembershipType::Admin, 1, false, false), + (MembershipType::Admin, -126, false, false), + ] { + member.set_type(role); + member.status = status; + member.access_all = all; + member.save(&conn).await.unwrap(); + assert_eq!( + CipherAccessScope::for_member(Some(&member)), + if allowed { + admin_scope + } else { + CipherAccessScope::User + } + ); + for org_id in [&org.uuid, &foreign.uuid] { + cipher.organization_uuid = Some(org_id.clone()); + for scope in [CipherAccessScope::User, admin_scope] { + assert_eq!( + cipher.is_write_accessible_to_user(&user.uuid, scope, &conn).await, + allowed && org_id == &org.uuid + ); + } + } + } + }); + } +} diff --git a/src/api/core/events.rs b/src/api/core/events.rs index 3b758bb2..a256d854 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, + }, }, error::MapResult, util::parse_date, @@ -220,7 +222,7 @@ 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 + && cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, &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 607db45b..a6835173 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -15,10 +15,11 @@ use crate::{ db::{ DbConn, models::{ - Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, - CustomPermission, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser, Invitation, - Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, - OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId, custom_roles_enabled, + Cipher, CipherAccessScope, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, + CollectionUser, CustomPermission, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser, + Invitation, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, + Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId, + custom_roles_enabled, }, }, mail, @@ -47,6 +48,7 @@ pub fn routes() -> Vec { bulk_delete_organization_collections, post_bulk_collections, get_org_details, + get_assigned_org_details, get_org_domain_sso_verified, get_members, send_invite, @@ -841,7 +843,7 @@ async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: Db err_code!("Resource not found.", "Organization id's do not match", Status::NotFound.code); } - if !headers.membership.has_full_access() { + if !headers.membership.can_edit_any_collection() { err_code!("Resource not found.", "User does not have full access", Status::NotFound.code); } @@ -859,7 +861,12 @@ async fn get_org_details_impl( conn: &DbConn, ) -> Result { let ciphers = Cipher::find_by_org(org_id, conn).await; - let cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::Organization, conn).await; + let mut cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::Organization, conn).await; + // Every cipher of the organization is listed, so list every collection of them too + cipher_sync_data.cipher_collections = HashMap::new(); + for (cipher_id, collection_id) in Cipher::get_collections_with_cipher_by_organization(org_id, conn).await { + cipher_sync_data.cipher_collections.entry(cipher_id).or_default().push(collection_id); + } let mut ciphers_json = Vec::with_capacity(ciphers.len()); for c in ciphers { @@ -868,6 +875,43 @@ async fn get_org_details_impl( Ok(json!(ciphers_json)) } +// The Admin Console lists these instead of every cipher of the organization for a member who may not edit every +// cipher, like a Custom member with Delete any collection only. As in Bitwarden, any confirmed member may list them. +#[get("/ciphers/organization-details/assigned?")] +async fn get_assigned_org_details(data: OrgIdData, headers: Headers, conn: DbConn) -> JsonResult { + // Only the Custom role needs this, every other role that opens the Admin Console may edit every cipher + if !custom_roles_enabled() { + err_code!("Resource not found.", "Custom roles are disabled", Status::NotFound.code); + } + let Some(member) = + Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await + else { + err_code!("Resource not found.", "User is not a confirmed member of the organization", Status::NotFound.code); + }; + + let (assigned, unassigned) = Cipher::find_assigned_to_member(&member, &conn).await; + let mut ciphers_json = Vec::with_capacity(assigned.len() + unassigned.len()); + // The ciphers of the member's own vault, as they are there + let cipher_sync_data = CipherSyncData::new(&headers.user.uuid, CipherSyncType::User, &conn).await; + for cipher in assigned { + ciphers_json.push( + cipher + .to_json(&headers.host, &headers.user.uuid, Some(&cipher_sync_data), CipherSyncType::User, &conn) + .await?, + ); + } + // The ciphers in no collection, as the member may use them in the Admin Console + for cipher in unassigned { + ciphers_json.push(cipher.to_json_org_admin(&headers.host, &headers.user.uuid, &conn).await?); + } + + Ok(Json(json!({ + "data": ciphers_json, + "object": "list", + "continuationToken": null, + }))) +} + // Returning a Domain/Organization here allow to prefill it and prevent prompting the user // So we return a dummy value, since we only support a single SSO integration, and do not use the response anywhere // In use since `v2025.6.0`, appears to use only the first `organizationIdentifier` @@ -1875,6 +1919,7 @@ async fn post_org_import( &conn, &nt, UpdateType::None, + true, ) .await .ok(); @@ -1908,29 +1953,37 @@ struct BulkCollectionsData { async fn post_bulk_collections(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { let data: BulkCollectionsData = data.into_inner(); - if Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await.is_none() { + let Some(member) = + Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await + else { err!("You need to be a Member of the Organization to call this endpoint") - } + }; + // A member who may edit any collection may use every collection of the organization + let can_edit_any_collection = member.can_edit_any_collection(); // Get all the collection available to the user in one query // Also filter based upon the provided collections - let user_collections: HashMap = - Collection::find_by_organization_and_user_uuid(&data.organization_id, &headers.user.uuid, &conn) - .await - .into_iter() - .filter_map(|c| { - if data.collection_ids.contains(&c.uuid) { - Some((c.uuid.clone(), c)) - } else { - None - } - }) - .collect(); + let collections = if can_edit_any_collection { + Collection::find_by_organization(&data.organization_id, &conn).await + } else { + Collection::find_by_organization_and_user_uuid(&data.organization_id, &headers.user.uuid, &conn).await + }; + let user_collections: HashMap = collections + .into_iter() + .filter_map(|c| { + if data.collection_ids.contains(&c.uuid) { + Some((c.uuid.clone(), c)) + } else { + None + } + }) + .collect(); // Verify if all the collections requested exists and are writable for the user, else abort for collection_uuid in &data.collection_ids { match user_collections.get(collection_uuid) { - Some(collection) if collection.is_writable_by_user(&headers.user.uuid, &conn).await => (), + Some(collection) + if can_edit_any_collection || collection.is_writable_by_user(&headers.user.uuid, &conn).await => {} _ => err_code!("Resource not found", "User does not have access to a collection", 404), } } @@ -1939,7 +1992,7 @@ async fn post_bulk_collections(data: Json, headers: Headers // 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. diff --git a/src/db/mod.rs b/src/db/mod.rs index 62b40965..3caa6cde 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,7 +1,11 @@ mod query_logger; use std::{ - sync::{Arc, OnceLock}, + future::Future, + sync::{ + Arc, OnceLock, + atomic::{AtomicBool, Ordering}, + }, time::Duration, }; @@ -123,6 +127,7 @@ pub static ACTIVE_DB_TYPE: OnceLock = OnceLock::new(); pub struct DbConn { conn: Arc>>>, permit: Option, + transaction_open: AtomicBool, } #[derive(Debug)] @@ -150,6 +155,7 @@ impl Drop for DbConn { fn drop(&mut self) { let conn = Arc::clone(&self.conn); let permit = self.permit.take(); + let transaction_open = self.transaction_open.load(Ordering::Acquire); // Since connection can't be on the stack in an async fn during an // await, we have to spawn a new blocking-safe thread... @@ -157,7 +163,13 @@ impl Drop for DbConn { // And then re-enter the runtime to wait on the async mutex, but in a blocking fashion. let mut conn = tokio::runtime::Handle::current().block_on(conn.lock_owned()); - if let Some(conn) = conn.take() { + if let Some(mut conn) = conn.take() { + if transaction_open { + // A cancelled request must not return an open transaction to the pool. + if let Err(error) = conn.batch_execute("ROLLBACK") { + error!("Failed to roll back a cancelled database transaction: {error}"); + } + } drop(conn); } @@ -248,6 +260,7 @@ impl DbPool { Ok(DbConn { conn: Arc::new(Mutex::new(Some(c))), permit: Some(permit), + transaction_open: AtomicBool::new(false), }) } } @@ -320,6 +333,29 @@ impl DbConnType { } impl DbConn { + /// Run asynchronous model calls on this request's pooled connection in one transaction. + /// Send external notifications only after this returns successfully. + pub async fn transaction(&self, operation: F) -> Result + where + F: FnOnce() -> Fut, + Fut: Future>, + { + self.run(|conn| conn.batch_execute("BEGIN")).await.map_res("Error starting database transaction")?; + self.transaction_open.store(true, Ordering::Release); + let result = operation().await; + if result.is_ok() { + let committed = self.run(|conn| conn.batch_execute("COMMIT")).await; + let closed = committed.is_ok() || self.run(|conn| conn.batch_execute("ROLLBACK")).await.is_ok(); + if closed { + self.transaction_open.store(false, Ordering::Release); + } + committed.map_res("Error committing database transaction")?; + } else if self.run(|conn| conn.batch_execute("ROLLBACK")).await.is_ok() { + self.transaction_open.store(false, Ordering::Release); + } + result + } + pub async fn run(&self, f: F) -> R where F: FnOnce(&mut DbConnInner) -> R + Send, @@ -538,9 +574,61 @@ mod postgresql_migrations { /// A throwaway SQLite database with the real schema, for tests that need to run queries. #[cfg(all(test, sqlite))] pub mod test_db { - use super::sqlite_migrations::MIGRATIONS; - use diesel::connection::SimpleConnection; + use super::{DbConn, DbConnManager, sqlite_migrations::MIGRATIONS}; + use diesel::{Connection, connection::SimpleConnection, r2d2::Pool, sqlite::SqliteConnection}; use diesel_migrations::MigrationHarness; + use std::sync::{Arc, atomic::AtomicBool}; + use tokio::sync::Mutex; + + pub struct TestDb { + pool: Pool, + // Keep the named in-memory database alive across pooled connections. + _keeper: SqliteConnection, + } + + impl TestDb { + fn new() -> Self { + let url = format!("sqlite://{}?mode=memory&cache=shared", crate::util::get_uuid()); + let mut keeper = SqliteConnection::establish(&url).unwrap(); + keeper.run_pending_migrations(MIGRATIONS).unwrap(); + let pool = Pool::builder().max_size(2).build(DbConnManager::new(&url)).unwrap(); + Self { + pool, + _keeper: keeper, + } + } + + pub fn conn(&self) -> DbConn { + DbConn { + conn: Arc::new(Mutex::new(Some(self.pool.get().unwrap()))), + permit: None, + transaction_open: AtomicBool::new(false), + } + } + } + + // CONFIG must initialize in a child process: the DNS bootstrap test needs it uninitialized. + pub fn run(test: impl AsyncFnOnce(&TestDb)) { + if std::env::var_os("VAULTWARDEN_ISOLATED_CONFIG_TEST").is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", std::thread::current().name().unwrap(), "--nocapture"]) + .env("VAULTWARDEN_ISOLATED_CONFIG_TEST", "1") + .env("DATABASE_URL", "sqlite://:memory:") + .output() + .unwrap(); + assert!( + output.status.success() && String::from_utf8_lossy(&output.stdout).contains("1 passed; 0 failed"), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + // DbConn::run uses block_in_place, requiring a multi-threaded runtime. + tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap().block_on(async { + test(&TestDb::new()).await; + }); + } #[test] fn custom_role_migration_preserves_memberships_and_guards_downgrade() { @@ -604,4 +692,70 @@ pub mod test_db { assert_eq!(grants(&mut conn), 0); assert_eq!(rows(&mut conn), before); } + + use super::models::{Collection, CustomPermission, Membership, MembershipType, Organization, OrganizationId, User}; + + pub async fn organization(conn: &DbConn) -> Organization { + let org = Organization::new("Org".into(), "billing@example.com", None, None); + org.save(conn).await.unwrap(); + org + } + + pub async fn member( + conn: &DbConn, + org: &OrganizationId, + role: MembershipType, + permissions: &[CustomPermission], + ) -> (User, Membership) { + let mut user = User::new(&format!("{}@example.com", crate::util::get_uuid()), None); + user.password_hash = b"existing-account".to_vec(); + user.save(conn).await.unwrap(); + let mut member = Membership::new(user.uuid.clone(), org.clone(), None); + member.set_type(role); + member.status = 2; + member.set_custom_permissions(&permissions.iter().copied().collect()); + member.save(conn).await.unwrap(); + (user, member) + } + + pub async fn custom_member(conn: &DbConn, permission: CustomPermission) -> (Organization, User, Membership) { + let org = organization(conn).await; + let (user, member) = member(conn, &org.uuid, MembershipType::Custom, &[permission]).await; + (org, user, member) + } + + pub async fn collection(conn: &DbConn, org: &OrganizationId) -> Collection { + let collection = Collection::new(org.clone(), "Collection".into(), None); + collection.save(conn).await.unwrap(); + collection + } + + #[test] + fn transactions_rollback_before_connection_reuse_and_commit_success() { + use crate::error::MapResult; + use diesel::RunQueryDsl; + run(async |db| { + let conn = db.conn(); + conn.run(|c| c.batch_execute("CREATE TABLE probe (id INTEGER PRIMARY KEY)")).await.unwrap(); + for (sql, expected) in [ + ("INSERT INTO probe VALUES (1); INSERT INTO probe VALUES (1)", 0_i64), + ("INSERT INTO probe VALUES (1)", 1), + ] { + let result: Result<(), super::Error> = conn + .transaction(async || conn.run(move |c| c.batch_execute(sql)).await.map_res("Insert failed")) + .await; + assert_eq!(result.is_ok(), expected == 1); + let count: i64 = conn + .run(|c| { + diesel::select(diesel::dsl::sql::("(SELECT COUNT(*) FROM probe)")) + .get_result(c) + }) + .await + .unwrap(); + assert_eq!(count, expected); + // The same live connection must accept another transaction, before DbConn::drop can help. + conn.run(|c| c.batch_execute("BEGIN; ROLLBACK")).await.unwrap(); + } + }); + } } diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index db8e9bf4..3e34c38e 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -1,4 +1,4 @@ -use std::borrow::Cow; +use std::{borrow::Cow, collections::HashSet}; use chrono::{NaiveDateTime, TimeDelta, Utc}; use derive_more::{AsRef, Deref, Display, From}; @@ -68,6 +68,47 @@ pub enum RepromptType { Password = 1, } +/// Which access a cipher operation is authorized with. +/// +/// The administrative cipher routes (`/ciphers//admin` and friends) are served by the same handlers as the +/// regular vault routes, so the handlers state which one applies. They only differ for a Custom member with Edit +/// any collection, who reaches every cipher of the organization on the administrative routes, but keeps their +/// own vault limited to the collections they are assigned to. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CipherAccessScope { + User, + OrganizationAdmin, +} + +impl CipherAccessScope { + /// Whether `member` reaches every cipher of their organization with this access. + fn reaches_all_ciphers(self, member: &Membership) -> bool { + match self { + CipherAccessScope::User => member.has_full_access(), + CipherAccessScope::OrganizationAdmin => member.can_edit_any_collection(), + } + } + + /// The access the v2 attachment upload asks for with `adminRequest`. It only picks which check runs, the + /// administrative one still needs a member who may edit any collection. + pub fn requested(admin_request: Option) -> Self { + if admin_request == Some(true) { + CipherAccessScope::OrganizationAdmin + } else { + CipherAccessScope::User + } + } + + /// The access for a route that does not tell which one it is, from the member's own membership. + pub fn for_member(member: Option<&Membership>) -> Self { + if member.is_some_and(Membership::can_edit_any_collection) { + CipherAccessScope::OrganizationAdmin + } else { + CipherAccessScope::User + } + } +} + /// Local methods impl Cipher { pub fn new(atype: i32, name: String) -> Self { @@ -151,6 +192,29 @@ impl Cipher { cipher_sync_data: Option<&CipherSyncData>, sync_type: CipherSyncType, conn: &DbConn, + ) -> Result { + self.to_json_scoped(host, user_uuid, cipher_sync_data, sync_type, CipherAccessScope::User, conn).await + } + + /// [`Cipher::to_json`] for an administrative route, telling what the member may do with the cipher there. + pub async fn to_json_org_admin( + &self, + host: &str, + user_uuid: &UserId, + conn: &DbConn, + ) -> Result { + self.to_json_scoped(host, user_uuid, None, CipherSyncType::User, CipherAccessScope::OrganizationAdmin, conn) + .await + } + + async fn to_json_scoped( + &self, + host: &str, + user_uuid: &UserId, + cipher_sync_data: Option<&CipherSyncData>, + sync_type: CipherSyncType, + scope: CipherAccessScope, + conn: &DbConn, ) -> Result { use crate::util::{format_date, validate_and_format_date}; @@ -179,7 +243,7 @@ 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 { + if let Some((ro, hp, mn)) = self.get_access_restrictions(user_uuid, scope, cipher_sync_data, conn).await { (ro, hp, mn) } else { error!("Cipher ownership assertion failure"); @@ -562,20 +626,21 @@ 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. + /// Returns whether this cipher is owned by an org in which the user reaches every cipher with `scope`. async fn is_in_full_access_org( &self, user_uuid: &UserId, + scope: CipherAccessScope, 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(); + return scope.reaches_all_ciphers(cached_member); } } else if let Some(member) = Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn).await { - return member.has_full_access(); + return scope.reaches_all_ciphers(&member); } } false @@ -608,6 +673,7 @@ 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)> { @@ -615,7 +681,7 @@ impl Cipher { // 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_org(user_uuid, scope, cipher_sync_data, conn).await || self.is_in_full_access_group(user_uuid, cipher_sync_data, conn).await { return Some((false, false, true)); @@ -738,8 +804,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, } @@ -747,15 +818,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. @@ -1193,6 +1269,50 @@ impl Cipher { }) .await } + + /// Every collection of every cipher of the organization, for the members who may see all of them. + pub async fn get_collections_with_cipher_by_organization( + org_uuid: &OrganizationId, + conn: &DbConn, + ) -> Vec<(CipherId, CollectionId)> { + 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)) + .select(ciphers_collections::all_columns) + .load::<(CipherId, CollectionId)>(conn) + .unwrap_or_default() + }) + .await + } + + /// The ciphers of the organization of `member` the Admin Console lists when they may not edit every cipher, like + /// Bitwarden does: the ones in the member's own vault, then, for a member who may edit any collection, the ones in + /// no collection at all. + pub async fn find_assigned_to_member(member: &Membership, conn: &DbConn) -> (Vec, Vec) { + let assigned: Vec = Self::find_by_user_visible(&member.user_uuid, conn) + .await + .into_iter() + .filter(|cipher| cipher.organization_uuid.as_ref() == Some(&member.org_uuid)) + .collect(); + if !member.can_edit_any_collection() { + return (assigned, Vec::new()); + } + + let listed: HashSet = assigned.iter().map(|cipher| cipher.uuid.clone()).collect(); + let in_collections: HashSet = + Self::get_collections_with_cipher_by_organization(&member.org_uuid, conn) + .await + .into_iter() + .map(|(cipher, _)| cipher) + .collect(); + let unassigned = Self::find_by_org(&member.org_uuid, conn) + .await + .into_iter() + .filter(|cipher| !in_collections.contains(&cipher.uuid) && !listed.contains(&cipher.uuid)) + .collect(); + (assigned, unassigned) + } } #[derive( diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 8f62bf0b..5feb5b97 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::{ diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index b7fcdfdf..ce4c2201 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -483,6 +483,35 @@ impl Membership { ) } + fn custom_permission(&self, permission: CustomPermission) -> bool { + match permission { + CustomPermission::ManageUsers => self.manage_users, + CustomPermission::ManageGroups => self.manage_groups, + CustomPermission::ManagePolicies => self.manage_policies, + CustomPermission::CreateNewCollections => self.create_new_collections, + CustomPermission::EditAnyCollection => self.edit_any_collection, + CustomPermission::DeleteAnyCollection => self.delete_any_collection, + CustomPermission::AccessEventLogs => self.access_event_logs, + CustomPermission::AccessImportExport => self.access_import_export, + CustomPermission::AccessReports => self.access_reports, + } + } + + /// Whether the role of this member grants `permission`. Only a Custom role grants any, only while Custom roles + /// are enabled, and only the permissions this version enforces. + fn grants_custom_permission(&self, permission: CustomPermission) -> bool { + custom_roles_enabled() + && permission.is_supported() + && self.has_type(MembershipType::Custom) + && self.custom_permission(permission) + } + + /// Whether this member may act with `permission`, granted by their Custom role. Like the authority of any + /// other role, it needs a confirmed membership. + pub fn has_custom_permission(&self, permission: CustomPermission) -> bool { + self.has_status(MembershipStatus::Confirmed) && self.grants_custom_permission(permission) + } + pub fn restore(&mut self) -> bool { if self.status < MembershipStatus::Invited as i32 { self.status += ACTIVATE_REVOKE_DIFF; @@ -1001,6 +1030,13 @@ impl Membership { (self.access_all || self.atype >= MembershipType::Admin) && self.has_status(MembershipStatus::Confirmed) } + /// Whether this member may edit every collection and the items in them in the Admin Console: every member with + /// full access, and a Custom member with Edit any collection. Unlike full access, the permission does not add + /// anything to the member's own vault. + pub fn can_edit_any_collection(&self) -> bool { + self.has_full_access() || self.has_custom_permission(CustomPermission::EditAnyCollection) + } + pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option { conn.run(move |conn| { users_organizations::table.filter(users_organizations::uuid.eq(uuid)).first::(conn).ok()