From f273cd1050a595a4e1e1ccb43bb4587e9b085696 Mon Sep 17 00:00:00 2001 From: FluffyDiscord Date: Mon, 8 Jun 2026 13:16:21 +0200 Subject: [PATCH 1/7] Agent - support non-interactive unlock via BW_ACCOUNT_PASSWORD env --- src/bin/rbw-agent/actions.rs | 163 ++++++++++++++++++++++++----------- 1 file changed, 114 insertions(+), 49 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 9ddd2ad9..ad20101a 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -1,6 +1,16 @@ use anyhow::Context as _; use sha2::Digest as _; +fn password_from_env() -> Option { + let val = std::env::var("BW_ACCOUNT_PASSWORD").ok()?; + if val.is_empty() { + return None; + } + let mut buf = rbw::locked::Vec::new(); + buf.extend(val.bytes()); + Some(rbw::locked::Password::new(buf)) +} + pub async fn register( sock: &mut crate::sock::Sock, environment: &rbw::protocol::Environment, @@ -95,24 +105,41 @@ pub async fn login( let email = config_email().await?; let mut err_msg = None; + let mut env_password_tried = false; 'attempts: for i in 1_u8..=3 { - let err = if i > 1 { - // this unwrap is safe because we only ever continue the loop - // if we have set err_msg - Some(format!("{} (attempt {}/3)", err_msg.unwrap(), i)) + let password = if i == 1 { + if let Some(pw) = password_from_env() { + env_password_tried = true; + pw + } else { + rbw::pinentry::getpin( + &config_pinentry().await?, + "Master Password", + &format!("Log in to {host}"), + None, + environment, + true, + ) + .await + .context("failed to read password from pinentry")? + } } else { - None + let err = Some(format!( + "{} (attempt {}/3)", + err_msg.as_ref().unwrap(), + if env_password_tried { i - 1 } else { i } + )); + rbw::pinentry::getpin( + &config_pinentry().await?, + "Master Password", + &format!("Log in to {host}"), + err.as_deref(), + environment, + true, + ) + .await + .context("failed to read password from pinentry")? }; - let password = rbw::pinentry::getpin( - &config_pinentry().await?, - "Master Password", - &format!("Log in to {host}"), - err.as_deref(), - environment, - true, - ) - .await - .context("failed to read password from pinentry")?; match rbw::actions::login(&email, password.clone(), None, None) .await { @@ -405,27 +432,47 @@ async fn unlock_state( let email = config_email().await?; let mut err_msg = None; + let mut env_password_tried = false; for i in 1_u8..=3 { - let err = if i > 1 { - // this unwrap is safe because we only ever continue the loop - // if we have set err_msg - Some(format!("{} (attempt {}/3)", err_msg.unwrap(), i)) + let password = if i == 1 { + if let Some(pw) = password_from_env() { + env_password_tried = true; + pw + } else { + rbw::pinentry::getpin( + &config_pinentry().await?, + "Master Password", + &format!( + "Unlock the local database for '{}'", + rbw::dirs::profile() + ), + None, + environment, + true, + ) + .await + .context("failed to read password from pinentry")? + } } else { - None + let err = Some(format!( + "{} (attempt {}/3)", + err_msg.as_ref().unwrap(), + if env_password_tried { i - 1 } else { i } + )); + rbw::pinentry::getpin( + &config_pinentry().await?, + "Master Password", + &format!( + "Unlock the local database for '{}'", + rbw::dirs::profile() + ), + err.as_deref(), + environment, + true, + ) + .await + .context("failed to read password from pinentry")? }; - let password = rbw::pinentry::getpin( - &config_pinentry().await?, - "Master Password", - &format!( - "Unlock the local database for '{}'", - rbw::dirs::profile() - ), - err.as_deref(), - environment, - true, - ) - .await - .context("failed to read password from pinentry")?; match rbw::actions::unlock( &email, &password, @@ -523,7 +570,7 @@ pub async fn sync( }; let ( access_token, - (protected_key, protected_private_key, protected_org_keys, entries), + (protected_key, protected_private_key, protected_org_keys, entries, collections), ) = rbw::actions::sync(&access_token, &refresh_token) .await .context("failed to sync database from server")?; @@ -535,6 +582,7 @@ pub async fn sync( db.protected_private_key = Some(protected_private_key); db.protected_org_keys = protected_org_keys; db.entries = entries; + db.collections = collections; save_db(&db).await?; if let Err(e) = subscribe_to_notifications(state.clone()).await { @@ -614,24 +662,41 @@ async fn decrypt_cipher( let email = config_email().await?; let mut err_msg = None; + let mut env_password_tried = false; for i in 1_u8..=3 { - let err = if i > 1 { - // this unwrap is safe because we only ever continue the loop - // if we have set err_msg - Some(format!("{} (attempt {}/3)", err_msg.unwrap(), i)) + let password = if i == 1 { + if let Some(pw) = password_from_env() { + env_password_tried = true; + pw + } else { + rbw::pinentry::getpin( + &config_pinentry().await?, + "Master Password", + "Accessing this entry requires the master password", + None, + environment, + true, + ) + .await + .context("failed to read password from pinentry")? + } } else { - None + let err = Some(format!( + "{} (attempt {}/3)", + err_msg.as_ref().unwrap(), + if env_password_tried { i - 1 } else { i } + )); + rbw::pinentry::getpin( + &config_pinentry().await?, + "Master Password", + "Accessing this entry requires the master password", + err.as_deref(), + environment, + true, + ) + .await + .context("failed to read password from pinentry")? }; - let password = rbw::pinentry::getpin( - &config_pinentry().await?, - "Master Password", - "Accessing this entry requires the master password", - err.as_deref(), - environment, - true, - ) - .await - .context("failed to read password from pinentry")?; match rbw::actions::unlock( &email, &password, From 86fd649960690cf957423906ecfb13f8079054e0 Mon Sep 17 00:00:00 2001 From: FluffyDiscord Date: Mon, 8 Jun 2026 13:16:21 +0200 Subject: [PATCH 2/7] Collections - add management commands --- src/actions.rs | 76 +++++++++++++ src/api.rs | 184 ++++++++++++++++++++++++++++++- src/bin/rbw/commands.rs | 233 ++++++++++++++++++++++++++++++++++++++++ src/bin/rbw/main.rs | 72 ++++++++++++- src/db.rs | 13 +++ 5 files changed, 572 insertions(+), 6 deletions(-) diff --git a/src/actions.rs b/src/actions.rs index 79d304d4..be37468c 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -143,6 +143,7 @@ pub async fn sync( String, std::collections::HashMap, Vec, + Vec, ), )> { with_exchange_refresh_token_async( @@ -163,6 +164,7 @@ async fn sync_once( String, std::collections::HashMap, Vec, + Vec, )> { let (client, _) = api_client_async().await?; client.sync(access_token).await @@ -262,6 +264,80 @@ fn remove_once(access_token: &str, id: &str) -> Result<()> { Ok(()) } +pub fn edit_collections( + access_token: &str, + refresh_token: &str, + id: &str, + collection_ids: &[String], +) -> Result<(Option, ())> { + with_exchange_refresh_token(access_token, refresh_token, |access_token| { + edit_collections_once(access_token, id, collection_ids) + }) +} + +fn edit_collections_once( + access_token: &str, + id: &str, + collection_ids: &[String], +) -> Result<()> { + let (client, _) = api_client()?; + client.edit_collections(access_token, id, collection_ids)?; + Ok(()) +} + +pub fn rename_collection( + access_token: &str, + refresh_token: &str, + org_id: &str, + collection_id: &str, + encrypted_name: &str, +) -> Result<(Option, ())> { + with_exchange_refresh_token(access_token, refresh_token, |access_token| { + rename_collection_once( + access_token, + org_id, + collection_id, + encrypted_name, + ) + }) +} + +fn rename_collection_once( + access_token: &str, + org_id: &str, + collection_id: &str, + encrypted_name: &str, +) -> Result<()> { + let (client, _) = api_client()?; + client.rename_collection( + access_token, + org_id, + collection_id, + encrypted_name, + )?; + Ok(()) +} + +pub fn create_collection( + access_token: &str, + refresh_token: &str, + org_id: &str, + encrypted_name: &str, +) -> Result<(Option, String)> { + with_exchange_refresh_token(access_token, refresh_token, |access_token| { + create_collection_once(access_token, org_id, encrypted_name) + }) +} + +fn create_collection_once( + access_token: &str, + org_id: &str, + encrypted_name: &str, +) -> Result { + let (client, _) = api_client()?; + client.create_collection(access_token, org_id, encrypted_name) +} + pub fn list_folders( access_token: &str, refresh_token: &str, diff --git a/src/api.rs b/src/api.rs index a817fb26..9cc575a0 100644 --- a/src/api.rs +++ b/src/api.rs @@ -394,6 +394,18 @@ struct SyncRes { profile: SyncResProfile, #[serde(rename = "Folders", alias = "folders")] folders: Vec, + #[serde(rename = "Collections", alias = "collections", default)] + collections: Vec, +} + +#[derive(serde::Deserialize, Debug, Clone)] +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::Serialize, serde::Deserialize, Debug, Clone)] @@ -428,6 +440,8 @@ struct SyncResCipher { key: Option, #[serde(rename = "Reprompt", alias = "reprompt")] reprompt: CipherRepromptType, + #[serde(rename = "CollectionIds", alias = "collectionIds", default)] + collection_ids: Vec, } impl SyncResCipher { @@ -551,6 +565,7 @@ impl SyncResCipher { history, key: self.key.clone(), master_password_reprompt: self.reprompt, + collection_ids: self.collection_ids.clone(), }) } } @@ -790,6 +805,29 @@ struct CiphersPutReqHistory { password: String, } +#[derive(serde::Serialize, Debug)] +struct CiphersCollectionsPutReq { + #[serde(rename = "collectionIds")] + collection_ids: Vec, +} + +#[derive(serde::Serialize, Debug)] +struct CollectionPutReq { + name: String, + #[serde(rename = "organizationId")] + organization_id: String, + #[serde(rename = "externalId")] + external_id: Option, + groups: Vec, + users: Vec, +} + +#[derive(serde::Deserialize, Debug)] +struct CollectionCreateRes { + #[serde(rename = "Id", alias = "id")] + id: String, +} + #[derive(serde::Deserialize, Debug)] struct FoldersRes { #[serde(rename = "Data", alias = "data")] @@ -1153,6 +1191,7 @@ impl Client { String, std::collections::HashMap, Vec, + Vec, )> { let client = self.reqwest_client().await?; let res = client @@ -1178,11 +1217,21 @@ impl Client { .iter() .map(|org| (org.id.clone(), org.key.clone())) .collect(); + let collections = sync_res + .collections + .iter() + .map(|c| crate::db::Collection { + id: c.id.clone(), + org_id: c.organization_id.clone(), + name: c.name.clone(), + }) + .collect(); Ok(( sync_res.profile.key, sync_res.profile.private_key, org_keys, ciphers, + collections, )) } reqwest::StatusCode::UNAUTHORIZED => { @@ -1485,6 +1534,104 @@ impl Client { } } + pub fn edit_collections( + &self, + access_token: &str, + id: &str, + collection_ids: &[String], + ) -> Result<()> { + let req = CiphersCollectionsPutReq { + 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 rename_collection( + &self, + access_token: &str, + org_id: &str, + collection_id: &str, + encrypted_name: &str, + ) -> Result<()> { + let req = CollectionPutReq { + name: encrypted_name.to_string(), + organization_id: org_id.to_string(), + external_id: None, + groups: vec![], + users: vec![], + }; + let client = reqwest::blocking::Client::new(); + let res = client + .put(self.api_url(&format!( + "/organizations/{org_id}/collections/{collection_id}" + ))) + .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 create_collection( + &self, + access_token: &str, + org_id: &str, + encrypted_name: &str, + ) -> Result { + let req = CollectionPutReq { + name: encrypted_name.to_string(), + organization_id: org_id.to_string(), + external_id: None, + groups: vec![], + users: vec![], + }; + let client = reqwest::blocking::Client::new(); + let res = client + .post(self.api_url(&format!( + "/organizations/{org_id}/collections" + ))) + .header("Authorization", format!("Bearer {access_token}")) + .json(&req) + .send() + .map_err(|source| Error::Reqwest { source })?; + match res.status() { + reqwest::StatusCode::OK => { + let collection_res: CollectionCreateRes = + res.json_with_path()?; + Ok(collection_res.id) + } + reqwest::StatusCode::UNAUTHORIZED => { + Err(Error::RequestUnauthorized) + } + _ => Err(Error::RequestFailed { + status: res.status().as_u16(), + }), + } + } + pub fn folders( &self, access_token: &str, @@ -1557,8 +1704,22 @@ impl Client { .form(&connect_req) .send() .map_err(|source| Error::Reqwest { source })?; - let connect_res: ConnectRefreshTokenRes = res.json_with_path()?; - Ok(connect_res.access_token) + match res.status() { + reqwest::StatusCode::OK => { + let connect_res: ConnectRefreshTokenRes = + res.json_with_path()?; + Ok(connect_res.access_token) + } + reqwest::StatusCode::UNAUTHORIZED => { + Err(Error::RequestUnauthorized) + } + s => { + let code = s.as_u16(); + let body = res.text().unwrap_or_default(); + log::warn!("refresh token exchange failed ({code}): {body}"); + Err(Error::RequestFailed { status: code }) + } + } } pub async fn exchange_refresh_token_async( @@ -1577,9 +1738,22 @@ impl Client { .send() .await .map_err(|source| Error::Reqwest { source })?; - let connect_res: ConnectRefreshTokenRes = - res.json_with_path().await?; - Ok(connect_res.access_token) + match res.status() { + reqwest::StatusCode::OK => { + let connect_res: ConnectRefreshTokenRes = + res.json_with_path().await?; + Ok(connect_res.access_token) + } + reqwest::StatusCode::UNAUTHORIZED => { + Err(Error::RequestUnauthorized) + } + s => { + let code = s.as_u16(); + let body = res.text().await.unwrap_or_default(); + log::warn!("refresh token exchange failed ({code}): {body}"); + Err(Error::RequestFailed { status: code }) + } + } } fn api_url(&self, path: &str) -> String { diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index bddf0efe..5a66cd73 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -187,6 +187,8 @@ struct DecryptedListCipher { uris: Option>, #[serde(rename = "type")] entry_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + collection_ids: Option>, } #[derive(Debug, Clone, serde::Serialize)] @@ -331,6 +333,7 @@ impl From for DecryptedListCipher { user: value.user, folder: value.folder, uris: Some(value.uris.into_iter().map(|(s, _)| s).collect()), + collection_ids: None, } } } @@ -1186,6 +1189,7 @@ enum ListField { Folder, Uri, EntryType, + Collections, } impl ListField { @@ -1197,6 +1201,7 @@ impl ListField { Self::Folder, Self::Uri, Self::EntryType, + Self::Collections, ] } } @@ -1211,6 +1216,7 @@ impl std::convert::TryFrom<&String> for ListField { "user" => Self::User, "folder" => Self::Folder, "type" => Self::EntryType, + "collections" => Self::Collections, _ => return Err(anyhow::anyhow!("unknown field {s}")), }) } @@ -1464,6 +1470,12 @@ fn print_entry_list( std::string::ToString::to_string, ) } + ListField::Collections => { + entry.collection_ids.as_ref().map_or_else( + String::new, + |ids| ids.join(","), + ) + } }) .collect(); @@ -1908,6 +1920,220 @@ pub fn remove( Ok(()) } +pub fn export() -> anyhow::Result<()> { + unlock()?; + + let db = load_db()?; + + #[derive(serde::Serialize)] + struct ExportedEntry { + id: String, + org_id: Option, + folder: Option, + name: String, + #[serde(flatten)] + data: DecryptedData, + fields: Vec, + notes: Option, + history: Vec, + collection_ids: Vec, + } + + #[derive(serde::Serialize)] + struct ExportedCollection { + id: String, + org_id: String, + name: String, + } + + #[derive(serde::Serialize)] + struct ExportedVault { + entries: Vec, + collections: Vec, + } + + let mut entries: Vec = Vec::new(); + for entry in &db.entries { + let decrypted = decrypt_cipher(entry)?; + entries.push(ExportedEntry { + id: decrypted.id, + org_id: entry.org_id.clone(), + folder: decrypted.folder, + name: decrypted.name, + data: decrypted.data, + fields: decrypted.fields, + notes: decrypted.notes, + history: decrypted.history, + collection_ids: entry.collection_ids.clone(), + }); + } + + let mut collections: Vec = db + .collections + .iter() + .map(|c| { + let name = crate::actions::decrypt( + &c.name, + None, + Some(&c.org_id), + )?; + Ok(ExportedCollection { + id: c.id.clone(), + org_id: c.org_id.clone(), + name, + }) + }) + .collect::>()?; + collections.sort_by(|a, b| a.name.cmp(&b.name)); + + let vault = ExportedVault { + entries, + collections, + }; + + serde_json::to_writer_pretty(std::io::stdout(), &vault) + .context("failed to write export to stdout")?; + println!(); + + Ok(()) +} + +pub fn list_collections(raw: bool) -> anyhow::Result<()> { + unlock()?; + + let db = load_db()?; + + #[derive(serde::Serialize)] + struct DecryptedCollection { + id: String, + org_id: String, + name: String, + } + + let mut collections: Vec = db + .collections + .iter() + .map(|c| { + let name = crate::actions::decrypt( + &c.name, + None, + Some(&c.org_id), + )?; + Ok(DecryptedCollection { + id: c.id.clone(), + org_id: c.org_id.clone(), + name, + }) + }) + .collect::>()?; + collections.sort_by(|a, b| a.name.cmp(&b.name)); + + if raw { + serde_json::to_writer_pretty(std::io::stdout(), &collections) + .context("failed to write collections to stdout")?; + println!(); + } else { + for c in &collections { + println!("{}\t{}", c.id, c.name); + } + } + + Ok(()) +} + +pub fn edit_collections( + id: &str, + collections_b64: &str, +) -> anyhow::Result<()> { + unlock()?; + + let mut db = load_db()?; + let access_token = db.access_token.as_ref().unwrap(); + let refresh_token = db.refresh_token.as_ref().unwrap(); + + let json_bytes = rbw::base64::decode(collections_b64) + .context("failed to decode base64 collections")?; + let json_str = std::str::from_utf8(&json_bytes) + .context("collections is not valid UTF-8")?; + let collection_ids: Vec = serde_json::from_str(json_str) + .context("failed to parse collection IDs JSON")?; + + if let (Some(access_token), ()) = rbw::actions::edit_collections( + access_token, + refresh_token, + id, + &collection_ids, + )? { + db.access_token = Some(access_token); + save_db(&db)?; + } + + crate::actions::sync()?; + + Ok(()) +} + +pub fn create_collection( + name: &str, + org_id: &str, +) -> anyhow::Result<()> { + unlock()?; + + let mut db = load_db()?; + let access_token = db.access_token.as_ref().unwrap(); + let refresh_token = db.refresh_token.as_ref().unwrap(); + + let encrypted_name = + crate::actions::encrypt(name, Some(org_id))?; + + let (new_access_token, id) = rbw::actions::create_collection( + access_token, + refresh_token, + org_id, + &encrypted_name, + )?; + if let Some(new_access_token) = new_access_token { + db.access_token = Some(new_access_token); + save_db(&db)?; + } + + crate::actions::sync()?; + + println!("{id}"); + + Ok(()) +} + +pub fn rename_collection( + collection_id: &str, + org_id: &str, + name: &str, +) -> anyhow::Result<()> { + unlock()?; + + let mut db = load_db()?; + let access_token = db.access_token.as_ref().unwrap(); + let refresh_token = db.refresh_token.as_ref().unwrap(); + + let encrypted_name = + crate::actions::encrypt(name, Some(org_id))?; + + if let (Some(access_token), ()) = rbw::actions::rename_collection( + access_token, + refresh_token, + org_id, + collection_id, + &encrypted_name, + )? { + db.access_token = Some(access_token); + save_db(&db)?; + } + + crate::actions::sync()?; + + Ok(()) +} + pub fn history( name: Needle, username: Option<&str>, @@ -2190,6 +2416,12 @@ fn decrypt_list_cipher( }) .map(str::to_string); + let collection_ids = if fields.contains(&ListField::Collections) { + Some(entry.collection_ids.clone()) + } else { + None + }; + Ok(DecryptedListCipher { id, name, @@ -2197,6 +2429,7 @@ fn decrypt_list_cipher( folder, uris, entry_type, + collection_ids, }) } diff --git a/src/bin/rbw/main.rs b/src/bin/rbw/main.rs index ff2ec740..be42e11c 100644 --- a/src/bin/rbw/main.rs +++ b/src/bin/rbw/main.rs @@ -51,6 +51,15 @@ enum Opt { #[command(about = "Update the local copy of the Bitwarden database")] Sync, + #[command( + about = "Export the entire vault as decrypted JSON", + long_about = "Export the entire vault as decrypted JSON\n\n\ + Outputs all entries (with full details) and collections \ + to stdout. Suitable for piping to a file for backup or \ + migration to another instance via `rbw import`." + )] + Export, + #[command( about = "List all entries in the local Bitwarden database", visible_alias = "ls" @@ -59,7 +68,7 @@ enum Opt { #[arg( long, help = "Fields to display. \ - Available options are id, name, user, folder, type. \ + Available options are id, name, user, folder, type, collections. \ Multiple fields will be separated by tabs.", default_value = "name", use_value_delimiter = true @@ -217,6 +226,41 @@ enum Opt { find_args: FindArgs, }, + #[command( + about = "List all collections in the organization", + visible_alias = "lsc" + )] + ListCollections { + #[structopt(long, help = "Display output as JSON")] + raw: bool, + }, + + #[command(about = "Create a new collection in an organization")] + CreateCollection { + #[arg(help = "Name of the collection")] + name: String, + #[arg(long = "org-id", help = "Organization ID")] + org_id: String, + }, + + #[command(about = "Edit collections for an entry")] + EditCollections { + #[arg(help = "ID of the entry")] + id: String, + #[arg(help = "Base64-encoded JSON array of collection IDs")] + collections: String, + }, + + #[command(about = "Rename an organization collection")] + RenameCollection { + #[arg(help = "ID of the collection")] + id: String, + #[arg(long, help = "Organization ID")] + organizationid: String, + #[arg(help = "New name for the collection")] + name: String, + }, + #[command(about = "View the password history for a given entry")] History { #[command(flatten)] @@ -250,6 +294,7 @@ impl Opt { Self::Unlock => "unlock".to_string(), Self::Unlocked => "unlocked".to_string(), Self::Sync => "sync".to_string(), + Self::Export => "export".to_string(), Self::List { .. } => "list".to_string(), Self::Get { .. } => "get".to_string(), Self::Search { .. } => "search".to_string(), @@ -258,6 +303,18 @@ impl Opt { Self::Generate { .. } => "generate".to_string(), Self::Edit { .. } => "edit".to_string(), Self::Remove { .. } => "remove".to_string(), + Self::ListCollections { .. } => { + "list-collections".to_string() + } + Self::CreateCollection { .. } => { + "create-collection".to_string() + } + Self::EditCollections { .. } => { + "edit-collections".to_string() + } + Self::RenameCollection { .. } => { + "rename-collection".to_string() + } Self::History { .. } => "history".to_string(), Self::Lock => "lock".to_string(), Self::Purge => "purge".to_string(), @@ -337,6 +394,7 @@ fn main() { Opt::Unlock => commands::unlock(), Opt::Unlocked => commands::unlocked(), Opt::Sync => commands::sync(), + Opt::Export => commands::export(), Opt::List { fields, raw } => commands::list(&fields, raw), Opt::Get { find_args, @@ -442,6 +500,18 @@ fn main() { find_args.folder.as_deref(), find_args.ignorecase, ), + Opt::ListCollections { raw } => commands::list_collections(raw), + Opt::CreateCollection { name, org_id } => { + commands::create_collection(&name, &org_id) + } + Opt::EditCollections { id, collections } => { + commands::edit_collections(&id, &collections) + } + Opt::RenameCollection { + id, + organizationid, + name, + } => commands::rename_collection(&id, &organizationid, &name), Opt::History { find_args } => commands::history( find_args.needle, find_args.user.as_deref(), diff --git a/src/db.rs b/src/db.rs index fec0af7c..74eb1c29 100644 --- a/src/db.rs +++ b/src/db.rs @@ -19,6 +19,17 @@ pub struct Entry { pub history: Vec, pub key: Option, pub master_password_reprompt: crate::api::CipherRepromptType, + #[serde(default)] + pub collection_ids: Vec, +} + +#[derive( + serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq, +)] +pub struct Collection { + pub id: String, + pub org_id: String, + pub name: String, } impl Entry { @@ -189,6 +200,8 @@ pub struct Db { pub protected_org_keys: std::collections::HashMap, pub entries: Vec, + #[serde(default)] + pub collections: Vec, } impl Db { From 660353900c96e4f4ffd93ef7ffbe178341c727f0 Mon Sep 17 00:00:00 2001 From: FluffyDiscord Date: Mon, 8 Jun 2026 13:16:21 +0200 Subject: [PATCH 3/7] Collections - add delete command --- src/actions.rs | 20 ++++++++++++ src/api.rs | 31 +++++++++++++++++-- src/bin/rbw-agent/actions.rs | 8 ++++- src/bin/rbw/commands.rs | 60 +++++++++++++++++++++--------------- src/bin/rbw/main.rs | 29 +++++++++-------- 5 files changed, 108 insertions(+), 40 deletions(-) diff --git a/src/actions.rs b/src/actions.rs index be37468c..cbe94ec5 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -338,6 +338,26 @@ fn create_collection_once( client.create_collection(access_token, org_id, encrypted_name) } +pub fn delete_collection( + access_token: &str, + refresh_token: &str, + org_id: &str, + collection_id: &str, +) -> Result<(Option, ())> { + with_exchange_refresh_token(access_token, refresh_token, |access_token| { + delete_collection_once(access_token, org_id, collection_id) + }) +} + +fn delete_collection_once( + access_token: &str, + org_id: &str, + collection_id: &str, +) -> Result<()> { + let (client, _) = api_client()?; + client.delete_collection(access_token, org_id, collection_id) +} + pub fn list_folders( access_token: &str, refresh_token: &str, diff --git a/src/api.rs b/src/api.rs index 9cc575a0..dc9a79ad 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1610,9 +1610,9 @@ impl Client { }; let client = reqwest::blocking::Client::new(); let res = client - .post(self.api_url(&format!( - "/organizations/{org_id}/collections" - ))) + .post( + self.api_url(&format!("/organizations/{org_id}/collections")), + ) .header("Authorization", format!("Bearer {access_token}")) .json(&req) .send() @@ -1632,6 +1632,31 @@ impl Client { } } + pub fn delete_collection( + &self, + access_token: &str, + org_id: &str, + collection_id: &str, + ) -> Result<()> { + let client = reqwest::blocking::Client::new(); + let res = client + .delete(self.api_url(&format!( + "/organizations/{org_id}/collections/{collection_id}" + ))) + .header("Authorization", format!("Bearer {access_token}")) + .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 folders( &self, access_token: &str, diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index ad20101a..c2301604 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -570,7 +570,13 @@ pub async fn sync( }; let ( access_token, - (protected_key, protected_private_key, protected_org_keys, entries, collections), + ( + protected_key, + protected_private_key, + protected_org_keys, + entries, + collections, + ), ) = rbw::actions::sync(&access_token, &refresh_token) .await .context("failed to sync database from server")?; diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 5a66cd73..9ab9549b 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1470,12 +1470,10 @@ fn print_entry_list( std::string::ToString::to_string, ) } - ListField::Collections => { - entry.collection_ids.as_ref().map_or_else( - String::new, - |ids| ids.join(","), - ) - } + ListField::Collections => entry + .collection_ids + .as_ref() + .map_or_else(String::new, |ids| ids.join(",")), }) .collect(); @@ -1972,11 +1970,8 @@ pub fn export() -> anyhow::Result<()> { .collections .iter() .map(|c| { - let name = crate::actions::decrypt( - &c.name, - None, - Some(&c.org_id), - )?; + let name = + crate::actions::decrypt(&c.name, None, Some(&c.org_id))?; Ok(ExportedCollection { id: c.id.clone(), org_id: c.org_id.clone(), @@ -2014,11 +2009,8 @@ pub fn list_collections(raw: bool) -> anyhow::Result<()> { .collections .iter() .map(|c| { - let name = crate::actions::decrypt( - &c.name, - None, - Some(&c.org_id), - )?; + let name = + crate::actions::decrypt(&c.name, None, Some(&c.org_id))?; Ok(DecryptedCollection { id: c.id.clone(), org_id: c.org_id.clone(), @@ -2073,18 +2065,14 @@ pub fn edit_collections( Ok(()) } -pub fn create_collection( - name: &str, - org_id: &str, -) -> anyhow::Result<()> { +pub fn create_collection(name: &str, org_id: &str) -> anyhow::Result<()> { unlock()?; let mut db = load_db()?; let access_token = db.access_token.as_ref().unwrap(); let refresh_token = db.refresh_token.as_ref().unwrap(); - let encrypted_name = - crate::actions::encrypt(name, Some(org_id))?; + let encrypted_name = crate::actions::encrypt(name, Some(org_id))?; let (new_access_token, id) = rbw::actions::create_collection( access_token, @@ -2104,6 +2092,31 @@ pub fn create_collection( Ok(()) } +pub fn delete_collection( + collection_id: &str, + org_id: &str, +) -> anyhow::Result<()> { + unlock()?; + + let mut db = load_db()?; + let access_token = db.access_token.as_ref().unwrap(); + let refresh_token = db.refresh_token.as_ref().unwrap(); + + if let (Some(access_token), ()) = rbw::actions::delete_collection( + access_token, + refresh_token, + org_id, + collection_id, + )? { + db.access_token = Some(access_token); + save_db(&db)?; + } + + crate::actions::sync()?; + + Ok(()) +} + pub fn rename_collection( collection_id: &str, org_id: &str, @@ -2115,8 +2128,7 @@ pub fn rename_collection( let access_token = db.access_token.as_ref().unwrap(); let refresh_token = db.refresh_token.as_ref().unwrap(); - let encrypted_name = - crate::actions::encrypt(name, Some(org_id))?; + let encrypted_name = crate::actions::encrypt(name, Some(org_id))?; if let (Some(access_token), ()) = rbw::actions::rename_collection( access_token, diff --git a/src/bin/rbw/main.rs b/src/bin/rbw/main.rs index be42e11c..829a4853 100644 --- a/src/bin/rbw/main.rs +++ b/src/bin/rbw/main.rs @@ -243,6 +243,14 @@ enum Opt { org_id: String, }, + #[command(about = "Delete an organization collection")] + DeleteCollection { + #[arg(help = "ID of the collection")] + collection_id: String, + #[arg(long = "org-id", help = "Organization ID")] + org_id: String, + }, + #[command(about = "Edit collections for an entry")] EditCollections { #[arg(help = "ID of the entry")] @@ -303,18 +311,11 @@ impl Opt { Self::Generate { .. } => "generate".to_string(), Self::Edit { .. } => "edit".to_string(), Self::Remove { .. } => "remove".to_string(), - Self::ListCollections { .. } => { - "list-collections".to_string() - } - Self::CreateCollection { .. } => { - "create-collection".to_string() - } - Self::EditCollections { .. } => { - "edit-collections".to_string() - } - Self::RenameCollection { .. } => { - "rename-collection".to_string() - } + Self::ListCollections { .. } => "list-collections".to_string(), + Self::CreateCollection { .. } => "create-collection".to_string(), + Self::DeleteCollection { .. } => "delete-collection".to_string(), + Self::EditCollections { .. } => "edit-collections".to_string(), + Self::RenameCollection { .. } => "rename-collection".to_string(), Self::History { .. } => "history".to_string(), Self::Lock => "lock".to_string(), Self::Purge => "purge".to_string(), @@ -504,6 +505,10 @@ fn main() { Opt::CreateCollection { name, org_id } => { commands::create_collection(&name, &org_id) } + Opt::DeleteCollection { + collection_id, + org_id, + } => commands::delete_collection(&collection_id, &org_id), Opt::EditCollections { id, collections } => { commands::edit_collections(&id, &collections) } From cc5c77e9f6613a87e3914a63b63a349f2e462945 Mon Sep 17 00:00:00 2001 From: FluffyDiscord Date: Mon, 8 Jun 2026 13:16:21 +0200 Subject: [PATCH 4/7] Collections - add permission propagation --- src/actions.rs | 80 ++++++++++ src/api.rs | 204 ++++++++++++++++++++++++ src/bin/rbw/commands.rs | 337 ++++++++++++++++++++++++++++++++++++++++ src/bin/rbw/main.rs | 27 ++++ 4 files changed, 648 insertions(+) diff --git a/src/actions.rs b/src/actions.rs index cbe94ec5..1bb0c3e1 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -358,6 +358,86 @@ fn delete_collection_once( client.delete_collection(access_token, org_id, collection_id) } +pub fn org_users( + access_token: &str, + refresh_token: &str, + org_id: &str, +) -> Result<(Option, Vec)> { + with_exchange_refresh_token(access_token, refresh_token, |access_token| { + org_users_once(access_token, org_id) + }) +} + +fn org_users_once( + access_token: &str, + org_id: &str, +) -> Result> { + let (client, _) = api_client()?; + client.org_users(access_token, org_id) +} + +pub fn collections_details( + access_token: &str, + refresh_token: &str, + org_id: &str, +) -> Result<(Option, Vec)> { + with_exchange_refresh_token(access_token, refresh_token, |access_token| { + collections_details_once(access_token, org_id) + }) +} + +fn collections_details_once( + access_token: &str, + org_id: &str, +) -> Result> { + let (client, _) = api_client()?; + client.collections_details(access_token, org_id) +} + +pub fn set_collection_users( + access_token: &str, + refresh_token: &str, + org_id: &str, + collection_id: &str, + encrypted_name: &str, + external_id: Option<&str>, + groups: &[serde_json::Value], + users: &[crate::api::CollectionUser], +) -> Result<(Option, ())> { + with_exchange_refresh_token(access_token, refresh_token, |access_token| { + set_collection_users_once( + access_token, + org_id, + collection_id, + encrypted_name, + external_id, + groups, + users, + ) + }) +} + +fn set_collection_users_once( + access_token: &str, + org_id: &str, + collection_id: &str, + encrypted_name: &str, + external_id: Option<&str>, + groups: &[serde_json::Value], + users: &[crate::api::CollectionUser], +) -> Result<()> { + let (client, _) = api_client()?; + client.set_collection_users( + access_token, + org_id, + collection_id, + encrypted_name, + external_id, + groups, + users, + ) +} + pub fn list_folders( access_token: &str, refresh_token: &str, diff --git a/src/api.rs b/src/api.rs index dc9a79ad..62adeeb8 100644 --- a/src/api.rs +++ b/src/api.rs @@ -828,6 +828,82 @@ struct CollectionCreateRes { id: String, } +#[derive(Debug, Clone)] +pub struct OrgUser { + pub id: String, + pub email: String, + pub status: i32, + // Organization role: 0=Owner, 1=Admin, 2=User, 3=Manager. + pub role: i32, + pub access_all: bool, +} + +#[derive(serde::Deserialize, Debug)] +struct OrgUsersRes { + #[serde(rename = "Data", alias = "data")] + data: Vec, +} + +#[derive(serde::Deserialize, Debug)] +struct OrgUsersResData { + #[serde(rename = "Id", alias = "id")] + id: String, + #[serde(rename = "Email", alias = "email")] + email: String, + #[serde(rename = "Status", alias = "status")] + status: i32, + #[serde(rename = "Type", alias = "type")] + role: i32, + #[serde(rename = "AccessAll", alias = "accessAll", default)] + access_all: bool, +} + +#[derive(Debug, Clone)] +pub struct CollectionUser { + pub id: String, + pub read_only: bool, + pub hide_passwords: bool, + pub manage: bool, +} + +#[derive(Debug, Clone)] +pub struct CollectionDetail { + pub id: String, + pub external_id: Option, + pub groups: Vec, + pub users: Vec, +} + +#[derive(serde::Deserialize, Debug)] +struct CollectionUserData { + #[serde(rename = "id", alias = "Id")] + id: String, + #[serde(rename = "readOnly", alias = "ReadOnly", default)] + read_only: bool, + #[serde(rename = "hidePasswords", alias = "HidePasswords", default)] + hide_passwords: bool, + #[serde(rename = "manage", alias = "Manage", default)] + manage: bool, +} + +#[derive(serde::Deserialize, Debug)] +struct CollectionDetailsRes { + #[serde(rename = "Data", alias = "data")] + data: Vec, +} + +#[derive(serde::Deserialize, Debug)] +struct CollectionDetailsResData { + #[serde(rename = "Id", alias = "id")] + id: String, + #[serde(rename = "ExternalId", alias = "externalId", default)] + external_id: Option, + #[serde(rename = "Groups", alias = "groups", default)] + groups: Vec, + #[serde(rename = "Users", alias = "users", default)] + users: Vec, +} + #[derive(serde::Deserialize, Debug)] struct FoldersRes { #[serde(rename = "Data", alias = "data")] @@ -1657,6 +1733,134 @@ impl Client { } } + pub fn org_users( + &self, + access_token: &str, + org_id: &str, + ) -> Result> { + let client = reqwest::blocking::Client::new(); + let res = client + .get(self.api_url(&format!("/organizations/{org_id}/users"))) + .header("Authorization", format!("Bearer {access_token}")) + .send() + .map_err(|source| Error::Reqwest { source })?; + match res.status() { + reqwest::StatusCode::OK => { + let users_res: OrgUsersRes = res.json_with_path()?; + Ok(users_res + .data + .into_iter() + .map(|u| OrgUser { + id: u.id, + email: u.email, + status: u.status, + role: u.role, + access_all: u.access_all, + }) + .collect()) + } + reqwest::StatusCode::UNAUTHORIZED => { + Err(Error::RequestUnauthorized) + } + _ => Err(Error::RequestFailed { + status: res.status().as_u16(), + }), + } + } + + pub fn collections_details( + &self, + access_token: &str, + org_id: &str, + ) -> Result> { + let client = reqwest::blocking::Client::new(); + let res = client + .get(self.api_url(&format!( + "/organizations/{org_id}/collections/details" + ))) + .header("Authorization", format!("Bearer {access_token}")) + .send() + .map_err(|source| Error::Reqwest { source })?; + match res.status() { + reqwest::StatusCode::OK => { + let details_res: CollectionDetailsRes = res.json_with_path()?; + Ok(details_res + .data + .into_iter() + .map(|c| CollectionDetail { + id: c.id, + external_id: c.external_id, + groups: c.groups, + users: c + .users + .into_iter() + .map(|u| CollectionUser { + id: u.id, + read_only: u.read_only, + hide_passwords: u.hide_passwords, + manage: u.manage, + }) + .collect(), + }) + .collect()) + } + reqwest::StatusCode::UNAUTHORIZED => { + Err(Error::RequestUnauthorized) + } + _ => Err(Error::RequestFailed { + status: res.status().as_u16(), + }), + } + } + + pub fn set_collection_users( + &self, + access_token: &str, + org_id: &str, + collection_id: &str, + encrypted_name: &str, + external_id: Option<&str>, + groups: &[serde_json::Value], + users: &[CollectionUser], + ) -> Result<()> { + let users: Vec = users + .iter() + .map(|u| { + serde_json::json!({ + "id": u.id, + "readOnly": u.read_only, + "hidePasswords": u.hide_passwords, + "manage": u.manage, + }) + }) + .collect(); + let req = CollectionPutReq { + name: encrypted_name.to_string(), + organization_id: org_id.to_string(), + external_id: external_id.map(std::string::ToString::to_string), + groups: groups.to_vec(), + users, + }; + let client = reqwest::blocking::Client::new(); + let res = client + .put(self.api_url(&format!( + "/organizations/{org_id}/collections/{collection_id}" + ))) + .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 folders( &self, access_token: &str, diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 9ab9549b..cb7ba751 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -2146,6 +2146,343 @@ pub fn rename_collection( Ok(()) } +const EDIT: rbw::api::CollectionUser = rbw::api::CollectionUser { + id: String::new(), + read_only: false, + hide_passwords: false, + manage: false, +}; + +const MANAGE: rbw::api::CollectionUser = rbw::api::CollectionUser { + id: String::new(), + read_only: false, + hide_passwords: false, + manage: true, +}; + +fn perm_rank(u: &rbw::api::CollectionUser) -> u8 { + if u.manage { + return 4; + } + match (u.read_only, u.hide_passwords) { + (false, false) => 3, + (false, true) => 2, + (true, false) => 1, + (true, true) => 0, + } +} + +fn perm_level_name(u: &rbw::api::CollectionUser) -> &'static str { + match perm_rank(u) { + 4 => "manage", + 3 => "edit", + 2 => "edit-no-pw", + 1 => "view", + _ => "view-no-pw", + } +} + +fn same_flags(a: &rbw::api::CollectionUser, b: &rbw::api::CollectionUser) -> bool { + a.read_only == b.read_only + && a.hide_passwords == b.hide_passwords + && a.manage == b.manage +} + +fn normalize_collection_name(name: &str) -> anyhow::Result { + let trimmed = name.trim(); + if trimmed.is_empty() + || trimmed.starts_with('/') + || trimmed.ends_with('/') + { + anyhow::bail!("collection name is empty or has a leading/trailing slash: {name:?}"); + } + Ok(trimmed.to_string()) +} + +fn resolve_org( + db: &rbw::db::Db, + org_id: Option<&str>, +) -> anyhow::Result { + let org_ids: std::collections::BTreeSet<&str> = + db.collections.iter().map(|c| c.org_id.as_str()).collect(); + org_id.map_or_else( + || match org_ids.len() { + 0 => Err(anyhow::anyhow!("no organization found in vault")), + 1 => Ok((*org_ids.iter().next().unwrap()).to_string()), + _ => Err(anyhow::anyhow!( + "multiple organizations found; pass --org-id" + )), + }, + |o| { + if org_ids.contains(o) { + Ok(o.to_string()) + } else { + Err(anyhow::anyhow!( + "org {o} has no collections in this vault" + )) + } + }, + ) +} + +pub fn propagate_collection_permissions( + org_id: Option<&str>, + apply: bool, + verbose: bool, +) -> anyhow::Result<()> { + unlock()?; + crate::actions::sync()?; + + let mut db = load_db()?; + let org_id = resolve_org(&db, org_id)?; + + let mut id2name: std::collections::HashMap = + std::collections::HashMap::new(); + for c in &db.collections { + if c.org_id != org_id { + continue; + } + let name = crate::actions::decrypt(&c.name, None, Some(&c.org_id)) + .with_context(|| { + format!("failed to decrypt collection name for {}", c.id) + })?; + let name = normalize_collection_name(&name)?; + id2name.insert(c.id.clone(), name); + } + + let mut access_token = db.access_token.as_ref().unwrap().clone(); + let refresh_token = db.refresh_token.as_ref().unwrap().clone(); + + let (new_token, members) = + rbw::actions::org_users(&access_token, &refresh_token, &org_id)?; + if let Some(t) = new_token { + access_token.clone_from(&t); + db.access_token = Some(t); + save_db(&db)?; + } + + let (new_token, details) = rbw::actions::collections_details( + &access_token, + &refresh_token, + &org_id, + )?; + if let Some(t) = new_token { + access_token.clone_from(&t); + db.access_token = Some(t); + save_db(&db)?; + } + + // Exclude Owners (role 0) and Admins (role 1); only Users (2) and + // Managers (3) get permission propagation. confirmed (status==2) and + // non-access-all members only. + let eligible: std::collections::HashMap = members + .iter() + .filter(|m| m.status == 2 && !m.access_all && m.role >= 2) + .map(|m| (m.id.clone(), m.email.clone())) + .collect(); + + let details_by_id: std::collections::HashMap<&str, &rbw::api::CollectionDetail> = + details.iter().map(|d| (d.id.as_str(), d)).collect(); + for d in &details { + if !id2name.contains_key(&d.id) { + anyhow::bail!( + "collection {} returned by the API is missing or undecryptable in the local db; aborting", + d.id + ); + } + } + for id in id2name.keys() { + if !details_by_id.contains_key(id.as_str()) { + anyhow::bail!( + "collection {} ({}) is in the local db but absent from the live API response; aborting", + id, + id2name[id] + ); + } + } + + let mut held: std::collections::HashMap< + String, + std::collections::HashMap, + > = std::collections::HashMap::new(); + for d in &details { + for u in &d.users { + if eligible.contains_key(&u.id) { + held.entry(u.id.clone()) + .or_default() + .insert(d.id.clone(), u.clone()); + } + } + } + + let mut desired: std::collections::HashMap< + (String, String), + rbw::api::CollectionUser, + > = std::collections::HashMap::new(); + for member_id in held.keys() { + let held_ids = &held[member_id]; + let held_names: Vec<&str> = held_ids + .keys() + .map(|id| id2name[id].as_str()) + .collect(); + let topmost: Vec<&str> = held_names + .iter() + .copied() + .filter(|n| { + !held_names.iter().any(|h| { + *h != *n && n.starts_with(&format!("{h}/")) + }) + }) + .collect(); + for (id, name) in &id2name { + if topmost + .iter() + .any(|t| name.starts_with(&format!("{t}/"))) + { + desired + .insert((member_id.clone(), id.clone()), MANAGE); + } + } + for (id, name) in &id2name { + if topmost.contains(&name.as_str()) { + desired.insert((member_id.clone(), id.clone()), EDIT); + } + } + } + + let mut changes: std::collections::BTreeMap< + String, + Vec<(String, rbw::api::CollectionUser)>, + > = std::collections::BTreeMap::new(); + for ((member_id, coll_id), target) in &desired { + let current = held.get(member_id).and_then(|h| h.get(coll_id)); + let needs_change = + current.is_none_or(|c| !same_flags(c, target)); + if needs_change { + changes + .entry(coll_id.clone()) + .or_default() + .push((member_id.clone(), target.clone())); + } + } + for member_targets in changes.values_mut() { + member_targets.sort_by(|a, b| a.0.cmp(&b.0)); + } + + for coll_id in changes.keys() { + if !details_by_id[coll_id.as_str()].groups.is_empty() { + anyhow::bail!( + "collection {} ({}) has groups assigned; groups passthrough on PUT is unverified, aborting (see docs/collection-permissions-spec.md ยง4.3)", + coll_id, + id2name[coll_id] + ); + } + } + + let mut changed_members: std::collections::BTreeSet = + std::collections::BTreeSet::new(); + let mut grants = 0usize; + for (coll_id, member_targets) in &changes { + let name = &id2name[coll_id]; + for (member_id, target) in member_targets { + let email = &eligible[member_id]; + let level = if target.manage { "MANAGE" } else { "EDIT" }; + let current = held.get(member_id).and_then(|h| h.get(coll_id)); + let downgrade = current + .is_some_and(|c| perm_rank(target) < perm_rank(c)); + let prefix = if apply { "" } else { "WOULD " }; + if downgrade { + let cur_level = perm_level_name(current.unwrap()); + let tgt_level = perm_level_name(target); + println!( + "{prefix}DOWNGRADE {email} {cur_level}->{tgt_level} on {name}" + ); + } else { + println!("{prefix}SET {email} -> {level} on {name}"); + } + changed_members.insert(member_id.clone()); + grants += 1; + } + } + + if verbose { + eprintln!( + "{} eligible members, {} collections in org, {} collections to change", + eligible.len(), + id2name.len(), + changes.len() + ); + } + + if apply { + let mut applied: Vec = Vec::new(); + for (coll_id, member_targets) in &changes { + let detail = details_by_id[coll_id.as_str()]; + let mut new_users = detail.users.clone(); + for (member_id, target) in member_targets { + let entry = new_users.iter_mut().find(|u| &u.id == member_id); + if let Some(u) = entry { + u.read_only = target.read_only; + u.hide_passwords = target.hide_passwords; + u.manage = target.manage; + } else { + new_users.push(rbw::api::CollectionUser { + id: member_id.clone(), + read_only: target.read_only, + hide_passwords: target.hide_passwords, + manage: target.manage, + }); + } + } + let enc_name = db + .collections + .iter() + .find(|c| &c.id == coll_id) + .map(|c| c.name.clone()) + .unwrap(); + let res = rbw::actions::set_collection_users( + &access_token, + &refresh_token, + &org_id, + coll_id, + &enc_name, + detail.external_id.as_deref(), + &detail.groups, + &new_users, + ); + match res { + Ok((new_token, ())) => { + if let Some(t) = new_token { + access_token.clone_from(&t); + db.access_token = Some(t); + save_db(&db)?; + } + applied.push(coll_id.clone()); + } + Err(e) => { + eprintln!( + "PUT failed on collection {} ({}); already applied to: {:?}", + coll_id, id2name[coll_id], applied + ); + return Err(e.into()); + } + } + } + crate::actions::sync()?; + } + + let mode = if apply { "applied" } else { "dry-run" }; + println!( + "Done: {} members, {} collections changed, {} grants set ({})", + changed_members.len(), + changes.len(), + grants, + mode + ); + + Ok(()) +} + pub fn history( name: Needle, username: Option<&str>, diff --git a/src/bin/rbw/main.rs b/src/bin/rbw/main.rs index 829a4853..ccab78db 100644 --- a/src/bin/rbw/main.rs +++ b/src/bin/rbw/main.rs @@ -259,6 +259,21 @@ enum Opt { collections: String, }, + #[command( + about = "Grant members access to nested collections (topmost held -> edit, descendants -> manage)" + )] + PropagateCollectionPermissions { + #[arg( + long = "org-id", + help = "Organization ID (auto-detected if the vault has a single org)" + )] + org_id: Option, + #[arg(long, help = "Execute the changes (default is a dry-run)")] + apply: bool, + #[arg(short, long, help = "Print per-run counts")] + verbose: bool, + }, + #[command(about = "Rename an organization collection")] RenameCollection { #[arg(help = "ID of the collection")] @@ -315,6 +330,9 @@ impl Opt { Self::CreateCollection { .. } => "create-collection".to_string(), Self::DeleteCollection { .. } => "delete-collection".to_string(), Self::EditCollections { .. } => "edit-collections".to_string(), + Self::PropagateCollectionPermissions { .. } => { + "propagate-collection-permissions".to_string() + } Self::RenameCollection { .. } => "rename-collection".to_string(), Self::History { .. } => "history".to_string(), Self::Lock => "lock".to_string(), @@ -512,6 +530,15 @@ fn main() { Opt::EditCollections { id, collections } => { commands::edit_collections(&id, &collections) } + Opt::PropagateCollectionPermissions { + org_id, + apply, + verbose, + } => commands::propagate_collection_permissions( + org_id.as_deref(), + apply, + verbose, + ), Opt::RenameCollection { id, organizationid, From de8b534653469abe638a788ef9b2ef7bb83f11d8 Mon Sep 17 00:00:00 2001 From: FluffyDiscord Date: Mon, 8 Jun 2026 13:54:35 +0200 Subject: [PATCH 5/7] Collections - fix all-features build and clippy lint --- src/api.rs | 3 ++- src/bin/rbw/commands.rs | 55 ++++++++++++++++++++--------------------- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/api.rs b/src/api.rs index 62adeeb8..67df7a75 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1783,7 +1783,8 @@ impl Client { .map_err(|source| Error::Reqwest { source })?; match res.status() { reqwest::StatusCode::OK => { - let details_res: CollectionDetailsRes = res.json_with_path()?; + let details_res: CollectionDetailsRes = + res.json_with_path()?; Ok(details_res .data .into_iter() diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index cb7ba751..3c5f7409 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1919,10 +1919,6 @@ pub fn remove( } pub fn export() -> anyhow::Result<()> { - unlock()?; - - let db = load_db()?; - #[derive(serde::Serialize)] struct ExportedEntry { id: String, @@ -1950,6 +1946,10 @@ pub fn export() -> anyhow::Result<()> { collections: Vec, } + unlock()?; + + let db = load_db()?; + let mut entries: Vec = Vec::new(); for entry in &db.entries { let decrypted = decrypt_cipher(entry)?; @@ -1994,10 +1994,6 @@ pub fn export() -> anyhow::Result<()> { } pub fn list_collections(raw: bool) -> anyhow::Result<()> { - unlock()?; - - let db = load_db()?; - #[derive(serde::Serialize)] struct DecryptedCollection { id: String, @@ -2005,6 +2001,10 @@ pub fn list_collections(raw: bool) -> anyhow::Result<()> { name: String, } + unlock()?; + + let db = load_db()?; + let mut collections: Vec = db .collections .iter() @@ -2182,7 +2182,10 @@ fn perm_level_name(u: &rbw::api::CollectionUser) -> &'static str { } } -fn same_flags(a: &rbw::api::CollectionUser, b: &rbw::api::CollectionUser) -> bool { +fn same_flags( + a: &rbw::api::CollectionUser, + b: &rbw::api::CollectionUser, +) -> bool { a.read_only == b.read_only && a.hide_passwords == b.hide_passwords && a.manage == b.manage @@ -2281,8 +2284,10 @@ pub fn propagate_collection_permissions( .map(|m| (m.id.clone(), m.email.clone())) .collect(); - let details_by_id: std::collections::HashMap<&str, &rbw::api::CollectionDetail> = - details.iter().map(|d| (d.id.as_str(), d)).collect(); + let details_by_id: std::collections::HashMap< + &str, + &rbw::api::CollectionDetail, + > = details.iter().map(|d| (d.id.as_str(), d)).collect(); for d in &details { if !id2name.contains_key(&d.id) { anyhow::bail!( @@ -2321,26 +2326,20 @@ pub fn propagate_collection_permissions( > = std::collections::HashMap::new(); for member_id in held.keys() { let held_ids = &held[member_id]; - let held_names: Vec<&str> = held_ids - .keys() - .map(|id| id2name[id].as_str()) - .collect(); + let held_names: Vec<&str> = + held_ids.keys().map(|id| id2name[id].as_str()).collect(); let topmost: Vec<&str> = held_names .iter() .copied() .filter(|n| { - !held_names.iter().any(|h| { - *h != *n && n.starts_with(&format!("{h}/")) - }) + !held_names + .iter() + .any(|h| *h != *n && n.starts_with(&format!("{h}/"))) }) .collect(); for (id, name) in &id2name { - if topmost - .iter() - .any(|t| name.starts_with(&format!("{t}/"))) - { - desired - .insert((member_id.clone(), id.clone()), MANAGE); + if topmost.iter().any(|t| name.starts_with(&format!("{t}/"))) { + desired.insert((member_id.clone(), id.clone()), MANAGE); } } for (id, name) in &id2name { @@ -2356,8 +2355,7 @@ pub fn propagate_collection_permissions( > = std::collections::BTreeMap::new(); for ((member_id, coll_id), target) in &desired { let current = held.get(member_id).and_then(|h| h.get(coll_id)); - let needs_change = - current.is_none_or(|c| !same_flags(c, target)); + let needs_change = current.is_none_or(|c| !same_flags(c, target)); if needs_change { changes .entry(coll_id.clone()) @@ -2388,8 +2386,8 @@ pub fn propagate_collection_permissions( let email = &eligible[member_id]; let level = if target.manage { "MANAGE" } else { "EDIT" }; let current = held.get(member_id).and_then(|h| h.get(coll_id)); - let downgrade = current - .is_some_and(|c| perm_rank(target) < perm_rank(c)); + let downgrade = + current.is_some_and(|c| perm_rank(target) < perm_rank(c)); let prefix = if apply { "" } else { "WOULD " }; if downgrade { let cur_level = perm_level_name(current.unwrap()); @@ -4739,6 +4737,7 @@ mod test { history: vec![], key: None, master_password_reprompt: rbw::api::CipherRepromptType::None, + collection_ids: vec![], }, DecryptedSearchCipher { id: id.to_string(), From 7b97220d5bbf83ac7345dbb69085de0a6004c7bd Mon Sep 17 00:00:00 2001 From: FluffyDiscord Date: Mon, 8 Jun 2026 14:11:10 +0200 Subject: [PATCH 6/7] Clippy - fix 1.96 lints in pre-existing login and search code --- src/api.rs | 5 ++--- src/bin/rbw/commands.rs | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/api.rs b/src/api.rs index 67df7a75..57b96d3c 100644 --- a/src/api.rs +++ b/src/api.rs @@ -2142,10 +2142,10 @@ 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 (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() { @@ -2163,7 +2163,6 @@ fn classify_login_error(error_res: &ConnectErrorRes, code: u16) -> Error { } } } - } _ => {} } diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 3c5f7409..92d41cf6 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1517,8 +1517,7 @@ pub fn search( .filter(|entry| { entry .as_ref() - .map(|entry| entry.search_match(term, folder)) - .unwrap_or(true) + .map_or(true, |entry| entry.search_match(term, folder)) }) .map(|entry| entry.map(std::convert::Into::into)) .collect::>()?; From 91b4fc173dd5086a326c49f542268f0c465f809f Mon Sep 17 00:00:00 2001 From: FluffyDiscord Date: Mon, 8 Jun 2026 14:32:17 +0200 Subject: [PATCH 7/7] Deps - patch RUSTSEC advisories in transitive dependencies --- Cargo.lock | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c8b0964d..def2ffef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -266,9 +266,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cbc" @@ -1350,7 +1350,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.6", "smallvec", "zeroize", ] @@ -1744,7 +1744,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -1787,9 +1787,9 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -1798,9 +1798,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", @@ -1887,8 +1887,8 @@ dependencies = [ "pbkdf2", "percent-encoding", "pkcs8", - "rand 0.8.5", - "rand 0.9.2", + "rand 0.8.6", + "rand 0.9.4", "regex", "region", "reqwest", @@ -2111,9 +2111,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.35" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "once_cell", "ring", @@ -2147,9 +2147,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -2811,7 +2811,7 @@ dependencies = [ "http", "httparse", "log", - "rand 0.9.2", + "rand 0.9.4", "rustls", "rustls-pki-types", "sha1",