From 6d93795095b5c50186a4e4ae5ad0d26da9f36591 Mon Sep 17 00:00:00 2001 From: Arthur Andersen Date: Wed, 16 Sep 2026 11:24:36 +0200 Subject: [PATCH 1/6] parse and store organizations and collections from sync --- src/actions.rs | 19 +-- src/api.rs | 256 ++++++++++++++++++++++++++++++++--- src/bin/rbw-agent/actions.rs | 23 ++-- src/bin/rbw/commands.rs | 1 + src/db.rs | 105 ++++++++++++++ 5 files changed, 360 insertions(+), 44 deletions(-) diff --git a/src/actions.rs b/src/actions.rs index 79d304d4..86611618 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -136,15 +136,7 @@ pub fn unlock( pub async fn sync( access_token: &str, refresh_token: &str, -) -> Result<( - Option, - ( - String, - String, - std::collections::HashMap, - Vec, - ), -)> { +) -> Result<(Option, crate::api::SyncData)> { with_exchange_refresh_token_async( access_token, refresh_token, @@ -156,14 +148,7 @@ pub async fn sync( .await } -async fn sync_once( - access_token: &str, -) -> Result<( - String, - String, - std::collections::HashMap, - Vec, -)> { +async fn sync_once(access_token: &str) -> Result { let (client, _) = api_client_async().await?; client.sync(access_token).await } diff --git a/src/api.rs b/src/api.rs index a817fb26..ac50d477 100644 --- a/src/api.rs +++ b/src/api.rs @@ -386,6 +386,16 @@ struct SendEmailLoginReq { sso_email_2fa_session_token: String, } +#[derive(Debug)] +pub struct SyncData { + pub protected_key: String, + pub protected_private_key: String, + pub protected_org_keys: std::collections::HashMap, + pub organizations: Vec, + pub collections: Vec, + pub entries: Vec, +} + #[derive(serde::Deserialize, Debug)] struct SyncRes { #[serde(rename = "Ciphers", alias = "ciphers")] @@ -394,6 +404,8 @@ struct SyncRes { profile: SyncResProfile, #[serde(rename = "Folders", alias = "folders")] folders: Vec, + #[serde(rename = "Collections", alias = "collections", default)] + collections: Vec, } #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] @@ -404,6 +416,8 @@ struct SyncResCipher { folder_id: Option, #[serde(rename = "OrganizationId", alias = "organizationId")] organization_id: Option, + #[serde(rename = "CollectionIds", alias = "collectionIds", default)] + collection_ids: Option>, #[serde(rename = "Name", alias = "name")] name: String, #[serde(rename = "Login", alias = "login")] @@ -542,6 +556,7 @@ impl SyncResCipher { Some(crate::db::Entry { id: self.id.clone(), org_id: self.organization_id.clone(), + collection_ids: self.collection_ids.clone().unwrap_or_default(), folder, folder_id: folder_id.map(std::string::ToString::to_string), name: self.name.clone(), @@ -571,6 +586,19 @@ struct SyncResProfileOrganization { id: String, #[serde(rename = "Key", alias = "key")] key: String, + // defensive: don't fail the whole sync on a server that omits the name + #[serde(rename = "Name", alias = "name", default)] + name: String, +} + +#[derive(serde::Deserialize, Debug)] +struct SyncResCollection { + #[serde(rename = "Id", alias = "id")] + id: String, + #[serde(rename = "OrganizationId", alias = "organizationId")] + organization_id: String, + #[serde(rename = "Name", alias = "name")] + name: String, } #[derive(serde::Deserialize, Debug, Clone)] @@ -1145,15 +1173,7 @@ impl Client { Ok((sso_code, sso_code_verifier, callback_url)) } - pub async fn sync( - &self, - access_token: &str, - ) -> Result<( - String, - String, - std::collections::HashMap, - Vec, - )> { + pub async fn sync(&self, access_token: &str) -> Result { let client = self.reqwest_client().await?; let res = client .get(self.api_url("/sync")) @@ -1167,23 +1187,47 @@ impl Client { reqwest::StatusCode::OK => { let sync_res: SyncRes = res.json_with_path().await?; let folders = sync_res.folders.clone(); - let ciphers = sync_res + let entries = sync_res .ciphers .iter() .filter_map(|cipher| cipher.to_entry(&folders)) .collect(); - let org_keys = sync_res + let protected_org_keys = sync_res .profile .organizations .iter() .map(|org| (org.id.clone(), org.key.clone())) .collect(); - Ok(( - sync_res.profile.key, - sync_res.profile.private_key, - org_keys, - ciphers, - )) + let organizations = sync_res + .profile + .organizations + .iter() + .map(|org| crate::db::Org { + id: org.id.clone(), + name: if org.name.is_empty() { + org.id.clone() + } else { + org.name.clone() + }, + }) + .collect(); + let collections = sync_res + .collections + .iter() + .map(|collection| crate::db::Collection { + id: collection.id.clone(), + org_id: collection.organization_id.clone(), + name: collection.name.clone(), + }) + .collect(); + Ok(SyncData { + protected_key: sync_res.profile.key, + protected_private_key: sync_res.profile.private_key, + protected_org_keys, + organizations, + collections, + entries, + }) } reqwest::StatusCode::UNAUTHORIZED => { Err(Error::RequestUnauthorized) @@ -1766,3 +1810,181 @@ fn classify_login_error(error_res: &ConnectErrorRes, code: u16) -> Error { log::warn!("unexpected error received during login: {error_res:?}"); Error::RequestFailed { status: code } } + +#[cfg(test)] +mod tests { + use super::*; + + fn sync_res_camel_case() -> &'static str { + r#"{ + "ciphers": [{ + "id": "cipher-id", + "folderId": null, + "organizationId": "org-id", + "collectionIds": ["collection-id-1", "collection-id-2"], + "name": "encrypted-name", + "login": { + "username": null, + "password": null, + "totp": null, + "uris": null + }, + "notes": null, + "passwordHistory": null, + "fields": null, + "deletedDate": null, + "key": null, + "reprompt": 0 + }], + "profile": { + "key": "profile-key", + "privateKey": "profile-private-key", + "organizations": [{ + "id": "org-id", + "key": "org-key", + "name": "org-name" + }] + }, + "folders": [], + "collections": [{ + "id": "collection-id-1", + "organizationId": "org-id", + "name": "encrypted-collection-name", + "readOnly": false + }] + }"# + } + + fn sync_res_pascal_case() -> &'static str { + r#"{ + "Ciphers": [{ + "Id": "cipher-id", + "FolderId": null, + "OrganizationId": "org-id", + "CollectionIds": ["collection-id-1", "collection-id-2"], + "Name": "encrypted-name", + "Login": { + "Username": null, + "Password": null, + "Totp": null, + "Uris": null + }, + "Notes": null, + "PasswordHistory": null, + "Fields": null, + "DeletedDate": null, + "Key": null, + "Reprompt": 0 + }], + "Profile": { + "Key": "profile-key", + "PrivateKey": "profile-private-key", + "Organizations": [{ + "Id": "org-id", + "Key": "org-key", + "Name": "org-name" + }] + }, + "Folders": [], + "Collections": [{ + "Id": "collection-id-1", + "OrganizationId": "org-id", + "Name": "encrypted-collection-name", + "ReadOnly": false + }] + }"# + } + + fn assert_sync_res(sync_res: &SyncRes) { + assert_eq!(sync_res.profile.organizations.len(), 1); + assert_eq!(sync_res.profile.organizations[0].id, "org-id"); + assert_eq!(sync_res.profile.organizations[0].name, "org-name"); + + assert_eq!(sync_res.collections.len(), 1); + assert_eq!(sync_res.collections[0].id, "collection-id-1"); + assert_eq!(sync_res.collections[0].organization_id, "org-id"); + assert_eq!(sync_res.collections[0].name, "encrypted-collection-name"); + + assert_eq!(sync_res.ciphers.len(), 1); + let entry = sync_res.ciphers[0].to_entry(&[]).unwrap(); + assert_eq!(entry.org_id.as_deref(), Some("org-id")); + assert_eq!( + entry.collection_ids, + vec![ + "collection-id-1".to_string(), + "collection-id-2".to_string() + ] + ); + } + + #[test] + fn test_parse_sync_res_camel_case() { + let sync_res: SyncRes = + serde_json::from_str(sync_res_camel_case()).unwrap(); + assert_sync_res(&sync_res); + } + + #[test] + fn test_parse_sync_res_pascal_case() { + let sync_res: SyncRes = + serde_json::from_str(sync_res_pascal_case()).unwrap(); + assert_sync_res(&sync_res); + } + + // servers may omit the collections array and per-cipher collectionIds + #[test] + fn test_parse_sync_res_without_collections() { + let json = r#"{ + "ciphers": [{ + "id": "cipher-id", + "folderId": null, + "organizationId": null, + "name": "encrypted-name", + "login": { + "username": null, + "password": null, + "totp": null, + "uris": null + }, + "notes": null, + "passwordHistory": null, + "fields": null, + "deletedDate": null, + "key": null, + "reprompt": 0 + }], + "profile": { + "key": "profile-key", + "privateKey": "profile-private-key", + "organizations": [] + }, + "folders": [] + }"#; + let sync_res: SyncRes = serde_json::from_str(json).unwrap(); + assert!(sync_res.collections.is_empty()); + let entry = sync_res.ciphers[0].to_entry(&[]).unwrap(); + assert_eq!(entry.org_id, None); + assert!(entry.collection_ids.is_empty()); + } + + // a server that omits organization names must not fail the whole sync + #[test] + fn test_parse_sync_res_without_org_name() { + let json = r#"{ + "ciphers": [], + "profile": { + "key": "profile-key", + "privateKey": "profile-private-key", + "organizations": [{ + "id": "org-id", + "key": "org-key" + }] + }, + "folders": [] + }"#; + let sync_res: SyncRes = serde_json::from_str(json).unwrap(); + assert_eq!(sync_res.profile.organizations.len(), 1); + assert_eq!(sync_res.profile.organizations[0].id, "org-id"); + assert_eq!(sync_res.profile.organizations[0].name, ""); + } +} diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 9ddd2ad9..e7a42e3e 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -521,20 +521,23 @@ pub async fn sync( } else { return Err(anyhow::anyhow!("failed to find refresh token in db")); }; - let ( - access_token, - (protected_key, protected_private_key, protected_org_keys, entries), - ) = rbw::actions::sync(&access_token, &refresh_token) + let (access_token, sync_data) = + rbw::actions::sync(&access_token, &refresh_token) + .await + .context("failed to sync database from server")?; + state + .lock() .await - .context("failed to sync database from server")?; - state.lock().await.set_master_password_reprompt(&entries); + .set_master_password_reprompt(&sync_data.entries); if let Some(access_token) = access_token { db.access_token = Some(access_token); } - db.protected_key = Some(protected_key); - db.protected_private_key = Some(protected_private_key); - db.protected_org_keys = protected_org_keys; - db.entries = entries; + db.protected_key = Some(sync_data.protected_key); + db.protected_private_key = Some(sync_data.protected_private_key); + db.protected_org_keys = sync_data.protected_org_keys; + db.organizations = sync_data.organizations; + db.collections = sync_data.collections; + db.entries = sync_data.entries; save_db(&db).await?; if let Err(e) = subscribe_to_notifications(state.clone()).await { diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index bddf0efe..7ecb8b82 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -4135,6 +4135,7 @@ mod test { rbw::db::Entry { id: id.to_string(), org_id: None, + collection_ids: vec![], folder: folder.map(|_| "encrypted folder name".to_string()), folder_id: None, name: "this is the encrypted name".to_string(), diff --git a/src/db.rs b/src/db.rs index fec0af7c..c1661407 100644 --- a/src/db.rs +++ b/src/db.rs @@ -10,6 +10,8 @@ use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; pub struct Entry { pub id: String, pub org_id: Option, + #[serde(default)] + pub collection_ids: Vec, pub folder: Option, pub folder_id: Option, pub name: String, @@ -175,6 +177,25 @@ pub struct HistoryEntry { pub password: String, } +#[derive( + serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq, +)] +pub struct Org { + pub id: String, + // organization names are plaintext in the sync response + pub name: String, +} + +#[derive( + serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq, +)] +pub struct Collection { + pub id: String, + pub org_id: String, + // collection names are encrypted with the organization key + pub name: String, +} + #[derive(serde::Serialize, serde::Deserialize, Default, Debug)] pub struct Db { pub access_token: Option, @@ -187,6 +208,10 @@ pub struct Db { pub protected_key: Option, pub protected_private_key: Option, pub protected_org_keys: std::collections::HashMap, + #[serde(default)] + pub organizations: Vec, + #[serde(default)] + pub collections: Vec, pub entries: Vec, } @@ -314,3 +339,83 @@ impl Db { || self.protected_key.is_none() } } + +#[cfg(test)] +mod tests { + use super::*; + + // db files written before organizations/collections support must still + // deserialize + #[test] + fn test_deserialize_old_format() { + let json = r#"{ + "access_token": "access-token", + "refresh_token": "refresh-token", + "kdf": null, + "iterations": 600000, + "memory": null, + "parallelism": null, + "protected_key": "protected-key", + "protected_private_key": "protected-private-key", + "protected_org_keys": { + "org-id": "protected-org-key" + }, + "entries": [{ + "id": "entry-id", + "org_id": "org-id", + "folder": null, + "folder_id": null, + "name": "encrypted-name", + "data": { + "Login": { + "username": null, + "password": null, + "totp": null, + "uris": [] + } + }, + "fields": [], + "notes": null, + "history": [], + "key": null, + "master_password_reprompt": 0 + }] + }"#; + let db: Db = serde_json::from_str(json).unwrap(); + assert!(db.organizations.is_empty()); + assert!(db.collections.is_empty()); + assert_eq!(db.entries.len(), 1); + assert!(db.entries[0].collection_ids.is_empty()); + } + + #[test] + fn test_roundtrip_organizations_and_collections() { + let mut db = Db::new(); + db.organizations = vec![Org { + id: "org-id".to_string(), + name: "org-name".to_string(), + }]; + db.collections = vec![Collection { + id: "collection-id".to_string(), + org_id: "org-id".to_string(), + name: "encrypted-collection-name".to_string(), + }]; + let json = serde_json::to_string(&db).unwrap(); + let db: Db = serde_json::from_str(&json).unwrap(); + assert_eq!( + db.organizations, + vec![Org { + id: "org-id".to_string(), + name: "org-name".to_string(), + }] + ); + assert_eq!( + db.collections, + vec![Collection { + id: "collection-id".to_string(), + org_id: "org-id".to_string(), + name: "encrypted-collection-name".to_string(), + }] + ); + } +} From 66776349b4790aaf33191e9f895276eb21b726c6 Mon Sep 17 00:00:00 2001 From: Arthur Andersen Date: Wed, 16 Sep 2026 11:31:20 +0200 Subject: [PATCH 2/6] add org and collection support to list, search, and entry lookup --- src/bin/rbw/commands.rs | 545 ++++++++++++++++++++++++++++++++++++++-- src/bin/rbw/main.rs | 35 ++- 2 files changed, 560 insertions(+), 20 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 7ecb8b82..8ac2bc06 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -184,6 +184,8 @@ struct DecryptedListCipher { name: Option, user: Option, folder: Option, + org: Option, + collections: Option>, uris: Option>, #[serde(rename = "type")] entry_type: Option, @@ -330,6 +332,8 @@ impl From for DecryptedListCipher { name: Some(value.name), user: value.user, folder: value.folder, + org: None, + collections: None, uris: Some(value.uris.into_iter().map(|(s, _)| s).collect()), } } @@ -340,6 +344,8 @@ impl From for DecryptedListCipher { struct DecryptedCipher { id: String, folder: Option, + org: Option, + collections: Vec, name: String, data: DecryptedData, fields: Vec, @@ -1184,6 +1190,8 @@ enum ListField { Name, User, Folder, + Org, + Collections, Uri, EntryType, } @@ -1195,6 +1203,8 @@ impl ListField { Self::Name, Self::User, Self::Folder, + Self::Org, + Self::Collections, Self::Uri, Self::EntryType, ] @@ -1210,6 +1220,8 @@ impl std::convert::TryFrom<&String> for ListField { "id" => Self::Id, "user" => Self::User, "folder" => Self::Folder, + "org" => Self::Org, + "collection" => Self::Collections, "type" => Self::EntryType, _ => return Err(anyhow::anyhow!("unknown field {s}")), }) @@ -1371,10 +1383,20 @@ pub fn list(fields: &[String], raw: bool) -> anyhow::Result<()> { unlock()?; let db = load_db()?; + let orgs = if fields.contains(&ListField::Org) { + org_map(&db) + } else { + std::collections::HashMap::new() + }; + let collections = if fields.contains(&ListField::Collections) { + collection_map(&db, None) + } else { + std::collections::HashMap::new() + }; let mut entries: Vec = db .entries .iter() - .map(|entry| decrypt_list_cipher(entry, &fields)) + .map(|entry| decrypt_list_cipher(entry, &fields, &orgs, &collections)) .collect::>()?; entries.sort_unstable_by(|a, b| a.name.cmp(&b.name)); @@ -1384,10 +1406,13 @@ pub fn list(fields: &[String], raw: bool) -> anyhow::Result<()> { } #[allow(clippy::fn_params_excessive_bools)] +#[allow(clippy::too_many_arguments)] pub fn get( needle: Needle, user: Option<&str>, folder: Option<&str>, + org: Option<&str>, + collection: Option<&str>, field: Option<&str>, full: bool, raw: bool, @@ -1406,7 +1431,7 @@ pub fn get( ); let (_, decrypted) = - find_entry(&db, needle, user, folder, ignore_case) + find_entry(&db, needle, user, folder, org, collection, ignore_case) .with_context(|| format!("couldn't find entry for '{desc}'"))?; if list_fields { decrypted.display_fields_list(); @@ -1450,6 +1475,16 @@ fn print_entry_list( String::new, std::string::ToString::to_string, ), + ListField::Org => entry.org.as_ref().map_or_else( + String::new, + std::string::ToString::to_string, + ), + ListField::Collections => entry + .collections + .as_ref() + .map_or_else(String::new, |collections| { + collections.join(",") + }), ListField::Uri => { // "uri" is not listed in the TryFrom // implementation, so there's no way to try to @@ -1485,6 +1520,8 @@ pub fn search( term: &str, fields: &[String], folder: Option<&str>, + org: Option<&str>, + collection: Option<&str>, raw: bool, ) -> anyhow::Result<()> { let fields: Vec = if raw { @@ -1500,17 +1537,53 @@ pub fn search( let db = load_db()?; + // build the collection name map at most once, shared between filter + // resolution and display + let collections = + if fields.contains(&ListField::Collections) || collection.is_some() { + collection_map(&db, None) + } else { + std::collections::HashMap::new() + }; + let (org_ids, collection_ids) = + resolve_filters(&db, org, collection, Some(&collections), false)?; + let orgs = if fields.contains(&ListField::Org) { + org_map(&db) + } else { + std::collections::HashMap::new() + }; + let mut entries: Vec = db .entries .iter() - .map(decrypt_search_cipher) .filter(|entry| { - entry - .as_ref() - .map(|entry| entry.search_match(term, folder)) - .unwrap_or(true) + entry_matches_filters( + entry, + org_ids.as_ref(), + collection_ids.as_ref(), + ) + }) + .map(|entry| { + decrypt_search_cipher(entry).map(|decrypted| (entry, decrypted)) + }) + .filter(|res| { + res.as_ref().map_or(true, |(_, decrypted)| { + decrypted.search_match(term, folder) + }) + }) + .map(|res| { + res.map(|(entry, decrypted)| { + let mut list_cipher: DecryptedListCipher = decrypted.into(); + if fields.contains(&ListField::Org) { + list_cipher.org = entry_org_name(entry, &orgs); + } + if fields.contains(&ListField::Collections) { + list_cipher.collections = + Some(entry_collection_names(entry, &collections)); + } + list_cipher + }) }) - .map(|entry| entry.map(std::convert::Into::into)) .collect::>()?; entries.sort_unstable_by(|a, b| a.name.cmp(&b.name)); @@ -1523,6 +1596,8 @@ pub fn code( needle: Needle, user: Option<&str>, folder: Option<&str>, + org: Option<&str>, + collection: Option<&str>, clipboard: bool, ignore_case: bool, ) -> anyhow::Result<()> { @@ -1537,7 +1612,7 @@ pub fn code( ); let (_, decrypted) = - find_entry(&db, needle, user, folder, ignore_case) + find_entry(&db, needle, user, folder, org, collection, ignore_case) .with_context(|| format!("couldn't find entry for '{desc}'"))?; if let DecryptedData::Login { totp, .. } = decrypted.data { @@ -1754,6 +1829,8 @@ pub fn edit( name: Needle, username: Option<&str>, folder: Option<&str>, + org: Option<&str>, + collection: Option<&str>, ignore_case: bool, ) -> anyhow::Result<()> { unlock()?; @@ -1769,7 +1846,7 @@ pub fn edit( ); let (entry, decrypted) = - find_entry(&db, name, username, folder, ignore_case) + find_entry(&db, name, username, folder, org, collection, ignore_case) .with_context(|| format!("couldn't find entry for '{desc}'"))?; let (data, fields, notes, history) = match &decrypted.data { @@ -1879,6 +1956,8 @@ pub fn remove( name: Needle, username: Option<&str>, folder: Option<&str>, + org: Option<&str>, + collection: Option<&str>, ignore_case: bool, ) -> anyhow::Result<()> { unlock()?; @@ -1893,8 +1972,9 @@ pub fn remove( name ); - let (entry, _) = find_entry(&db, name, username, folder, ignore_case) - .with_context(|| format!("couldn't find entry for '{desc}'"))?; + let (entry, _) = + find_entry(&db, name, username, folder, org, collection, ignore_case) + .with_context(|| format!("couldn't find entry for '{desc}'"))?; if let (Some(access_token), ()) = rbw::actions::remove(access_token, refresh_token, &entry.id)? @@ -1912,6 +1992,8 @@ pub fn history( name: Needle, username: Option<&str>, folder: Option<&str>, + org: Option<&str>, + collection: Option<&str>, ignore_case: bool, ) -> anyhow::Result<()> { unlock()?; @@ -1924,8 +2006,9 @@ pub fn history( name ); - let (_, decrypted) = find_entry(&db, name, username, folder, ignore_case) - .with_context(|| format!("couldn't find entry for '{desc}'"))?; + let (_, decrypted) = + find_entry(&db, name, username, folder, org, collection, ignore_case) + .with_context(|| format!("couldn't find entry for '{desc}'"))?; for history in decrypted.history { println!("{}: {}", history.last_used_date, history.password); } @@ -2015,12 +2098,24 @@ fn find_entry( mut needle: Needle, username: Option<&str>, folder: Option<&str>, + org: Option<&str>, + collection: Option<&str>, ignore_case: bool, ) -> anyhow::Result<(rbw::db::Entry, DecryptedCipher)> { + let (org_ids, collection_ids) = + resolve_filters(db, org, collection, None, ignore_case)?; + if let Needle::Uuid(uuid, s) = needle { for cipher in &db.entries { if uuid::Uuid::parse_str(&cipher.id) == Ok(uuid) { - return Ok((cipher.clone(), decrypt_cipher(cipher)?)); + if !entry_matches_filters( + cipher, + org_ids.as_ref(), + collection_ids.as_ref(), + ) { + return Err(anyhow::anyhow!("no entry found")); + } + return Ok((cipher.clone(), decrypt_cipher(cipher, db)?)); } } needle = Needle::Name(s); @@ -2029,6 +2124,13 @@ fn find_entry( let ciphers: Vec<(rbw::db::Entry, DecryptedSearchCipher)> = db .entries .iter() + .filter(|entry| { + entry_matches_filters( + entry, + org_ids.as_ref(), + collection_ids.as_ref(), + ) + }) .map(|entry| { decrypt_search_cipher(entry) .map(|decrypted| (entry.clone(), decrypted)) @@ -2036,7 +2138,7 @@ fn find_entry( .collect::>()?; let (entry, _) = find_entry_raw(&ciphers, &needle, username, folder, ignore_case)?; - let decrypted_entry = decrypt_cipher(&entry)?; + let decrypted_entry = decrypt_cipher(&entry, db)?; Ok((entry, decrypted_entry)) } @@ -2122,9 +2224,187 @@ fn decrypt_field( } } +fn org_map(db: &rbw::db::Db) -> std::collections::HashMap { + db.organizations + .iter() + .map(|org| (org.id.clone(), org.name.clone())) + .collect() +} + +fn org_name(db: &rbw::db::Db, org_id: &str) -> String { + db.organizations + .iter() + .find(|org| org.id == org_id) + .map_or_else(|| org_id.to_string(), |org| org.name.clone()) +} + +// collection names are encrypted with the organization key, unlike folder +// names. failure to decrypt a single collection name shouldn't break +// displaying entries entirely, so fall back to displaying the collection id +// in that case. +fn decrypt_collection_name(collection: &rbw::db::Collection) -> String { + crate::actions::decrypt(&collection.name, None, Some(&collection.org_id)) + .unwrap_or_else(|e| { + log::warn!("failed to decrypt collection name: {e}"); + collection.id.clone() + }) +} + +// decrypts each collection name once (each decryption is an agent round +// trip), optionally restricted to the collections of a single organization +fn collection_map( + db: &rbw::db::Db, + org_id: Option<&str>, +) -> std::collections::HashMap { + db.collections + .iter() + .filter(|collection| { + org_id.is_none_or(|org_id| collection.org_id == org_id) + }) + .map(|collection| { + (collection.id.clone(), decrypt_collection_name(collection)) + }) + .collect() +} + +fn entry_org_name( + entry: &rbw::db::Entry, + orgs: &std::collections::HashMap, +) -> Option { + entry.org_id.as_ref().map(|org_id| { + orgs.get(org_id).cloned().unwrap_or_else(|| org_id.clone()) + }) +} + +fn entry_collection_names( + entry: &rbw::db::Entry, + collections: &std::collections::HashMap, +) -> Vec { + entry + .collection_ids + .iter() + .map(|id| collections.get(id).cloned().unwrap_or_else(|| id.clone())) + .collect() +} + +fn match_name(name: &str, given_name: &str, ignore_case: bool) -> bool { + if ignore_case { + name.to_lowercase() == given_name.to_lowercase() + } else { + name == given_name + } +} + +fn resolve_org_filter( + organizations: &[rbw::db::Org], + name: &str, + ignore_case: bool, +) -> anyhow::Result> { + let org_ids: std::collections::HashSet = organizations + .iter() + .filter(|org| match_name(&org.name, name, ignore_case)) + .map(|org| org.id.clone()) + .collect(); + if org_ids.is_empty() { + let known: Vec<&str> = + organizations.iter().map(|org| org.name.as_str()).collect(); + Err(anyhow::anyhow!( + "no organization found matching '{}' (known organizations: {})", + name, + known.join(", ") + )) + } else { + Ok(org_ids) + } +} + +fn resolve_collection_filter( + collections: &[rbw::db::Collection], + names: &std::collections::HashMap, + org_ids: Option<&std::collections::HashSet>, + name: &str, + ignore_case: bool, +) -> anyhow::Result> { + let candidates: Vec<&rbw::db::Collection> = collections + .iter() + .filter(|collection| { + org_ids.is_none_or(|ids| ids.contains(&collection.org_id)) + }) + .collect(); + let collection_ids: std::collections::HashSet = candidates + .iter() + .filter(|collection| { + names + .get(&collection.id) + .is_some_and(|n| match_name(n, name, ignore_case)) + }) + .map(|collection| collection.id.clone()) + .collect(); + if collection_ids.is_empty() { + let known: Vec<&str> = candidates + .iter() + .filter_map(|collection| { + names.get(&collection.id).map(String::as_str) + }) + .collect(); + Err(anyhow::anyhow!( + "no collection found matching '{}' (known collections: {})", + name, + known.join(", ") + )) + } else { + Ok(collection_ids) + } +} + +type EntryFilters = ( + Option>, + Option>, +); + +fn resolve_filters( + db: &rbw::db::Db, + org: Option<&str>, + collection: Option<&str>, + collection_names: Option<&std::collections::HashMap>, + ignore_case: bool, +) -> anyhow::Result { + let org_ids = org + .map(|name| resolve_org_filter(&db.organizations, name, ignore_case)) + .transpose()?; + let collection_ids = collection + .map(|name| { + let owned = + collection_names.is_none().then(|| collection_map(db, None)); + resolve_collection_filter( + &db.collections, + collection_names.or(owned.as_ref()).unwrap(), + org_ids.as_ref(), + name, + ignore_case, + ) + }) + .transpose()?; + Ok((org_ids, collection_ids)) +} + +fn entry_matches_filters( + entry: &rbw::db::Entry, + org_ids: Option<&std::collections::HashSet>, + collection_ids: Option<&std::collections::HashSet>, +) -> bool { + org_ids.is_none_or(|ids| { + entry.org_id.as_ref().is_some_and(|id| ids.contains(id)) + }) && collection_ids.is_none_or(|ids| { + entry.collection_ids.iter().any(|id| ids.contains(id)) + }) +} + fn decrypt_list_cipher( entry: &rbw::db::Entry, fields: &[ListField], + orgs: &std::collections::HashMap, + collections: &std::collections::HashMap, ) -> anyhow::Result { let id = entry.id.clone(); let name = if fields.contains(&ListField::Name) { @@ -2160,6 +2440,16 @@ fn decrypt_list_cipher( } else { None }; + let org = if fields.contains(&ListField::Org) { + entry_org_name(entry, orgs) + } else { + None + }; + let collections = if fields.contains(&ListField::Collections) { + Some(entry_collection_names(entry, collections)) + } else { + None + }; let uris = if fields.contains(&ListField::Uri) { match &entry.data { rbw::db::EntryData::Login { uris, .. } => Some( @@ -2195,6 +2485,8 @@ fn decrypt_list_cipher( name, user, folder, + org, + collections, uris, entry_type, }) @@ -2297,7 +2589,10 @@ fn decrypt_search_cipher( }) } -fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { +fn decrypt_cipher( + entry: &rbw::db::Entry, + db: &rbw::db::Db, +) -> anyhow::Result { // folder name should always be decrypted with the local key because // folders are local to a specific user's vault, not the organization let folder = entry @@ -2312,6 +2607,21 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { None } }; + let org = entry.org_id.as_ref().map(|org_id| org_name(db, org_id)); + // only decrypt the names of the collections this entry belongs to + let collections = entry + .collection_ids + .iter() + .map(|collection_id| { + db.collections + .iter() + .find(|collection| &collection.id == collection_id) + .map_or_else( + || collection_id.clone(), + decrypt_collection_name, + ) + }) + .collect(); let fields = entry .fields .iter() @@ -2614,6 +2924,8 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { Ok(DecryptedCipher { id: entry.id.clone(), folder, + org, + collections, name: crate::actions::decrypt( &entry.name, entry.key.as_deref(), @@ -4176,4 +4488,203 @@ mod test { }, ) } + + fn make_orgs_and_collections( + ) -> (Vec, Vec) { + let organizations = vec![ + rbw::db::Org { + id: "org-id-1".to_string(), + name: "org one".to_string(), + }, + rbw::db::Org { + id: "org-id-2".to_string(), + name: "org two".to_string(), + }, + ]; + let collections = vec![ + rbw::db::Collection { + id: "collection-id-1".to_string(), + org_id: "org-id-1".to_string(), + name: "encrypted-name-1".to_string(), + }, + rbw::db::Collection { + id: "collection-id-2".to_string(), + org_id: "org-id-2".to_string(), + name: "encrypted-name-2".to_string(), + }, + rbw::db::Collection { + id: "collection-id-3".to_string(), + org_id: "org-id-2".to_string(), + name: "encrypted-name-3".to_string(), + }, + ]; + (organizations, collections) + } + + fn collection_names() -> std::collections::HashMap { + [ + ("collection-id-1", "shared"), + ("collection-id-2", "shared"), + ("collection-id-3", "admin passwords"), + ] + .iter() + .map(|(id, name)| ((*id).to_string(), (*name).to_string())) + .collect() + } + + #[test] + fn test_resolve_org_filter() { + let (organizations, _) = make_orgs_and_collections(); + + assert_eq!( + resolve_org_filter(&organizations, "org one", false).unwrap(), + std::iter::once("org-id-1".to_string()).collect() + ); + assert_eq!( + resolve_org_filter(&organizations, "ORG Two", true).unwrap(), + std::iter::once("org-id-2".to_string()).collect() + ); + // exact match required, no substring matching + assert!(resolve_org_filter(&organizations, "org", false).is_err()); + // case sensitive without ignore_case + assert!(resolve_org_filter(&organizations, "ORG Two", false).is_err()); + let err = resolve_org_filter(&organizations, "nonexistent", false) + .unwrap_err(); + assert!(err.to_string().contains("org one")); + assert!(err.to_string().contains("org two")); + } + + #[test] + fn test_resolve_collection_filter() { + let (_, collections) = make_orgs_and_collections(); + let names = collection_names(); + + // both orgs have a collection named "shared" + assert_eq!( + resolve_collection_filter( + &collections, + &names, + None, + "shared", + false + ) + .unwrap(), + ["collection-id-1".to_string(), "collection-id-2".to_string()] + .into_iter() + .collect() + ); + // restricting to an org restricts the candidates + let org_ids: std::collections::HashSet = + std::iter::once("org-id-1".to_string()).collect(); + assert_eq!( + resolve_collection_filter( + &collections, + &names, + Some(&org_ids), + "shared", + false + ) + .unwrap(), + std::iter::once("collection-id-1".to_string()).collect() + ); + assert_eq!( + resolve_collection_filter( + &collections, + &names, + None, + "ADMIN Passwords", + true + ) + .unwrap(), + std::iter::once("collection-id-3".to_string()).collect() + ); + // "admin passwords" only exists in org two + let err = resolve_collection_filter( + &collections, + &names, + Some(&org_ids), + "admin passwords", + false, + ) + .unwrap_err(); + assert!(err.to_string().contains("shared")); + assert!(!err.to_string().contains("admin passwords (")); + } + + #[test] + fn test_entry_matches_filters() { + let (mut entry, _) = make_entry("name", None, None, &[]); + let org_ids: std::collections::HashSet = + std::iter::once("org-id-1".to_string()).collect(); + let collection_ids: std::collections::HashSet = + std::iter::once("collection-id-1".to_string()).collect(); + + // no filters matches everything + assert!(entry_matches_filters(&entry, None, None)); + // a personal entry doesn't match any org or collection filter + assert!(!entry_matches_filters(&entry, Some(&org_ids), None)); + assert!(!entry_matches_filters(&entry, None, Some(&collection_ids))); + + entry.org_id = Some("org-id-1".to_string()); + assert!(entry_matches_filters(&entry, Some(&org_ids), None)); + // an org entry without collections ("unassigned") doesn't match any + // collection filter + assert!(!entry_matches_filters(&entry, None, Some(&collection_ids))); + + entry.collection_ids = vec![ + "collection-id-1".to_string(), + "collection-id-2".to_string(), + ]; + assert!(entry_matches_filters( + &entry, + Some(&org_ids), + Some(&collection_ids) + )); + + entry.org_id = Some("org-id-2".to_string()); + assert!(!entry_matches_filters(&entry, Some(&org_ids), None)); + assert!(entry_matches_filters(&entry, None, Some(&collection_ids))); + } + + #[test] + fn test_find_entry_uuid_respects_filters() { + let (organizations, collections) = make_orgs_and_collections(); + let (entry, _) = make_entry("name", None, None, &[]); + let uuid = uuid::Uuid::parse_str(&entry.id).unwrap(); + let mut db = rbw::db::Db::new(); + db.organizations = organizations; + db.collections = collections; + db.entries = vec![entry]; + + // a uuid lookup with an org filter the entry doesn't match must + // fail instead of ignoring the filter + let err = find_entry( + &db, + Needle::Uuid(uuid, uuid.to_string()), + None, + None, + Some("org one"), + None, + false, + ) + .unwrap_err(); + assert!(err.to_string().contains("no entry found"), "{err}"); + + // and the filter names must be validated for uuid lookups too + let err = find_entry( + &db, + Needle::Uuid(uuid, uuid.to_string()), + None, + None, + Some("nonexistent"), + None, + false, + ) + .unwrap_err(); + assert!( + err.to_string() + .contains("no organization found matching 'nonexistent'"), + "{err}" + ); + } } diff --git a/src/bin/rbw/main.rs b/src/bin/rbw/main.rs index ff2ec740..0b6d2c38 100644 --- a/src/bin/rbw/main.rs +++ b/src/bin/rbw/main.rs @@ -15,6 +15,10 @@ struct FindArgs { user: Option, #[arg(long, help = "Folder name to search in")] folder: Option, + #[arg(long, help = "Organization name to search in")] + org: Option, + #[arg(long, help = "Collection name to search in")] + collection: Option, #[arg(short, long, help = "Ignore case")] ignorecase: bool, } @@ -59,7 +63,8 @@ enum Opt { #[arg( long, help = "Fields to display. \ - Available options are id, name, user, folder, type. \ + Available options are id, name, user, folder, org, \ + collection, type. \ Multiple fields will be separated by tabs.", default_value = "name", use_value_delimiter = true @@ -93,7 +98,8 @@ enum Opt { #[arg( long, help = "Fields to display. \ - Available options are id, name, user, folder. \ + Available options are id, name, user, folder, org, \ + collection. \ Multiple fields will be separated by tabs.", default_value = "name", use_value_delimiter = true @@ -101,6 +107,10 @@ enum Opt { fields: Vec, #[arg(long, help = "Folder name to search in")] folder: Option, + #[arg(long, help = "Organization name to search in")] + org: Option, + #[arg(long, help = "Collection name to search in")] + collection: Option, #[structopt(long, help = "Display output as JSON")] raw: bool, }, @@ -350,6 +360,8 @@ fn main() { find_args.needle.clone(), find_args.user.as_deref(), find_args.folder.as_deref(), + find_args.org.as_deref(), + find_args.collection.as_deref(), field.as_deref(), full, raw, @@ -364,8 +376,17 @@ fn main() { term, fields, folder, + org, + collection, raw, - } => commands::search(&term, &fields, folder.as_deref(), raw), + } => commands::search( + &term, + &fields, + folder.as_deref(), + org.as_deref(), + collection.as_deref(), + raw, + ), Opt::Code { find_args, #[cfg(feature = "clipboard")] @@ -374,6 +395,8 @@ fn main() { find_args.needle, find_args.user.as_deref(), find_args.folder.as_deref(), + find_args.org.as_deref(), + find_args.collection.as_deref(), #[cfg(feature = "clipboard")] clipboard, #[cfg(not(feature = "clipboard"))] @@ -434,18 +457,24 @@ fn main() { find_args.needle, find_args.user.as_deref(), find_args.folder.as_deref(), + find_args.org.as_deref(), + find_args.collection.as_deref(), find_args.ignorecase, ), Opt::Remove { find_args } => commands::remove( find_args.needle, find_args.user.as_deref(), find_args.folder.as_deref(), + find_args.org.as_deref(), + find_args.collection.as_deref(), find_args.ignorecase, ), Opt::History { find_args } => commands::history( find_args.needle, find_args.user.as_deref(), find_args.folder.as_deref(), + find_args.org.as_deref(), + find_args.collection.as_deref(), find_args.ignorecase, ), Opt::Lock => commands::lock(), From b9b7a5757e329349444a51bdc0123e0f900a8570 Mon Sep 17 00:00:00 2001 From: Arthur Andersen Date: Wed, 16 Sep 2026 11:33:30 +0200 Subject: [PATCH 3/6] support creating entries in an organization collection --- src/actions.rs | 26 +++++++++- src/api.rs | 30 ++++++++++-- src/bin/rbw/commands.rs | 102 ++++++++++++++++++++++++++++++++++++---- src/bin/rbw/main.rs | 92 ++++++++++++++++++++++++++++++++++++ 4 files changed, 235 insertions(+), 15 deletions(-) diff --git a/src/actions.rs b/src/actions.rs index 86611618..9a4b9645 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -153,6 +153,7 @@ async fn sync_once(access_token: &str) -> Result { client.sync(access_token).await } +#[allow(clippy::too_many_arguments)] pub fn add( access_token: &str, refresh_token: &str, @@ -160,21 +161,42 @@ pub fn add( data: &crate::db::EntryData, notes: Option<&str>, folder_id: Option<&str>, + org_id: Option<&str>, + collection_ids: &[String], ) -> Result<(Option, ())> { with_exchange_refresh_token(access_token, refresh_token, |access_token| { - add_once(access_token, name, data, notes, folder_id) + add_once( + access_token, + name, + data, + notes, + folder_id, + org_id, + collection_ids, + ) }) } +#[allow(clippy::too_many_arguments)] fn add_once( access_token: &str, name: &str, data: &crate::db::EntryData, notes: Option<&str>, folder_id: Option<&str>, + org_id: Option<&str>, + collection_ids: &[String], ) -> Result<()> { let (client, _) = api_client()?; - client.add(access_token, name, data, notes, folder_id)?; + client.add( + access_token, + name, + data, + notes, + folder_id, + org_id, + collection_ids, + )?; Ok(()) } diff --git a/src/api.rs b/src/api.rs index ac50d477..3058922b 100644 --- a/src/api.rs +++ b/src/api.rs @@ -781,6 +781,8 @@ struct CiphersPostReq { ty: u32, // XXX what are the valid types? #[serde(rename = "folderId")] folder_id: Option, + #[serde(rename = "organizationId")] + organization_id: Option, name: String, notes: Option, login: Option, @@ -790,6 +792,16 @@ struct CiphersPostReq { secure_note: Option, } +// creating a cipher in an organization requires the /ciphers/create +// endpoint, which wraps the cipher in an envelope carrying the collection +// ids +#[derive(serde::Serialize, Debug)] +struct CiphersCreateReq { + cipher: CiphersPostReq, + #[serde(rename = "collectionIds")] + collection_ids: Vec, +} + #[derive(serde::Serialize, Debug)] struct CiphersPutReq { #[serde(rename = "type")] @@ -1238,6 +1250,7 @@ impl Client { } } + #[allow(clippy::too_many_arguments)] pub fn add( &self, access_token: &str, @@ -1245,10 +1258,13 @@ impl Client { data: &crate::db::EntryData, notes: Option<&str>, folder_id: Option<&str>, + org_id: Option<&str>, + collection_ids: &[String], ) -> Result<()> { let mut req = CiphersPostReq { ty: 1, folder_id: folder_id.map(std::string::ToString::to_string), + organization_id: org_id.map(std::string::ToString::to_string), name: name.to_string(), notes: notes.map(std::string::ToString::to_string), login: None, @@ -1344,10 +1360,18 @@ impl Client { crate::db::EntryData::SshKey { .. } => unreachable!(), } let client = reqwest::blocking::Client::new(); - let res = client - .post(self.api_url("/ciphers")) + let req = if org_id.is_some() { + client.post(self.api_url("/ciphers/create")).json( + &CiphersCreateReq { + cipher: req, + collection_ids: collection_ids.to_vec(), + }, + ) + } else { + client.post(self.api_url("/ciphers")).json(&req) + }; + let res = req .header("Authorization", format!("Bearer {access_token}")) - .json(&req) .send() .map_err(|source| Error::Reqwest { source })?; match res.status() { diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 8ac2bc06..64b472a1 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1635,6 +1635,8 @@ pub fn add( username: Option<&str>, uris: &[(String, Option)], folder: Option<&str>, + org: Option<&str>, + collections: &[String], ) -> anyhow::Result<()> { unlock()?; @@ -1644,26 +1646,31 @@ pub fn add( let mut access_token = db.access_token.as_ref().unwrap().clone(); let refresh_token = db.refresh_token.as_ref().unwrap(); - let name = crate::actions::encrypt(name, None)?; + // ciphers in an organization are encrypted with the organization key + let (org_id, collection_ids) = + resolve_org_and_collections(&db, org, collections)?; + let org_id = org_id.as_deref(); + + let name = crate::actions::encrypt(name, org_id)?; let username = username - .map(|username| crate::actions::encrypt(username, None)) + .map(|username| crate::actions::encrypt(username, org_id)) .transpose()?; let contents = rbw::edit::edit("", HELP_PW)?; let (password, notes) = parse_editor(&contents); let password = password - .map(|password| crate::actions::encrypt(&password, None)) + .map(|password| crate::actions::encrypt(&password, org_id)) .transpose()?; let notes = notes - .map(|notes| crate::actions::encrypt(¬es, None)) + .map(|notes| crate::actions::encrypt(¬es, org_id)) .transpose()?; let uris: Vec<_> = uris .iter() .map(|uri| { Ok(rbw::db::Uri { - uri: crate::actions::encrypt(&uri.0, None)?, + uri: crate::actions::encrypt(&uri.0, org_id)?, match_type: uri.1, }) }) @@ -1719,6 +1726,8 @@ pub fn add( }, notes.as_deref(), folder_id.as_deref(), + org_id, + &collection_ids, )? { db.access_token = Some(access_token); save_db(&db)?; @@ -1734,11 +1743,12 @@ pub fn generate( username: Option<&str>, uris: &[(String, Option)], folder: Option<&str>, + org: Option<&str>, + collections: &[String], len: usize, ty: rbw::pwgen::Type, ) -> anyhow::Result<()> { let password = rbw::pwgen::pwgen(ty, len); - println!("{password}"); if let Some(name) = name { unlock()?; @@ -1749,16 +1759,25 @@ pub fn generate( let mut access_token = db.access_token.as_ref().unwrap().clone(); let refresh_token = db.refresh_token.as_ref().unwrap(); - let name = crate::actions::encrypt(name, None)?; + // ciphers in an organization are encrypted with the organization + // key. resolve names before printing the password, so that a + // password that can't be saved isn't printed. + let (org_id, collection_ids) = + resolve_org_and_collections(&db, org, collections)?; + let org_id = org_id.as_deref(); + + println!("{password}"); + + let name = crate::actions::encrypt(name, org_id)?; let username = username - .map(|username| crate::actions::encrypt(username, None)) + .map(|username| crate::actions::encrypt(username, org_id)) .transpose()?; - let password = crate::actions::encrypt(&password, None)?; + let password = crate::actions::encrypt(&password, org_id)?; let uris: Vec<_> = uris .iter() .map(|uri| { Ok(rbw::db::Uri { - uri: crate::actions::encrypt(&uri.0, None)?, + uri: crate::actions::encrypt(&uri.0, org_id)?, match_type: uri.1, }) }) @@ -1814,12 +1833,16 @@ pub fn generate( }, None, folder_id.as_deref(), + org_id, + &collection_ids, )? { db.access_token = Some(access_token); save_db(&db)?; } crate::actions::sync()?; + } else { + println!("{password}"); } Ok(()) @@ -2388,6 +2411,65 @@ fn resolve_filters( Ok((org_ids, collection_ids)) } +// resolves an organization name and collection names to ids for creating a +// new cipher. unlike the search filters, this must resolve each name to +// exactly one id. +fn resolve_org_and_collections( + db: &rbw::db::Db, + org: Option<&str>, + collections: &[String], +) -> anyhow::Result<(Option, Vec)> { + let Some(org_name) = org else { + return Ok((None, vec![])); + }; + let org_ids = resolve_org_filter(&db.organizations, org_name, false)?; + if org_ids.len() > 1 { + return Err(anyhow::anyhow!( + "multiple organizations found matching '{org_name}'" + )); + } + let org_id = org_ids.into_iter().next().unwrap(); + if collections.is_empty() { + return Err(anyhow::anyhow!( + "an organization item must be placed in at least one \ + collection (use --collection)" + )); + } + let collection_ids = + resolve_collection_names(db, &org_id, collections, false)?; + Ok((Some(org_id), collection_ids)) +} + +// resolves collection names to ids within a single organization. each name +// must resolve to exactly one id. +fn resolve_collection_names( + db: &rbw::db::Db, + org_id: &str, + collections: &[String], + ignore_case: bool, +) -> anyhow::Result> { + let names = collection_map(db, Some(org_id)); + let org_id_filter: std::collections::HashSet = + std::iter::once(org_id.to_string()).collect(); + let mut collection_ids = Vec::new(); + for name in collections { + let ids = resolve_collection_filter( + &db.collections, + &names, + Some(&org_id_filter), + name, + ignore_case, + )?; + if ids.len() > 1 { + return Err(anyhow::anyhow!( + "multiple collections found matching '{name}'" + )); + } + collection_ids.push(ids.into_iter().next().unwrap()); + } + Ok(collection_ids) +} + fn entry_matches_filters( entry: &rbw::db::Entry, org_ids: Option<&std::collections::HashSet>, diff --git a/src/bin/rbw/main.rs b/src/bin/rbw/main.rs index 0b6d2c38..c1c176bb 100644 --- a/src/bin/rbw/main.rs +++ b/src/bin/rbw/main.rs @@ -149,6 +149,21 @@ enum Opt { uri: Vec, #[arg(long, help = "Folder for the password entry")] folder: Option, + #[arg( + long, + help = "Organization for the password entry \ + (requires --collection)", + requires = "collection" + )] + org: Option, + #[arg( + long, + help = "Collection for the password entry \ + (requires --org, may be given multiple times)", + number_of_values = 1, + requires = "org" + )] + collection: Vec, }, #[command( @@ -179,6 +194,21 @@ enum Opt { uri: Vec, #[arg(long, help = "Folder for the password entry")] folder: Option, + #[arg( + long, + help = "Organization for the password entry \ + (requires --collection)", + requires = "collection" + )] + org: Option, + #[arg( + long, + help = "Collection for the password entry \ + (requires --org, may be given multiple times)", + number_of_values = 1, + requires = "org" + )] + collection: Vec, #[arg( long = "no-symbols", help = "Generate a password with no special characters" @@ -408,6 +438,8 @@ fn main() { user, uri, folder, + org, + collection, } => commands::add( &name, user.as_deref(), @@ -417,6 +449,8 @@ fn main() { .map(|uri| (uri.clone(), None)) .collect::>(), folder.as_deref(), + org.as_deref(), + &collection, ), Opt::Generate { len, @@ -424,6 +458,8 @@ fn main() { user, uri, folder, + org, + collection, no_symbols, only_numbers, nonconfusables, @@ -449,6 +485,8 @@ fn main() { .map(|uri| (uri.clone(), None)) .collect::>(), folder.as_deref(), + org.as_deref(), + &collection, len, ty, ) @@ -552,3 +590,57 @@ fn main() { std::process::exit(1); } } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_cli() { + Opt::command().debug_assert(); + } + + // creating an entry in an organization requires a collection, and a + // collection is meaningless without an organization + #[test] + fn test_org_and_collection_require_each_other() { + for args in [ + &["rbw", "add", "name", "--org", "org"][..], + &["rbw", "add", "name", "--collection", "collection"][..], + &["rbw", "generate", "12", "name", "--org", "org"][..], + &[ + "rbw", + "generate", + "12", + "name", + "--collection", + "collection", + ][..], + ] { + assert!(Opt::try_parse_from(args).is_err(), "{args:?}"); + } + for args in [ + &[ + "rbw", + "add", + "name", + "--org", + "org", + "--collection", + "collection", + ][..], + &[ + "rbw", + "generate", + "12", + "name", + "--org", + "org", + "--collection", + "collection", + ][..], + ] { + assert!(Opt::try_parse_from(args).is_ok(), "{args:?}"); + } + } +} From fc243d37fa3460ab1c714da257cb6de1966351c1 Mon Sep 17 00:00:00 2001 From: Arthur Andersen Date: Wed, 16 Sep 2026 11:35:24 +0200 Subject: [PATCH 4/6] support moving entries between collections with rbw edit --set-collection --- src/actions.rs | 21 +++++++++++++++++++++ src/api.rs | 34 ++++++++++++++++++++++++++++++++++ src/bin/rbw/commands.rs | 33 +++++++++++++++++++++++++++++++++ src/bin/rbw/main.rs | 15 ++++++++++++++- 4 files changed, 102 insertions(+), 1 deletion(-) diff --git a/src/actions.rs b/src/actions.rs index 9a4b9645..46357991 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -253,6 +253,27 @@ fn edit_once( Ok(()) } +pub fn set_collections( + access_token: &str, + refresh_token: &str, + id: &str, + collection_ids: &[String], +) -> Result<(Option, ())> { + with_exchange_refresh_token(access_token, refresh_token, |access_token| { + set_collections_once(access_token, id, collection_ids) + }) +} + +fn set_collections_once( + access_token: &str, + id: &str, + collection_ids: &[String], +) -> Result<()> { + let (client, _) = api_client()?; + client.set_collections(access_token, id, collection_ids)?; + Ok(()) +} + pub fn remove( access_token: &str, refresh_token: &str, diff --git a/src/api.rs b/src/api.rs index 3058922b..34ddebc5 100644 --- a/src/api.rs +++ b/src/api.rs @@ -822,6 +822,12 @@ struct CiphersPutReq { password_history: Vec, } +#[derive(serde::Serialize, Debug)] +struct CiphersIdCollectionsPutReq { + #[serde(rename = "collectionIds")] + collection_ids: Vec, +} + #[derive(serde::Serialize, Debug)] struct CiphersPutReqHistory { #[serde(rename = "LastUsedDate")] @@ -1535,6 +1541,34 @@ impl Client { } } + // replaces the set of collections an organization cipher belongs to + pub fn set_collections( + &self, + access_token: &str, + id: &str, + collection_ids: &[String], + ) -> Result<()> { + let req = CiphersIdCollectionsPutReq { + collection_ids: collection_ids.to_vec(), + }; + let client = reqwest::blocking::Client::new(); + let res = client + .put(self.api_url(&format!("/ciphers/{id}/collections"))) + .header("Authorization", format!("Bearer {access_token}")) + .json(&req) + .send() + .map_err(|source| Error::Reqwest { source })?; + match res.status() { + reqwest::StatusCode::OK => Ok(()), + reqwest::StatusCode::UNAUTHORIZED => { + Err(Error::RequestUnauthorized) + } + _ => Err(Error::RequestFailed { + status: res.status().as_u16(), + }), + } + } + pub fn remove(&self, access_token: &str, id: &str) -> Result<()> { let client = reqwest::blocking::Client::new(); let res = client diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 64b472a1..b8d325c5 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1854,6 +1854,7 @@ pub fn edit( folder: Option<&str>, org: Option<&str>, collection: Option<&str>, + set_collections: &[String], ignore_case: bool, ) -> anyhow::Result<()> { unlock()?; @@ -1872,6 +1873,38 @@ pub fn edit( find_entry(&db, name, username, folder, org, collection, ignore_case) .with_context(|| format!("couldn't find entry for '{desc}'"))?; + // --set-collection only changes which collections the entry belongs + // to, without editing its contents (so it works for every entry type + // and doesn't open an editor). moving an entry between collections + // doesn't require re-encryption, since all collections of an + // organization share the organization key. + if !set_collections.is_empty() { + let Some(org_id) = entry.org_id.as_deref() else { + return Err(anyhow::anyhow!( + "'{desc}' is not an organization entry, so it can't be \ + placed in a collection" + )); + }; + let collection_ids = resolve_collection_names( + &db, + org_id, + set_collections, + ignore_case, + )?; + if let (Some(access_token), ()) = rbw::actions::set_collections( + access_token, + refresh_token, + &entry.id, + &collection_ids, + )? { + db.access_token = Some(access_token); + save_db(&db)?; + } + + crate::actions::sync()?; + return Ok(()); + } + let (data, fields, notes, history) = match &decrypted.data { DecryptedData::Login { password, .. } => { let mut contents = diff --git a/src/bin/rbw/main.rs b/src/bin/rbw/main.rs index c1c176bb..29ecd39a 100644 --- a/src/bin/rbw/main.rs +++ b/src/bin/rbw/main.rs @@ -249,6 +249,15 @@ enum Opt { Edit { #[command(flatten)] find_args: FindArgs, + #[arg( + long, + help = "Set the collections the entry belongs to, replacing \ + its current collections, without editing its contents \ + (only valid for entries in an organization, may be given \ + multiple times)", + number_of_values = 1 + )] + set_collection: Vec, }, #[command(about = "Remove a given entry", visible_alias = "rm")] @@ -491,12 +500,16 @@ fn main() { ty, ) } - Opt::Edit { find_args } => commands::edit( + Opt::Edit { + find_args, + set_collection, + } => commands::edit( find_args.needle, find_args.user.as_deref(), find_args.folder.as_deref(), find_args.org.as_deref(), find_args.collection.as_deref(), + &set_collection, find_args.ignorecase, ), Opt::Remove { find_args } => commands::remove( From 9de751ecafb36f716219ac3de8ba5f2b69768f98 Mon Sep 17 00:00:00 2001 From: Arthur Andersen Date: Wed, 16 Sep 2026 11:35:57 +0200 Subject: [PATCH 5/6] document org and collection support, update completions --- README.md | 17 +++++++++++++++++ src/bin/rbw/completion/rbw.bash | 4 ++-- src/bin/rbw/completion/rbw.fish | 6 ++++-- src/bin/rbw/completion/rbw.zsh | 4 ++-- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index eb9074b8..6e42cb79 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,23 @@ flag will show the output as JSON. In addition to matching against the name, you can pass a UUID as the name to search for the entry with that id, or a URL to search for an entry with a matching website entry. +### Organizations and collections + +Entries shared with you through an organization are part of your vault, and +`rbw list --fields name,org,collection` will show which organization and +collections each entry belongs to. When multiple entries share a name, you +can disambiguate with `--org ` or `--collection ` on `rbw get` +(as well as `code`, `edit`, `remove`, `history`, and `search`). + +`rbw add` and `rbw generate` can create entries directly in an organization +with `--org --collection ` (an organization entry must be +placed in at least one collection, and `--collection` may be given multiple +times). `rbw edit --set-collection ` replaces the set of collections +an existing organization entry belongs to without editing its contents, +which allows moving entries between collections of the same organization. Moving entries into or out of +an organization is not currently supported, and neither is creating or +managing the collections themselves. + *Note to users of the official Bitwarden server (at bitwarden.com)*: The official server has a tendency to detect command line traffic as bot traffic (see [this issue](https://github.com/bitwarden/cli/issues/383) for details). In diff --git a/src/bin/rbw/completion/rbw.bash b/src/bin/rbw/completion/rbw.bash index 0b1f2ae8..1ca2d18b 100644 --- a/src/bin/rbw/completion/rbw.bash +++ b/src/bin/rbw/completion/rbw.bash @@ -5,7 +5,7 @@ _rbw_wrapper() { if [[ "${COMP_WORDS[1]}" == "get" ]] && [[ $COMP_CWORD -gt 1 ]]; then for (( i=2; i < COMP_CWORD; i++ )); do case "${COMP_WORDS[i]}" in - --folder|-f|--field) + --folder|--org|--collection|-f|--field) (( i++ )) if [ "${COMP_WORDS[i]}" == "=" ]; then (( i++ )) @@ -70,7 +70,7 @@ _rbw_wrapper() { fi if [[ "$cur" == -* ]]; then - res="-f --field --full --raw --clipboard -i --ignorecase -h --help $res" + res="-f --field --full --raw --clipboard -i --ignorecase -h --help --org --collection $res" if [ -z "$folder" ]; then res="--folder $res" fi diff --git a/src/bin/rbw/completion/rbw.fish b/src/bin/rbw/completion/rbw.fish index 3cbb1ec9..c34199ed 100644 --- a/src/bin/rbw/completion/rbw.fish +++ b/src/bin/rbw/completion/rbw.fish @@ -2,7 +2,7 @@ function __fish_rbw_get_completion_name set -l cmd (commandline -xpc) set -e cmd[1] # rbw - argparse -i folder= f/field= full raw clipboard i/ignorecase h/help l/list-fields -- $cmd + argparse -i folder= org= collection= f/field= full raw clipboard i/ignorecase h/help l/list-fields -- $cmd set -e argv[1] # get set -l candidates (command rbw list --fields name,folder,user) @@ -57,7 +57,7 @@ function __fish_rbw_get_completion_fields set -e cmd[-1] # -f/--field end - argparse -i folder= f/field= full raw clipboard i/ignorecase h/help l/list-fields -- $cmd + argparse -i folder= org= collection= f/field= full raw clipboard i/ignorecase h/help l/list-fields -- $cmd set -e argv[1] # get if test (count $argv) -gt 0 @@ -72,6 +72,8 @@ complete -f -c rbw -n '__fish_seen_subcommand_from get' -s i -l ignorecase -d 'I complete -f -c rbw -n '__fish_seen_subcommand_from get' -s f -l field -r -d 'Field to get' -a '(__fish_rbw_get_completion_fields)' complete -f -c rbw -n '__fish_seen_subcommand_from get' -s l -l list-fields -r -d 'List fields in this entry' complete -f -c rbw -n '__fish_seen_subcommand_from get' -l folder -r -d 'Folder name to search in' -a '(command rbw list --fields folder)' +complete -f -c rbw -n '__fish_seen_subcommand_from get' -l org -r -d 'Organization name to search in' -a '(command rbw list --fields org)' +complete -f -c rbw -n '__fish_seen_subcommand_from get' -l collection -r -d 'Collection name to search in' -a '(command rbw list --fields collection | string split ,)' complete -f -c rbw -n '__fish_seen_subcommand_from get' -l full -d 'Display the notes in addition to the password' complete -f -c rbw -n '__fish_seen_subcommand_from get' -l raw -d 'Display output as JSON' complete -f -c rbw -n '__fish_seen_subcommand_from get' -s c -l clipboard -d 'Copy result to clipboard' diff --git a/src/bin/rbw/completion/rbw.zsh b/src/bin/rbw/completion/rbw.zsh index b1739e78..ff507cc2 100644 --- a/src/bin/rbw/completion/rbw.zsh +++ b/src/bin/rbw/completion/rbw.zsh @@ -7,7 +7,7 @@ _rbw_wrapper() { cur="${(Q)words[i]}" case "$cur" in - --folder|-f|--field) + --folder|--org|--collection|-f|--field) (( i++ )) ;; -*) @@ -60,7 +60,7 @@ _rbw_wrapper() { fi if [[ "$cur" == -* ]] && [[ "$cur" != "--folder="* ]]; then - res=$'-f\n--field\n--full\n--raw\n--clipboard\n-i\n--ignorecase\n-h\n--help\n'"$res" + res=$'-f\n--field\n--full\n--raw\n--clipboard\n-i\n--ignorecase\n-h\n--help\n--org\n--collection\n'"$res" if [ -z "$folder" ]; then res=$'--folder\n'"$res" fi From 901d2248810e2f23037ac3e3650ba55f9de14adb Mon Sep 17 00:00:00 2001 From: Arthur Andersen Date: Wed, 16 Sep 2026 19:06:33 +0200 Subject: [PATCH 6/6] fix clippy lints in existing code these are pre-existing lints that newer clippy versions have started flagging (they fail the lint CI job on main as well), and are unrelated to the organization and collection support in this branch: - collapse two match expressions into ? in actions::unlock - use a match guard instead of a nested if in classify_login_error - drop a redundant reference in a format! argument in dirs cargo clippy --all-targets --all-features -- -Dwarnings is clean with this applied. --- src/actions.rs | 16 ++++++---------- src/api.rs | 28 ++++++++++++---------------- src/dirs.rs | 2 +- 3 files changed, 19 insertions(+), 27 deletions(-) diff --git a/src/actions.rs b/src/actions.rs index 46357991..2dc7e800 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -112,21 +112,17 @@ pub fn unlock( let protected_private_key = crate::cipherstring::CipherString::new(protected_private_key)?; - let private_key = - match protected_private_key.decrypt_locked_symmetric(&key) { - Ok(private_key) => crate::locked::PrivateKey::new(private_key), - Err(e) => return Err(e), - }; + let private_key = crate::locked::PrivateKey::new( + protected_private_key.decrypt_locked_symmetric(&key)?, + ); let mut org_keys = std::collections::HashMap::new(); for (org_id, protected_org_key) in protected_org_keys { let protected_org_key = crate::cipherstring::CipherString::new(protected_org_key)?; - let org_key = - match protected_org_key.decrypt_locked_asymmetric(&private_key) { - Ok(org_key) => crate::locked::Keys::new(org_key), - Err(e) => return Err(e), - }; + let org_key = crate::locked::Keys::new( + protected_org_key.decrypt_locked_asymmetric(&private_key)?, + ); org_keys.insert(org_id.clone(), org_key); } diff --git a/src/api.rs b/src/api.rs index 34ddebc5..22b10d80 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1840,24 +1840,20 @@ fn classify_login_error(error_res: &ConnectErrorRes, code: u16) -> Error { "invalid_client" => { return Error::IncorrectApiKey; } - "" => { - // bitwarden_rs returns an empty error and error_description for - // this case, for some reason - if error_desc.is_none() || error_desc == Some("") { - if let Some(error_model) = error_res.error_model.as_ref() { - let message = error_model.message.as_str().to_string(); - match message.as_str() { - "Username or password is incorrect. Try again" - | "TOTP code is not a number" => { + // bitwarden_rs returns an empty error and error_description for + // this case, for some reason + "" if error_desc.is_none() || error_desc == Some("") => { + if let Some(error_model) = error_res.error_model.as_ref() { + let message = error_model.message.as_str().to_string(); + match message.as_str() { + "Username or password is incorrect. Try again" + | "TOTP code is not a number" => { + return Error::IncorrectPassword { message }; + } + s => { + if s.starts_with("Invalid TOTP code! Server time: ") { return Error::IncorrectPassword { message }; } - s => { - if s.starts_with( - "Invalid TOTP code! Server time: ", - ) { - return Error::IncorrectPassword { message }; - } - } } } } diff --git a/src/dirs.rs b/src/dirs.rs index 079fc880..2fb12dd3 100644 --- a/src/dirs.rs +++ b/src/dirs.rs @@ -97,7 +97,7 @@ fn runtime_dir() -> std::path::PathBuf { format!( "{}/{}-{}", std::env::temp_dir().to_string_lossy(), - &profile(), + profile(), rustix::process::getuid().as_raw() ) .into()