From 7ec00d3850f0c058314b4bc4a3c5d1094882c434 Mon Sep 17 00:00:00 2001 From: BlackDex Date: Mon, 27 Feb 2023 16:37:58 +0100 Subject: [PATCH] Fix the web-vault v2023.2.0 API calls - Supports the new Collection/Group/User editing UI's - Support `/partial` endpoint for cipher updating to allow folder and favorite update for read-only ciphers. - Prevent `Favorite`, `Folder`, `read-only` and `hide-passwords` from being added to the organizational sync. - Added and corrected some `Object` key's to the output json. Fixes #3279 --- src/api/admin.rs | 2 +- src/api/core/accounts.rs | 3 + src/api/core/ciphers.rs | 78 ++++++++-- src/api/core/emergency_access.rs | 12 +- src/api/core/mod.rs | 1 + src/api/core/organizations.rs | 248 +++++++++++++++++++++++-------- src/db/models/cipher.rs | 49 ++++-- src/db/models/collection.rs | 28 ++++ src/db/models/group.rs | 27 +++- src/db/models/organization.rs | 39 ++++- 10 files changed, 394 insertions(+), 93 deletions(-) diff --git a/src/api/admin.rs b/src/api/admin.rs index d875d8be..67ab1756 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -627,7 +627,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, mut conn: DbConn) "latest_release": latest_release, "latest_commit": latest_commit, "web_vault_enabled": &CONFIG.web_vault_enabled(), - "web_vault_version": web_vault_version.version, + "web_vault_version": web_vault_version.version.trim_start_matches('v'), "latest_web_build": latest_web_build, "running_within_docker": running_within_docker, "docker_base_image": if running_within_docker { docker_base_image() } else { "Not applicable" }, diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 0354f464..67633f0b 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -841,6 +841,8 @@ async fn _api_key( headers: Headers, mut conn: DbConn, ) -> JsonResult { + use crate::util::format_date; + let data: SecretVerificationRequest = data.into_inner().data; let mut user = headers.user; @@ -855,6 +857,7 @@ async fn _api_key( Ok(Json(json!({ "ApiKey": user.api_key, + "RevisionDate": format_date(&user.updated_at), "Object": "apiKey", }))) } diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 0b0f1080..14d44597 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -56,7 +56,9 @@ pub fn routes() -> Vec { put_cipher_share, put_cipher_share_selected, post_cipher, + post_cipher_partial, put_cipher, + put_cipher_partial, delete_cipher_post, delete_cipher_post_admin, delete_cipher_put, @@ -109,7 +111,10 @@ async fn sync(data: SyncData, headers: Headers, mut conn: DbConn) -> Json // Lets generate the ciphers_json using all the gathered info let mut ciphers_json = Vec::with_capacity(ciphers.len()); for c in ciphers { - ciphers_json.push(c.to_json(&headers.host, &headers.user.uuid, Some(&cipher_sync_data), &mut conn).await); + ciphers_json.push( + c.to_json(&headers.host, &headers.user.uuid, Some(&cipher_sync_data), CipherSyncType::User, &mut conn) + .await, + ); } let collections = Collection::find_by_user_uuid(headers.user.uuid.clone(), &mut conn).await; @@ -153,7 +158,10 @@ async fn get_ciphers(headers: Headers, mut conn: DbConn) -> Json { let mut ciphers_json = Vec::with_capacity(ciphers.len()); for c in ciphers { - ciphers_json.push(c.to_json(&headers.host, &headers.user.uuid, Some(&cipher_sync_data), &mut conn).await); + ciphers_json.push( + c.to_json(&headers.host, &headers.user.uuid, Some(&cipher_sync_data), CipherSyncType::User, &mut conn) + .await, + ); } Json(json!({ @@ -174,7 +182,7 @@ async fn get_cipher(uuid: String, headers: Headers, mut conn: DbConn) -> JsonRes err!("Cipher is not owned by user") } - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, &mut conn).await)) + Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &mut conn).await)) } #[get("/ciphers//admin")] @@ -235,6 +243,13 @@ pub struct CipherData { LastKnownRevisionDate: Option, } +#[derive(Deserialize, Debug)] +#[allow(non_snake_case)] +pub struct PartialCipherData { + FolderId: Option, + Favorite: bool, +} + #[derive(Deserialize, Debug)] #[allow(non_snake_case)] pub struct Attachments2Data { @@ -314,7 +329,7 @@ async fn post_ciphers( update_cipher_from_data(&mut cipher, data, &headers, false, &mut conn, &ip, &nt, UpdateType::SyncCipherCreate) .await?; - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, &mut conn).await)) + Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &mut conn).await)) } /// Enforces the personal ownership policy on user-owned ciphers, if applicable. @@ -646,7 +661,51 @@ async fn put_cipher( update_cipher_from_data(&mut cipher, data, &headers, false, &mut conn, &ip, &nt, UpdateType::SyncCipherUpdate) .await?; - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, &mut conn).await)) + Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &mut conn).await)) +} + +#[post("/ciphers//partial", data = "")] +async fn post_cipher_partial( + uuid: String, + data: JsonUpcase, + headers: Headers, + conn: DbConn, +) -> JsonResult { + put_cipher_partial(uuid, data, headers, conn).await +} + +// Only update the folder and favorite for the user, since this cipher is read-only +#[put("/ciphers//partial", data = "")] +async fn put_cipher_partial( + uuid: String, + data: JsonUpcase, + headers: Headers, + mut conn: DbConn, +) -> JsonResult { + let data: PartialCipherData = data.into_inner().data; + + let cipher = match Cipher::find_by_uuid(&uuid, &mut conn).await { + Some(cipher) => cipher, + None => err!("Cipher doesn't exist"), + }; + + if let Some(ref folder_id) = data.FolderId { + match Folder::find_by_uuid(folder_id, &mut conn).await { + Some(folder) => { + if folder.user_uuid != headers.user.uuid { + err!("Folder is not owned by user") + } + } + None => err!("Folder doesn't exist"), + } + } + + // Move cipher + cipher.move_to_folder(data.FolderId.clone(), &headers.user.uuid, &mut conn).await?; + // Update favorite + cipher.set_favorite(Some(data.Favorite), &headers.user.uuid, &mut conn).await?; + + Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &mut conn).await)) } #[derive(Deserialize)] @@ -873,7 +932,7 @@ async fn share_cipher_by_uuid( update_cipher_from_data(&mut cipher, data.Cipher, headers, shared_to_collection, conn, ip, nt, ut).await?; - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, conn).await)) + Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await)) } /// v2 API for downloading an attachment. This just redirects the client to @@ -942,7 +1001,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, &mut conn).await, + response_key: cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &mut conn).await, }))) } @@ -1135,7 +1194,7 @@ async fn post_attachment( let (cipher, mut conn) = save_attachment(attachment, uuid, data, &headers, conn, ip, nt).await?; - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, &mut conn).await)) + Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &mut conn).await)) } #[post("/ciphers//attachment-admin", format = "multipart/form-data", data = "")] @@ -1616,7 +1675,7 @@ async fn _restore_cipher_by_uuid( .await; } - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, conn).await)) + Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await)) } async fn _restore_multiple_ciphers( @@ -1716,6 +1775,7 @@ pub struct CipherSyncData { pub user_group_full_access_for_organizations: HashSet, } +#[derive(Eq, PartialEq)] pub enum CipherSyncType { User, Organization, diff --git a/src/api/core/emergency_access.rs b/src/api/core/emergency_access.rs index 1730de01..dd0617e2 100644 --- a/src/api/core/emergency_access.rs +++ b/src/api/core/emergency_access.rs @@ -590,8 +590,16 @@ async fn view_emergency_access(emer_id: String, headers: Headers, mut conn: DbCo let mut ciphers_json = Vec::with_capacity(ciphers.len()); for c in ciphers { - ciphers_json - .push(c.to_json(&headers.host, &emergency_access.grantor_uuid, Some(&cipher_sync_data), &mut conn).await); + ciphers_json.push( + c.to_json( + &headers.host, + &emergency_access.grantor_uuid, + Some(&cipher_sync_data), + CipherSyncType::User, + &mut conn, + ) + .await, + ); } Ok(Json(json!({ diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index d029cb60..6a483842 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -237,6 +237,7 @@ fn config() -> Json { "notifications": format!("{domain}/notifications"), "sso": "", }, + "object": "config", })) } diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 38ff63b7..1353e61b 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -118,12 +118,13 @@ struct OrganizationUpdateData { #[allow(non_snake_case)] struct NewCollectionData { Name: String, - Groups: Vec, + Groups: Vec, + Users: Vec, } #[derive(Deserialize)] #[allow(non_snake_case)] -struct NewCollectionGroupData { +struct NewCollectionObjectData { HidePasswords: bool, Id: String, ReadOnly: bool, @@ -311,29 +312,62 @@ async fn get_org_collections(org_id: String, _headers: ManagerHeadersLoose, mut } #[get("/organizations//collections/details")] -async fn get_org_collections_details(org_id: String, _headers: ManagerHeadersLoose, mut conn: DbConn) -> Json { +async fn get_org_collections_details(org_id: String, headers: ManagerHeadersLoose, mut conn: DbConn) -> JsonResult { let mut data = Vec::new(); + let user_org = match UserOrganization::find_by_user_and_org(&headers.user.uuid, &org_id, &mut conn).await { + Some(u) => u, + None => err!("User is not part of organization"), + }; + + let coll_users = CollectionUser::find_by_organization(&org_id, &mut conn).await; + for col in Collection::find_by_organization(&org_id, &mut conn).await { - let groups: Vec = CollectionGroup::find_by_collection(&col.uuid, &mut conn) - .await + let groups: Vec = if CONFIG.org_groups_enabled() { + CollectionGroup::find_by_collection(&col.uuid, &mut conn) + .await + .iter() + .map(|collection_group| { + SelectionReadOnly::to_collection_group_details_read_only(collection_group).to_json() + }) + .collect() + } else { + // The Bitwarden clients seem to call this API regardless of whether groups are enabled, + // so just act as if there are no groups. + Vec::with_capacity(0) + }; + + let mut assigned = false; + let users: Vec = coll_users .iter() - .map(|collection_group| { - SelectionReadOnly::to_collection_group_details_read_only(collection_group).to_json() + .filter(|collection_user| collection_user.collection_uuid == col.uuid) + .map(|collection_user| { + // Remember `user_uuid` is swapped here with the `user_org.uuid` with a join during the `CollectionUser::find_by_organization` call. + // We check here if the current user is assigned to this collection or not. + if collection_user.user_uuid == user_org.uuid { + assigned = true; + } + SelectionReadOnly::to_collection_user_details_read_only(collection_user).to_json() }) .collect(); + if user_org.access_all { + assigned = true; + } + let mut json_object = col.to_json(); + json_object["Assigned"] = json!(assigned); + json_object["Users"] = json!(users); json_object["Groups"] = json!(groups); - json_object["Object"] = json!("collectionGroupDetails"); + json_object["Object"] = json!("collectionAccessDetails"); data.push(json_object) } - Json(json!({ + Ok(Json(json!({ "Data": data, "Object": "list", "ContinuationToken": null, - })) + }))) } async fn _get_org_collections(org_id: &str, conn: &mut DbConn) -> Value { @@ -355,12 +389,6 @@ async fn post_organization_collections( None => err!("Can't find organization details"), }; - // Get the user_organization record so that we can check if the user has access to all collections. - let user_org = match UserOrganization::find_by_user_and_org(&headers.user.uuid, &org_id, &mut conn).await { - Some(u) => u, - None => err!("User is not part of organization"), - }; - let collection = Collection::new(org.uuid, data.Name); collection.save(&mut conn).await?; @@ -381,11 +409,18 @@ async fn post_organization_collections( .await?; } - // If the user doesn't have access to all collections, only in case of a Manger, - // then we need to save the creating user uuid (Manager) to the users_collection table. - // Else the user will not have access to his own created collection. - if !user_org.access_all { - CollectionUser::save(&headers.user.uuid, &collection.uuid, false, false, &mut conn).await?; + for user in data.Users { + let org_user = match UserOrganization::find_by_uuid(&user.Id, &mut conn).await { + Some(u) => u, + None => err!("User is not part of organization"), + }; + + if org_user.access_all { + continue; + } + + CollectionUser::save(&org_user.user_uuid, &collection.uuid, user.ReadOnly, user.HidePasswords, &mut conn) + .await?; } Ok(Json(collection.to_json())) @@ -448,6 +483,21 @@ async fn post_organization_collection_update( CollectionGroup::new(col_id.clone(), group.Id, group.ReadOnly, group.HidePasswords).save(&mut conn).await?; } + CollectionUser::delete_all_by_collection(&col_id, &mut conn).await?; + + for user in data.Users { + let org_user = match UserOrganization::find_by_uuid(&user.Id, &mut conn).await { + Some(u) => u, + None => err!("User is not part of organization"), + }; + + if org_user.access_all { + continue; + } + + CollectionUser::save(&org_user.user_uuid, &col_id, user.ReadOnly, user.HidePasswords, &mut conn).await?; + } + Ok(Json(collection.to_json())) } @@ -555,17 +605,49 @@ async fn get_org_collection_detail( err!("Collection is not owned by organization") } - let groups: Vec = CollectionGroup::find_by_collection(&collection.uuid, &mut conn) - .await - .iter() - .map(|collection_group| { - SelectionReadOnly::to_collection_group_details_read_only(collection_group).to_json() - }) - .collect(); + let user_org = match UserOrganization::find_by_user_and_org(&headers.user.uuid, &org_id, &mut conn).await { + Some(u) => u, + None => err!("User is not part of organization"), + }; + + let groups: Vec = if CONFIG.org_groups_enabled() { + CollectionGroup::find_by_collection(&collection.uuid, &mut conn) + .await + .iter() + .map(|collection_group| { + SelectionReadOnly::to_collection_group_details_read_only(collection_group).to_json() + }) + .collect() + } else { + // The Bitwarden clients seem to call this API regardless of whether groups are enabled, + // so just act as if there are no groups. + Vec::with_capacity(0) + }; + + let mut assigned = false; + let users: Vec = + CollectionUser::find_by_collection_swap_user_uuid_with_org_user_uuid(&collection.uuid, &mut conn) + .await + .iter() + .map(|collection_user| { + // Remember `user_uuid` is swapped here with the `user_org.uuid` with a join during the `find_by_collection_swap_user_uuid_with_org_user_uuid` call. + // We check here if the current user is assigned to this collection or not. + if collection_user.user_uuid == user_org.uuid { + assigned = true; + } + SelectionReadOnly::to_collection_user_details_read_only(collection_user).to_json() + }) + .collect(); + + if user_org.access_all { + assigned = true; + } let mut json_object = collection.to_json(); + json_object["Assigned"] = json!(assigned); + json_object["Users"] = json!(users); json_object["Groups"] = json!(groups); - json_object["Object"] = json!("collectionGroupDetails"); + json_object["Object"] = json!("collectionAccessDetails"); Ok(Json(json_object)) } @@ -652,16 +734,39 @@ async fn _get_org_details(org_id: &str, host: &str, user_uuid: &str, conn: &mut let mut ciphers_json = Vec::with_capacity(ciphers.len()); for c in ciphers { - ciphers_json.push(c.to_json(host, user_uuid, Some(&cipher_sync_data), conn).await); + ciphers_json + .push(c.to_json(host, user_uuid, Some(&cipher_sync_data), CipherSyncType::Organization, conn).await); } json!(ciphers_json) } -#[get("/organizations//users")] -async fn get_org_users(org_id: String, _headers: ManagerHeadersLoose, mut conn: DbConn) -> Json { +#[derive(FromForm)] +struct GetOrgUserData { + #[field(name = "includeCollections")] + include_collections: Option, + #[field(name = "includeGroups")] + include_groups: Option, +} + +// includeCollections +// includeGroups +#[get("/organizations//users?")] +async fn get_org_users( + data: GetOrgUserData, + org_id: String, + _headers: ManagerHeadersLoose, + mut conn: DbConn, +) -> Json { let mut users_json = Vec::new(); for u in UserOrganization::find_by_org(&org_id, &mut conn).await { - users_json.push(u.to_json_user_details(&mut conn).await); + users_json.push( + u.to_json_user_details( + data.include_collections.unwrap_or(false), + data.include_groups.unwrap_or(false), + &mut conn, + ) + .await, + ); } Json(json!({ @@ -2056,12 +2161,18 @@ async fn _restore_organization_user( #[get("/organizations//groups")] async fn get_groups(org_id: String, _headers: ManagerHeadersLoose, mut conn: DbConn) -> JsonResult { - let groups = if CONFIG.org_groups_enabled() { - Group::find_by_organization(&org_id, &mut conn).await.iter().map(Group::to_json).collect::() + let groups: Vec = if CONFIG.org_groups_enabled() { + // Group::find_by_organization(&org_id, &mut conn).await.iter().map(Group::to_json).collect::() + let groups = Group::find_by_organization(&org_id, &mut conn).await; + let mut groups_json = Vec::with_capacity(groups.len()); + for g in groups { + groups_json.push(g.to_json_details(&mut conn).await) + } + groups_json } else { // The Bitwarden clients seem to call this API regardless of whether groups are enabled, // so just act as if there are no groups. - Value::Array(Vec::new()) + Vec::with_capacity(0) }; Ok(Json(json!({ @@ -2078,6 +2189,7 @@ struct GroupRequest { AccessAll: Option, ExternalId: Option, Collections: Vec, + Users: Vec, } impl GroupRequest { @@ -2120,19 +2232,19 @@ impl SelectionReadOnly { CollectionGroup::new(self.Id.clone(), groups_uuid, self.ReadOnly, self.HidePasswords) } - pub fn to_group_details_read_only(collection_group: &CollectionGroup) -> SelectionReadOnly { + pub fn to_collection_group_details_read_only(collection_group: &CollectionGroup) -> SelectionReadOnly { SelectionReadOnly { - Id: collection_group.collections_uuid.clone(), + Id: collection_group.groups_uuid.clone(), ReadOnly: collection_group.read_only, HidePasswords: collection_group.hide_passwords, } } - pub fn to_collection_group_details_read_only(collection_group: &CollectionGroup) -> SelectionReadOnly { + pub fn to_collection_user_details_read_only(collection_user: &CollectionUser) -> SelectionReadOnly { SelectionReadOnly { - Id: collection_group.groups_uuid.clone(), - ReadOnly: collection_group.read_only, - HidePasswords: collection_group.hide_passwords, + Id: collection_user.user_uuid.clone(), + ReadOnly: collection_user.read_only, + HidePasswords: collection_user.hide_passwords, } } @@ -2171,7 +2283,7 @@ async fn post_groups( log_event( EventType::GroupCreated as i32, &group.uuid, - org_id, + org_id.clone(), headers.user.uuid.clone(), headers.device.atype, &ip.ip, @@ -2179,7 +2291,7 @@ async fn post_groups( ) .await; - add_update_group(group, group_request.Collections, &mut conn).await + add_update_group(group, group_request.Collections, group_request.Users, &org_id, &headers, &ip, &mut conn).await } #[put("/organizations//groups/", data = "")] @@ -2204,11 +2316,12 @@ async fn put_group( let updated_group = group_request.update_group(group)?; CollectionGroup::delete_all_by_group(&group_id, &mut conn).await?; + GroupUser::delete_all_by_group(&group_id, &mut conn).await?; log_event( EventType::GroupUpdated as i32, &updated_group.uuid, - org_id, + org_id.clone(), headers.user.uuid.clone(), headers.device.atype, &ip.ip, @@ -2216,18 +2329,42 @@ async fn put_group( ) .await; - add_update_group(updated_group, group_request.Collections, &mut conn).await + add_update_group(updated_group, group_request.Collections, group_request.Users, &org_id, &headers, &ip, &mut conn) + .await } -async fn add_update_group(mut group: Group, collections: Vec, conn: &mut DbConn) -> JsonResult { +async fn add_update_group( + mut group: Group, + collections: Vec, + users: Vec, + org_id: &str, + headers: &AdminHeaders, + ip: &ClientIp, + conn: &mut DbConn, +) -> JsonResult { group.save(conn).await?; for selection_read_only_request in collections { let mut collection_group = selection_read_only_request.to_collection_group(group.uuid.clone()); - collection_group.save(conn).await?; } + for assigned_user_id in users { + let mut user_entry = GroupUser::new(group.uuid.clone(), assigned_user_id.clone()); + user_entry.save(conn).await?; + + log_event( + EventType::OrganizationUserUpdatedGroups as i32, + &assigned_user_id, + String::from(org_id), + headers.user.uuid.clone(), + headers.device.atype, + &ip.ip, + conn, + ) + .await; + } + Ok(Json(json!({ "Id": group.uuid, "OrganizationId": group.organizations_uuid, @@ -2248,20 +2385,7 @@ async fn get_group_details(_org_id: String, group_id: String, _headers: AdminHea _ => err!("Group could not be found!"), }; - let collections_groups = CollectionGroup::find_by_group(&group_id, &mut conn) - .await - .iter() - .map(|entry| SelectionReadOnly::to_group_details_read_only(entry).to_json()) - .collect::(); - - Ok(Json(json!({ - "Id": group.uuid, - "OrganizationId": group.organizations_uuid, - "Name": group.name, - "AccessAll": group.access_all, - "ExternalId": group.get_external_id(), - "Collections": collections_groups - }))) + Ok(Json(group.to_json_details(&mut conn).await)) } #[post("/organizations//groups//delete")] diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index b7d26bd3..79212f6a 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -6,7 +6,7 @@ use super::{ Attachment, CollectionCipher, Favorite, FolderCipher, Group, User, UserOrgStatus, UserOrgType, UserOrganization, }; -use crate::api::core::{CipherData, CipherSyncData}; +use crate::api::core::{CipherData, CipherSyncData, CipherSyncType}; use std::borrow::Cow; @@ -114,6 +114,7 @@ impl Cipher { host: &str, user_uuid: &str, cipher_sync_data: Option<&CipherSyncData>, + sync_type: CipherSyncType, conn: &mut DbConn, ) -> Value { use crate::util::format_date; @@ -134,12 +135,18 @@ impl Cipher { let password_history_json = self.password_history.as_ref().and_then(|s| serde_json::from_str(s).ok()).unwrap_or(Value::Null); - let (read_only, hide_passwords) = match self.get_access_restrictions(user_uuid, cipher_sync_data, conn).await { - Some((ro, hp)) => (ro, hp), - None => { - error!("Cipher ownership assertion failure"); - (true, true) + // 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 { + match self.get_access_restrictions(user_uuid, cipher_sync_data, conn).await { + Some((ro, hp)) => (ro, hp), + None => { + error!("Cipher ownership assertion failure"); + (true, true) + } } + } else { + (false, false) }; // Get the type_data or a default to an empty json object '{}'. @@ -192,8 +199,6 @@ impl Cipher { "CreationDate": format_date(&self.created_at), "RevisionDate": format_date(&self.updated_at), "DeletedDate": self.deleted_at.map_or(Value::Null, |d| Value::String(format_date(&d))), - "FolderId": if let Some(cipher_sync_data) = cipher_sync_data { cipher_sync_data.cipher_folders.get(&self.uuid).map(|c| c.to_string() ) } else { self.get_folder_uuid(user_uuid, conn).await }, - "Favorite": if let Some(cipher_sync_data) = cipher_sync_data { cipher_sync_data.cipher_favorites.contains(&self.uuid) } else { self.is_favorite(user_uuid, conn).await }, "Reprompt": self.reprompt.unwrap_or(RepromptType::None as i32), "OrganizationId": self.organization_uuid, "Attachments": attachments_json, @@ -210,12 +215,6 @@ impl Cipher { "Data": data_json, - // These values are true by default, but can be false if the - // cipher belongs to a collection where the org owner has enabled - // the "Read Only" or "Hide Passwords" restrictions for the user. - "Edit": !read_only, - "ViewPassword": !hide_passwords, - "PasswordHistory": password_history_json, // All Cipher types are included by default as null, but only the matching one will be populated @@ -225,6 +224,27 @@ impl Cipher { "Identity": null, }); + // These values are only needed for user/default syncs + // Not during an organizational sync like `get_org_details` + // Skip adding these fields in that case + if sync_type == CipherSyncType::User { + json_object["FolderId"] = json!(if let Some(cipher_sync_data) = cipher_sync_data { + cipher_sync_data.cipher_folders.get(&self.uuid).map(|c| c.to_string()) + } else { + self.get_folder_uuid(user_uuid, conn).await + }); + json_object["Favorite"] = json!(if let Some(cipher_sync_data) = cipher_sync_data { + cipher_sync_data.cipher_favorites.contains(&self.uuid) + } else { + self.is_favorite(user_uuid, conn).await + }); + // These values are true by default, but can be false if the + // cipher belongs to a collection or group where the org owner has enabled + // the "Read Only" or "Hide Passwords" restrictions for the user. + json_object["Edit"] = json!(!read_only); + json_object["ViewPassword"] = json!(!hide_passwords); + } + let key = match self.atype { 1 => "Login", 2 => "SecureNote", @@ -740,6 +760,7 @@ impl Cipher { .or_filter(groups::access_all.eq(true)) //Access via group .or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group .select(ciphers_collections::all_columns) + .distinct() .load::<(String, String)>(conn).unwrap_or_default() }} } diff --git a/src/db/models/collection.rs b/src/db/models/collection.rs index 1ae29493..365efefb 100644 --- a/src/db/models/collection.rs +++ b/src/db/models/collection.rs @@ -396,6 +396,19 @@ impl CollectionUser { }} } + pub async fn find_by_organization(org_uuid: &str, conn: &mut DbConn) -> Vec { + db_run! { conn: { + users_collections::table + .inner_join(collections::table.on(collections::uuid.eq(users_collections::collection_uuid))) + .filter(collections::org_uuid.eq(org_uuid)) + .inner_join(users_organizations::table.on(users_organizations::user_uuid.eq(users_collections::user_uuid))) + .select((users_organizations::uuid, users_collections::collection_uuid, users_collections::read_only, users_collections::hide_passwords)) + .load::(conn) + .expect("Error loading users_collections") + .from_db() + }} + } + pub async fn save( user_uuid: &str, collection_uuid: &str, @@ -479,6 +492,21 @@ impl CollectionUser { }} } + pub async fn find_by_collection_swap_user_uuid_with_org_user_uuid( + collection_uuid: &str, + conn: &mut DbConn, + ) -> Vec { + db_run! { conn: { + users_collections::table + .filter(users_collections::collection_uuid.eq(collection_uuid)) + .inner_join(users_organizations::table.on(users_organizations::user_uuid.eq(users_collections::user_uuid))) + .select((users_organizations::uuid, users_collections::collection_uuid, users_collections::read_only, users_collections::hide_passwords)) + .load::(conn) + .expect("Error loading users_collections") + .from_db() + }} + } + pub async fn find_by_collection_and_user( collection_uuid: &str, user_uuid: &str, diff --git a/src/db/models/group.rs b/src/db/models/group.rs index 6f267c10..8fbde296 100644 --- a/src/db/models/group.rs +++ b/src/db/models/group.rs @@ -64,7 +64,32 @@ impl Group { "AccessAll": self.access_all, "ExternalId": self.external_id, "CreationDate": format_date(&self.creation_date), - "RevisionDate": format_date(&self.revision_date) + "RevisionDate": format_date(&self.revision_date), + "Object": "group" + }) + } + + pub async fn to_json_details(&self, conn: &mut DbConn) -> Value { + let collections_groups: Vec = CollectionGroup::find_by_group(&self.uuid, conn) + .await + .iter() + .map(|entry| { + json!({ + "Id": entry.collections_uuid, + "ReadOnly": entry.read_only, + "HidePasswords": entry.hide_passwords + }) + }) + .collect(); + + json!({ + "Id": self.uuid, + "OrganizationId": self.organizations_uuid, + "Name": self.name, + "AccessAll": self.access_all, + "ExternalId": self.external_id, + "Collections": collections_groups, + "Object": "groupDetails" }) } diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 34325b78..6010c207 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -326,7 +326,7 @@ impl UserOrganization { // TODO: Add support for Custom User Roles // See: https://bitwarden.com/help/article/user-types-access-control/#custom-role // "Permissions": { - // "AccessEventLogs": false, // Not supported + // "AccessEventLogs": false, // "AccessImportExport": false, // "AccessReports": false, // "ManageAllCollections": false, @@ -337,9 +337,9 @@ impl UserOrganization { // "editAssignedCollections": false, // "deleteAssignedCollections": false, // "ManageCiphers": false, - // "ManageGroups": false, // Not supported + // "ManageGroups": false, // "ManagePolicies": false, - // "ManageResetPassword": false, // Not supported + // "ManageResetPassword": false, // "ManageSso": false, // Not supported // "ManageUsers": false, // "ManageScim": false, // Not supported (Not AGPLv3 Licensed) @@ -358,7 +358,12 @@ impl UserOrganization { }) } - pub async fn to_json_user_details(&self, conn: &mut DbConn) -> Value { + pub async fn to_json_user_details( + &self, + include_collections: bool, + include_groups: bool, + conn: &mut DbConn, + ) -> Value { let user = User::find_by_uuid(&self.user_uuid, conn).await.unwrap(); // Because BitWarden want the status to be -1 for revoked users we need to catch that here. @@ -371,11 +376,37 @@ impl UserOrganization { let twofactor_enabled = !TwoFactor::find_by_user(&user.uuid, conn).await.is_empty(); + let groups: Vec = if include_groups && CONFIG.org_groups_enabled() { + GroupUser::find_by_user(&self.uuid, conn).await.iter().map(|gu| gu.groups_uuid.clone()).collect() + } else { + // The Bitwarden clients seem to call this API regardless of whether groups are enabled, + // so just act as if there are no groups. + Vec::with_capacity(0) + }; + + let collections: Vec = if include_collections { + CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn) + .await + .iter() + .map(|cu| { + json!({ + "Id": cu.collection_uuid, + "ReadOnly": cu.read_only, + "HidePasswords": cu.hide_passwords, + }) + }) + .collect() + } else { + Vec::with_capacity(0) + }; + json!({ "Id": self.uuid, "UserId": self.user_uuid, "Name": user.name, "Email": user.email, + "Groups": groups, + "Collections": collections, "Status": status, "Type": self.atype,