From f426fbaeeecdcb17470063c9a4bd9f7451b4ff3e Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Thu, 13 Nov 2025 14:20:39 -0800 Subject: [PATCH 01/38] Add create and edit admin operations --- .../src/cipher/cipher_client/admin.rs | 124 ++++++++++++++++++ .../src/cipher/cipher_client/edit.rs | 56 ++++++-- .../src/cipher/cipher_client/mod.rs | 1 + 3 files changed, 168 insertions(+), 13 deletions(-) create mode 100644 crates/bitwarden-vault/src/cipher/cipher_client/admin.rs diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/admin.rs b/crates/bitwarden-vault/src/cipher/cipher_client/admin.rs new file mode 100644 index 000000000..85e2f2f69 --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin.rs @@ -0,0 +1,124 @@ +use bitwarden_api_api::models::{CipherCreateRequestModel, CipherMiniResponseModel}; +use bitwarden_collections::collection::{self, CollectionId}; +use bitwarden_core::{NotAuthenticatedError, OrganizationId}; +use bitwarden_crypto::{CompositeEncryptable, IdentifyKey}; + +use crate::{ + Cipher, CipherError, CiphersClient, + cipher_client::{ + create::{CipherCreateRequest, CipherCreateRequestInternal}, + edit::{CipherEditRequest, EditCipherError}, + }, +}; + +// TS Api Service +// SDK +// postCipherAdmin(request: CipherCreateRequest) +#[allow(missing_docs)] // TODO: remove +impl CiphersClient { + // TODO / QUESTION - do we want to make this a separate operation? Or just add it to the existing `create` detail? + // ciphers_admin_post + pub async fn admin_create( + &self, + request: CipherCreateRequest, + collection_ids: Vec, + ) -> Result { + // let api_req = request + let mut request_internal: CipherCreateRequestInternal = request.into(); + let key_store = self.client.internal.get_key_store(); + + // TODO: move this to CipherCreateRequestInternal::CompositeEncryptable implementation + // once the feature flag is removed. + if self + .client + .internal + .get_flags() + .enable_cipher_key_encryption + { + let key = request_internal.key_identifier(); + request_internal + .generate_cipher_key(&mut self.client.internal.get_key_store().context(), key)?; + } + + let request = CipherCreateRequestModel { + collection_ids: Some(collection_ids.clone().into_iter().map(Into::into).collect()), + cipher: Box::new(key_store.encrypt(request_internal)?), + }; + + let response = self + .client + .internal + .get_api_configurations() + .await + .api_client + .ciphers_api() + .post_admin(Some(request)) + .await; + + let mut cipher: Cipher = response.unwrap().try_into()?; // TODO: Fix unwrap + cipher.collection_ids = collection_ids; + + Ok(cipher) + } + + // getCiphersOrganization(organizationId) + // ciphers_organization_details_get + pub async fn admin_get_org_details( + &self, + org_id: OrganizationId, + includeMemberItems: bool, + ) -> Result { + let _ = org_id; + todo!() + } + // deleteCipherAdmin(id) + // ciphers_id_admin_delete + pub async fn admin_delete(&self, request: CipherCreateRequest) -> Result { + todo!() + } + // deleteManyCiphersAdmin(request) + // ciphers_admin_delete + pub async fn admin_delete_many( + &self, + request: CipherCreateRequest, + ) -> Result { + todo!() + } + // putCipherCollectionsAdmin(id, request) + // ciphers_id_collections_admin_put + pub async fn admin_update_collection( + &self, + request: CipherCreateRequest, + ) -> Result { + todo!() + } + // putDeleteCipherAdmin(id) + // ciphers_id_delete_admin_put + pub async fn admin_soft_delete( + &self, + request: CipherCreateRequest, + ) -> Result { + todo!() + } + // putDeleteManyCiphersAdmin(request) + // ciphers_delete_admin_put + pub async fn admin_soft_delete_many( + &self, + request: CipherCreateRequest, + ) -> Result { + todo!() + } + // putRestoreCipherAdmin(id) + // ciphers_id_restore_admin_put + pub async fn admin_restore(&self, request: CipherCreateRequest) -> Result { + todo!() + } + // putRestoreManyCiphersAdmin(request) + // ciphers_restore_admin_put + pub async fn admin_restore_many( + &self, + request: CipherCreateRequest, + ) -> Result { + todo!() + } +} diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs b/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs index cf7a97646..37c10470e 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs @@ -99,7 +99,7 @@ impl TryFrom for CipherEditRequest { } impl CipherEditRequest { - fn generate_cipher_key( + pub(crate) fn generate_cipher_key( &mut self, ctx: &mut KeyStoreContext, key: SymmetricKeyId, @@ -319,6 +319,7 @@ async fn edit_cipher + ?Sized>( repository: &R, encrypted_for: UserId, request: CipherEditRequest, + is_admin: bool, ) -> Result { let cipher_id = request.id; @@ -333,19 +334,29 @@ async fn edit_cipher + ?Sized>( let mut cipher_request = key_store.encrypt(request)?; cipher_request.encrypted_for = Some(encrypted_for.into()); - let response = api_client - .ciphers_api() - .put(cipher_id.into(), Some(cipher_request)) - .await - .map_err(ApiError::from)?; - - let cipher: Cipher = response.try_into()?; + let cipher = if is_admin { + api_client + .ciphers_api() + .put_admin(cipher_id.into(), Some(cipher_request)) + .await + .map_err(ApiError::from)? + .try_into() + .unwrap() + } else { + let cipher: Cipher = api_client + .ciphers_api() + .put(cipher_id.into(), Some(cipher_request)) + .await + .map_err(ApiError::from)? + .try_into()?; - debug_assert!(cipher.id.unwrap_or_default() == cipher_id); + debug_assert!(cipher.id.unwrap_or_default() == cipher_id); - repository - .set(cipher_id.to_string(), cipher.clone()) - .await?; + repository + .set(cipher_id.to_string(), cipher.clone()) + .await?; + cipher + }; Ok(key_store.decrypt(&cipher)?) } @@ -353,9 +364,24 @@ async fn edit_cipher + ?Sized>( #[cfg_attr(feature = "wasm", wasm_bindgen)] impl CiphersClient { /// Edit an existing [Cipher] and save it to the server. - pub async fn edit( + pub async fn edit(&self, request: CipherEditRequest) -> Result { + self.edit_internal(request, false).await + } + + // putCipherAdmin(id, request: CipherRequest) + // ciphers_id_admin_put + #[allow(missing_docs)] // TODO: add docs + pub async fn admin_edit( + &self, + request: CipherEditRequest, + ) -> Result { + self.edit_internal(request, true).await + } + + async fn edit_internal( &self, mut request: CipherEditRequest, + is_admin: bool, ) -> Result { let key_store = self.client.internal.get_key_store(); let config = self.client.internal.get_api_configurations().await; @@ -386,6 +412,7 @@ impl CiphersClient { repository.as_ref(), user_id, request, + is_admin, ) .await } @@ -583,6 +610,7 @@ mod tests { &repository, TEST_USER_ID.parse().unwrap(), request, + false, ) .await .unwrap(); @@ -608,6 +636,7 @@ mod tests { &repository, TEST_USER_ID.parse().unwrap(), request, + false, ) .await; @@ -649,6 +678,7 @@ mod tests { &repository, TEST_USER_ID.parse().unwrap(), request, + false, ) .await; diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs b/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs index 5e9546a6a..128acc9fb 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs @@ -18,6 +18,7 @@ use crate::{ cipher::cipher::DecryptCipherListResult, }; +mod admin; mod create; mod edit; mod get; From 3756f3899a33fc09ae80f3d95e498f559c14babc Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Thu, 13 Nov 2025 15:28:08 -0800 Subject: [PATCH 02/38] Add delete operations --- .../src/cipher/cipher_client/delete.rs | 43 +++++++++++++++++++ .../src/cipher/cipher_client/mod.rs | 8 +++- 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 crates/bitwarden-vault/src/cipher/cipher_client/delete.rs diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs new file mode 100644 index 000000000..b3ec36b77 --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs @@ -0,0 +1,43 @@ +use bitwarden_api_api::models::CipherBulkDeleteRequestModel; +use bitwarden_core::OrganizationId; + +use crate::{CipherError, CipherId, CiphersClient}; + +impl CiphersClient { + pub async fn delete(&self, cipher_id: CipherId, as_admin: bool) -> Result<(), CipherError> { + let configs = self.get_api_configurations().await; + let api = configs.api_client.ciphers_api(); + if as_admin { + api.delete_admin(cipher_id.into()).await.unwrap(); // TODO: Map errors properly. + } else { + api.delete(cipher_id.into()).await.unwrap(); + }; + Ok(()) + } + + pub async fn delete_many( + &self, + cipher_ids: Vec, + organization_id: Option, + as_admin: bool, + ) -> Result<(), CipherError> { + let configs = self.get_api_configurations().await; + let api = configs.api_client.ciphers_api(); + if as_admin { + api.delete_many_admin(Some(CipherBulkDeleteRequestModel { + ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), + organization_id: organization_id.map(|id| id.to_string()), + })) + .await + .unwrap(); // TODO: Map errors properly. + } else { + api.delete_many(Some(CipherBulkDeleteRequestModel { + ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), + organization_id: organization_id.map(|id| id.to_string()), + })) + .await + .unwrap(); // TODO: Map errors properly. + }; + Ok(()) + } +} diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs b/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs index 128acc9fb..7c29d8abd 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs @@ -1,6 +1,7 @@ use std::sync::Arc; -use bitwarden_core::{Client, OrganizationId}; +use bitwarden_api_api::apis::{ApiClient, ciphers_api::CiphersApi}; +use bitwarden_core::{Client, OrganizationId, client::ApiConfigurations}; use bitwarden_crypto::IdentifyKey; #[cfg(feature = "wasm")] use bitwarden_crypto::{CompositeEncryptable, SymmetricCryptoKey}; @@ -20,6 +21,7 @@ use crate::{ mod admin; mod create; +mod delete; mod edit; mod get; mod share_cipher; @@ -195,6 +197,10 @@ impl CiphersClient { .state() .get_client_managed::()?) } + + async fn get_api_configurations(&self) -> Arc { + self.client.internal.get_api_configurations().await + } } #[cfg(test)] From 99d1685aeef7fa0a49ac2aef6930f68e424dfb71 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Thu, 13 Nov 2025 15:42:06 -0800 Subject: [PATCH 03/38] Add soft delete (PUT delete) operations --- .../src/cipher/cipher_client/delete.rs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs index b3ec36b77..3fdbc27aa 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs @@ -40,4 +40,45 @@ impl CiphersClient { }; Ok(()) } + + pub async fn soft_delete( + &self, + cipher_id: CipherId, + as_admin: bool, + ) -> Result<(), CipherError> { + let configs = self.get_api_configurations().await; + let api = configs.api_client.ciphers_api(); + if as_admin { + api.put_delete_admin(cipher_id.into()).await.unwrap(); // TODO: Map errors properly. + } else { + api.put_delete(cipher_id.into()).await.unwrap(); + }; + Ok(()) + } + + pub async fn soft_delete_many( + &self, + cipher_ids: Vec, + organization_id: Option, + as_admin: bool, + ) -> Result<(), CipherError> { + let configs = self.get_api_configurations().await; + let api = configs.api_client.ciphers_api(); + if as_admin { + api.put_delete_many_admin(Some(CipherBulkDeleteRequestModel { + ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), + organization_id: organization_id.map(|id| id.to_string()), + })) + .await + .unwrap(); // TODO: Map errors properly. + } else { + api.put_delete_many(Some(CipherBulkDeleteRequestModel { + ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), + organization_id: organization_id.map(|id| id.to_string()), + })) + .await + .unwrap(); // TODO: Map errors properly. + }; + Ok(()) + } } From 0cd1aee907b806674ed7ab71a88c2452bebf4c2d Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Fri, 14 Nov 2025 15:12:36 -0800 Subject: [PATCH 04/38] Add update_collection call for ciphers --- .../src/cipher/cipher_client/admin.rs | 97 +++++++++---------- 1 file changed, 45 insertions(+), 52 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/admin.rs b/crates/bitwarden-vault/src/cipher/cipher_client/admin.rs index 85e2f2f69..0d0907b0c 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/admin.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin.rs @@ -1,14 +1,13 @@ -use bitwarden_api_api::models::{CipherCreateRequestModel, CipherMiniResponseModel}; +use bitwarden_api_api::models::{ + CipherCollectionsRequestModel, CipherCreateRequestModel, CipherDetailsResponseModel, + CipherMiniDetailsResponseModelListResponseModel, CipherMiniResponseModel, +}; use bitwarden_collections::collection::{self, CollectionId}; -use bitwarden_core::{NotAuthenticatedError, OrganizationId}; -use bitwarden_crypto::{CompositeEncryptable, IdentifyKey}; +use bitwarden_crypto::IdentifyKey; use crate::{ - Cipher, CipherError, CiphersClient, - cipher_client::{ - create::{CipherCreateRequest, CipherCreateRequestInternal}, - edit::{CipherEditRequest, EditCipherError}, - }, + Cipher, CipherError, CipherId, CipherView, CiphersClient, cipher, + cipher_client::create::{CipherCreateRequest, CipherCreateRequestInternal}, }; // TS Api Service @@ -58,61 +57,55 @@ impl CiphersClient { let mut cipher: Cipher = response.unwrap().try_into()?; // TODO: Fix unwrap cipher.collection_ids = collection_ids; + // TODO: Decrypt this. Ok(cipher) } - // getCiphersOrganization(organizationId) - // ciphers_organization_details_get - pub async fn admin_get_org_details( - &self, - org_id: OrganizationId, - includeMemberItems: bool, - ) -> Result { - let _ = org_id; - todo!() - } - // deleteCipherAdmin(id) - // ciphers_id_admin_delete - pub async fn admin_delete(&self, request: CipherCreateRequest) -> Result { - todo!() - } - // deleteManyCiphersAdmin(request) - // ciphers_admin_delete - pub async fn admin_delete_many( - &self, - request: CipherCreateRequest, - ) -> Result { - todo!() - } - // putCipherCollectionsAdmin(id, request) // ciphers_id_collections_admin_put pub async fn admin_update_collection( &self, - request: CipherCreateRequest, - ) -> Result { - todo!() - } - // putDeleteCipherAdmin(id) - // ciphers_id_delete_admin_put - pub async fn admin_soft_delete( - &self, - request: CipherCreateRequest, - ) -> Result { - todo!() - } - // putDeleteManyCiphersAdmin(request) - // ciphers_delete_admin_put - pub async fn admin_soft_delete_many( - &self, - request: CipherCreateRequest, - ) -> Result { - todo!() + cipher_id: CipherId, + collection_ids: Vec, + is_admin: bool, + ) -> Result { + let req = CipherCollectionsRequestModel { + collection_ids: collection_ids + .into_iter() + .map(|id| id.to_string()) + .collect(), + }; + + let api_config = self.get_api_configurations().await; + let api = api_config.api_client.ciphers_api(); + let cipher = if is_admin { + api.put_collections_admin(&cipher_id.to_string(), Some(req)) + .await + .unwrap() + .try_into()? + } else { + let response: Cipher = api + .put_collections(cipher_id.into(), Some(req)) + .await + .unwrap() + .try_into()?; // TODO: the uszhe + self.get_repository()? + .set(cipher_id.to_string(), response.clone()) + .await?; + response + }; + + Ok(self.decrypt(cipher)?) } + // putRestoreCipherAdmin(id) // ciphers_id_restore_admin_put - pub async fn admin_restore(&self, request: CipherCreateRequest) -> Result { + pub async fn admin_restore( + &self, + _request: CipherCreateRequest, + ) -> Result { todo!() } + // putRestoreManyCiphersAdmin(request) // ciphers_restore_admin_put pub async fn admin_restore_many( From 9f82e22cabb1f036a2731d0f0dc834ff8ef5f7ce Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Fri, 14 Nov 2025 15:13:32 -0800 Subject: [PATCH 05/38] Add get_ciphers_for_org to CiphersClient --- .../src/cipher/cipher_client/get.rs | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/get.rs b/crates/bitwarden-vault/src/cipher/cipher_client/get.rs index dfe222a02..ac0b1960b 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/get.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/get.rs @@ -1,11 +1,14 @@ -use bitwarden_core::key_management::KeyIds; +use bitwarden_api_api::models::CipherMiniDetailsResponseModelListResponseModel; +use bitwarden_core::{OrganizationId, key_management::KeyIds}; use bitwarden_crypto::{CryptoError, KeyStore}; use bitwarden_error::bitwarden_error; use bitwarden_state::repository::{Repository, RepositoryError}; use thiserror::Error; use super::CiphersClient; -use crate::{Cipher, CipherView, ItemNotFoundError, cipher::cipher::DecryptCipherListResult}; +use crate::{ + Cipher, CipherError, CipherView, ItemNotFoundError, cipher::cipher::DecryptCipherListResult, +}; #[allow(missing_docs)] #[bitwarden_error(flat)] @@ -55,6 +58,29 @@ impl CiphersClient { list_ciphers(key_store, repository.as_ref()).await } + /// Get all ciphers for an organization. Currently returns `Err` if any ciphers fail to + /// deserialize or decrypt + pub async fn list_org_ciphers( + &self, + org_id: OrganizationId, + include_member_items: bool, + ) -> Result { + let configs = self.get_api_configurations().await; + let api = configs.api_client.ciphers_api(); + let response: CipherMiniDetailsResponseModelListResponseModel = api + .get_organization_ciphers(Some(org_id.into()), Some(include_member_items)) + .await + .unwrap(); // TODO: Remove unwrap + let ciphers = response + .data + .into_iter() + .flatten() + .map(TryInto::::try_into) + .collect::, _>>()?; + + Ok(self.decrypt_list_with_failures(ciphers)) + } + /// Get [Cipher] by ID from state and decrypt it to a [CipherView]. pub async fn get(&self, cipher_id: &str) -> Result { let key_store = self.client.internal.get_key_store(); From f89f43d8b9779d9668cca312b44f491c6b7ad687 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Fri, 14 Nov 2025 16:50:29 -0800 Subject: [PATCH 06/38] Add admin endpoints for update and restore --- crates/bitwarden-vault/src/cipher/cipher.rs | 135 +++++++++++++++++- .../src/cipher/cipher_client/admin.rs | 84 +++++++---- .../src/cipher/cipher_client/create.rs | 4 +- 3 files changed, 196 insertions(+), 27 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher.rs b/crates/bitwarden-vault/src/cipher/cipher.rs index 9b6854f76..f795360bc 100644 --- a/crates/bitwarden-vault/src/cipher/cipher.rs +++ b/crates/bitwarden-vault/src/cipher/cipher.rs @@ -1,7 +1,8 @@ use bitwarden_api_api::{ apis::ciphers_api::{PutShareError, PutShareManyError}, models::{ - CipherDetailsResponseModel, CipherRequestModel, CipherResponseModel, + CipherDetailsResponseModel, CipherMiniDetailsResponseModel, CipherMiniResponseModel, + CipherMiniResponseModelListResponseModel, CipherRequestModel, CipherResponseModel, CipherWithIdRequestModel, }, }; @@ -2114,3 +2115,135 @@ mod tests { assert!(matches!(result, Err(VaultParseError::SerdeJson(_)))); } } + +impl TryFrom for Cipher { + type Error = CipherError; + fn try_from(cipher_mini: CipherMiniResponseModel) -> Result { + Ok(Cipher { + id: cipher_mini.id.map(CipherId::new), + organization_id: cipher_mini.organization_id.map(OrganizationId::new), + key: EncString::try_from_optional(cipher_mini.key)?, + name: require!(EncString::try_from_optional(cipher_mini.name)?), + notes: EncString::try_from_optional(cipher_mini.notes)?, + r#type: require!(cipher_mini.r#type).into(), + login: cipher_mini.login.map(|l| (*l).try_into()).transpose()?, + identity: cipher_mini.identity.map(|i| (*i).try_into()).transpose()?, + card: cipher_mini.card.map(|c| (*c).try_into()).transpose()?, + secure_note: cipher_mini + .secure_note + .map(|s| (*s).try_into()) + .transpose()?, + ssh_key: cipher_mini.ssh_key.map(|s| (*s).try_into()).transpose()?, + reprompt: cipher_mini + .reprompt + .map(|r| r.into()) + .unwrap_or(CipherRepromptType::None), + organization_use_totp: cipher_mini.organization_use_totp.unwrap_or(true), + attachments: cipher_mini + .attachments + .map(|a| a.into_iter().map(|a| a.try_into()).collect()) + .transpose()?, + fields: cipher_mini + .fields + .map(|f| f.into_iter().map(|f| f.try_into()).collect()) + .transpose()?, + password_history: cipher_mini + .password_history + .map(|p| p.into_iter().map(|p| p.try_into()).collect()) + .transpose()?, + creation_date: require!(cipher_mini.creation_date) + .parse() + .map_err(Into::::into)?, + deleted_date: cipher_mini + .deleted_date + .map(|d| d.parse()) + .transpose() + .map_err(Into::::into)?, + revision_date: require!(cipher_mini.revision_date) + .parse() + .map_err(Into::::into)?, + archived_date: cipher_mini + .archived_date + .map(|d| d.parse()) + .transpose() + .map_err(Into::::into)?, + edit: Default::default(), + favorite: Default::default(), + folder_id: Default::default(), + permissions: Default::default(), + view_password: Default::default(), + local_data: Default::default(), + collection_ids: Default::default(), + data: None, + }) + } +} + +impl TryFrom for Cipher { + type Error = CipherError; + + fn try_from(cipher_mini: CipherMiniDetailsResponseModel) -> Result { + Ok(Cipher { + id: cipher_mini.id.map(CipherId::new), + organization_id: cipher_mini.organization_id.map(OrganizationId::new), + key: EncString::try_from_optional(cipher_mini.key)?, + name: require!(EncString::try_from_optional(cipher_mini.name)?), + notes: EncString::try_from_optional(cipher_mini.notes)?, + r#type: require!(cipher_mini.r#type).into(), + login: cipher_mini.login.map(|l| (*l).try_into()).transpose()?, + identity: cipher_mini.identity.map(|i| (*i).try_into()).transpose()?, + card: cipher_mini.card.map(|c| (*c).try_into()).transpose()?, + secure_note: cipher_mini + .secure_note + .map(|s| (*s).try_into()) + .transpose()?, + ssh_key: cipher_mini.ssh_key.map(|s| (*s).try_into()).transpose()?, + reprompt: cipher_mini + .reprompt + .map(|r| r.into()) + .unwrap_or(CipherRepromptType::None), + organization_use_totp: cipher_mini.organization_use_totp.unwrap_or(true), + attachments: cipher_mini + .attachments + .map(|a| a.into_iter().map(|a| a.try_into()).collect()) + .transpose()?, + fields: cipher_mini + .fields + .map(|f| f.into_iter().map(|f| f.try_into()).collect()) + .transpose()?, + password_history: cipher_mini + .password_history + .map(|p| p.into_iter().map(|p| p.try_into()).collect()) + .transpose()?, + creation_date: require!(cipher_mini.creation_date) + .parse() + .map_err(Into::::into)?, + deleted_date: cipher_mini + .deleted_date + .map(|d| d.parse()) + .transpose() + .map_err(Into::::into)?, + revision_date: require!(cipher_mini.revision_date) + .parse() + .map_err(Into::::into)?, + archived_date: cipher_mini + .archived_date + .map(|d| d.parse()) + .transpose() + .map_err(Into::::into)?, + collection_ids: cipher_mini + .collection_ids + .into_iter() + .flatten() + .map(CollectionId::new) + .collect(), + data: None, + folder_id: Default::default(), + favorite: Default::default(), + edit: Default::default(), + permissions: Default::default(), + view_password: Default::default(), + local_data: Default::default(), + }) + } +} diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/admin.rs b/crates/bitwarden-vault/src/cipher/cipher_client/admin.rs index 0d0907b0c..e83b22b36 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/admin.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin.rs @@ -1,27 +1,25 @@ use bitwarden_api_api::models::{ - CipherCollectionsRequestModel, CipherCreateRequestModel, CipherDetailsResponseModel, - CipherMiniDetailsResponseModelListResponseModel, CipherMiniResponseModel, + CipherBulkRestoreRequestModel, CipherCollectionsRequestModel, CipherCreateRequestModel, + CipherMiniResponseModelListResponseModel, }; -use bitwarden_collections::collection::{self, CollectionId}; -use bitwarden_crypto::IdentifyKey; +use bitwarden_collections::collection::CollectionId; +use bitwarden_core::OrganizationId; +use bitwarden_crypto::{Decryptable, IdentifyKey}; use crate::{ - Cipher, CipherError, CipherId, CipherView, CiphersClient, cipher, + Cipher, CipherError, CipherId, CipherView, CiphersClient, DecryptCipherListResult, cipher_client::create::{CipherCreateRequest, CipherCreateRequestInternal}, }; -// TS Api Service -// SDK -// postCipherAdmin(request: CipherCreateRequest) #[allow(missing_docs)] // TODO: remove impl CiphersClient { - // TODO / QUESTION - do we want to make this a separate operation? Or just add it to the existing `create` detail? + // TODO Add it to the existing `create` detail - doesn't need a separate impl to just acll a different endpoint. // ciphers_admin_post pub async fn admin_create( &self, request: CipherCreateRequest, collection_ids: Vec, - ) -> Result { + ) -> Result { // let api_req = request let mut request_internal: CipherCreateRequestInternal = request.into(); let key_store = self.client.internal.get_key_store(); @@ -57,12 +55,11 @@ impl CiphersClient { let mut cipher: Cipher = response.unwrap().try_into()?; // TODO: Fix unwrap cipher.collection_ids = collection_ids; - // TODO: Decrypt this. - Ok(cipher) + Ok(self.decrypt(cipher)?) } // ciphers_id_collections_admin_put - pub async fn admin_update_collection( + pub async fn update_collection( &self, cipher_id: CipherId, collection_ids: Vec, @@ -97,21 +94,60 @@ impl CiphersClient { Ok(self.decrypt(cipher)?) } - // putRestoreCipherAdmin(id) - // ciphers_id_restore_admin_put - pub async fn admin_restore( + pub async fn restore( &self, - _request: CipherCreateRequest, - ) -> Result { - todo!() + cipher_id: CipherId, + is_admin: bool, + ) -> Result { + let api_config = self.get_api_configurations().await; + let api = api_config.api_client.ciphers_api(); + + let cipher: Cipher = if is_admin { + let response = api.put_restore_admin(cipher_id.into()).await.unwrap(); + response.try_into()? + } else { + let response = api.put_restore(cipher_id.into()).await.unwrap(); + let cipher: Cipher = response.try_into()?; + + cipher + }; + + Ok(self.decrypt(cipher)?) } - // putRestoreManyCiphersAdmin(request) - // ciphers_restore_admin_put pub async fn admin_restore_many( &self, - request: CipherCreateRequest, - ) -> Result { - todo!() + cipher_ids: Vec, + org_id: Option, + ) -> Result { + let api_config = self.get_api_configurations().await; + let api = api_config.api_client.ciphers_api(); + + let ciphers: Vec = if let Some(org_id) = org_id { + api.put_restore_many_admin(Some(CipherBulkRestoreRequestModel { + ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), + organization_id: Some(org_id.into()), + })) + .await + .unwrap() // TODO - handle error + .data + .into_iter() + .flatten() + .map(|c| c.try_into()) + .collect::, _>>()? + } else { + api.put_restore_many(Some(CipherBulkRestoreRequestModel { + ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), + organization_id: None, + })) + .await + .unwrap() // TODO - handle error + .data + .into_iter() + .flatten() + .map(|c| c.try_into()) + .collect::, _>>()? + }; + Ok(self.decrypt_list_with_failures(ciphers)) } } diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/create.rs b/crates/bitwarden-vault/src/cipher/cipher_client/create.rs index 6f6f71d73..ecbdab1f5 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/create.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/create.rs @@ -60,7 +60,7 @@ pub struct CipherCreateRequest { /// Used as an intermediary between the public-facing [CipherCreateRequest], and the encrypted /// value. This allows us to manage the cipher key creation internally. #[derive(Clone, Debug)] -struct CipherCreateRequestInternal { +pub(crate) struct CipherCreateRequestInternal { create_request: CipherCreateRequest, key: Option, } @@ -77,7 +77,7 @@ impl From for CipherCreateRequestInternal { impl CipherCreateRequestInternal { /// Generate a new key for the cipher, re-encrypting internal data, if necessary, and stores the /// encrypted key to the cipher data. - fn generate_cipher_key( + pub(crate) fn generate_cipher_key( &mut self, ctx: &mut KeyStoreContext, key: SymmetricKeyId, From fe95edd28a7ed3de762c8bc9fda89024c2153abc Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Tue, 18 Nov 2025 16:22:32 -0800 Subject: [PATCH 07/38] Add CipherError::Api(ApiError) variant --- crates/bitwarden-vault/src/cipher/cipher.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher.rs b/crates/bitwarden-vault/src/cipher/cipher.rs index f795360bc..d2afdbd31 100644 --- a/crates/bitwarden-vault/src/cipher/cipher.rs +++ b/crates/bitwarden-vault/src/cipher/cipher.rs @@ -8,7 +8,7 @@ use bitwarden_api_api::{ }; use bitwarden_collections::collection::CollectionId; use bitwarden_core::{ - MissingFieldError, OrganizationId, UserId, + ApiError, MissingFieldError, OrganizationId, UserId, key_management::{KeyIds, SymmetricKeyId}, require, }; @@ -64,15 +64,19 @@ pub enum CipherError { #[error("This cipher cannot be moved to the specified organization")] OrganizationAlreadySet, #[error(transparent)] - PutShare(#[from] bitwarden_api_api::apis::Error), - #[error(transparent)] - PutShareMany(#[from] bitwarden_api_api::apis::Error), - #[error(transparent)] Repository(#[from] RepositoryError), #[error(transparent)] Chrono(#[from] chrono::ParseError), #[error(transparent)] SerdeJson(#[from] serde_json::Error), + #[error(transparent)] + Api(#[from] ApiError), +} + +impl From> for CipherError { + fn from(value: bitwarden_api_api::apis::Error) -> Self { + Self::Api(value.into()) + } } /// Helper trait for operations on cipher types. From bd13498741163217f4e382c31d376f819196e5fd Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Tue, 18 Nov 2025 16:22:55 -0800 Subject: [PATCH 08/38] Clean up error handling --- .../src/cipher/cipher_client/admin.rs | 40 +++++++++---------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/admin.rs b/crates/bitwarden-vault/src/cipher/cipher_client/admin.rs index e83b22b36..7aa970c9d 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/admin.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin.rs @@ -1,13 +1,16 @@ -use bitwarden_api_api::models::{ - CipherBulkRestoreRequestModel, CipherCollectionsRequestModel, CipherCreateRequestModel, - CipherMiniResponseModelListResponseModel, +use bitwarden_api_api::{ + apis::ciphers_api::PostAdminError, + models::{ + CipherBulkRestoreRequestModel, CipherCollectionsRequestModel, CipherCreateRequestModel, + CipherMiniResponseModel, CipherMiniResponseModelListResponseModel, + }, }; use bitwarden_collections::collection::CollectionId; -use bitwarden_core::OrganizationId; +use bitwarden_core::{ApiError, OrganizationId}; use bitwarden_crypto::{Decryptable, IdentifyKey}; use crate::{ - Cipher, CipherError, CipherId, CipherView, CiphersClient, DecryptCipherListResult, + Cipher, CipherError, CipherId, CipherView, CiphersClient, DecryptCipherListResult, cipher, cipher_client::create::{CipherCreateRequest, CipherCreateRequestInternal}, }; @@ -42,7 +45,7 @@ impl CiphersClient { cipher: Box::new(key_store.encrypt(request_internal)?), }; - let response = self + let cipher: Cipher = self .client .internal .get_api_configurations() @@ -50,12 +53,11 @@ impl CiphersClient { .api_client .ciphers_api() .post_admin(Some(request)) - .await; + .await? + .try_into()?; + let cipher_view = self.decrypt(cipher)?; - let mut cipher: Cipher = response.unwrap().try_into()?; // TODO: Fix unwrap - cipher.collection_ids = collection_ids; - - Ok(self.decrypt(cipher)?) + Ok(cipher_view) } // ciphers_id_collections_admin_put @@ -76,14 +78,12 @@ impl CiphersClient { let api = api_config.api_client.ciphers_api(); let cipher = if is_admin { api.put_collections_admin(&cipher_id.to_string(), Some(req)) - .await - .unwrap() + .await? .try_into()? } else { let response: Cipher = api .put_collections(cipher_id.into(), Some(req)) - .await - .unwrap() + .await? .try_into()?; // TODO: the uszhe self.get_repository()? .set(cipher_id.to_string(), response.clone()) @@ -103,10 +103,10 @@ impl CiphersClient { let api = api_config.api_client.ciphers_api(); let cipher: Cipher = if is_admin { - let response = api.put_restore_admin(cipher_id.into()).await.unwrap(); + let response = api.put_restore_admin(cipher_id.into()).await?; response.try_into()? } else { - let response = api.put_restore(cipher_id.into()).await.unwrap(); + let response = api.put_restore(cipher_id.into()).await?; let cipher: Cipher = response.try_into()?; cipher @@ -128,8 +128,7 @@ impl CiphersClient { ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), organization_id: Some(org_id.into()), })) - .await - .unwrap() // TODO - handle error + .await? .data .into_iter() .flatten() @@ -140,8 +139,7 @@ impl CiphersClient { ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), organization_id: None, })) - .await - .unwrap() // TODO - handle error + .await? .data .into_iter() .flatten() From c173c98253c013e60fe8b2145b20be70d8b4a53c Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Fri, 21 Nov 2025 15:08:58 -0800 Subject: [PATCH 09/38] index on vault/pm-25821/cipher-admin-ops: bd134987 Clean up error handling --- crates/bitwarden-vault/src/cipher/cipher.rs | 26 +-- .../src/cipher/cipher_client/admin.rs | 151 ------------------ .../src/cipher/cipher_client/create.rs | 99 ++++++++++-- .../src/cipher/cipher_client/edit.rs | 63 +++++++- .../src/cipher/cipher_client/get.rs | 21 ++- .../src/cipher/cipher_client/mod.rs | 2 +- .../src/cipher/cipher_client/restore.rs | 61 +++++++ 7 files changed, 240 insertions(+), 183 deletions(-) delete mode 100644 crates/bitwarden-vault/src/cipher/cipher_client/admin.rs create mode 100644 crates/bitwarden-vault/src/cipher/cipher_client/restore.rs diff --git a/crates/bitwarden-vault/src/cipher/cipher.rs b/crates/bitwarden-vault/src/cipher/cipher.rs index d2afdbd31..6c45e5deb 100644 --- a/crates/bitwarden-vault/src/cipher/cipher.rs +++ b/crates/bitwarden-vault/src/cipher/cipher.rs @@ -1,10 +1,6 @@ -use bitwarden_api_api::{ - apis::ciphers_api::{PutShareError, PutShareManyError}, - models::{ - CipherDetailsResponseModel, CipherMiniDetailsResponseModel, CipherMiniResponseModel, - CipherMiniResponseModelListResponseModel, CipherRequestModel, CipherResponseModel, - CipherWithIdRequestModel, - }, +use bitwarden_api_api::models::{ + CipherDetailsResponseModel, CipherMiniDetailsResponseModel, CipherMiniResponseModel, + CipherRequestModel, CipherResponseModel, CipherWithIdRequestModel, }; use bitwarden_collections::collection::CollectionId; use bitwarden_core::{ @@ -2121,7 +2117,7 @@ mod tests { } impl TryFrom for Cipher { - type Error = CipherError; + type Error = VaultParseError; fn try_from(cipher_mini: CipherMiniResponseModel) -> Result { Ok(Cipher { id: cipher_mini.id.map(CipherId::new), @@ -2183,8 +2179,20 @@ impl TryFrom for Cipher { } } +#[allow(missing_docs)] +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum IntoCipherError { + #[error(transparent)] + Crypto(#[from] CryptoError), + #[error(transparent)] + VaultParse(#[from] VaultParseError), + #[error(transparent)] + MissingField(#[from] MissingFieldError), +} + impl TryFrom for Cipher { - type Error = CipherError; + type Error = IntoCipherError; fn try_from(cipher_mini: CipherMiniDetailsResponseModel) -> Result { Ok(Cipher { diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/admin.rs b/crates/bitwarden-vault/src/cipher/cipher_client/admin.rs deleted file mode 100644 index 7aa970c9d..000000000 --- a/crates/bitwarden-vault/src/cipher/cipher_client/admin.rs +++ /dev/null @@ -1,151 +0,0 @@ -use bitwarden_api_api::{ - apis::ciphers_api::PostAdminError, - models::{ - CipherBulkRestoreRequestModel, CipherCollectionsRequestModel, CipherCreateRequestModel, - CipherMiniResponseModel, CipherMiniResponseModelListResponseModel, - }, -}; -use bitwarden_collections::collection::CollectionId; -use bitwarden_core::{ApiError, OrganizationId}; -use bitwarden_crypto::{Decryptable, IdentifyKey}; - -use crate::{ - Cipher, CipherError, CipherId, CipherView, CiphersClient, DecryptCipherListResult, cipher, - cipher_client::create::{CipherCreateRequest, CipherCreateRequestInternal}, -}; - -#[allow(missing_docs)] // TODO: remove -impl CiphersClient { - // TODO Add it to the existing `create` detail - doesn't need a separate impl to just acll a different endpoint. - // ciphers_admin_post - pub async fn admin_create( - &self, - request: CipherCreateRequest, - collection_ids: Vec, - ) -> Result { - // let api_req = request - let mut request_internal: CipherCreateRequestInternal = request.into(); - let key_store = self.client.internal.get_key_store(); - - // TODO: move this to CipherCreateRequestInternal::CompositeEncryptable implementation - // once the feature flag is removed. - if self - .client - .internal - .get_flags() - .enable_cipher_key_encryption - { - let key = request_internal.key_identifier(); - request_internal - .generate_cipher_key(&mut self.client.internal.get_key_store().context(), key)?; - } - - let request = CipherCreateRequestModel { - collection_ids: Some(collection_ids.clone().into_iter().map(Into::into).collect()), - cipher: Box::new(key_store.encrypt(request_internal)?), - }; - - let cipher: Cipher = self - .client - .internal - .get_api_configurations() - .await - .api_client - .ciphers_api() - .post_admin(Some(request)) - .await? - .try_into()?; - let cipher_view = self.decrypt(cipher)?; - - Ok(cipher_view) - } - - // ciphers_id_collections_admin_put - pub async fn update_collection( - &self, - cipher_id: CipherId, - collection_ids: Vec, - is_admin: bool, - ) -> Result { - let req = CipherCollectionsRequestModel { - collection_ids: collection_ids - .into_iter() - .map(|id| id.to_string()) - .collect(), - }; - - let api_config = self.get_api_configurations().await; - let api = api_config.api_client.ciphers_api(); - let cipher = if is_admin { - api.put_collections_admin(&cipher_id.to_string(), Some(req)) - .await? - .try_into()? - } else { - let response: Cipher = api - .put_collections(cipher_id.into(), Some(req)) - .await? - .try_into()?; // TODO: the uszhe - self.get_repository()? - .set(cipher_id.to_string(), response.clone()) - .await?; - response - }; - - Ok(self.decrypt(cipher)?) - } - - pub async fn restore( - &self, - cipher_id: CipherId, - is_admin: bool, - ) -> Result { - let api_config = self.get_api_configurations().await; - let api = api_config.api_client.ciphers_api(); - - let cipher: Cipher = if is_admin { - let response = api.put_restore_admin(cipher_id.into()).await?; - response.try_into()? - } else { - let response = api.put_restore(cipher_id.into()).await?; - let cipher: Cipher = response.try_into()?; - - cipher - }; - - Ok(self.decrypt(cipher)?) - } - - pub async fn admin_restore_many( - &self, - cipher_ids: Vec, - org_id: Option, - ) -> Result { - let api_config = self.get_api_configurations().await; - let api = api_config.api_client.ciphers_api(); - - let ciphers: Vec = if let Some(org_id) = org_id { - api.put_restore_many_admin(Some(CipherBulkRestoreRequestModel { - ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), - organization_id: Some(org_id.into()), - })) - .await? - .data - .into_iter() - .flatten() - .map(|c| c.try_into()) - .collect::, _>>()? - } else { - api.put_restore_many(Some(CipherBulkRestoreRequestModel { - ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), - organization_id: None, - })) - .await? - .data - .into_iter() - .flatten() - .map(|c| c.try_into()) - .collect::, _>>()? - }; - Ok(self.decrypt_list_with_failures(ciphers)) - } -} diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/create.rs b/crates/bitwarden-vault/src/cipher/cipher_client/create.rs index ecbdab1f5..cb61d714b 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/create.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/create.rs @@ -1,4 +1,5 @@ -use bitwarden_api_api::models::CipherRequestModel; +use bitwarden_api_api::models::{CipherCreateRequestModel, CipherRequestModel}; +use bitwarden_collections::collection::CollectionId; use bitwarden_core::{ ApiError, MissingFieldError, NotAuthenticatedError, OrganizationId, UserId, key_management::{KeyIds, SymmetricKeyId}, @@ -20,7 +21,7 @@ use wasm_bindgen::prelude::*; use super::CiphersClient; use crate::{ Cipher, CipherRepromptType, CipherView, FieldView, FolderId, VaultParseError, - cipher_view_type::CipherViewType, + cipher::cipher::IntoCipherError, cipher_view_type::CipherViewType, }; #[allow(missing_docs)] @@ -41,6 +42,22 @@ pub enum CreateCipherError { Repository(#[from] RepositoryError), } +impl From for CreateCipherError { + fn from(value: IntoCipherError) -> Self { + match value { + IntoCipherError::Crypto(e) => Self::Crypto(e), + IntoCipherError::VaultParse(e) => Self::VaultParse(e), + IntoCipherError::MissingField(e) => Self::MissingField(e), + } + } +} + +impl From> for CreateCipherError { + fn from(val: bitwarden_api_api::apis::Error) -> Self { + Self::Api(val.into()) + } +} + /// Request to add a cipher. #[derive(Serialize, Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase")] @@ -218,28 +235,57 @@ async fn create_cipher + ?Sized>( repository: &R, encrypted_for: UserId, request: CipherCreateRequestInternal, + collection_ids: Vec, + as_admin: bool, ) -> Result { let mut cipher_request = key_store.encrypt(request)?; cipher_request.encrypted_for = Some(encrypted_for.into()); - let resp = api_client - .ciphers_api() - .post(Some(cipher_request)) - .await - .map_err(ApiError::from)?; - let cipher: Cipher = resp.try_into()?; - repository - .set(require!(cipher.id).to_string(), cipher.clone()) - .await?; + let cipher: Cipher; + if as_admin && cipher_request.organization_id.is_some() { + cipher = api_client + .ciphers_api() + .post_admin(Some(CipherCreateRequestModel { + collection_ids: Some(collection_ids.into_iter().map(Into::into).collect()), + cipher: Box::new(cipher_request), + })) + .await? + .try_into()?; + } else if !collection_ids.is_empty() { + cipher = api_client + .ciphers_api() + .post_create(Some(CipherCreateRequestModel { + collection_ids: Some(collection_ids.into_iter().map(Into::into).collect()), + cipher: Box::new(cipher_request), + })) + .await + .map_err(ApiError::from)? + .try_into()?; + repository + .set(require!(cipher.id).to_string(), cipher.clone()) + .await?; + } else { + cipher = api_client + .ciphers_api() + .post(Some(cipher_request)) + .await + .map_err(ApiError::from)? + .try_into()?; + repository + .set(require!(cipher.id).to_string(), cipher.clone()) + .await?; + } + Ok(key_store.decrypt(&cipher)?) } #[cfg_attr(feature = "wasm", wasm_bindgen)] impl CiphersClient { - /// Create a new [Cipher] and save it to the server. - pub async fn create( + async fn create_cipher( &self, request: CipherCreateRequest, + collection_ids: Vec, + as_admin: bool, ) -> Result { let key_store = self.client.internal.get_key_store(); let config = self.client.internal.get_api_configurations().await; @@ -264,14 +310,35 @@ impl CiphersClient { internal_request.generate_cipher_key(&mut key_store.context(), key)?; } - create_cipher( + Ok(create_cipher( key_store, &config.api_client, repository.as_ref(), user_id, internal_request, + collection_ids, + as_admin, ) .await + .unwrap()) + } + + /// Creates a new [Cipher] and save it to the server. + pub async fn create( + &self, + request: CipherCreateRequest, + collection_ids: Vec, + ) -> Result { + self.create_cipher(request, collection_ids, false).await + } + + /// Creates a new [Cipher] for an organization, using the admin server endpoints endpoints. + pub async fn create_as_admin( + &self, + request: CipherCreateRequest, + collection_ids: Vec, + ) -> Result { + self.create_cipher(request, collection_ids, false).await } } @@ -372,6 +439,8 @@ mod tests { &repository, TEST_USER_ID.parse().unwrap(), request.into(), + vec![], + false, ) .await .unwrap(); @@ -435,6 +504,8 @@ mod tests { &repository, TEST_USER_ID.parse().unwrap(), request.into(), + vec![], + false, ) .await; diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs b/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs index 37c10470e..56b8715db 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs @@ -1,4 +1,5 @@ -use bitwarden_api_api::models::CipherRequestModel; +use bitwarden_api_api::models::{CipherCollectionsRequestModel, CipherRequestModel}; +use bitwarden_collections::collection::CollectionId; use bitwarden_core::{ ApiError, MissingFieldError, NotAuthenticatedError, OrganizationId, UserId, key_management::{KeyIds, SymmetricKeyId}, @@ -20,9 +21,10 @@ use wasm_bindgen::prelude::*; use super::CiphersClient; use crate::{ - AttachmentView, Cipher, CipherId, CipherRepromptType, CipherType, CipherView, FieldView, - FolderId, ItemNotFoundError, PasswordHistoryView, VaultParseError, - cipher_view_type::CipherViewType, password_history::MAX_PASSWORD_HISTORY_ENTRIES, + AttachmentView, Cipher, CipherId, CipherRepromptType, CipherType, CipherView, DecryptError, + FieldView, FolderId, ItemNotFoundError, PasswordHistoryView, VaultParseError, + cipher::cipher::IntoCipherError, cipher_view_type::CipherViewType, + password_history::MAX_PASSWORD_HISTORY_ENTRIES, }; #[allow(missing_docs)] @@ -45,6 +47,24 @@ pub enum EditCipherError { Repository(#[from] RepositoryError), #[error(transparent)] Uuid(#[from] uuid::Error), + #[error(transparent)] + Decrypt(#[from] DecryptError), +} + +impl From for EditCipherError { + fn from(value: IntoCipherError) -> Self { + match value { + IntoCipherError::Crypto(e) => Self::Crypto(e), + IntoCipherError::VaultParse(e) => Self::VaultParse(e), + IntoCipherError::MissingField(e) => Self::MissingField(e), + } + } +} + +impl From> for EditCipherError { + fn from(val: bitwarden_api_api::apis::Error) -> Self { + Self::Api(val.into()) + } } /// Request to edit a cipher. @@ -371,7 +391,7 @@ impl CiphersClient { // putCipherAdmin(id, request: CipherRequest) // ciphers_id_admin_put #[allow(missing_docs)] // TODO: add docs - pub async fn admin_edit( + pub async fn edit_as_admin( &self, request: CipherEditRequest, ) -> Result { @@ -416,6 +436,39 @@ impl CiphersClient { ) .await } + + pub async fn update_collection( + &self, + cipher_id: CipherId, + collection_ids: Vec, + is_admin: bool, + ) -> Result { + let req = CipherCollectionsRequestModel { + collection_ids: collection_ids + .into_iter() + .map(|id| id.to_string()) + .collect(), + }; + + let api_config = self.get_api_configurations().await; + let api = api_config.api_client.ciphers_api(); + let cipher = if is_admin { + api.put_collections_admin(&cipher_id.to_string(), Some(req)) + .await? + .try_into()? + } else { + let response: Cipher = api + .put_collections(cipher_id.into(), Some(req)) + .await? + .try_into()?; + self.get_repository()? + .set(cipher_id.to_string(), response.clone()) + .await?; + response + }; + + Ok(self.decrypt(cipher)?) + } } #[cfg(test)] diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/get.rs b/crates/bitwarden-vault/src/cipher/cipher_client/get.rs index ac0b1960b..8f11f2ec0 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/get.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/get.rs @@ -1,5 +1,5 @@ use bitwarden_api_api::models::CipherMiniDetailsResponseModelListResponseModel; -use bitwarden_core::{OrganizationId, key_management::KeyIds}; +use bitwarden_core::{MissingFieldError, OrganizationId, key_management::KeyIds}; use bitwarden_crypto::{CryptoError, KeyStore}; use bitwarden_error::bitwarden_error; use bitwarden_state::repository::{Repository, RepositoryError}; @@ -7,7 +7,8 @@ use thiserror::Error; use super::CiphersClient; use crate::{ - Cipher, CipherError, CipherView, ItemNotFoundError, cipher::cipher::DecryptCipherListResult, + Cipher, CipherError, CipherView, ItemNotFoundError, VaultParseError, + cipher::cipher::{DecryptCipherListResult, IntoCipherError}, }; #[allow(missing_docs)] @@ -19,9 +20,23 @@ pub enum GetCipherError { #[error(transparent)] Crypto(#[from] CryptoError), #[error(transparent)] + VaultParse(#[from] VaultParseError), + #[error(transparent)] + MissingField(#[from] MissingFieldError), + #[error(transparent)] RepositoryError(#[from] RepositoryError), } +impl From for GetCipherError { + fn from(value: IntoCipherError) -> Self { + match value { + IntoCipherError::Crypto(e) => e.into(), + IntoCipherError::VaultParse(e) => e.into(), + IntoCipherError::MissingField(e) => e.into(), + } + } +} + async fn get_cipher( store: &KeyStore, repository: &dyn Repository, @@ -64,7 +79,7 @@ impl CiphersClient { &self, org_id: OrganizationId, include_member_items: bool, - ) -> Result { + ) -> Result { let configs = self.get_api_configurations().await; let api = configs.api_client.ciphers_api(); let response: CipherMiniDetailsResponseModelListResponseModel = api diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs b/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs index 7c29d8abd..995093fb7 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs @@ -19,11 +19,11 @@ use crate::{ cipher::cipher::DecryptCipherListResult, }; -mod admin; mod create; mod delete; mod edit; mod get; +mod restore; mod share_cipher; #[allow(missing_docs)] diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs b/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs new file mode 100644 index 000000000..c8ef0114f --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs @@ -0,0 +1,61 @@ +use bitwarden_api_api::models::CipherBulkRestoreRequestModel; +use bitwarden_core::OrganizationId; + +use crate::{Cipher, CipherError, CipherId, CipherView, CiphersClient, DecryptCipherListResult}; + +impl CiphersClient { + pub async fn restore( + &self, + cipher_id: CipherId, + is_admin: bool, + ) -> Result { + let api_config = self.get_api_configurations().await; + let api = api_config.api_client.ciphers_api(); + + let cipher: Cipher = if is_admin { + let response = api.put_restore_admin(cipher_id.into()).await?; + response.try_into()? + } else { + let response = api.put_restore(cipher_id.into()).await?; + let cipher: Cipher = response.try_into()?; + + cipher + }; + + Ok(self.decrypt(cipher)?) + } + + pub async fn restore_many( + &self, + cipher_ids: Vec, + org_id: Option, + ) -> Result { + let api_config = self.get_api_configurations().await; + let api = api_config.api_client.ciphers_api(); + + let ciphers: Vec = if let Some(org_id) = org_id { + api.put_restore_many_admin(Some(CipherBulkRestoreRequestModel { + ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), + organization_id: Some(org_id.into()), + })) + .await? + .data + .into_iter() + .flatten() + .map(|c| c.try_into()) + .collect::, _>>()? + } else { + api.put_restore_many(Some(CipherBulkRestoreRequestModel { + ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), + organization_id: None, + })) + .await? + .data + .into_iter() + .flatten() + .map(|c| c.try_into()) + .collect::, _>>()? + }; + Ok(self.decrypt_list_with_failures(ciphers)) + } +} From 079cb89ce60b3d22baa1f5d2cf5b092163790607 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Fri, 21 Nov 2025 15:57:08 -0800 Subject: [PATCH 10/38] Consolidate delete & restore operations --- .../src/cipher/cipher_client/delete.rs | 122 ++++++++++++++---- .../src/cipher/cipher_client/mod.rs | 2 - .../src/cipher/cipher_client/restore.rs | 61 --------- 3 files changed, 99 insertions(+), 86 deletions(-) delete mode 100644 crates/bitwarden-vault/src/cipher/cipher_client/restore.rs diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs index 3fdbc27aa..7243b38dd 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs @@ -1,26 +1,51 @@ -use bitwarden_api_api::models::CipherBulkDeleteRequestModel; -use bitwarden_core::OrganizationId; +use bitwarden_api_api::models::{CipherBulkDeleteRequestModel, CipherBulkRestoreRequestModel}; +use bitwarden_core::{ApiError, OrganizationId}; +use bitwarden_error::bitwarden_error; +use thiserror::Error; -use crate::{CipherError, CipherId, CiphersClient}; +use crate::{ + Cipher, CipherId, CipherView, CiphersClient, DecryptCipherListResult, DecryptError, + VaultParseError, +}; + +#[allow(missing_docs)] +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum RestoreCipherError { + #[error(transparent)] + Api(#[from] ApiError), + #[error(transparent)] + VaultParse(#[from] VaultParseError), + #[error(transparent)] + Decrypt(#[from] DecryptError), +} + +impl From> for RestoreCipherError { + fn from(val: bitwarden_api_api::apis::Error) -> Self { + Self::Api(val.into()) + } +} impl CiphersClient { - pub async fn delete(&self, cipher_id: CipherId, as_admin: bool) -> Result<(), CipherError> { + /// Deletes the [Cipher] with the matching [CipherId] from the server. + pub async fn delete(&self, cipher_id: CipherId, as_admin: bool) -> Result<(), ApiError> { let configs = self.get_api_configurations().await; let api = configs.api_client.ciphers_api(); if as_admin { - api.delete_admin(cipher_id.into()).await.unwrap(); // TODO: Map errors properly. + api.delete_admin(cipher_id.into()).await? } else { - api.delete(cipher_id.into()).await.unwrap(); + api.delete(cipher_id.into()).await? }; Ok(()) } + /// Deletes all [Cipher] objects with a matching [CipherId] from the server. pub async fn delete_many( &self, cipher_ids: Vec, organization_id: Option, as_admin: bool, - ) -> Result<(), CipherError> { + ) -> Result<(), ApiError> { let configs = self.get_api_configurations().await; let api = configs.api_client.ciphers_api(); if as_admin { @@ -28,40 +53,36 @@ impl CiphersClient { ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), organization_id: organization_id.map(|id| id.to_string()), })) - .await - .unwrap(); // TODO: Map errors properly. + .await?; } else { api.delete_many(Some(CipherBulkDeleteRequestModel { ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), organization_id: organization_id.map(|id| id.to_string()), })) - .await - .unwrap(); // TODO: Map errors properly. + .await?; }; Ok(()) } - pub async fn soft_delete( - &self, - cipher_id: CipherId, - as_admin: bool, - ) -> Result<(), CipherError> { + /// Soft-deletes the [Cipher] with the matching [CipherId] from the server. + pub async fn soft_delete(&self, cipher_id: CipherId, as_admin: bool) -> Result<(), ApiError> { let configs = self.get_api_configurations().await; let api = configs.api_client.ciphers_api(); if as_admin { - api.put_delete_admin(cipher_id.into()).await.unwrap(); // TODO: Map errors properly. + api.put_delete_admin(cipher_id.into()).await?; // TODO: Map errors properly. } else { - api.put_delete(cipher_id.into()).await.unwrap(); + api.put_delete(cipher_id.into()).await?; }; Ok(()) } + /// Soft-deletes all [Cipher] objects for the given [CipherId]s from the server. pub async fn soft_delete_many( &self, cipher_ids: Vec, organization_id: Option, as_admin: bool, - ) -> Result<(), CipherError> { + ) -> Result<(), ApiError> { let configs = self.get_api_configurations().await; let api = configs.api_client.ciphers_api(); if as_admin { @@ -69,16 +90,71 @@ impl CiphersClient { ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), organization_id: organization_id.map(|id| id.to_string()), })) - .await - .unwrap(); // TODO: Map errors properly. + .await?; } else { api.put_delete_many(Some(CipherBulkDeleteRequestModel { ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), organization_id: organization_id.map(|id| id.to_string()), })) - .await - .unwrap(); // TODO: Map errors properly. + .await? }; Ok(()) } + + /// Restores a soft-deleted cipher on the server. + pub async fn restore( + &self, + cipher_id: CipherId, + is_admin: bool, + ) -> Result { + let api_config = self.get_api_configurations().await; + let api = api_config.api_client.ciphers_api(); + + let cipher: Cipher = if is_admin { + let response = api.put_restore_admin(cipher_id.into()).await?; + response.try_into()? + } else { + let response = api.put_restore(cipher_id.into()).await?; + let cipher: Cipher = response.try_into()?; + + cipher + }; + + Ok(self.decrypt(cipher)?) + } + + /// Restores multiple soft-deleted ciphers on the server. + pub async fn restore_many( + &self, + cipher_ids: Vec, + org_id: Option, + ) -> Result { + let api_config = self.get_api_configurations().await; + let api = api_config.api_client.ciphers_api(); + + let ciphers: Vec = if let Some(org_id) = org_id { + api.put_restore_many_admin(Some(CipherBulkRestoreRequestModel { + ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), + organization_id: Some(org_id.into()), + })) + .await? + .data + .into_iter() + .flatten() + .map(|c| c.try_into()) + .collect::, _>>()? + } else { + api.put_restore_many(Some(CipherBulkRestoreRequestModel { + ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), + organization_id: None, + })) + .await? + .data + .into_iter() + .flatten() + .map(|c| c.try_into()) + .collect::, _>>()? + }; + Ok(self.decrypt_list_with_failures(ciphers)) + } } diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs b/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs index 995093fb7..57f73399f 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs @@ -1,6 +1,5 @@ use std::sync::Arc; -use bitwarden_api_api::apis::{ApiClient, ciphers_api::CiphersApi}; use bitwarden_core::{Client, OrganizationId, client::ApiConfigurations}; use bitwarden_crypto::IdentifyKey; #[cfg(feature = "wasm")] @@ -23,7 +22,6 @@ mod create; mod delete; mod edit; mod get; -mod restore; mod share_cipher; #[allow(missing_docs)] diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs b/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs deleted file mode 100644 index c8ef0114f..000000000 --- a/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs +++ /dev/null @@ -1,61 +0,0 @@ -use bitwarden_api_api::models::CipherBulkRestoreRequestModel; -use bitwarden_core::OrganizationId; - -use crate::{Cipher, CipherError, CipherId, CipherView, CiphersClient, DecryptCipherListResult}; - -impl CiphersClient { - pub async fn restore( - &self, - cipher_id: CipherId, - is_admin: bool, - ) -> Result { - let api_config = self.get_api_configurations().await; - let api = api_config.api_client.ciphers_api(); - - let cipher: Cipher = if is_admin { - let response = api.put_restore_admin(cipher_id.into()).await?; - response.try_into()? - } else { - let response = api.put_restore(cipher_id.into()).await?; - let cipher: Cipher = response.try_into()?; - - cipher - }; - - Ok(self.decrypt(cipher)?) - } - - pub async fn restore_many( - &self, - cipher_ids: Vec, - org_id: Option, - ) -> Result { - let api_config = self.get_api_configurations().await; - let api = api_config.api_client.ciphers_api(); - - let ciphers: Vec = if let Some(org_id) = org_id { - api.put_restore_many_admin(Some(CipherBulkRestoreRequestModel { - ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), - organization_id: Some(org_id.into()), - })) - .await? - .data - .into_iter() - .flatten() - .map(|c| c.try_into()) - .collect::, _>>()? - } else { - api.put_restore_many(Some(CipherBulkRestoreRequestModel { - ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), - organization_id: None, - })) - .await? - .data - .into_iter() - .flatten() - .map(|c| c.try_into()) - .collect::, _>>()? - }; - Ok(self.decrypt_list_with_failures(ciphers)) - } -} From f9eaafacb3adc245bdc825f45dde0dd878e85e0b Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Fri, 21 Nov 2025 15:58:55 -0800 Subject: [PATCH 11/38] Cleanup and logic consolidation --- crates/bitwarden-vault/src/cipher/cipher.rs | 276 +++++++++--------- .../src/cipher/cipher_client/create.rs | 15 +- .../src/cipher/cipher_client/edit.rs | 17 +- .../src/cipher/cipher_client/get.rs | 19 +- 4 files changed, 142 insertions(+), 185 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher.rs b/crates/bitwarden-vault/src/cipher/cipher.rs index 6c45e5deb..10a843cc7 100644 --- a/crates/bitwarden-vault/src/cipher/cipher.rs +++ b/crates/bitwarden-vault/src/cipher/cipher.rs @@ -1059,6 +1059,138 @@ impl TryFrom for Cipher { } } +impl TryFrom for Cipher { + type Error = VaultParseError; + fn try_from(cipher_mini: CipherMiniResponseModel) -> Result { + Ok(Cipher { + id: cipher_mini.id.map(CipherId::new), + organization_id: cipher_mini.organization_id.map(OrganizationId::new), + key: EncString::try_from_optional(cipher_mini.key)?, + name: require!(EncString::try_from_optional(cipher_mini.name)?), + notes: EncString::try_from_optional(cipher_mini.notes)?, + r#type: require!(cipher_mini.r#type).into(), + login: cipher_mini.login.map(|l| (*l).try_into()).transpose()?, + identity: cipher_mini.identity.map(|i| (*i).try_into()).transpose()?, + card: cipher_mini.card.map(|c| (*c).try_into()).transpose()?, + secure_note: cipher_mini + .secure_note + .map(|s| (*s).try_into()) + .transpose()?, + ssh_key: cipher_mini.ssh_key.map(|s| (*s).try_into()).transpose()?, + reprompt: cipher_mini + .reprompt + .map(|r| r.into()) + .unwrap_or(CipherRepromptType::None), + organization_use_totp: cipher_mini.organization_use_totp.unwrap_or(true), + attachments: cipher_mini + .attachments + .map(|a| a.into_iter().map(|a| a.try_into()).collect()) + .transpose()?, + fields: cipher_mini + .fields + .map(|f| f.into_iter().map(|f| f.try_into()).collect()) + .transpose()?, + password_history: cipher_mini + .password_history + .map(|p| p.into_iter().map(|p| p.try_into()).collect()) + .transpose()?, + creation_date: require!(cipher_mini.creation_date) + .parse() + .map_err(Into::::into)?, + deleted_date: cipher_mini + .deleted_date + .map(|d| d.parse()) + .transpose() + .map_err(Into::::into)?, + revision_date: require!(cipher_mini.revision_date) + .parse() + .map_err(Into::::into)?, + archived_date: cipher_mini + .archived_date + .map(|d| d.parse()) + .transpose() + .map_err(Into::::into)?, + edit: Default::default(), + favorite: Default::default(), + folder_id: Default::default(), + permissions: Default::default(), + view_password: Default::default(), + local_data: Default::default(), + collection_ids: Default::default(), + data: None, + }) + } +} + +impl TryFrom for Cipher { + type Error = VaultParseError; + + fn try_from(cipher_mini: CipherMiniDetailsResponseModel) -> Result { + Ok(Cipher { + id: cipher_mini.id.map(CipherId::new), + organization_id: cipher_mini.organization_id.map(OrganizationId::new), + key: EncString::try_from_optional(cipher_mini.key)?, + name: require!(EncString::try_from_optional(cipher_mini.name)?), + notes: EncString::try_from_optional(cipher_mini.notes)?, + r#type: require!(cipher_mini.r#type).into(), + login: cipher_mini.login.map(|l| (*l).try_into()).transpose()?, + identity: cipher_mini.identity.map(|i| (*i).try_into()).transpose()?, + card: cipher_mini.card.map(|c| (*c).try_into()).transpose()?, + secure_note: cipher_mini + .secure_note + .map(|s| (*s).try_into()) + .transpose()?, + ssh_key: cipher_mini.ssh_key.map(|s| (*s).try_into()).transpose()?, + reprompt: cipher_mini + .reprompt + .map(|r| r.into()) + .unwrap_or(CipherRepromptType::None), + organization_use_totp: cipher_mini.organization_use_totp.unwrap_or(true), + attachments: cipher_mini + .attachments + .map(|a| a.into_iter().map(|a| a.try_into()).collect()) + .transpose()?, + fields: cipher_mini + .fields + .map(|f| f.into_iter().map(|f| f.try_into()).collect()) + .transpose()?, + password_history: cipher_mini + .password_history + .map(|p| p.into_iter().map(|p| p.try_into()).collect()) + .transpose()?, + creation_date: require!(cipher_mini.creation_date) + .parse() + .map_err(Into::::into)?, + deleted_date: cipher_mini + .deleted_date + .map(|d| d.parse()) + .transpose() + .map_err(Into::::into)?, + revision_date: require!(cipher_mini.revision_date) + .parse() + .map_err(Into::::into)?, + archived_date: cipher_mini + .archived_date + .map(|d| d.parse()) + .transpose() + .map_err(Into::::into)?, + collection_ids: cipher_mini + .collection_ids + .into_iter() + .flatten() + .map(CollectionId::new) + .collect(), + data: None, + folder_id: Default::default(), + favorite: Default::default(), + edit: Default::default(), + permissions: Default::default(), + view_password: Default::default(), + local_data: Default::default(), + }) + } +} + #[cfg(test)] mod tests { @@ -2115,147 +2247,3 @@ mod tests { assert!(matches!(result, Err(VaultParseError::SerdeJson(_)))); } } - -impl TryFrom for Cipher { - type Error = VaultParseError; - fn try_from(cipher_mini: CipherMiniResponseModel) -> Result { - Ok(Cipher { - id: cipher_mini.id.map(CipherId::new), - organization_id: cipher_mini.organization_id.map(OrganizationId::new), - key: EncString::try_from_optional(cipher_mini.key)?, - name: require!(EncString::try_from_optional(cipher_mini.name)?), - notes: EncString::try_from_optional(cipher_mini.notes)?, - r#type: require!(cipher_mini.r#type).into(), - login: cipher_mini.login.map(|l| (*l).try_into()).transpose()?, - identity: cipher_mini.identity.map(|i| (*i).try_into()).transpose()?, - card: cipher_mini.card.map(|c| (*c).try_into()).transpose()?, - secure_note: cipher_mini - .secure_note - .map(|s| (*s).try_into()) - .transpose()?, - ssh_key: cipher_mini.ssh_key.map(|s| (*s).try_into()).transpose()?, - reprompt: cipher_mini - .reprompt - .map(|r| r.into()) - .unwrap_or(CipherRepromptType::None), - organization_use_totp: cipher_mini.organization_use_totp.unwrap_or(true), - attachments: cipher_mini - .attachments - .map(|a| a.into_iter().map(|a| a.try_into()).collect()) - .transpose()?, - fields: cipher_mini - .fields - .map(|f| f.into_iter().map(|f| f.try_into()).collect()) - .transpose()?, - password_history: cipher_mini - .password_history - .map(|p| p.into_iter().map(|p| p.try_into()).collect()) - .transpose()?, - creation_date: require!(cipher_mini.creation_date) - .parse() - .map_err(Into::::into)?, - deleted_date: cipher_mini - .deleted_date - .map(|d| d.parse()) - .transpose() - .map_err(Into::::into)?, - revision_date: require!(cipher_mini.revision_date) - .parse() - .map_err(Into::::into)?, - archived_date: cipher_mini - .archived_date - .map(|d| d.parse()) - .transpose() - .map_err(Into::::into)?, - edit: Default::default(), - favorite: Default::default(), - folder_id: Default::default(), - permissions: Default::default(), - view_password: Default::default(), - local_data: Default::default(), - collection_ids: Default::default(), - data: None, - }) - } -} - -#[allow(missing_docs)] -#[bitwarden_error(flat)] -#[derive(Debug, Error)] -pub enum IntoCipherError { - #[error(transparent)] - Crypto(#[from] CryptoError), - #[error(transparent)] - VaultParse(#[from] VaultParseError), - #[error(transparent)] - MissingField(#[from] MissingFieldError), -} - -impl TryFrom for Cipher { - type Error = IntoCipherError; - - fn try_from(cipher_mini: CipherMiniDetailsResponseModel) -> Result { - Ok(Cipher { - id: cipher_mini.id.map(CipherId::new), - organization_id: cipher_mini.organization_id.map(OrganizationId::new), - key: EncString::try_from_optional(cipher_mini.key)?, - name: require!(EncString::try_from_optional(cipher_mini.name)?), - notes: EncString::try_from_optional(cipher_mini.notes)?, - r#type: require!(cipher_mini.r#type).into(), - login: cipher_mini.login.map(|l| (*l).try_into()).transpose()?, - identity: cipher_mini.identity.map(|i| (*i).try_into()).transpose()?, - card: cipher_mini.card.map(|c| (*c).try_into()).transpose()?, - secure_note: cipher_mini - .secure_note - .map(|s| (*s).try_into()) - .transpose()?, - ssh_key: cipher_mini.ssh_key.map(|s| (*s).try_into()).transpose()?, - reprompt: cipher_mini - .reprompt - .map(|r| r.into()) - .unwrap_or(CipherRepromptType::None), - organization_use_totp: cipher_mini.organization_use_totp.unwrap_or(true), - attachments: cipher_mini - .attachments - .map(|a| a.into_iter().map(|a| a.try_into()).collect()) - .transpose()?, - fields: cipher_mini - .fields - .map(|f| f.into_iter().map(|f| f.try_into()).collect()) - .transpose()?, - password_history: cipher_mini - .password_history - .map(|p| p.into_iter().map(|p| p.try_into()).collect()) - .transpose()?, - creation_date: require!(cipher_mini.creation_date) - .parse() - .map_err(Into::::into)?, - deleted_date: cipher_mini - .deleted_date - .map(|d| d.parse()) - .transpose() - .map_err(Into::::into)?, - revision_date: require!(cipher_mini.revision_date) - .parse() - .map_err(Into::::into)?, - archived_date: cipher_mini - .archived_date - .map(|d| d.parse()) - .transpose() - .map_err(Into::::into)?, - collection_ids: cipher_mini - .collection_ids - .into_iter() - .flatten() - .map(CollectionId::new) - .collect(), - data: None, - folder_id: Default::default(), - favorite: Default::default(), - edit: Default::default(), - permissions: Default::default(), - view_password: Default::default(), - local_data: Default::default(), - }) - } -} diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/create.rs b/crates/bitwarden-vault/src/cipher/cipher_client/create.rs index cb61d714b..b3b86ed05 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/create.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/create.rs @@ -21,7 +21,7 @@ use wasm_bindgen::prelude::*; use super::CiphersClient; use crate::{ Cipher, CipherRepromptType, CipherView, FieldView, FolderId, VaultParseError, - cipher::cipher::IntoCipherError, cipher_view_type::CipherViewType, + cipher_view_type::CipherViewType, }; #[allow(missing_docs)] @@ -42,16 +42,6 @@ pub enum CreateCipherError { Repository(#[from] RepositoryError), } -impl From for CreateCipherError { - fn from(value: IntoCipherError) -> Self { - match value { - IntoCipherError::Crypto(e) => Self::Crypto(e), - IntoCipherError::VaultParse(e) => Self::VaultParse(e), - IntoCipherError::MissingField(e) => Self::MissingField(e), - } - } -} - impl From> for CreateCipherError { fn from(val: bitwarden_api_api::apis::Error) -> Self { Self::Api(val.into()) @@ -310,7 +300,7 @@ impl CiphersClient { internal_request.generate_cipher_key(&mut key_store.context(), key)?; } - Ok(create_cipher( + create_cipher( key_store, &config.api_client, repository.as_ref(), @@ -320,7 +310,6 @@ impl CiphersClient { as_admin, ) .await - .unwrap()) } /// Creates a new [Cipher] and save it to the server. diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs b/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs index 56b8715db..289016be4 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs @@ -23,8 +23,7 @@ use super::CiphersClient; use crate::{ AttachmentView, Cipher, CipherId, CipherRepromptType, CipherType, CipherView, DecryptError, FieldView, FolderId, ItemNotFoundError, PasswordHistoryView, VaultParseError, - cipher::cipher::IntoCipherError, cipher_view_type::CipherViewType, - password_history::MAX_PASSWORD_HISTORY_ENTRIES, + cipher_view_type::CipherViewType, password_history::MAX_PASSWORD_HISTORY_ENTRIES, }; #[allow(missing_docs)] @@ -51,16 +50,6 @@ pub enum EditCipherError { Decrypt(#[from] DecryptError), } -impl From for EditCipherError { - fn from(value: IntoCipherError) -> Self { - match value { - IntoCipherError::Crypto(e) => Self::Crypto(e), - IntoCipherError::VaultParse(e) => Self::VaultParse(e), - IntoCipherError::MissingField(e) => Self::MissingField(e), - } - } -} - impl From> for EditCipherError { fn from(val: bitwarden_api_api::apis::Error) -> Self { Self::Api(val.into()) @@ -360,8 +349,7 @@ async fn edit_cipher + ?Sized>( .put_admin(cipher_id.into(), Some(cipher_request)) .await .map_err(ApiError::from)? - .try_into() - .unwrap() + .try_into()? } else { let cipher: Cipher = api_client .ciphers_api() @@ -437,6 +425,7 @@ impl CiphersClient { .await } + /// Adds the cipher matched by [CipherId] to any number of collections on the server. pub async fn update_collection( &self, cipher_id: CipherId, diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/get.rs b/crates/bitwarden-vault/src/cipher/cipher_client/get.rs index 8f11f2ec0..e8399d060 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/get.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/get.rs @@ -1,5 +1,5 @@ use bitwarden_api_api::models::CipherMiniDetailsResponseModelListResponseModel; -use bitwarden_core::{MissingFieldError, OrganizationId, key_management::KeyIds}; +use bitwarden_core::{ApiError, MissingFieldError, OrganizationId, key_management::KeyIds}; use bitwarden_crypto::{CryptoError, KeyStore}; use bitwarden_error::bitwarden_error; use bitwarden_state::repository::{Repository, RepositoryError}; @@ -7,8 +7,7 @@ use thiserror::Error; use super::CiphersClient; use crate::{ - Cipher, CipherError, CipherView, ItemNotFoundError, VaultParseError, - cipher::cipher::{DecryptCipherListResult, IntoCipherError}, + Cipher, CipherView, ItemNotFoundError, VaultParseError, cipher::cipher::DecryptCipherListResult, }; #[allow(missing_docs)] @@ -25,16 +24,8 @@ pub enum GetCipherError { MissingField(#[from] MissingFieldError), #[error(transparent)] RepositoryError(#[from] RepositoryError), -} - -impl From for GetCipherError { - fn from(value: IntoCipherError) -> Self { - match value { - IntoCipherError::Crypto(e) => e.into(), - IntoCipherError::VaultParse(e) => e.into(), - IntoCipherError::MissingField(e) => e.into(), - } - } + #[error(transparent)] + Api(#[from] ApiError), } async fn get_cipher( @@ -85,7 +76,7 @@ impl CiphersClient { let response: CipherMiniDetailsResponseModelListResponseModel = api .get_organization_ciphers(Some(org_id.into()), Some(include_member_items)) .await - .unwrap(); // TODO: Remove unwrap + .map_err(Into::::into)?; let ciphers = response .data .into_iter() From 4e4874309b591afffa1baf0e1addedcf493c5c52 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Fri, 21 Nov 2025 16:20:55 -0800 Subject: [PATCH 12/38] Update docs for CiphersClient::list_org_ciphers --- crates/bitwarden-vault/src/cipher/cipher_client/get.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/get.rs b/crates/bitwarden-vault/src/cipher/cipher_client/get.rs index e8399d060..19f30c937 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/get.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/get.rs @@ -64,8 +64,7 @@ impl CiphersClient { list_ciphers(key_store, repository.as_ref()).await } - /// Get all ciphers for an organization. Currently returns `Err` if any ciphers fail to - /// deserialize or decrypt + /// Get all ciphers for an organization. pub async fn list_org_ciphers( &self, org_id: OrganizationId, From f3a06f6b6076b09e17717cba769423bcd8fb6196 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Mon, 24 Nov 2025 13:39:19 -0800 Subject: [PATCH 13/38] Add separate delete_as_admin functions --- .../src/cipher/cipher_client/delete.rs | 125 +++++++++++------- 1 file changed, 74 insertions(+), 51 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs index 7243b38dd..f93e345b9 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs @@ -27,15 +27,35 @@ impl From> for RestoreCipherError { } impl CiphersClient { + /// Deletes the [Cipher] with the matching [CipherId] from the server, using the admin endpoint. + pub async fn delete_as_admin(&self, cipher_id: CipherId) -> Result<(), ApiError> { + let configs = self.get_api_configurations().await; + let api = configs.api_client.ciphers_api(); + api.delete_admin(cipher_id.into()).await?; + Ok(()) + } + /// Deletes the [Cipher] with the matching [CipherId] from the server. - pub async fn delete(&self, cipher_id: CipherId, as_admin: bool) -> Result<(), ApiError> { + pub async fn delete(&self, cipher_id: CipherId) -> Result<(), ApiError> { let configs = self.get_api_configurations().await; let api = configs.api_client.ciphers_api(); - if as_admin { - api.delete_admin(cipher_id.into()).await? - } else { - api.delete(cipher_id.into()).await? - }; + api.delete(cipher_id.into()).await?; + Ok(()) + } + + /// Deletes all [Cipher] objects with a matching [CipherId] from the server, using the admin endpoint. + pub async fn delete_many_as_admin( + &self, + cipher_ids: Vec, + organization_id: Option, + ) -> Result<(), ApiError> { + let configs = self.get_api_configurations().await; + let api = configs.api_client.ciphers_api(); + api.delete_many_admin(Some(CipherBulkDeleteRequestModel { + ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), + organization_id: organization_id.map(|id| id.to_string()), + })) + .await?; Ok(()) } @@ -44,35 +64,30 @@ impl CiphersClient { &self, cipher_ids: Vec, organization_id: Option, - as_admin: bool, ) -> Result<(), ApiError> { let configs = self.get_api_configurations().await; let api = configs.api_client.ciphers_api(); - if as_admin { - api.delete_many_admin(Some(CipherBulkDeleteRequestModel { - ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), - organization_id: organization_id.map(|id| id.to_string()), - })) - .await?; - } else { - api.delete_many(Some(CipherBulkDeleteRequestModel { - ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), - organization_id: organization_id.map(|id| id.to_string()), - })) - .await?; - }; + api.delete_many(Some(CipherBulkDeleteRequestModel { + ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), + organization_id: organization_id.map(|id| id.to_string()), + })) + .await?; Ok(()) } /// Soft-deletes the [Cipher] with the matching [CipherId] from the server. - pub async fn soft_delete(&self, cipher_id: CipherId, as_admin: bool) -> Result<(), ApiError> { + pub async fn soft_delete(&self, cipher_id: CipherId) -> Result<(), ApiError> { let configs = self.get_api_configurations().await; let api = configs.api_client.ciphers_api(); - if as_admin { - api.put_delete_admin(cipher_id.into()).await?; // TODO: Map errors properly. - } else { - api.put_delete(cipher_id.into()).await?; - }; + api.put_delete(cipher_id.into()).await?; + Ok(()) + } + + /// Soft-deletes the [Cipher] with the matching [CipherId] from the server, using the admin endpoint. + pub async fn soft_delete_as_admin(&self, cipher_id: CipherId) -> Result<(), ApiError> { + let configs = self.get_api_configurations().await; + let api = configs.api_client.ciphers_api(); + api.put_delete_admin(cipher_id.into()).await?; // TODO: Map errors properly. Ok(()) } @@ -81,44 +96,52 @@ impl CiphersClient { &self, cipher_ids: Vec, organization_id: Option, - as_admin: bool, ) -> Result<(), ApiError> { let configs = self.get_api_configurations().await; let api = configs.api_client.ciphers_api(); - if as_admin { - api.put_delete_many_admin(Some(CipherBulkDeleteRequestModel { - ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), - organization_id: organization_id.map(|id| id.to_string()), - })) - .await?; - } else { - api.put_delete_many(Some(CipherBulkDeleteRequestModel { - ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), - organization_id: organization_id.map(|id| id.to_string()), - })) - .await? - }; + api.put_delete_many(Some(CipherBulkDeleteRequestModel { + ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), + organization_id: organization_id.map(|id| id.to_string()), + })) + .await?; + Ok(()) + } + + /// Soft-deletes all [Cipher] objects for the given [CipherId]s from the server, using the admin endpoint. + pub async fn soft_delete_many_as_admin( + &self, + cipher_ids: Vec, + organization_id: Option, + ) -> Result<(), ApiError> { + let configs = self.get_api_configurations().await; + let api = configs.api_client.ciphers_api(); + api.put_delete_many_admin(Some(CipherBulkDeleteRequestModel { + ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), + organization_id: organization_id.map(|id| id.to_string()), + })) + .await?; Ok(()) } /// Restores a soft-deleted cipher on the server. - pub async fn restore( + pub async fn restore(&self, cipher_id: CipherId) -> Result { + let api_config = self.get_api_configurations().await; + let api = api_config.api_client.ciphers_api(); + + let cipher: Cipher = api.put_restore(cipher_id.into()).await?.try_into()?; + + Ok(self.decrypt(cipher)?) + } + + /// Restores a soft-deleted cipher on the server, using the admin endpoint. + pub async fn restore_as_admin( &self, cipher_id: CipherId, - is_admin: bool, ) -> Result { let api_config = self.get_api_configurations().await; let api = api_config.api_client.ciphers_api(); - let cipher: Cipher = if is_admin { - let response = api.put_restore_admin(cipher_id.into()).await?; - response.try_into()? - } else { - let response = api.put_restore(cipher_id.into()).await?; - let cipher: Cipher = response.try_into()?; - - cipher - }; + let cipher: Cipher = api.put_restore_admin(cipher_id.into()).await?.try_into()?; Ok(self.decrypt(cipher)?) } From eb3291aafb6df4f99c9abfd8cfa24d224fabdda9 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Mon, 24 Nov 2025 14:57:21 -0800 Subject: [PATCH 14/38] Add tests for new create methods --- .../src/cipher/cipher_client/create.rs | 222 +++++++++++++++++- 1 file changed, 218 insertions(+), 4 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/create.rs b/crates/bitwarden-vault/src/cipher/cipher_client/create.rs index b3b86ed05..4e9d0f612 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/create.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/create.rs @@ -312,10 +312,18 @@ impl CiphersClient { .await } - /// Creates a new [Cipher] and save it to the server. + /// Creates a new [Cipher] and saves it to the server. pub async fn create( &self, request: CipherCreateRequest, + ) -> Result { + self.create_cipher(request, vec![], false).await + } + + /// Creates a new [Cipher] for an organization, and saves it to the server. + pub async fn create_org_cipher( + &self, + request: CipherCreateRequest, collection_ids: Vec, ) -> Result { self.create_cipher(request, collection_ids, false).await @@ -327,21 +335,36 @@ impl CiphersClient { request: CipherCreateRequest, collection_ids: Vec, ) -> Result { - self.create_cipher(request, collection_ids, false).await + self.create_cipher(request, collection_ids, true).await } } #[cfg(test)] mod tests { use bitwarden_api_api::{apis::ApiClient, models::CipherResponseModel}; + use bitwarden_core::Client; + use bitwarden_core::{ + ClientSettings, DeviceType, UserId, + key_management::crypto::{ + InitOrgCryptoRequest, InitUserCryptoMethod, InitUserCryptoRequest, + }, + }; use bitwarden_crypto::SymmetricCryptoKey; - use bitwarden_test::MemoryRepository; + use bitwarden_crypto::{EncString, Kdf}; + use bitwarden_test::{MemoryRepository, start_api_mock}; + use chrono::Utc; + use wiremock::{ + Mock, ResponseTemplate, + matchers::{method, path}, + }; use super::*; - use crate::{CipherId, LoginView}; + use crate::{CipherId, LoginView, VaultClientExt}; const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097"; + const TEST_COLLECTION_ID: &str = "73546b86-8802-4449-ad2a-69ea981b4ffd"; const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000"; + const TEST_ORG_ID: &str = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8"; fn generate_test_cipher_create_request() -> CipherCreateRequest { CipherCreateRequest { @@ -364,6 +387,65 @@ mod tests { } } + async fn create_client_with_wiremock(mock_server: &wiremock::MockServer) -> CiphersClient { + let settings = ClientSettings { + identity_url: format!("http://{}", mock_server.address()), + api_url: format!("http://{}", mock_server.address()), + user_agent: "Bitwarden Test".into(), + device_type: DeviceType::SDK, + bitwarden_client_version: None, + }; + + let client = Client::new(Some(settings)); + + client + .internal + .load_flags(std::collections::HashMap::from([( + "enableCipherKeyEncryption".to_owned(), + true, + )])); + + let user_request = InitUserCryptoRequest { + user_id: Some(UserId::new(uuid::uuid!(TEST_USER_ID))), + kdf_params: Kdf::PBKDF2 { + iterations: 600_000.try_into().unwrap(), + }, + email: "test@bitwarden.com".to_owned(), + private_key: "2.yN7l00BOlUE0Sb0M//Q53w==|EwKG/BduQRQ33Izqc/ogoBROIoI5dmgrxSo82sgzgAMIBt3A2FZ9vPRMY+GWT85JiqytDitGR3TqwnFUBhKUpRRAq4x7rA6A1arHrFp5Tp1p21O3SfjtvB3quiOKbqWk6ZaU1Np9HwqwAecddFcB0YyBEiRX3VwF2pgpAdiPbSMuvo2qIgyob0CUoC/h4Bz1be7Qa7B0Xw9/fMKkB1LpOm925lzqosyMQM62YpMGkjMsbZz0uPopu32fxzDWSPr+kekNNyLt9InGhTpxLmq1go/pXR2uw5dfpXc5yuta7DB0EGBwnQ8Vl5HPdDooqOTD9I1jE0mRyuBpWTTI3FRnu3JUh3rIyGBJhUmHqGZvw2CKdqHCIrQeQkkEYqOeJRJVdBjhv5KGJifqT3BFRwX/YFJIChAQpebNQKXe/0kPivWokHWwXlDB7S7mBZzhaAPidZvnuIhalE2qmTypDwHy22FyqV58T8MGGMchcASDi/QXI6kcdpJzPXSeU9o+NC68QDlOIrMVxKFeE7w7PvVmAaxEo0YwmuAzzKy9QpdlK0aab/xEi8V4iXj4hGepqAvHkXIQd+r3FNeiLfllkb61p6WTjr5urcmDQMR94/wYoilpG5OlybHdbhsYHvIzYoLrC7fzl630gcO6t4nM24vdB6Ymg9BVpEgKRAxSbE62Tqacxqnz9AcmgItb48NiR/He3n3ydGjPYuKk/ihZMgEwAEZvSlNxYONSbYrIGDtOY+8Nbt6KiH3l06wjZW8tcmFeVlWv+tWotnTY9IqlAfvNVTjtsobqtQnvsiDjdEVtNy/s2ci5TH+NdZluca2OVEr91Wayxh70kpM6ib4UGbfdmGgCo74gtKvKSJU0rTHakQ5L9JlaSDD5FamBRyI0qfL43Ad9qOUZ8DaffDCyuaVyuqk7cz9HwmEmvWU3VQ+5t06n/5kRDXttcw8w+3qClEEdGo1KeENcnXCB32dQe3tDTFpuAIMLqwXs6FhpawfZ5kPYvLPczGWaqftIs/RXJ/EltGc0ugw2dmTLpoQhCqrcKEBDoYVk0LDZKsnzitOGdi9mOWse7Se8798ib1UsHFUjGzISEt6upestxOeupSTOh0v4+AjXbDzRUyogHww3V+Bqg71bkcMxtB+WM+pn1XNbVTyl9NR040nhP7KEf6e9ruXAtmrBC2ah5cFEpLIot77VFZ9ilLuitSz+7T8n1yAh1IEG6xxXxninAZIzi2qGbH69O5RSpOJuJTv17zTLJQIIc781JwQ2TTwTGnx5wZLbffhCasowJKd2EVcyMJyhz6ru0PvXWJ4hUdkARJs3Xu8dus9a86N8Xk6aAPzBDqzYb1vyFIfBxP0oO8xFHgd30Cgmz8UrSE3qeWRrF8ftrI6xQnFjHBGWD/JWSvd6YMcQED0aVuQkuNW9ST/DzQThPzRfPUoiL10yAmV7Ytu4fR3x2sF0Yfi87YhHFuCMpV/DsqxmUizyiJuD938eRcH8hzR/VO53Qo3UIsqOLcyXtTv6THjSlTopQ+JOLOnHm1w8dzYbLN44OG44rRsbihMUQp+wUZ6bsI8rrOnm9WErzkbQFbrfAINdoCiNa6cimYIjvvnMTaFWNymqY1vZxGztQiMiHiHYwTfwHTXrb9j0uPM=|09J28iXv9oWzYtzK2LBT6Yht4IT4MijEkk0fwFdrVQ4=".parse::().unwrap(), + signing_key: None, + security_state: None, + method: InitUserCryptoMethod::Password { + password: "asdfasdfasdf".to_owned(), + user_key: "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=".parse().unwrap(), + } + }; + + let org_request = InitOrgCryptoRequest { + organization_keys: std::collections::HashMap::from([( + TEST_ORG_ID.parse().unwrap(), + "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==".parse().unwrap() + )]) + }; + + client + .crypto() + .initialize_user_crypto(user_request) + .await + .unwrap(); + client + .crypto() + .initialize_org_crypto(org_request) + .await + .unwrap(); + + client + .platform() + .state() + .register_client_managed(std::sync::Arc::new(MemoryRepository::::default())); + + client.vault().ciphers() + } + #[tokio::test] async fn test_create_cipher() { let store: KeyStore = KeyStore::default(); @@ -501,4 +583,136 @@ mod tests { assert!(result.is_err()); assert!(matches!(result.unwrap_err(), CreateCipherError::Api(_))); } + + #[tokio::test] + async fn test_create_org_cipher() { + let (mock_server, _config) = start_api_mock(vec![ + Mock::given(method("POST")) + .and(path(r"/ciphers/create")) + .respond_with(move |req: &wiremock::Request| { + let body_bytes = req.body.as_slice(); + let request_body: CipherCreateRequestModel = + serde_json::from_slice(body_bytes).expect("Failed to parse request body"); + + let response = CipherResponseModel { + id: Some(TEST_CIPHER_ID.try_into().unwrap()), + organization_id: request_body + .cipher + .organization_id + .and_then(|id| id.parse().ok()), + name: Some(request_body.cipher.name.clone()), + r#type: request_body.cipher.r#type.clone(), + creation_date: Some(Utc::now().to_string()), + revision_date: Some(Utc::now().to_string()), + ..Default::default() + }; + + ResponseTemplate::new(200).set_body_json(&response) + }), + ]) + .await; + + let client = create_client_with_wiremock(&mock_server).await; + let response = client + .create_org_cipher( + CipherCreateRequest { + organization_id: Some(TEST_ORG_ID.parse().unwrap()), + folder_id: None, + name: "Test Cipher".into(), + notes: None, + favorite: false, + reprompt: CipherRepromptType::None, + r#type: CipherViewType::Login(LoginView { + username: None, + password: None, + password_revision_date: None, + uris: None, + totp: None, + autofill_on_page_load: None, + fido2_credentials: None, + }), + fields: vec![], + }, + vec![TEST_COLLECTION_ID.parse().unwrap()], + ) + .await + .unwrap(); + + let repository = client.get_repository().unwrap(); + let cipher: Cipher = repository + .get(TEST_CIPHER_ID.to_string()) + .await + .unwrap() + .unwrap(); + let cipher_view = client.decrypt(cipher).unwrap(); + + assert_eq!(response.id, cipher_view.id); + assert_eq!(response.organization_id, cipher_view.organization_id); + + assert_eq!(response.id, Some(TEST_CIPHER_ID.parse().unwrap())); + assert_eq!(response.organization_id, Some(TEST_ORG_ID.parse().unwrap())); + } + + #[tokio::test] + async fn test_create_cipher_as_admin() { + let (mock_server, _config) = start_api_mock(vec![ + Mock::given(method("POST")) + .and(path(r"/ciphers/admin")) + .respond_with(move |req: &wiremock::Request| { + let body_bytes = req.body.as_slice(); + let request_body: CipherCreateRequestModel = + serde_json::from_slice(body_bytes).expect("Failed to parse request body"); + + let response = CipherResponseModel { + id: Some(TEST_CIPHER_ID.try_into().unwrap()), + organization_id: request_body + .cipher + .organization_id + .and_then(|id| id.parse().ok()), + name: Some(request_body.cipher.name.clone()), + r#type: request_body.cipher.r#type.clone(), + creation_date: Some(Utc::now().to_string()), + revision_date: Some(Utc::now().to_string()), + ..Default::default() + }; + + ResponseTemplate::new(200).set_body_json(&response) + }), + ]) + .await; + + let client = create_client_with_wiremock(&mock_server).await; + let response = client + .create_as_admin( + CipherCreateRequest { + organization_id: Some(TEST_ORG_ID.parse().unwrap()), + folder_id: None, + name: "Test Cipher".into(), + notes: None, + favorite: false, + reprompt: CipherRepromptType::None, + r#type: CipherViewType::Login(LoginView { + username: None, + password: None, + password_revision_date: None, + uris: None, + totp: None, + autofill_on_page_load: None, + fido2_credentials: None, + }), + fields: vec![], + }, + vec![TEST_COLLECTION_ID.parse().unwrap()], + ) + .await + .unwrap(); + + let repository = client.get_repository().unwrap(); + let cipher = repository.get(TEST_CIPHER_ID.to_string()).await.unwrap(); + // Should not update local repository for admin endpoints. + assert!(matches!(cipher, None)); + + assert_eq!(response.id, Some(TEST_CIPHER_ID.parse().unwrap())); + assert_eq!(response.organization_id, Some(TEST_ORG_ID.parse().unwrap())); + } } From 74714e6785b9162df8e471b9e78bd38c11be5fdb Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Mon, 24 Nov 2025 16:28:20 -0800 Subject: [PATCH 15/38] Add tests for edit admin cipher endpoints --- .../src/cipher/cipher_client/edit.rs | 120 +++++++++++++++++- 1 file changed, 116 insertions(+), 4 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs b/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs index 289016be4..5df481602 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs @@ -463,19 +463,30 @@ impl CiphersClient { #[cfg(test)] mod tests { use bitwarden_api_api::{apis::ApiClient, models::CipherResponseModel}; - use bitwarden_core::key_management::SymmetricKeyId; - use bitwarden_crypto::{KeyStore, PrimitiveEncryptable, SymmetricCryptoKey}; - use bitwarden_test::MemoryRepository; + use bitwarden_core::{ + Client, ClientSettings, DeviceType, + key_management::{ + SymmetricKeyId, + crypto::{InitOrgCryptoRequest, InitUserCryptoMethod, InitUserCryptoRequest}, + }, + }; + use bitwarden_crypto::{Kdf, KeyStore, PrimitiveEncryptable, SymmetricCryptoKey}; + use bitwarden_test::{MemoryRepository, start_api_mock}; use chrono::TimeZone; + use wiremock::{ + Mock, ResponseTemplate, + matchers::{method, path_regex}, + }; use super::*; use crate::{ Cipher, CipherId, CipherRepromptType, CipherType, FieldType, Login, LoginView, - PasswordHistoryView, + PasswordHistoryView, VaultClientExt, }; const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097"; const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000"; + const TEST_ORG_ID: &str = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8"; fn generate_test_cipher() -> CipherView { CipherView { @@ -517,6 +528,65 @@ mod tests { } } + async fn create_client_with_wiremock(mock_server: &wiremock::MockServer) -> CiphersClient { + let settings = ClientSettings { + identity_url: format!("http://{}", mock_server.address()), + api_url: format!("http://{}", mock_server.address()), + user_agent: "Bitwarden Test".into(), + device_type: DeviceType::SDK, + bitwarden_client_version: None, + }; + + let client = Client::new(Some(settings)); + + client + .internal + .load_flags(std::collections::HashMap::from([( + "enableCipherKeyEncryption".to_owned(), + true, + )])); + + let user_request = InitUserCryptoRequest { + user_id: Some(UserId::new(uuid::uuid!(TEST_USER_ID))), + kdf_params: Kdf::PBKDF2 { + iterations: 600_000.try_into().unwrap(), + }, + email: "test@bitwarden.com".to_owned(), + private_key: "2.yN7l00BOlUE0Sb0M//Q53w==|EwKG/BduQRQ33Izqc/ogoBROIoI5dmgrxSo82sgzgAMIBt3A2FZ9vPRMY+GWT85JiqytDitGR3TqwnFUBhKUpRRAq4x7rA6A1arHrFp5Tp1p21O3SfjtvB3quiOKbqWk6ZaU1Np9HwqwAecddFcB0YyBEiRX3VwF2pgpAdiPbSMuvo2qIgyob0CUoC/h4Bz1be7Qa7B0Xw9/fMKkB1LpOm925lzqosyMQM62YpMGkjMsbZz0uPopu32fxzDWSPr+kekNNyLt9InGhTpxLmq1go/pXR2uw5dfpXc5yuta7DB0EGBwnQ8Vl5HPdDooqOTD9I1jE0mRyuBpWTTI3FRnu3JUh3rIyGBJhUmHqGZvw2CKdqHCIrQeQkkEYqOeJRJVdBjhv5KGJifqT3BFRwX/YFJIChAQpebNQKXe/0kPivWokHWwXlDB7S7mBZzhaAPidZvnuIhalE2qmTypDwHy22FyqV58T8MGGMchcASDi/QXI6kcdpJzPXSeU9o+NC68QDlOIrMVxKFeE7w7PvVmAaxEo0YwmuAzzKy9QpdlK0aab/xEi8V4iXj4hGepqAvHkXIQd+r3FNeiLfllkb61p6WTjr5urcmDQMR94/wYoilpG5OlybHdbhsYHvIzYoLrC7fzl630gcO6t4nM24vdB6Ymg9BVpEgKRAxSbE62Tqacxqnz9AcmgItb48NiR/He3n3ydGjPYuKk/ihZMgEwAEZvSlNxYONSbYrIGDtOY+8Nbt6KiH3l06wjZW8tcmFeVlWv+tWotnTY9IqlAfvNVTjtsobqtQnvsiDjdEVtNy/s2ci5TH+NdZluca2OVEr91Wayxh70kpM6ib4UGbfdmGgCo74gtKvKSJU0rTHakQ5L9JlaSDD5FamBRyI0qfL43Ad9qOUZ8DaffDCyuaVyuqk7cz9HwmEmvWU3VQ+5t06n/5kRDXttcw8w+3qClEEdGo1KeENcnXCB32dQe3tDTFpuAIMLqwXs6FhpawfZ5kPYvLPczGWaqftIs/RXJ/EltGc0ugw2dmTLpoQhCqrcKEBDoYVk0LDZKsnzitOGdi9mOWse7Se8798ib1UsHFUjGzISEt6upestxOeupSTOh0v4+AjXbDzRUyogHww3V+Bqg71bkcMxtB+WM+pn1XNbVTyl9NR040nhP7KEf6e9ruXAtmrBC2ah5cFEpLIot77VFZ9ilLuitSz+7T8n1yAh1IEG6xxXxninAZIzi2qGbH69O5RSpOJuJTv17zTLJQIIc781JwQ2TTwTGnx5wZLbffhCasowJKd2EVcyMJyhz6ru0PvXWJ4hUdkARJs3Xu8dus9a86N8Xk6aAPzBDqzYb1vyFIfBxP0oO8xFHgd30Cgmz8UrSE3qeWRrF8ftrI6xQnFjHBGWD/JWSvd6YMcQED0aVuQkuNW9ST/DzQThPzRfPUoiL10yAmV7Ytu4fR3x2sF0Yfi87YhHFuCMpV/DsqxmUizyiJuD938eRcH8hzR/VO53Qo3UIsqOLcyXtTv6THjSlTopQ+JOLOnHm1w8dzYbLN44OG44rRsbihMUQp+wUZ6bsI8rrOnm9WErzkbQFbrfAINdoCiNa6cimYIjvvnMTaFWNymqY1vZxGztQiMiHiHYwTfwHTXrb9j0uPM=|09J28iXv9oWzYtzK2LBT6Yht4IT4MijEkk0fwFdrVQ4=".parse::().unwrap(), + signing_key: None, + security_state: None, + method: InitUserCryptoMethod::Password { + password: "asdfasdfasdf".to_owned(), + user_key: "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=".parse().unwrap(), + } + }; + + let org_request = InitOrgCryptoRequest { + organization_keys: std::collections::HashMap::from([( + TEST_ORG_ID.parse().unwrap(), + "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==".parse().unwrap() + )]) + }; + + client + .crypto() + .initialize_user_crypto(user_request) + .await + .unwrap(); + client + .crypto() + .initialize_org_crypto(org_request) + .await + .unwrap(); + + client + .platform() + .state() + .register_client_managed(std::sync::Arc::new(MemoryRepository::::default())); + + client.vault().ciphers() + } + fn create_test_login_cipher(password: &str) -> CipherView { let mut cipher_view = generate_test_cipher(); if let Some(ref mut login) = cipher_view.login { @@ -661,6 +731,48 @@ mod tests { assert_eq!(result.name, "Test Login"); } + #[tokio::test] + async fn test_edit_cipher_as_admin() { + let (mock_server, _config) = start_api_mock(vec![ + Mock::given(method("PUT")) + .and(path_regex(r"/ciphers/[a-f0-9-]+")) + .respond_with(move |req: &wiremock::Request| { + let body_bytes = req.body.as_slice(); + let request_body: CipherRequestModel = + serde_json::from_slice(body_bytes).expect("Failed to parse request body"); + + let response = CipherResponseModel { + id: Some(TEST_CIPHER_ID.try_into().unwrap()), + organization_id: request_body + .organization_id + .and_then(|id| id.parse().ok()), + name: Some(request_body.name.clone()), + r#type: request_body.r#type.clone(), + creation_date: Some(Utc::now().to_string()), + revision_date: Some(Utc::now().to_string()), + ..Default::default() + }; + + ResponseTemplate::new(200).set_body_json(&response) + }), + ]) + .await; + let client = create_client_with_wiremock(&mock_server).await; + + let cipher_view = generate_test_cipher(); + let request = cipher_view.try_into().unwrap(); + + let result = client.edit_as_admin(request).await.unwrap(); + + let repository = client.get_repository().unwrap(); + let cipher = repository.get(TEST_CIPHER_ID.to_string()).await.unwrap(); + // Should not update local repository for admin endpoints. + assert!(matches!(cipher, None)); + + assert_eq!(result.id, TEST_CIPHER_ID.parse().ok()); + assert_eq!(result.name, "Test Login"); + } + #[tokio::test] async fn test_edit_cipher_does_not_exist() { let store: KeyStore = KeyStore::default(); From 3330a781b39a7f009547213cac386bf0cacfd2eb Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Mon, 24 Nov 2025 16:28:34 -0800 Subject: [PATCH 16/38] Add tests for delete cipher endpoints --- .../src/cipher/cipher_client/delete.rs | 355 +++++++++++++++++- 1 file changed, 347 insertions(+), 8 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs index f93e345b9..a73604bf8 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs @@ -1,6 +1,7 @@ use bitwarden_api_api::models::{CipherBulkDeleteRequestModel, CipherBulkRestoreRequestModel}; use bitwarden_core::{ApiError, OrganizationId}; use bitwarden_error::bitwarden_error; +use bitwarden_state::repository::RepositoryError; use thiserror::Error; use crate::{ @@ -20,6 +21,22 @@ pub enum RestoreCipherError { Decrypt(#[from] DecryptError), } +#[allow(missing_docs)] +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum DeleteCipherError { + #[error(transparent)] + Api(#[from] ApiError), + #[error(transparent)] + Repository(#[from] RepositoryError), +} + +impl From> for DeleteCipherError { + fn from(value: bitwarden_api_api::apis::Error) -> Self { + Self::Api(value.into()) + } +} + impl From> for RestoreCipherError { fn from(val: bitwarden_api_api::apis::Error) -> Self { Self::Api(val.into()) @@ -36,10 +53,11 @@ impl CiphersClient { } /// Deletes the [Cipher] with the matching [CipherId] from the server. - pub async fn delete(&self, cipher_id: CipherId) -> Result<(), ApiError> { + pub async fn delete(&self, cipher_id: CipherId) -> Result<(), DeleteCipherError> { let configs = self.get_api_configurations().await; let api = configs.api_client.ciphers_api(); api.delete(cipher_id.into()).await?; + self.get_repository()?.remove(cipher_id.to_string()).await?; Ok(()) } @@ -48,7 +66,7 @@ impl CiphersClient { &self, cipher_ids: Vec, organization_id: Option, - ) -> Result<(), ApiError> { + ) -> Result<(), DeleteCipherError> { let configs = self.get_api_configurations().await; let api = configs.api_client.ciphers_api(); api.delete_many_admin(Some(CipherBulkDeleteRequestModel { @@ -64,19 +82,23 @@ impl CiphersClient { &self, cipher_ids: Vec, organization_id: Option, - ) -> Result<(), ApiError> { + ) -> Result<(), DeleteCipherError> { let configs = self.get_api_configurations().await; let api = configs.api_client.ciphers_api(); api.delete_many(Some(CipherBulkDeleteRequestModel { - ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), + ids: cipher_ids.iter().map(|id| id.to_string()).collect(), organization_id: organization_id.map(|id| id.to_string()), })) .await?; + + for cipher_id in cipher_ids { + self.get_repository()?.remove(cipher_id.to_string()).await?; + } Ok(()) } /// Soft-deletes the [Cipher] with the matching [CipherId] from the server. - pub async fn soft_delete(&self, cipher_id: CipherId) -> Result<(), ApiError> { + pub async fn soft_delete(&self, cipher_id: CipherId) -> Result<(), DeleteCipherError> { let configs = self.get_api_configurations().await; let api = configs.api_client.ciphers_api(); api.put_delete(cipher_id.into()).await?; @@ -84,7 +106,7 @@ impl CiphersClient { } /// Soft-deletes the [Cipher] with the matching [CipherId] from the server, using the admin endpoint. - pub async fn soft_delete_as_admin(&self, cipher_id: CipherId) -> Result<(), ApiError> { + pub async fn soft_delete_as_admin(&self, cipher_id: CipherId) -> Result<(), DeleteCipherError> { let configs = self.get_api_configurations().await; let api = configs.api_client.ciphers_api(); api.put_delete_admin(cipher_id.into()).await?; // TODO: Map errors properly. @@ -96,7 +118,7 @@ impl CiphersClient { &self, cipher_ids: Vec, organization_id: Option, - ) -> Result<(), ApiError> { + ) -> Result<(), DeleteCipherError> { let configs = self.get_api_configurations().await; let api = configs.api_client.ciphers_api(); api.put_delete_many(Some(CipherBulkDeleteRequestModel { @@ -112,7 +134,7 @@ impl CiphersClient { &self, cipher_ids: Vec, organization_id: Option, - ) -> Result<(), ApiError> { + ) -> Result<(), DeleteCipherError> { let configs = self.get_api_configurations().await; let api = configs.api_client.ciphers_api(); api.put_delete_many_admin(Some(CipherBulkDeleteRequestModel { @@ -181,3 +203,320 @@ impl CiphersClient { Ok(self.decrypt_list_with_failures(ciphers)) } } + +#[cfg(test)] +mod tests { + use bitwarden_core::{ + Client, ClientSettings, DeviceType, UserId, + key_management::crypto::{ + InitOrgCryptoRequest, InitUserCryptoMethod, InitUserCryptoRequest, + }, + }; + use bitwarden_crypto::{EncString, Kdf}; + use bitwarden_test::{MemoryRepository, start_api_mock}; + + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{method, path_regex}, + }; + + use crate::{Cipher, CipherId, CiphersClient, VaultClientExt}; + + const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097"; + const TEST_CIPHER_ID_2: &str = "6faa9684-c793-4a2d-8a12-b33900187098"; + const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000"; + const TEST_ORG_ID: &str = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8"; + + async fn create_client_with_wiremock(mock_server: MockServer) -> CiphersClient { + let settings = ClientSettings { + identity_url: format!("http://{}", mock_server.address()), + api_url: format!("http://{}", mock_server.address()), + user_agent: "Bitwarden Test".into(), + device_type: DeviceType::SDK, + bitwarden_client_version: None, + }; + + let client = Client::new(Some(settings)); + + client + .internal + .load_flags(std::collections::HashMap::from([( + "enableCipherKeyEncryption".to_owned(), + true, + )])); + + let user_request = InitUserCryptoRequest { + user_id: Some(UserId::new(uuid::uuid!(TEST_USER_ID))), + kdf_params: Kdf::PBKDF2 { + iterations: 600_000.try_into().unwrap(), + }, + email: "test@bitwarden.com".to_owned(), + private_key: "2.yN7l00BOlUE0Sb0M//Q53w==|EwKG/BduQRQ33Izqc/ogoBROIoI5dmgrxSo82sgzgAMIBt3A2FZ9vPRMY+GWT85JiqytDitGR3TqwnFUBhKUpRRAq4x7rA6A1arHrFp5Tp1p21O3SfjtvB3quiOKbqWk6ZaU1Np9HwqwAecddFcB0YyBEiRX3VwF2pgpAdiPbSMuvo2qIgyob0CUoC/h4Bz1be7Qa7B0Xw9/fMKkB1LpOm925lzqosyMQM62YpMGkjMsbZz0uPopu32fxzDWSPr+kekNNyLt9InGhTpxLmq1go/pXR2uw5dfpXc5yuta7DB0EGBwnQ8Vl5HPdDooqOTD9I1jE0mRyuBpWTTI3FRnu3JUh3rIyGBJhUmHqGZvw2CKdqHCIrQeQkkEYqOeJRJVdBjhv5KGJifqT3BFRwX/YFJIChAQpebNQKXe/0kPivWokHWwXlDB7S7mBZzhaAPidZvnuIhalE2qmTypDwHy22FyqV58T8MGGMchcASDi/QXI6kcdpJzPXSeU9o+NC68QDlOIrMVxKFeE7w7PvVmAaxEo0YwmuAzzKy9QpdlK0aab/xEi8V4iXj4hGepqAvHkXIQd+r3FNeiLfllkb61p6WTjr5urcmDQMR94/wYoilpG5OlybHdbhsYHvIzYoLrC7fzl630gcO6t4nM24vdB6Ymg9BVpEgKRAxSbE62Tqacxqnz9AcmgItb48NiR/He3n3ydGjPYuKk/ihZMgEwAEZvSlNxYONSbYrIGDtOY+8Nbt6KiH3l06wjZW8tcmFeVlWv+tWotnTY9IqlAfvNVTjtsobqtQnvsiDjdEVtNy/s2ci5TH+NdZluca2OVEr91Wayxh70kpM6ib4UGbfdmGgCo74gtKvKSJU0rTHakQ5L9JlaSDD5FamBRyI0qfL43Ad9qOUZ8DaffDCyuaVyuqk7cz9HwmEmvWU3VQ+5t06n/5kRDXttcw8w+3qClEEdGo1KeENcnXCB32dQe3tDTFpuAIMLqwXs6FhpawfZ5kPYvLPczGWaqftIs/RXJ/EltGc0ugw2dmTLpoQhCqrcKEBDoYVk0LDZKsnzitOGdi9mOWse7Se8798ib1UsHFUjGzISEt6upestxOeupSTOh0v4+AjXbDzRUyogHww3V+Bqg71bkcMxtB+WM+pn1XNbVTyl9NR040nhP7KEf6e9ruXAtmrBC2ah5cFEpLIot77VFZ9ilLuitSz+7T8n1yAh1IEG6xxXxninAZIzi2qGbH69O5RSpOJuJTv17zTLJQIIc781JwQ2TTwTGnx5wZLbffhCasowJKd2EVcyMJyhz6ru0PvXWJ4hUdkARJs3Xu8dus9a86N8Xk6aAPzBDqzYb1vyFIfBxP0oO8xFHgd30Cgmz8UrSE3qeWRrF8ftrI6xQnFjHBGWD/JWSvd6YMcQED0aVuQkuNW9ST/DzQThPzRfPUoiL10yAmV7Ytu4fR3x2sF0Yfi87YhHFuCMpV/DsqxmUizyiJuD938eRcH8hzR/VO53Qo3UIsqOLcyXtTv6THjSlTopQ+JOLOnHm1w8dzYbLN44OG44rRsbihMUQp+wUZ6bsI8rrOnm9WErzkbQFbrfAINdoCiNa6cimYIjvvnMTaFWNymqY1vZxGztQiMiHiHYwTfwHTXrb9j0uPM=|09J28iXv9oWzYtzK2LBT6Yht4IT4MijEkk0fwFdrVQ4=".parse::().unwrap(), + signing_key: None, + security_state: None, + method: InitUserCryptoMethod::Password { + password: "asdfasdfasdf".to_owned(), + user_key: "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=".parse().unwrap(), + } + }; + + let org_request = InitOrgCryptoRequest { + organization_keys: std::collections::HashMap::from([( + TEST_ORG_ID.parse().unwrap(), + "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==".parse().unwrap() + )]) + }; + + client + .crypto() + .initialize_user_crypto(user_request) + .await + .unwrap(); + client + .crypto() + .initialize_org_crypto(org_request) + .await + .unwrap(); + + client + .platform() + .state() + .register_client_managed(std::sync::Arc::new(MemoryRepository::::default())); + + client.vault().ciphers() + } + + fn generate_test_cipher() -> Cipher { + Cipher { + id: TEST_CIPHER_ID.parse().ok(), + name: "2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8=".parse().unwrap(), + r#type: crate::CipherType::Login, + notes: Default::default(), + organization_id: Default::default(), + folder_id: Default::default(), + favorite: Default::default(), + reprompt: Default::default(), + fields: Default::default(), + collection_ids: Default::default(), + key: Default::default(), + login: Default::default(), + identity: Default::default(), + card: Default::default(), + secure_note: Default::default(), + ssh_key: Default::default(), + organization_use_totp: Default::default(), + edit: Default::default(), + permissions: Default::default(), + view_password: Default::default(), + local_data: Default::default(), + attachments: Default::default(), + password_history: Default::default(), + creation_date: Default::default(), + deleted_date: Default::default(), + revision_date: Default::default(), + archived_date: Default::default(), + data: Default::default(), + } + } + + #[tokio::test] + async fn test_delete() { + let (mock_server, _config) = start_api_mock(vec![ + Mock::given(method("DELETE")) + .and(path_regex(r"/ciphers/[a-f0-9-]+")) + .respond_with(move |_req: &wiremock::Request| ResponseTemplate::new(200)), + ]) + .await; + + let client = create_client_with_wiremock(mock_server).await; + let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); + let repository = client.get_repository().unwrap(); + repository + .set(cipher_id.to_string(), generate_test_cipher()) + .await + .unwrap(); + client.delete(cipher_id).await.unwrap(); + let cipher = repository.get(cipher_id.to_string()).await.unwrap(); + assert!( + cipher.is_none(), + "Cipher is deleted from the local repository" + ); + } + + #[tokio::test] + async fn test_delete_as_admin() { + let (mock_server, _config) = start_api_mock(vec![ + Mock::given(method("DELETE")) + .and(path_regex(r"/ciphers/[a-f0-9-]+/admin")) + .respond_with(move |_req: &wiremock::Request| ResponseTemplate::new(200)), + ]) + .await; + + let client = create_client_with_wiremock(mock_server).await; + let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); + + client.delete_as_admin(cipher_id).await.unwrap(); + } + + #[tokio::test] + async fn test_delete_many() { + let (mock_server, _config) = start_api_mock(vec![ + Mock::given(method("DELETE")) + .and(path_regex(r"/ciphers")) + .respond_with(move |_req: &wiremock::Request| ResponseTemplate::new(200)), + ]) + .await; + + let cipher_1 = generate_test_cipher(); + let mut cipher_2 = generate_test_cipher(); + cipher_2.id = Some(TEST_CIPHER_ID_2.parse().unwrap()); + + let client = create_client_with_wiremock(mock_server).await; + let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); + let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap(); + let repository = client.get_repository().unwrap(); + repository + .set(cipher_id.to_string(), cipher_1) + .await + .unwrap(); + repository + .set(TEST_CIPHER_ID_2.to_string(), cipher_2) + .await + .unwrap(); + client + .delete_many(vec![cipher_id, cipher_id_2], None) + .await + .unwrap(); + let cipher_1 = repository.get(cipher_id.to_string()).await.unwrap(); + let cipher_2 = repository.get(cipher_id_2.to_string()).await.unwrap(); + assert!( + cipher_1.is_none(), + "Cipher is deleted from the local repository" + ); + assert!( + cipher_2.is_none(), + "Cipher is deleted from the local repository" + ); + } + + #[tokio::test] + async fn test_delete_many_as_admin() { + let (mock_server, _config) = start_api_mock(vec![ + Mock::given(method("DELETE")) + .and(path_regex(r"/ciphers/admin")) + .respond_with(move |_req: &wiremock::Request| ResponseTemplate::new(200)), + ]) + .await; + + let client = create_client_with_wiremock(mock_server).await; + let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); + let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap(); + client + .delete_many_as_admin(vec![cipher_id, cipher_id_2], None) + .await + .unwrap(); + } + + #[tokio::test] + async fn test_soft_delete() { + let (mock_server, _config) = start_api_mock(vec![ + Mock::given(method("PUT")) + .and(path_regex(r"/ciphers/[a-f0-9-]+/delete")) + .respond_with(move |_req: &wiremock::Request| ResponseTemplate::new(200)), + ]) + .await; + + let client = create_client_with_wiremock(mock_server).await; + let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); + let repository = client.get_repository().unwrap(); + repository + .set(cipher_id.to_string(), generate_test_cipher()) + .await + .unwrap(); + client.soft_delete(cipher_id).await.unwrap(); + } + + #[tokio::test] + async fn test_soft_delete_as_admin() { + let (mock_server, _config) = start_api_mock(vec![ + Mock::given(method("PUT")) + .and(path_regex(r"/ciphers/[a-f0-9-]+/delete-admin")) + .respond_with(move |_req: &wiremock::Request| ResponseTemplate::new(200)), + ]) + .await; + + let client = create_client_with_wiremock(mock_server).await; + let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); + + client.soft_delete_as_admin(cipher_id).await.unwrap(); + } + + #[tokio::test] + async fn test_soft_delete_many() { + let (mock_server, _config) = start_api_mock(vec![ + Mock::given(method("PUT")) + .and(path_regex(r"/ciphers/delete")) + .respond_with(move |_req: &wiremock::Request| ResponseTemplate::new(200)), + ]) + .await; + + let cipher_1 = generate_test_cipher(); + let mut cipher_2 = generate_test_cipher(); + cipher_2.id = Some(TEST_CIPHER_ID_2.parse().unwrap()); + + let client = create_client_with_wiremock(mock_server).await; + let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); + let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap(); + let repository = client.get_repository().unwrap(); + repository + .set(cipher_id.to_string(), cipher_1) + .await + .unwrap(); + repository + .set(TEST_CIPHER_ID_2.to_string(), cipher_2) + .await + .unwrap(); + client + .soft_delete_many(vec![cipher_id, cipher_id_2], None) + .await + .unwrap(); + } + + #[tokio::test] + async fn test_soft_delete_many_as_admin() { + let (mock_server, _config) = start_api_mock(vec![ + Mock::given(method("PUT")) + .and(path_regex(r"/ciphers/delete-admin")) + .respond_with(move |_req: &wiremock::Request| ResponseTemplate::new(200)), + ]) + .await; + + let client = create_client_with_wiremock(mock_server).await; + let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); + let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap(); + client + .soft_delete_many_as_admin(vec![cipher_id, cipher_id_2], TEST_ORG_ID.parse().ok()) + .await + .unwrap(); + } + + #[tokio::test] + async fn test_restore() { + todo!() + } + + #[tokio::test] + async fn test_restore_as_admin() { + todo!() + } + + #[tokio::test] + async fn test_restore_many() { + todo!() + } + + #[tokio::test] + async fn test_restore_many_as_admin() { + todo!() + } +} From 8307f070a97a1c6204564913e4b007b71de8f9f6 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Mon, 24 Nov 2025 16:40:10 -0800 Subject: [PATCH 17/38] Update repository when a cipher is soft-deleted --- .../src/cipher/cipher_client/delete.rs | 72 ++++++++++++++++++- 1 file changed, 69 insertions(+), 3 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs index a73604bf8..695ff40e2 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs @@ -2,6 +2,7 @@ use bitwarden_api_api::models::{CipherBulkDeleteRequestModel, CipherBulkRestoreR use bitwarden_core::{ApiError, OrganizationId}; use bitwarden_error::bitwarden_error; use bitwarden_state::repository::RepositoryError; +use chrono::Utc; use thiserror::Error; use crate::{ @@ -97,11 +98,23 @@ impl CiphersClient { Ok(()) } + async fn process_soft_delete(&self, cipher_id: CipherId) -> Result<(), RepositoryError> { + let repository = self.get_repository()?; + let cipher: Option = repository.get(cipher_id.to_string()).await?; + if let Some(mut cipher) = cipher { + cipher.deleted_date = Some(Utc::now()); + cipher.archived_date = None; + repository.set(cipher_id.to_string(), cipher).await?; + } + Ok(()) + } + /// Soft-deletes the [Cipher] with the matching [CipherId] from the server. pub async fn soft_delete(&self, cipher_id: CipherId) -> Result<(), DeleteCipherError> { let configs = self.get_api_configurations().await; let api = configs.api_client.ciphers_api(); api.put_delete(cipher_id.into()).await?; + self.process_soft_delete(cipher_id).await?; Ok(()) } @@ -122,10 +135,13 @@ impl CiphersClient { let configs = self.get_api_configurations().await; let api = configs.api_client.ciphers_api(); api.put_delete_many(Some(CipherBulkDeleteRequestModel { - ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), + ids: cipher_ids.iter().map(|id| id.to_string()).collect(), organization_id: organization_id.map(|id| id.to_string()), })) .await?; + for cipher_id in cipher_ids { + self.process_soft_delete(cipher_id).await?; + } Ok(()) } @@ -145,6 +161,17 @@ impl CiphersClient { Ok(()) } + async fn process_restore(&self, cipher_id: CipherId) -> Result<(), RepositoryError> { + let repository = self.get_repository()?; + let cipher: Option = repository.get(cipher_id.to_string()).await?; + if let Some(mut cipher) = cipher { + cipher.deleted_date = Some(Utc::now()); + cipher.archived_date = None; + repository.set(cipher_id.to_string(), cipher).await?; + } + Ok(()) + } + /// Restores a soft-deleted cipher on the server. pub async fn restore(&self, cipher_id: CipherId) -> Result { let api_config = self.get_api_configurations().await; @@ -215,6 +242,7 @@ mod tests { use bitwarden_crypto::{EncString, Kdf}; use bitwarden_test::{MemoryRepository, start_api_mock}; + use chrono::Utc; use wiremock::{ Mock, MockServer, ResponseTemplate, matchers::{method, path_regex}, @@ -412,7 +440,7 @@ mod tests { let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap(); client - .delete_many_as_admin(vec![cipher_id, cipher_id_2], None) + .delete_many_as_admin(vec![cipher_id, cipher_id_2], TEST_ORG_ID.parse().ok()) .await .unwrap(); } @@ -433,7 +461,20 @@ mod tests { .set(cipher_id.to_string(), generate_test_cipher()) .await .unwrap(); + + let start_time = Utc::now(); client.soft_delete(cipher_id).await.unwrap(); + let end_time = Utc::now(); + + let cipher: Cipher = repository + .get(cipher_id.to_string()) + .await + .unwrap() + .unwrap(); + assert!( + cipher.deleted_date.unwrap() >= start_time && cipher.deleted_date.unwrap() <= end_time, + "Cipher was flagged as deleted in the repository." + ); } #[tokio::test] @@ -476,10 +517,35 @@ mod tests { .set(TEST_CIPHER_ID_2.to_string(), cipher_2) .await .unwrap(); + client .soft_delete_many(vec![cipher_id, cipher_id_2], None) .await .unwrap(); + + let start_time = Utc::now(); + let cipher_1 = repository + .get(cipher_id.to_string()) + .await + .unwrap() + .unwrap(); + let cipher_2 = repository + .get(cipher_id_2.to_string()) + .await + .unwrap() + .unwrap(); + let end_time = Utc::now(); + + assert!( + cipher_1.deleted_date.unwrap() >= start_time + && cipher_1.deleted_date.unwrap() <= end_time, + "Cipher was flagged as deleted in the repository." + ); + assert!( + cipher_2.deleted_date.unwrap() >= start_time + && cipher_2.deleted_date.unwrap() <= end_time, + "Cipher was flagged as deleted in the repository." + ); } #[tokio::test] @@ -495,7 +561,7 @@ mod tests { let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap(); client - .soft_delete_many_as_admin(vec![cipher_id, cipher_id_2], TEST_ORG_ID.parse().ok()) + .delete_many_as_admin(vec![cipher_id, cipher_id_2], TEST_ORG_ID.parse().ok()) .await .unwrap(); } From 6485b2ec93533baec23e848ec1289c3e1a4e6ad1 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Wed, 26 Nov 2025 12:52:47 -0800 Subject: [PATCH 18/38] Update tests for delete --- .../src/cipher/cipher_client/delete.rs | 120 +----------------- 1 file changed, 6 insertions(+), 114 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs index 695ff40e2..2fcc5ec6f 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs @@ -1,26 +1,11 @@ -use bitwarden_api_api::models::{CipherBulkDeleteRequestModel, CipherBulkRestoreRequestModel}; +use bitwarden_api_api::models::CipherBulkDeleteRequestModel; use bitwarden_core::{ApiError, OrganizationId}; use bitwarden_error::bitwarden_error; use bitwarden_state::repository::RepositoryError; use chrono::Utc; use thiserror::Error; -use crate::{ - Cipher, CipherId, CipherView, CiphersClient, DecryptCipherListResult, DecryptError, - VaultParseError, -}; - -#[allow(missing_docs)] -#[bitwarden_error(flat)] -#[derive(Debug, Error)] -pub enum RestoreCipherError { - #[error(transparent)] - Api(#[from] ApiError), - #[error(transparent)] - VaultParse(#[from] VaultParseError), - #[error(transparent)] - Decrypt(#[from] DecryptError), -} +use crate::{Cipher, CipherId, CiphersClient}; #[allow(missing_docs)] #[bitwarden_error(flat)] @@ -38,12 +23,6 @@ impl From> for DeleteCipherError { } } -impl From> for RestoreCipherError { - fn from(val: bitwarden_api_api::apis::Error) -> Self { - Self::Api(val.into()) - } -} - impl CiphersClient { /// Deletes the [Cipher] with the matching [CipherId] from the server, using the admin endpoint. pub async fn delete_as_admin(&self, cipher_id: CipherId) -> Result<(), ApiError> { @@ -160,75 +139,6 @@ impl CiphersClient { .await?; Ok(()) } - - async fn process_restore(&self, cipher_id: CipherId) -> Result<(), RepositoryError> { - let repository = self.get_repository()?; - let cipher: Option = repository.get(cipher_id.to_string()).await?; - if let Some(mut cipher) = cipher { - cipher.deleted_date = Some(Utc::now()); - cipher.archived_date = None; - repository.set(cipher_id.to_string(), cipher).await?; - } - Ok(()) - } - - /// Restores a soft-deleted cipher on the server. - pub async fn restore(&self, cipher_id: CipherId) -> Result { - let api_config = self.get_api_configurations().await; - let api = api_config.api_client.ciphers_api(); - - let cipher: Cipher = api.put_restore(cipher_id.into()).await?.try_into()?; - - Ok(self.decrypt(cipher)?) - } - - /// Restores a soft-deleted cipher on the server, using the admin endpoint. - pub async fn restore_as_admin( - &self, - cipher_id: CipherId, - ) -> Result { - let api_config = self.get_api_configurations().await; - let api = api_config.api_client.ciphers_api(); - - let cipher: Cipher = api.put_restore_admin(cipher_id.into()).await?.try_into()?; - - Ok(self.decrypt(cipher)?) - } - - /// Restores multiple soft-deleted ciphers on the server. - pub async fn restore_many( - &self, - cipher_ids: Vec, - org_id: Option, - ) -> Result { - let api_config = self.get_api_configurations().await; - let api = api_config.api_client.ciphers_api(); - - let ciphers: Vec = if let Some(org_id) = org_id { - api.put_restore_many_admin(Some(CipherBulkRestoreRequestModel { - ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), - organization_id: Some(org_id.into()), - })) - .await? - .data - .into_iter() - .flatten() - .map(|c| c.try_into()) - .collect::, _>>()? - } else { - api.put_restore_many(Some(CipherBulkRestoreRequestModel { - ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), - organization_id: None, - })) - .await? - .data - .into_iter() - .flatten() - .map(|c| c.try_into()) - .collect::, _>>()? - }; - Ok(self.decrypt_list_with_failures(ciphers)) - } } #[cfg(test)] @@ -518,12 +428,13 @@ mod tests { .await .unwrap(); + let start_time = Utc::now(); client .soft_delete_many(vec![cipher_id, cipher_id_2], None) .await .unwrap(); + let end_time = Utc::now(); - let start_time = Utc::now(); let cipher_1 = repository .get(cipher_id.to_string()) .await @@ -534,7 +445,6 @@ mod tests { .await .unwrap() .unwrap(); - let end_time = Utc::now(); assert!( cipher_1.deleted_date.unwrap() >= start_time @@ -557,32 +467,14 @@ mod tests { ]) .await; + // Populate the repository with test ciphers. let client = create_client_with_wiremock(mock_server).await; let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap(); + client .delete_many_as_admin(vec![cipher_id, cipher_id_2], TEST_ORG_ID.parse().ok()) .await .unwrap(); } - - #[tokio::test] - async fn test_restore() { - todo!() - } - - #[tokio::test] - async fn test_restore_as_admin() { - todo!() - } - - #[tokio::test] - async fn test_restore_many() { - todo!() - } - - #[tokio::test] - async fn test_restore_many_as_admin() { - todo!() - } } From 28880f96cc18c87492588e2f9b0ec469209e7cb0 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Wed, 26 Nov 2025 12:53:11 -0800 Subject: [PATCH 19/38] Move restore operations to separate file --- .../src/cipher/cipher_client/mod.rs | 1 + .../src/cipher/cipher_client/restore.rs | 443 ++++++++++++++++++ 2 files changed, 444 insertions(+) create mode 100644 crates/bitwarden-vault/src/cipher/cipher_client/restore.rs diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs b/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs index 57f73399f..1dd26fa4c 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs @@ -22,6 +22,7 @@ mod create; mod delete; mod edit; mod get; +mod restore; mod share_cipher; #[allow(missing_docs)] diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs b/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs new file mode 100644 index 000000000..241513363 --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs @@ -0,0 +1,443 @@ +use bitwarden_api_api::models::{CipherBulkDeleteRequestModel, CipherBulkRestoreRequestModel}; +use bitwarden_core::{ApiError, OrganizationId}; +use bitwarden_error::bitwarden_error; +use bitwarden_state::repository::RepositoryError; +use chrono::Utc; +use thiserror::Error; + +use crate::{ + Cipher, CipherId, CipherView, CiphersClient, DecryptCipherListResult, DecryptError, + VaultParseError, +}; + +#[allow(missing_docs)] +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum RestoreCipherError { + #[error(transparent)] + Api(#[from] ApiError), + #[error(transparent)] + VaultParse(#[from] VaultParseError), + #[error(transparent)] + Decrypt(#[from] DecryptError), + #[error(transparent)] + Repository(#[from] RepositoryError), +} + +impl From> for RestoreCipherError { + fn from(val: bitwarden_api_api::apis::Error) -> Self { + Self::Api(val.into()) + } +} + +impl CiphersClient { + /// Restores a soft-deleted cipher on the server. + pub async fn restore(&self, cipher_id: CipherId) -> Result { + let api_config = self.get_api_configurations().await; + let api = api_config.api_client.ciphers_api(); + + let cipher: Cipher = api.put_restore(cipher_id.into()).await?.try_into()?; + self.get_repository()? + .set(cipher_id.to_string(), cipher.clone()) + .await?; + + Ok(self.decrypt(cipher)?) + } + + /// Restores a soft-deleted cipher on the server, using the admin endpoint. + pub async fn restore_as_admin( + &self, + cipher_id: CipherId, + ) -> Result { + let api_config = self.get_api_configurations().await; + let api = api_config.api_client.ciphers_api(); + + let cipher: Cipher = api.put_restore_admin(cipher_id.into()).await?.try_into()?; + + Ok(self.decrypt(cipher)?) + } + + /// Restores multiple soft-deleted ciphers on the server. + pub async fn restore_many( + &self, + cipher_ids: Vec, + ) -> Result { + let api_config = self.get_api_configurations().await; + let api = api_config.api_client.ciphers_api(); + + let ciphers: Vec = api + .put_restore_many(Some(CipherBulkRestoreRequestModel { + ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), + organization_id: None, + })) + .await? + .data + .into_iter() + .flatten() + .map(|c| c.try_into()) + .collect::, _>>()?; + + for cipher in &ciphers { + if let Some(id) = &cipher.id { + self.get_repository()? + .set(id.to_string(), cipher.clone()) + .await?; + } + } + + Ok(self.decrypt_list_with_failures(ciphers)) + } + + /// Restores multiple soft-deleted ciphers on the server. + pub async fn restore_many_as_admin( + &self, + cipher_ids: Vec, + org_id: OrganizationId, + ) -> Result { + let api_config = self.get_api_configurations().await; + let api = api_config.api_client.ciphers_api(); + + let ciphers: Vec = api + .put_restore_many_admin(Some(CipherBulkRestoreRequestModel { + ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), + organization_id: Some(org_id.into()), + })) + .await? + .data + .into_iter() + .flatten() + .map(|c| c.try_into()) + .collect::, _>>()?; + Ok(self.decrypt_list_with_failures(ciphers)) + } +} + +#[cfg(test)] +mod tests { + use bitwarden_api_api::models::{ + CipherMiniResponseModel, CipherMiniResponseModelListResponseModel, CipherResponseModel, + }; + use bitwarden_core::{ + Client, ClientSettings, DeviceType, UserId, + key_management::crypto::{ + InitOrgCryptoRequest, InitUserCryptoMethod, InitUserCryptoRequest, + }, + }; + use bitwarden_crypto::{EncString, Kdf}; + use bitwarden_test::{MemoryRepository, start_api_mock}; + + use chrono::Utc; + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{method, path_regex}, + }; + + use crate::{Cipher, CipherId, CiphersClient, VaultClientExt}; + + const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097"; + const TEST_CIPHER_ID_2: &str = "6faa9684-c793-4a2d-8a12-b33900187098"; + const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000"; + const TEST_ORG_ID: &str = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8"; + + async fn create_client_with_wiremock(mock_server: MockServer) -> CiphersClient { + let settings = ClientSettings { + identity_url: format!("http://{}", mock_server.address()), + api_url: format!("http://{}", mock_server.address()), + user_agent: "Bitwarden Test".into(), + device_type: DeviceType::SDK, + bitwarden_client_version: None, + }; + + let client = Client::new(Some(settings)); + + client + .internal + .load_flags(std::collections::HashMap::from([( + "enableCipherKeyEncryption".to_owned(), + true, + )])); + + let user_request = InitUserCryptoRequest { + user_id: Some(UserId::new(uuid::uuid!(TEST_USER_ID))), + kdf_params: Kdf::PBKDF2 { + iterations: 600_000.try_into().unwrap(), + }, + email: "test@bitwarden.com".to_owned(), + private_key: "2.yN7l00BOlUE0Sb0M//Q53w==|EwKG/BduQRQ33Izqc/ogoBROIoI5dmgrxSo82sgzgAMIBt3A2FZ9vPRMY+GWT85JiqytDitGR3TqwnFUBhKUpRRAq4x7rA6A1arHrFp5Tp1p21O3SfjtvB3quiOKbqWk6ZaU1Np9HwqwAecddFcB0YyBEiRX3VwF2pgpAdiPbSMuvo2qIgyob0CUoC/h4Bz1be7Qa7B0Xw9/fMKkB1LpOm925lzqosyMQM62YpMGkjMsbZz0uPopu32fxzDWSPr+kekNNyLt9InGhTpxLmq1go/pXR2uw5dfpXc5yuta7DB0EGBwnQ8Vl5HPdDooqOTD9I1jE0mRyuBpWTTI3FRnu3JUh3rIyGBJhUmHqGZvw2CKdqHCIrQeQkkEYqOeJRJVdBjhv5KGJifqT3BFRwX/YFJIChAQpebNQKXe/0kPivWokHWwXlDB7S7mBZzhaAPidZvnuIhalE2qmTypDwHy22FyqV58T8MGGMchcASDi/QXI6kcdpJzPXSeU9o+NC68QDlOIrMVxKFeE7w7PvVmAaxEo0YwmuAzzKy9QpdlK0aab/xEi8V4iXj4hGepqAvHkXIQd+r3FNeiLfllkb61p6WTjr5urcmDQMR94/wYoilpG5OlybHdbhsYHvIzYoLrC7fzl630gcO6t4nM24vdB6Ymg9BVpEgKRAxSbE62Tqacxqnz9AcmgItb48NiR/He3n3ydGjPYuKk/ihZMgEwAEZvSlNxYONSbYrIGDtOY+8Nbt6KiH3l06wjZW8tcmFeVlWv+tWotnTY9IqlAfvNVTjtsobqtQnvsiDjdEVtNy/s2ci5TH+NdZluca2OVEr91Wayxh70kpM6ib4UGbfdmGgCo74gtKvKSJU0rTHakQ5L9JlaSDD5FamBRyI0qfL43Ad9qOUZ8DaffDCyuaVyuqk7cz9HwmEmvWU3VQ+5t06n/5kRDXttcw8w+3qClEEdGo1KeENcnXCB32dQe3tDTFpuAIMLqwXs6FhpawfZ5kPYvLPczGWaqftIs/RXJ/EltGc0ugw2dmTLpoQhCqrcKEBDoYVk0LDZKsnzitOGdi9mOWse7Se8798ib1UsHFUjGzISEt6upestxOeupSTOh0v4+AjXbDzRUyogHww3V+Bqg71bkcMxtB+WM+pn1XNbVTyl9NR040nhP7KEf6e9ruXAtmrBC2ah5cFEpLIot77VFZ9ilLuitSz+7T8n1yAh1IEG6xxXxninAZIzi2qGbH69O5RSpOJuJTv17zTLJQIIc781JwQ2TTwTGnx5wZLbffhCasowJKd2EVcyMJyhz6ru0PvXWJ4hUdkARJs3Xu8dus9a86N8Xk6aAPzBDqzYb1vyFIfBxP0oO8xFHgd30Cgmz8UrSE3qeWRrF8ftrI6xQnFjHBGWD/JWSvd6YMcQED0aVuQkuNW9ST/DzQThPzRfPUoiL10yAmV7Ytu4fR3x2sF0Yfi87YhHFuCMpV/DsqxmUizyiJuD938eRcH8hzR/VO53Qo3UIsqOLcyXtTv6THjSlTopQ+JOLOnHm1w8dzYbLN44OG44rRsbihMUQp+wUZ6bsI8rrOnm9WErzkbQFbrfAINdoCiNa6cimYIjvvnMTaFWNymqY1vZxGztQiMiHiHYwTfwHTXrb9j0uPM=|09J28iXv9oWzYtzK2LBT6Yht4IT4MijEkk0fwFdrVQ4=".parse::().unwrap(), + signing_key: None, + security_state: None, + method: InitUserCryptoMethod::Password { + password: "asdfasdfasdf".to_owned(), + user_key: "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=".parse().unwrap(), + } + }; + + let org_request = InitOrgCryptoRequest { + organization_keys: std::collections::HashMap::from([( + TEST_ORG_ID.parse().unwrap(), + "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==".parse().unwrap() + )]) + }; + + client + .crypto() + .initialize_user_crypto(user_request) + .await + .unwrap(); + client + .crypto() + .initialize_org_crypto(org_request) + .await + .unwrap(); + + client + .platform() + .state() + .register_client_managed(std::sync::Arc::new(MemoryRepository::::default())); + + client.vault().ciphers() + } + + fn generate_test_cipher() -> Cipher { + Cipher { + id: TEST_CIPHER_ID.parse().ok(), + name: "2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8=".parse().unwrap(), + r#type: crate::CipherType::Login, + notes: Default::default(), + organization_id: Default::default(), + folder_id: Default::default(), + favorite: Default::default(), + reprompt: Default::default(), + fields: Default::default(), + collection_ids: Default::default(), + key: Default::default(), + login: Default::default(), + identity: Default::default(), + card: Default::default(), + secure_note: Default::default(), + ssh_key: Default::default(), + organization_use_totp: Default::default(), + edit: Default::default(), + permissions: Default::default(), + view_password: Default::default(), + local_data: Default::default(), + attachments: Default::default(), + password_history: Default::default(), + creation_date: Default::default(), + deleted_date: Default::default(), + revision_date: Default::default(), + archived_date: Default::default(), + data: Default::default(), + } + } + + #[tokio::test] + async fn test_restore() { + // Set up test ciphers in the repository. + let mut cipher_1 = generate_test_cipher(); + cipher_1.deleted_date = Some(Utc::now()); + + let (mock_server, _config) = start_api_mock(vec![ + Mock::given(method("PUT")) + .and(path_regex(r"/ciphers/[a-f0-9-]+/restore")) + .respond_with(move |_req: &wiremock::Request| { + ResponseTemplate::new(200).set_body_json(CipherResponseModel { + id: Some(TEST_CIPHER_ID.try_into().unwrap()), + name: Some(cipher_1.name.to_string()), + r#type: Some(cipher_1.r#type.into()), + creation_date: Some(cipher_1.creation_date.to_string()), + revision_date: Some(Utc::now().to_string()), + ..Default::default() + }) + }), + ]) + .await; + let client = create_client_with_wiremock(mock_server).await; + + let mut cipher = generate_test_cipher(); + cipher.deleted_date = Some(Utc::now()); + let repository = client.get_repository().unwrap(); + repository + .set(TEST_CIPHER_ID.to_string(), cipher) + .await + .unwrap(); + + let start_time = Utc::now(); + let updated_cipher = client + .restore(TEST_CIPHER_ID.parse().unwrap()) + .await + .unwrap(); + let end_time = Utc::now(); + assert!(updated_cipher.deleted_date.is_none()); + assert!( + updated_cipher.revision_date >= start_time && updated_cipher.revision_date <= end_time + ); + + let repo_cipher = client + .get_repository() + .unwrap() + .get(TEST_CIPHER_ID.to_string()) + .await + .unwrap() + .unwrap(); + assert!(repo_cipher.deleted_date.is_none()); + assert!( + repo_cipher.revision_date >= start_time && updated_cipher.revision_date <= end_time + ); + } + + #[tokio::test] + async fn test_restore_as_admin() { + let mut cipher = generate_test_cipher(); + cipher.deleted_date = Some(Utc::now()); + let (mock_server, _config) = start_api_mock(vec![ + Mock::given(method("PUT")) + .and(path_regex(r"/ciphers/[a-f0-9-]+/restore-admin")) + .respond_with(move |_req: &wiremock::Request| { + ResponseTemplate::new(200).set_body_json(CipherResponseModel { + id: Some(TEST_CIPHER_ID.try_into().unwrap()), + name: Some(cipher.name.to_string()), + r#type: Some(cipher.r#type.into()), + creation_date: Some(cipher.creation_date.to_string()), + revision_date: Some(Utc::now().to_string()), + ..Default::default() + }) + }), + ]) + .await; + + let client = create_client_with_wiremock(mock_server).await; + + let start_time = Utc::now(); + let updated_cipher = client + .restore_as_admin(TEST_CIPHER_ID.parse().unwrap()) + .await + .unwrap(); + let end_time = Utc::now(); + + assert!(updated_cipher.deleted_date.is_none()); + assert!( + updated_cipher.revision_date >= start_time && updated_cipher.revision_date <= end_time + ); + } + + #[tokio::test] + async fn test_restore_many() { + let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); + let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap(); + let mut cipher_1 = generate_test_cipher(); + cipher_1.deleted_date = Some(Utc::now()); + let mut cipher_2 = generate_test_cipher(); + cipher_2.deleted_date = Some(Utc::now()); + cipher_2.id = Some(cipher_id_2); + + let (mock_server, _config) = { + let (cipher_1, cipher_2) = (cipher_1.clone(), cipher_2.clone()); + start_api_mock(vec![ + Mock::given(method("PUT")) + .and(path_regex(r"/ciphers/restore")) + .respond_with(move |_req: &wiremock::Request| { + let (cipher_1, cipher_2) = (cipher_1.clone(), cipher_2.clone()); + ResponseTemplate::new(200).set_body_json( + CipherMiniResponseModelListResponseModel { + object: None, + data: Some(vec![ + CipherMiniResponseModel { + id: cipher_1.id.map(|id| id.into()), + name: Some(cipher_1.name.to_string()), + r#type: Some(cipher_1.r#type.into()), + creation_date: cipher_1.creation_date.to_string().into(), + deleted_date: None, + revision_date: Some(Utc::now().to_string()), + ..Default::default() + }, + CipherMiniResponseModel { + id: cipher_2.id.map(|id| id.into()), + name: Some(cipher_2.name.to_string()), + r#type: Some(cipher_2.r#type.into()), + creation_date: cipher_2.creation_date.to_string().into(), + deleted_date: None, + revision_date: Some(Utc::now().to_string()), + ..Default::default() + }, + ]), + continuation_token: None, + }, + ) + }), + ]) + .await + }; + let client = create_client_with_wiremock(mock_server).await; + + let repository = client.get_repository().unwrap(); + repository + .set(cipher_id.to_string(), cipher_1) + .await + .unwrap(); + repository + .set(TEST_CIPHER_ID_2.to_string(), cipher_2) + .await + .unwrap(); + + let start_time = Utc::now(); + let ciphers = client + .restore_many(vec![cipher_id, cipher_id_2]) + .await + .unwrap(); + let end_time = Utc::now(); + + assert_eq!(ciphers.successes.len(), 2,); + assert_eq!(ciphers.failures.len(), 0,); + assert_eq!(ciphers.successes[0].deleted_date, None,); + assert_eq!(ciphers.successes[1].deleted_date, None,); + + // Confirm repository was updated + let cipher_1 = repository + .get(cipher_id.to_string()) + .await + .unwrap() + .unwrap(); + let cipher_2 = repository + .get(cipher_id_2.to_string()) + .await + .unwrap() + .unwrap(); + assert!(cipher_1.deleted_date.is_none()); + assert!(cipher_2.deleted_date.is_none()); + assert!(cipher_1.revision_date >= start_time && cipher_1.revision_date <= end_time); + assert!(cipher_2.revision_date >= start_time && cipher_2.revision_date <= end_time); + } + + #[tokio::test] + async fn test_restore_many_as_admin() { + let (mock_server, _config) = start_api_mock(vec![ + Mock::given(method("PUT")) + .and(path_regex(r"/ciphers/restore-admin")) + .respond_with(move |_req: &wiremock::Request| ResponseTemplate::new(200)), + ]) + .await; + let client = create_client_with_wiremock(mock_server).await; + + let ciphers = client + .restore_many(vec![ + TEST_CIPHER_ID.parse().unwrap(), + TEST_CIPHER_ID_2.parse().unwrap(), + ]) + .await + .unwrap(); + + assert_eq!(ciphers.successes.len(), 2,); + assert_eq!(ciphers.failures.len(), 0,); + assert_eq!( + ciphers.successes[0].id, + Some(TEST_CIPHER_ID.parse().unwrap()), + ); + assert_eq!( + ciphers.successes[1].id, + Some(TEST_CIPHER_ID.parse().unwrap()), + ); + assert_eq!(ciphers.successes[0].deleted_date, None,); + assert_eq!(ciphers.successes[1].deleted_date, None,); + } +} From 0356b2da83ce3ef004745879a0b609f0c017e652 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Wed, 26 Nov 2025 13:38:45 -0800 Subject: [PATCH 20/38] Fix test_restore_many tests --- .../src/cipher/cipher_client/restore.rs | 94 ++++++++++++++++--- 1 file changed, 79 insertions(+), 15 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs b/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs index 241513363..a52adc39a 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs @@ -6,8 +6,8 @@ use chrono::Utc; use thiserror::Error; use crate::{ - Cipher, CipherId, CipherView, CiphersClient, DecryptCipherListResult, DecryptError, - VaultParseError, + Cipher, CipherId, CipherListView, CipherListViewType, CipherView, CiphersClient, + DecryptCipherListResult, DecryptError, VaultParseError, }; #[allow(missing_docs)] @@ -132,7 +132,7 @@ mod tests { matchers::{method, path_regex}, }; - use crate::{Cipher, CipherId, CiphersClient, VaultClientExt}; + use crate::{Cipher, CipherId, CiphersClient, Login, VaultClientExt}; const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097"; const TEST_CIPHER_ID_2: &str = "6faa9684-c793-4a2d-8a12-b33900187098"; @@ -211,7 +211,14 @@ mod tests { fields: Default::default(), collection_ids: Default::default(), key: Default::default(), - login: Default::default(), + login: Some(Login{ + username: None, + password: None, + password_revision_date: None, + uris: None, totp: None, + autofill_on_page_load: None, + fido2_credentials: None, + }), identity: Default::default(), card: Default::default(), secure_note: Default::default(), @@ -300,6 +307,7 @@ mod tests { r#type: Some(cipher.r#type.into()), creation_date: Some(cipher.creation_date.to_string()), revision_date: Some(Utc::now().to_string()), + login: cipher.login.clone().map(|l| Box::new(l.into())), ..Default::default() }) }), @@ -346,6 +354,7 @@ mod tests { id: cipher_1.id.map(|id| id.into()), name: Some(cipher_1.name.to_string()), r#type: Some(cipher_1.r#type.into()), + login: cipher_1.login.clone().map(|l| Box::new(l.into())), creation_date: cipher_1.creation_date.to_string().into(), deleted_date: None, revision_date: Some(Utc::now().to_string()), @@ -355,6 +364,7 @@ mod tests { id: cipher_2.id.map(|id| id.into()), name: Some(cipher_2.name.to_string()), r#type: Some(cipher_2.r#type.into()), + login: cipher_2.login.clone().map(|l| Box::new(l.into())), creation_date: cipher_2.creation_date.to_string().into(), deleted_date: None, revision_date: Some(Utc::now().to_string()), @@ -411,21 +421,66 @@ mod tests { #[tokio::test] async fn test_restore_many_as_admin() { - let (mock_server, _config) = start_api_mock(vec![ - Mock::given(method("PUT")) - .and(path_regex(r"/ciphers/restore-admin")) - .respond_with(move |_req: &wiremock::Request| ResponseTemplate::new(200)), - ]) - .await; + let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap(); + let mut cipher_1 = generate_test_cipher(); + cipher_1.deleted_date = Some(Utc::now()); + let mut cipher_2 = generate_test_cipher(); + cipher_2.deleted_date = Some(Utc::now()); + cipher_2.id = Some(cipher_id_2); + + let (mock_server, _config) = { + let (cipher_1, cipher_2) = (cipher_1.clone(), cipher_2.clone()); + start_api_mock(vec![ + Mock::given(method("PUT")) + .and(path_regex(r"/ciphers/restore-admin")) + .respond_with(move |_req: &wiremock::Request| { + let (cipher_1, cipher_2) = (cipher_1.clone(), cipher_2.clone()); + ResponseTemplate::new(200).set_body_json( + CipherMiniResponseModelListResponseModel { + object: None, + data: Some(vec![ + CipherMiniResponseModel { + id: cipher_1.id.map(|id| id.into()), + name: Some(cipher_1.name.to_string()), + r#type: Some(cipher_1.r#type.into()), + login: cipher_1.login.clone().map(|l| Box::new(l.into())), + creation_date: cipher_1.creation_date.to_string().into(), + deleted_date: None, + revision_date: Some(Utc::now().to_string()), + ..Default::default() + }, + CipherMiniResponseModel { + id: cipher_2.id.map(|id| id.into()), + name: Some(cipher_2.name.to_string()), + r#type: Some(cipher_2.r#type.into()), + login: cipher_2.login.clone().map(|l| Box::new(l.into())), + creation_date: cipher_2.creation_date.to_string().into(), + deleted_date: None, + revision_date: Some(Utc::now().to_string()), + ..Default::default() + }, + ]), + continuation_token: None, + }, + ) + }), + ]) + .await + }; let client = create_client_with_wiremock(mock_server).await; + let start_time = Utc::now(); let ciphers = client - .restore_many(vec![ - TEST_CIPHER_ID.parse().unwrap(), - TEST_CIPHER_ID_2.parse().unwrap(), - ]) + .restore_many_as_admin( + vec![ + TEST_CIPHER_ID.parse().unwrap(), + TEST_CIPHER_ID_2.parse().unwrap(), + ], + TEST_ORG_ID.parse().unwrap(), + ) .await .unwrap(); + let end_time = Utc::now(); assert_eq!(ciphers.successes.len(), 2,); assert_eq!(ciphers.failures.len(), 0,); @@ -435,9 +490,18 @@ mod tests { ); assert_eq!( ciphers.successes[1].id, - Some(TEST_CIPHER_ID.parse().unwrap()), + Some(TEST_CIPHER_ID_2.parse().unwrap()), ); assert_eq!(ciphers.successes[0].deleted_date, None,); assert_eq!(ciphers.successes[1].deleted_date, None,); + + assert!( + ciphers.successes[0].revision_date >= start_time + && ciphers.successes[0].revision_date <= end_time + ); + assert!( + ciphers.successes[1].revision_date >= start_time + && ciphers.successes[1].revision_date <= end_time + ); } } From 2246af55798c7bd07ea0871313b76ac0917417ac Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Wed, 26 Nov 2025 13:39:28 -0800 Subject: [PATCH 21/38] Fix soft_delete_as_admin test --- crates/bitwarden-vault/src/cipher/cipher_client/delete.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs index 2fcc5ec6f..dbd7e8bf0 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs @@ -473,7 +473,7 @@ mod tests { let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap(); client - .delete_many_as_admin(vec![cipher_id, cipher_id_2], TEST_ORG_ID.parse().ok()) + .soft_delete_many_as_admin(vec![cipher_id, cipher_id_2], TEST_ORG_ID.parse().ok()) .await .unwrap(); } From 0a558c1c65d1ffb9a4179b043231742db9a2a458 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Wed, 26 Nov 2025 13:48:10 -0800 Subject: [PATCH 22/38] Housekeeping - remove comments & warnings --- crates/bitwarden-vault/src/cipher/cipher_client/create.rs | 2 +- crates/bitwarden-vault/src/cipher/cipher_client/delete.rs | 2 +- crates/bitwarden-vault/src/cipher/cipher_client/restore.rs | 7 +++---- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/create.rs b/crates/bitwarden-vault/src/cipher/cipher_client/create.rs index 4e9d0f612..4bc53e688 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/create.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/create.rs @@ -67,7 +67,7 @@ pub struct CipherCreateRequest { /// Used as an intermediary between the public-facing [CipherCreateRequest], and the encrypted /// value. This allows us to manage the cipher key creation internally. #[derive(Clone, Debug)] -pub(crate) struct CipherCreateRequestInternal { +struct CipherCreateRequestInternal { create_request: CipherCreateRequest, key: Option, } diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs index dbd7e8bf0..2c4b0991d 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs @@ -101,7 +101,7 @@ impl CiphersClient { pub async fn soft_delete_as_admin(&self, cipher_id: CipherId) -> Result<(), DeleteCipherError> { let configs = self.get_api_configurations().await; let api = configs.api_client.ciphers_api(); - api.put_delete_admin(cipher_id.into()).await?; // TODO: Map errors properly. + api.put_delete_admin(cipher_id.into()).await?; Ok(()) } diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs b/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs index a52adc39a..9aad168ad 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs @@ -1,13 +1,12 @@ -use bitwarden_api_api::models::{CipherBulkDeleteRequestModel, CipherBulkRestoreRequestModel}; +use bitwarden_api_api::models::CipherBulkRestoreRequestModel; use bitwarden_core::{ApiError, OrganizationId}; use bitwarden_error::bitwarden_error; use bitwarden_state::repository::RepositoryError; -use chrono::Utc; use thiserror::Error; use crate::{ - Cipher, CipherId, CipherListView, CipherListViewType, CipherView, CiphersClient, - DecryptCipherListResult, DecryptError, VaultParseError, + Cipher, CipherId, CipherView, CiphersClient, DecryptCipherListResult, DecryptError, + VaultParseError, }; #[allow(missing_docs)] From 7dc66a5973482fca23e299be3ae1cf9a52c3e59e Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Wed, 26 Nov 2025 13:55:29 -0800 Subject: [PATCH 23/38] Fix edit_as_admin tests --- .../src/cipher/cipher_client/create.rs | 6 ++---- .../src/cipher/cipher_client/delete.rs | 10 ++++++---- .../src/cipher/cipher_client/edit.rs | 15 ++++++++++++--- .../src/cipher/cipher_client/restore.rs | 1 - 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/create.rs b/crates/bitwarden-vault/src/cipher/cipher_client/create.rs index 4bc53e688..86cb2abe8 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/create.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/create.rs @@ -342,15 +342,13 @@ impl CiphersClient { #[cfg(test)] mod tests { use bitwarden_api_api::{apis::ApiClient, models::CipherResponseModel}; - use bitwarden_core::Client; use bitwarden_core::{ - ClientSettings, DeviceType, UserId, + Client, ClientSettings, DeviceType, UserId, key_management::crypto::{ InitOrgCryptoRequest, InitUserCryptoMethod, InitUserCryptoRequest, }, }; - use bitwarden_crypto::SymmetricCryptoKey; - use bitwarden_crypto::{EncString, Kdf}; + use bitwarden_crypto::{EncString, Kdf, SymmetricCryptoKey}; use bitwarden_test::{MemoryRepository, start_api_mock}; use chrono::Utc; use wiremock::{ diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs index 2c4b0991d..3da2467af 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs @@ -41,7 +41,8 @@ impl CiphersClient { Ok(()) } - /// Deletes all [Cipher] objects with a matching [CipherId] from the server, using the admin endpoint. + /// Deletes all [Cipher] objects with a matching [CipherId] from the server, using the admin + /// endpoint. pub async fn delete_many_as_admin( &self, cipher_ids: Vec, @@ -97,7 +98,8 @@ impl CiphersClient { Ok(()) } - /// Soft-deletes the [Cipher] with the matching [CipherId] from the server, using the admin endpoint. + /// Soft-deletes the [Cipher] with the matching [CipherId] from the server, using the admin + /// endpoint. pub async fn soft_delete_as_admin(&self, cipher_id: CipherId) -> Result<(), DeleteCipherError> { let configs = self.get_api_configurations().await; let api = configs.api_client.ciphers_api(); @@ -124,7 +126,8 @@ impl CiphersClient { Ok(()) } - /// Soft-deletes all [Cipher] objects for the given [CipherId]s from the server, using the admin endpoint. + /// Soft-deletes all [Cipher] objects for the given [CipherId]s from the server, using the admin + /// endpoint. pub async fn soft_delete_many_as_admin( &self, cipher_ids: Vec, @@ -151,7 +154,6 @@ mod tests { }; use bitwarden_crypto::{EncString, Kdf}; use bitwarden_test::{MemoryRepository, start_api_mock}; - use chrono::Utc; use wiremock::{ Mock, MockServer, ResponseTemplate, diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs b/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs index 5df481602..51e159e0e 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs @@ -758,16 +758,25 @@ mod tests { ]) .await; let client = create_client_with_wiremock(&mock_server).await; + let repository = client.get_repository().unwrap(); let cipher_view = generate_test_cipher(); - let request = cipher_view.try_into().unwrap(); + repository + .set( + TEST_CIPHER_ID.to_string(), + client.encrypt(cipher_view.clone()).unwrap().cipher, + ) + .await + .unwrap(); + let request = cipher_view.try_into().unwrap(); + let start_time = Utc::now(); let result = client.edit_as_admin(request).await.unwrap(); - let repository = client.get_repository().unwrap(); let cipher = repository.get(TEST_CIPHER_ID.to_string()).await.unwrap(); // Should not update local repository for admin endpoints. - assert!(matches!(cipher, None)); + assert!(result.revision_date > start_time); + assert!(cipher.unwrap().revision_date < start_time); assert_eq!(result.id, TEST_CIPHER_ID.parse().ok()); assert_eq!(result.name, "Test Login"); diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs b/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs index 9aad168ad..46a28d596 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs @@ -124,7 +124,6 @@ mod tests { }; use bitwarden_crypto::{EncString, Kdf}; use bitwarden_test::{MemoryRepository, start_api_mock}; - use chrono::Utc; use wiremock::{ Mock, MockServer, ResponseTemplate, From a0ba6e3942fdb3733a7965de711c6cfd9957fdd3 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Wed, 26 Nov 2025 14:28:54 -0800 Subject: [PATCH 24/38] Fix clippy warnings --- crates/bitwarden-vault/src/cipher/cipher_client/create.rs | 6 +++--- crates/bitwarden-vault/src/cipher/cipher_client/edit.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/create.rs b/crates/bitwarden-vault/src/cipher/cipher_client/create.rs index 86cb2abe8..9e716cec6 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/create.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/create.rs @@ -599,7 +599,7 @@ mod tests { .organization_id .and_then(|id| id.parse().ok()), name: Some(request_body.cipher.name.clone()), - r#type: request_body.cipher.r#type.clone(), + r#type: request_body.cipher.r#type, creation_date: Some(Utc::now().to_string()), revision_date: Some(Utc::now().to_string()), ..Default::default() @@ -668,7 +668,7 @@ mod tests { .organization_id .and_then(|id| id.parse().ok()), name: Some(request_body.cipher.name.clone()), - r#type: request_body.cipher.r#type.clone(), + r#type: request_body.cipher.r#type, creation_date: Some(Utc::now().to_string()), revision_date: Some(Utc::now().to_string()), ..Default::default() @@ -708,7 +708,7 @@ mod tests { let repository = client.get_repository().unwrap(); let cipher = repository.get(TEST_CIPHER_ID.to_string()).await.unwrap(); // Should not update local repository for admin endpoints. - assert!(matches!(cipher, None)); + assert!(cipher.is_none()); assert_eq!(response.id, Some(TEST_CIPHER_ID.parse().unwrap())); assert_eq!(response.organization_id, Some(TEST_ORG_ID.parse().unwrap())); diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs b/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs index d0f93d0bb..9390e60eb 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs @@ -745,7 +745,7 @@ mod tests { .organization_id .and_then(|id| id.parse().ok()), name: Some(request_body.name.clone()), - r#type: request_body.r#type.clone(), + r#type: request_body.r#type, creation_date: Some(Utc::now().to_string()), revision_date: Some(Utc::now().to_string()), ..Default::default() From f90129b0dc7bc08daa3e833d064c0ddcb81305f2 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Wed, 3 Dec 2025 12:38:29 -0800 Subject: [PATCH 25/38] Add soft-delete funciton to Cipher --- crates/bitwarden-vault/src/cipher/cipher.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/bitwarden-vault/src/cipher/cipher.rs b/crates/bitwarden-vault/src/cipher/cipher.rs index 9d80f1d05..0e4fc9921 100644 --- a/crates/bitwarden-vault/src/cipher/cipher.rs +++ b/crates/bitwarden-vault/src/cipher/cipher.rs @@ -637,6 +637,11 @@ impl Cipher { } Ok(()) } + + pub(crate) fn soft_delete(&mut self) { + self.deleted_date = Some(Utc::now()); + self.archived_date = None; + } } impl CipherView { #[allow(missing_docs)] From 8c633b9adf215c4e46577facf2e2af9d61c08c51 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Wed, 3 Dec 2025 12:39:11 -0800 Subject: [PATCH 26/38] Move cipher admin functions to separate client --- .../cipher_client/cipher_admin_client.rs | 211 ++++++++++++++++++ .../src/cipher/cipher_client/create.rs | 74 +----- .../src/cipher/cipher_client/delete.rs | 56 +---- .../src/cipher/cipher_client/edit.rs | 64 +----- .../src/cipher/cipher_client/mod.rs | 1 + 5 files changed, 216 insertions(+), 190 deletions(-) create mode 100644 crates/bitwarden-vault/src/cipher/cipher_client/cipher_admin_client.rs diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/cipher_admin_client.rs b/crates/bitwarden-vault/src/cipher/cipher_client/cipher_admin_client.rs new file mode 100644 index 000000000..12688e820 --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_client/cipher_admin_client.rs @@ -0,0 +1,211 @@ +use bitwarden_api_api::models::CipherBulkDeleteRequestModel; +use bitwarden_collections::collection::CollectionId; +use bitwarden_core::{ApiError, OrganizationId}; +use wasm_bindgen::prelude::*; + +use crate::{ + CipherId, CipherView, CiphersClient, + cipher_client::{ + create::{CipherCreateRequest, CreateCipherError}, + delete::DeleteCipherError, + edit::{CipherEditRequest, EditCipherError}, + }, +}; + +#[allow(missing_docs)] +#[cfg_attr(feature = "wasm", wasm_bindgen)] +pub struct CipherAdminClient { + pub(crate) client: CiphersClient, +} + +impl CipherAdminClient { + /// Creates a new [Cipher] for an organization, using the admin server endpoints endpoints. + pub async fn create_as_admin( + &self, + request: CipherCreateRequest, + collection_ids: Vec, + ) -> Result { + self.client + .create_cipher(request, collection_ids, true) + .await + } + + // putCipherAdmin(id, request: CipherRequest) + // ciphers_id_admin_put + #[allow(missing_docs)] // + pub async fn edit_as_admin( + &self, + request: CipherEditRequest, + ) -> Result { + self.client.edit_internal(request, true).await + } + + /// Deletes the [Cipher] with the matching [CipherId] from the server, using the admin endpoint. + pub async fn delete_as_admin(&self, cipher_id: CipherId) -> Result<(), ApiError> { + let configs = self.client.get_api_configurations().await; + let api = configs.api_client.ciphers_api(); + api.delete_admin(cipher_id.into()).await?; + Ok(()) + } + + /// Soft-deletes the [Cipher] with the matching [CipherId] from the server, using the admin + /// endpoint. + pub async fn soft_delete_as_admin(&self, cipher_id: CipherId) -> Result<(), DeleteCipherError> { + let configs = self.client.get_api_configurations().await; + let api = configs.api_client.ciphers_api(); + api.put_delete_admin(cipher_id.into()).await?; + Ok(()) + } + + /// Deletes all [Cipher] objects with a matching [CipherId] from the server, using the admin + /// endpoint. + pub async fn delete_many_as_admin( + &self, + cipher_ids: Vec, + organization_id: Option, + ) -> Result<(), DeleteCipherError> { + let configs = self.client.get_api_configurations().await; + let api = configs.api_client.ciphers_api(); + api.delete_many_admin(Some(CipherBulkDeleteRequestModel { + ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), + organization_id: organization_id.map(|id| id.to_string()), + })) + .await?; + Ok(()) + } + + /// Soft-deletes all [Cipher] objects for the given [CipherId]s from the server, using the admin + /// endpoint. + pub async fn soft_delete_many_as_admin( + &self, + cipher_ids: Vec, + organization_id: Option, + ) -> Result<(), DeleteCipherError> { + let configs = self.client.get_api_configurations().await; + let api = configs.api_client.ciphers_api(); + api.put_delete_many_admin(Some(CipherBulkDeleteRequestModel { + ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), + organization_id: organization_id.map(|id| id.to_string()), + })) + .await?; + Ok(()) + } +} + +// #[cfg(test)] +// mod tests { +// #[tokio::test] +// async fn test_edit_cipher_as_admin() { +// let (mock_server, _config) = start_api_mock(vec![ +// Mock::given(method("PUT")) +// .and(path_regex(r"/ciphers/[a-f0-9-]+")) +// .respond_with(move |req: &wiremock::Request| { +// let body_bytes = req.body.as_slice(); +// let request_body: CipherRequestModel = +// serde_json::from_slice(body_bytes).expect("Failed to parse request +// body"); + +// let response = CipherResponseModel { +// id: Some(TEST_CIPHER_ID.try_into().unwrap()), +// organization_id: request_body +// .organization_id +// .and_then(|id| id.parse().ok()), +// name: Some(request_body.name.clone()), +// r#type: request_body.r#type, +// creation_date: Some(Utc::now().to_string()), +// revision_date: Some(Utc::now().to_string()), +// ..Default::default() +// }; + +// ResponseTemplate::new(200).set_body_json(&response) +// }), +// ]) +// .await; +// let client = create_client_with_wiremock(&mock_server).await; +// let repository = client.get_repository().unwrap(); + +// let cipher_view = generate_test_cipher(); +// repository +// .set( +// TEST_CIPHER_ID.to_string(), +// client.encrypt(cipher_view.clone()).unwrap().cipher, +// ) +// .await +// .unwrap(); + +// let request = cipher_view.try_into().unwrap(); +// let start_time = Utc::now(); +// let result = client.edit_as_admin(request).await.unwrap(); + +// let cipher = repository.get(TEST_CIPHER_ID.to_string()).await.unwrap(); +// // Should not update local repository for admin endpoints. +// assert!(result.revision_date > start_time); +// assert!(cipher.unwrap().revision_date < start_time); + +// assert_eq!(result.id, TEST_CIPHER_ID.parse().ok()); +// assert_eq!(result.name, "Test Login"); +// } +// } + +// #[tokio::test] +// async fn test_create_cipher_as_admin() { +// let (mock_server, _config) = start_api_mock(vec![ +// Mock::given(method("POST")) +// .and(path(r"/ciphers/admin")) +// .respond_with(move |req: &wiremock::Request| { +// let body_bytes = req.body.as_slice(); +// let request_body: CipherCreateRequestModel = +// serde_json::from_slice(body_bytes).expect("Failed to parse request body"); + +// let response = CipherResponseModel { +// id: Some(TEST_CIPHER_ID.try_into().unwrap()), +// organization_id: request_body +// .cipher +// .organization_id +// .and_then(|id| id.parse().ok()), +// name: Some(request_body.cipher.name.clone()), +// r#type: request_body.cipher.r#type, +// creation_date: Some(Utc::now().to_string()), +// revision_date: Some(Utc::now().to_string()), +// ..Default::default() +// }; + +// ResponseTemplate::new(200).set_body_json(&response) +// }), +// ]) +// .await; + +// let client = create_client_with_wiremock(&mock_server).await; +// let response = client +// .create_as_admin( +// CipherCreateRequest { +// organization_id: Some(TEST_ORG_ID.parse().unwrap()), +// folder_id: None, +// name: "Test Cipher".into(), +// notes: None, +// favorite: false, +// reprompt: CipherRepromptType::None, +// r#type: CipherViewType::Login(LoginView { +// username: None, +// password: None, +// password_revision_date: None, +// uris: None, +// totp: None, +// autofill_on_page_load: None, +// fido2_credentials: None, +// }), +// fields: vec![], +// }, +// vec![TEST_COLLECTION_ID.parse().unwrap()], +// ) +// .await +// .unwrap(); + +// let repository = client.get_repository().unwrap(); +// let cipher = repository.get(TEST_CIPHER_ID.to_string()).await.unwrap(); +// // Should not update local repository for admin endpoints. +// assert!(cipher.is_none()); + +// assert_eq!(response.id, Some(TEST_CIPHER_ID.parse().unwrap())); +// assert_eq!(response.organization_id, Some(TEST_ORG_ID.parse().unwrap())); +// } diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/create.rs b/crates/bitwarden-vault/src/cipher/cipher_client/create.rs index 9e716cec6..b65762d3e 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/create.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/create.rs @@ -271,7 +271,7 @@ async fn create_cipher + ?Sized>( #[cfg_attr(feature = "wasm", wasm_bindgen)] impl CiphersClient { - async fn create_cipher( + pub(super) async fn create_cipher( &self, request: CipherCreateRequest, collection_ids: Vec, @@ -328,15 +328,6 @@ impl CiphersClient { ) -> Result { self.create_cipher(request, collection_ids, false).await } - - /// Creates a new [Cipher] for an organization, using the admin server endpoints endpoints. - pub async fn create_as_admin( - &self, - request: CipherCreateRequest, - collection_ids: Vec, - ) -> Result { - self.create_cipher(request, collection_ids, true).await - } } #[cfg(test)] @@ -650,67 +641,4 @@ mod tests { assert_eq!(response.id, Some(TEST_CIPHER_ID.parse().unwrap())); assert_eq!(response.organization_id, Some(TEST_ORG_ID.parse().unwrap())); } - - #[tokio::test] - async fn test_create_cipher_as_admin() { - let (mock_server, _config) = start_api_mock(vec![ - Mock::given(method("POST")) - .and(path(r"/ciphers/admin")) - .respond_with(move |req: &wiremock::Request| { - let body_bytes = req.body.as_slice(); - let request_body: CipherCreateRequestModel = - serde_json::from_slice(body_bytes).expect("Failed to parse request body"); - - let response = CipherResponseModel { - id: Some(TEST_CIPHER_ID.try_into().unwrap()), - organization_id: request_body - .cipher - .organization_id - .and_then(|id| id.parse().ok()), - name: Some(request_body.cipher.name.clone()), - r#type: request_body.cipher.r#type, - creation_date: Some(Utc::now().to_string()), - revision_date: Some(Utc::now().to_string()), - ..Default::default() - }; - - ResponseTemplate::new(200).set_body_json(&response) - }), - ]) - .await; - - let client = create_client_with_wiremock(&mock_server).await; - let response = client - .create_as_admin( - CipherCreateRequest { - organization_id: Some(TEST_ORG_ID.parse().unwrap()), - folder_id: None, - name: "Test Cipher".into(), - notes: None, - favorite: false, - reprompt: CipherRepromptType::None, - r#type: CipherViewType::Login(LoginView { - username: None, - password: None, - password_revision_date: None, - uris: None, - totp: None, - autofill_on_page_load: None, - fido2_credentials: None, - }), - fields: vec![], - }, - vec![TEST_COLLECTION_ID.parse().unwrap()], - ) - .await - .unwrap(); - - let repository = client.get_repository().unwrap(); - let cipher = repository.get(TEST_CIPHER_ID.to_string()).await.unwrap(); - // Should not update local repository for admin endpoints. - assert!(cipher.is_none()); - - assert_eq!(response.id, Some(TEST_CIPHER_ID.parse().unwrap())); - assert_eq!(response.organization_id, Some(TEST_ORG_ID.parse().unwrap())); - } } diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs index 3da2467af..52b053f78 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs @@ -24,14 +24,6 @@ impl From> for DeleteCipherError { } impl CiphersClient { - /// Deletes the [Cipher] with the matching [CipherId] from the server, using the admin endpoint. - pub async fn delete_as_admin(&self, cipher_id: CipherId) -> Result<(), ApiError> { - let configs = self.get_api_configurations().await; - let api = configs.api_client.ciphers_api(); - api.delete_admin(cipher_id.into()).await?; - Ok(()) - } - /// Deletes the [Cipher] with the matching [CipherId] from the server. pub async fn delete(&self, cipher_id: CipherId) -> Result<(), DeleteCipherError> { let configs = self.get_api_configurations().await; @@ -40,24 +32,6 @@ impl CiphersClient { self.get_repository()?.remove(cipher_id.to_string()).await?; Ok(()) } - - /// Deletes all [Cipher] objects with a matching [CipherId] from the server, using the admin - /// endpoint. - pub async fn delete_many_as_admin( - &self, - cipher_ids: Vec, - organization_id: Option, - ) -> Result<(), DeleteCipherError> { - let configs = self.get_api_configurations().await; - let api = configs.api_client.ciphers_api(); - api.delete_many_admin(Some(CipherBulkDeleteRequestModel { - ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), - organization_id: organization_id.map(|id| id.to_string()), - })) - .await?; - Ok(()) - } - /// Deletes all [Cipher] objects with a matching [CipherId] from the server. pub async fn delete_many( &self, @@ -82,8 +56,7 @@ impl CiphersClient { let repository = self.get_repository()?; let cipher: Option = repository.get(cipher_id.to_string()).await?; if let Some(mut cipher) = cipher { - cipher.deleted_date = Some(Utc::now()); - cipher.archived_date = None; + cipher.soft_delete(); repository.set(cipher_id.to_string(), cipher).await?; } Ok(()) @@ -97,16 +70,6 @@ impl CiphersClient { self.process_soft_delete(cipher_id).await?; Ok(()) } - - /// Soft-deletes the [Cipher] with the matching [CipherId] from the server, using the admin - /// endpoint. - pub async fn soft_delete_as_admin(&self, cipher_id: CipherId) -> Result<(), DeleteCipherError> { - let configs = self.get_api_configurations().await; - let api = configs.api_client.ciphers_api(); - api.put_delete_admin(cipher_id.into()).await?; - Ok(()) - } - /// Soft-deletes all [Cipher] objects for the given [CipherId]s from the server. pub async fn soft_delete_many( &self, @@ -125,23 +88,6 @@ impl CiphersClient { } Ok(()) } - - /// Soft-deletes all [Cipher] objects for the given [CipherId]s from the server, using the admin - /// endpoint. - pub async fn soft_delete_many_as_admin( - &self, - cipher_ids: Vec, - organization_id: Option, - ) -> Result<(), DeleteCipherError> { - let configs = self.get_api_configurations().await; - let api = configs.api_client.ciphers_api(); - api.put_delete_many_admin(Some(CipherBulkDeleteRequestModel { - ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), - organization_id: organization_id.map(|id| id.to_string()), - })) - .await?; - Ok(()) - } } #[cfg(test)] diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs b/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs index 9390e60eb..3f6c50b23 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs @@ -376,17 +376,8 @@ impl CiphersClient { self.edit_internal(request, false).await } - // putCipherAdmin(id, request: CipherRequest) - // ciphers_id_admin_put - #[allow(missing_docs)] // TODO: add docs - pub async fn edit_as_admin( - &self, - request: CipherEditRequest, - ) -> Result { - self.edit_internal(request, true).await - } - - async fn edit_internal( + /// A helper function to wrap all of the cipher edit routing logic. + pub(super) async fn edit_internal( &self, mut request: CipherEditRequest, is_admin: bool, @@ -729,57 +720,6 @@ mod tests { assert_eq!(result.name, "Test Login"); } - #[tokio::test] - async fn test_edit_cipher_as_admin() { - let (mock_server, _config) = start_api_mock(vec![ - Mock::given(method("PUT")) - .and(path_regex(r"/ciphers/[a-f0-9-]+")) - .respond_with(move |req: &wiremock::Request| { - let body_bytes = req.body.as_slice(); - let request_body: CipherRequestModel = - serde_json::from_slice(body_bytes).expect("Failed to parse request body"); - - let response = CipherResponseModel { - id: Some(TEST_CIPHER_ID.try_into().unwrap()), - organization_id: request_body - .organization_id - .and_then(|id| id.parse().ok()), - name: Some(request_body.name.clone()), - r#type: request_body.r#type, - creation_date: Some(Utc::now().to_string()), - revision_date: Some(Utc::now().to_string()), - ..Default::default() - }; - - ResponseTemplate::new(200).set_body_json(&response) - }), - ]) - .await; - let client = create_client_with_wiremock(&mock_server).await; - let repository = client.get_repository().unwrap(); - - let cipher_view = generate_test_cipher(); - repository - .set( - TEST_CIPHER_ID.to_string(), - client.encrypt(cipher_view.clone()).unwrap().cipher, - ) - .await - .unwrap(); - - let request = cipher_view.try_into().unwrap(); - let start_time = Utc::now(); - let result = client.edit_as_admin(request).await.unwrap(); - - let cipher = repository.get(TEST_CIPHER_ID.to_string()).await.unwrap(); - // Should not update local repository for admin endpoints. - assert!(result.revision_date > start_time); - assert!(cipher.unwrap().revision_date < start_time); - - assert_eq!(result.id, TEST_CIPHER_ID.parse().ok()); - assert_eq!(result.name, "Test Login"); - } - #[tokio::test] async fn test_edit_cipher_does_not_exist() { let store: KeyStore = KeyStore::default(); diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs b/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs index 1dd26fa4c..2f1403824 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs @@ -18,6 +18,7 @@ use crate::{ cipher::cipher::DecryptCipherListResult, }; +mod cipher_admin_client; mod create; mod delete; mod edit; From 3e386265e77571c258060931dbab2dd328980ed7 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Thu, 4 Dec 2025 13:35:59 -0800 Subject: [PATCH 27/38] Move delete logic to isolated functions, outside of CiphersClient --- .../src/cipher/cipher_client/delete.rs | 364 +++++++----------- 1 file changed, 140 insertions(+), 224 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs index 52b053f78..e3b29f48b 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs @@ -1,8 +1,7 @@ use bitwarden_api_api::models::CipherBulkDeleteRequestModel; use bitwarden_core::{ApiError, OrganizationId}; use bitwarden_error::bitwarden_error; -use bitwarden_state::repository::RepositoryError; -use chrono::Utc; +use bitwarden_state::repository::{Repository, RepositoryError}; use thiserror::Error; use crate::{Cipher, CipherId, CiphersClient}; @@ -23,15 +22,86 @@ impl From> for DeleteCipherError { } } +async fn delete_cipher( + cipher_id: CipherId, + api_client: &bitwarden_api_api::apis::ApiClient, + repository: &(impl Repository + ?Sized), +) -> Result<(), DeleteCipherError> { + let api = api_client.ciphers_api(); + api.delete(cipher_id.into()).await?; + repository.remove(cipher_id.to_string()).await?; + Ok(()) +} + +async fn delete_ciphers( + cipher_ids: Vec, + organization_id: Option, + api_client: &bitwarden_api_api::apis::ApiClient, + repository: &(impl Repository + ?Sized), +) -> Result<(), DeleteCipherError> { + let api = api_client.ciphers_api(); + + api.delete_many(Some(CipherBulkDeleteRequestModel { + ids: cipher_ids.iter().map(|id| id.to_string()).collect(), + organization_id: organization_id.map(|id| id.to_string()), + })) + .await?; + + for cipher_id in cipher_ids { + repository.remove(cipher_id.to_string()).await?; + } + Ok(()) +} + +async fn soft_delete( + cipher_id: CipherId, + api_client: &bitwarden_api_api::apis::ApiClient, + repository: &(impl Repository + ?Sized), +) -> Result<(), DeleteCipherError> { + let api = api_client.ciphers_api(); + api.put_delete(cipher_id.into()).await?; + process_soft_delete(repository, cipher_id).await?; + Ok(()) +} + +async fn soft_delete_many( + cipher_ids: Vec, + organization_id: Option, + api_client: &bitwarden_api_api::apis::ApiClient, + repository: &(impl Repository + ?Sized), +) -> Result<(), DeleteCipherError> { + let api = api_client.ciphers_api(); + + api.put_delete_many(Some(CipherBulkDeleteRequestModel { + ids: cipher_ids.iter().map(|id| id.to_string()).collect(), + organization_id: organization_id.map(|id| id.to_string()), + })) + .await?; + for cipher_id in cipher_ids { + process_soft_delete(repository, cipher_id).await?; + } + Ok(()) +} + +async fn process_soft_delete( + repository: &(impl Repository + ?Sized), + cipher_id: CipherId, +) -> Result<(), RepositoryError> { + let cipher: Option = repository.get(cipher_id.to_string()).await?; + if let Some(mut cipher) = cipher { + cipher.soft_delete(); + repository.set(cipher_id.to_string(), cipher).await?; + } + Ok(()) +} + impl CiphersClient { /// Deletes the [Cipher] with the matching [CipherId] from the server. pub async fn delete(&self, cipher_id: CipherId) -> Result<(), DeleteCipherError> { let configs = self.get_api_configurations().await; - let api = configs.api_client.ciphers_api(); - api.delete(cipher_id.into()).await?; - self.get_repository()?.remove(cipher_id.to_string()).await?; - Ok(()) + delete_cipher(cipher_id, &configs.api_client, &*self.get_repository()?).await } + /// Deletes all [Cipher] objects with a matching [CipherId] from the server. pub async fn delete_many( &self, @@ -39,138 +109,51 @@ impl CiphersClient { organization_id: Option, ) -> Result<(), DeleteCipherError> { let configs = self.get_api_configurations().await; - let api = configs.api_client.ciphers_api(); - api.delete_many(Some(CipherBulkDeleteRequestModel { - ids: cipher_ids.iter().map(|id| id.to_string()).collect(), - organization_id: organization_id.map(|id| id.to_string()), - })) - .await?; - - for cipher_id in cipher_ids { - self.get_repository()?.remove(cipher_id.to_string()).await?; - } - Ok(()) - } - - async fn process_soft_delete(&self, cipher_id: CipherId) -> Result<(), RepositoryError> { - let repository = self.get_repository()?; - let cipher: Option = repository.get(cipher_id.to_string()).await?; - if let Some(mut cipher) = cipher { - cipher.soft_delete(); - repository.set(cipher_id.to_string(), cipher).await?; - } - Ok(()) + delete_ciphers( + cipher_ids, + organization_id, + &configs.api_client, + &*self.get_repository()?, + ) + .await } /// Soft-deletes the [Cipher] with the matching [CipherId] from the server. pub async fn soft_delete(&self, cipher_id: CipherId) -> Result<(), DeleteCipherError> { let configs = self.get_api_configurations().await; - let api = configs.api_client.ciphers_api(); - api.put_delete(cipher_id.into()).await?; - self.process_soft_delete(cipher_id).await?; - Ok(()) + soft_delete(cipher_id, &configs.api_client, &*self.get_repository()?).await } + /// Soft-deletes all [Cipher] objects for the given [CipherId]s from the server. pub async fn soft_delete_many( &self, cipher_ids: Vec, organization_id: Option, ) -> Result<(), DeleteCipherError> { - let configs = self.get_api_configurations().await; - let api = configs.api_client.ciphers_api(); - api.put_delete_many(Some(CipherBulkDeleteRequestModel { - ids: cipher_ids.iter().map(|id| id.to_string()).collect(), - organization_id: organization_id.map(|id| id.to_string()), - })) - .await?; - for cipher_id in cipher_ids { - self.process_soft_delete(cipher_id).await?; - } - Ok(()) + soft_delete_many( + cipher_ids, + organization_id, + &self.get_api_configurations().await.api_client, + &*self.get_repository()?, + ) + .await } } #[cfg(test)] mod tests { - use bitwarden_core::{ - Client, ClientSettings, DeviceType, UserId, - key_management::crypto::{ - InitOrgCryptoRequest, InitUserCryptoMethod, InitUserCryptoRequest, - }, - }; - use bitwarden_crypto::{EncString, Kdf}; - use bitwarden_test::{MemoryRepository, start_api_mock}; + use bitwarden_api_api::apis::ApiClient; + use bitwarden_state::repository::Repository; + use bitwarden_test::MemoryRepository; use chrono::Utc; - use wiremock::{ - Mock, MockServer, ResponseTemplate, - matchers::{method, path_regex}, - }; - use crate::{Cipher, CipherId, CiphersClient, VaultClientExt}; + use crate::{ + Cipher, CipherId, + cipher_client::delete::{delete_cipher, delete_ciphers, soft_delete, soft_delete_many}, + }; const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097"; const TEST_CIPHER_ID_2: &str = "6faa9684-c793-4a2d-8a12-b33900187098"; - const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000"; - const TEST_ORG_ID: &str = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8"; - - async fn create_client_with_wiremock(mock_server: MockServer) -> CiphersClient { - let settings = ClientSettings { - identity_url: format!("http://{}", mock_server.address()), - api_url: format!("http://{}", mock_server.address()), - user_agent: "Bitwarden Test".into(), - device_type: DeviceType::SDK, - bitwarden_client_version: None, - }; - - let client = Client::new(Some(settings)); - - client - .internal - .load_flags(std::collections::HashMap::from([( - "enableCipherKeyEncryption".to_owned(), - true, - )])); - - let user_request = InitUserCryptoRequest { - user_id: Some(UserId::new(uuid::uuid!(TEST_USER_ID))), - kdf_params: Kdf::PBKDF2 { - iterations: 600_000.try_into().unwrap(), - }, - email: "test@bitwarden.com".to_owned(), - private_key: "2.yN7l00BOlUE0Sb0M//Q53w==|EwKG/BduQRQ33Izqc/ogoBROIoI5dmgrxSo82sgzgAMIBt3A2FZ9vPRMY+GWT85JiqytDitGR3TqwnFUBhKUpRRAq4x7rA6A1arHrFp5Tp1p21O3SfjtvB3quiOKbqWk6ZaU1Np9HwqwAecddFcB0YyBEiRX3VwF2pgpAdiPbSMuvo2qIgyob0CUoC/h4Bz1be7Qa7B0Xw9/fMKkB1LpOm925lzqosyMQM62YpMGkjMsbZz0uPopu32fxzDWSPr+kekNNyLt9InGhTpxLmq1go/pXR2uw5dfpXc5yuta7DB0EGBwnQ8Vl5HPdDooqOTD9I1jE0mRyuBpWTTI3FRnu3JUh3rIyGBJhUmHqGZvw2CKdqHCIrQeQkkEYqOeJRJVdBjhv5KGJifqT3BFRwX/YFJIChAQpebNQKXe/0kPivWokHWwXlDB7S7mBZzhaAPidZvnuIhalE2qmTypDwHy22FyqV58T8MGGMchcASDi/QXI6kcdpJzPXSeU9o+NC68QDlOIrMVxKFeE7w7PvVmAaxEo0YwmuAzzKy9QpdlK0aab/xEi8V4iXj4hGepqAvHkXIQd+r3FNeiLfllkb61p6WTjr5urcmDQMR94/wYoilpG5OlybHdbhsYHvIzYoLrC7fzl630gcO6t4nM24vdB6Ymg9BVpEgKRAxSbE62Tqacxqnz9AcmgItb48NiR/He3n3ydGjPYuKk/ihZMgEwAEZvSlNxYONSbYrIGDtOY+8Nbt6KiH3l06wjZW8tcmFeVlWv+tWotnTY9IqlAfvNVTjtsobqtQnvsiDjdEVtNy/s2ci5TH+NdZluca2OVEr91Wayxh70kpM6ib4UGbfdmGgCo74gtKvKSJU0rTHakQ5L9JlaSDD5FamBRyI0qfL43Ad9qOUZ8DaffDCyuaVyuqk7cz9HwmEmvWU3VQ+5t06n/5kRDXttcw8w+3qClEEdGo1KeENcnXCB32dQe3tDTFpuAIMLqwXs6FhpawfZ5kPYvLPczGWaqftIs/RXJ/EltGc0ugw2dmTLpoQhCqrcKEBDoYVk0LDZKsnzitOGdi9mOWse7Se8798ib1UsHFUjGzISEt6upestxOeupSTOh0v4+AjXbDzRUyogHww3V+Bqg71bkcMxtB+WM+pn1XNbVTyl9NR040nhP7KEf6e9ruXAtmrBC2ah5cFEpLIot77VFZ9ilLuitSz+7T8n1yAh1IEG6xxXxninAZIzi2qGbH69O5RSpOJuJTv17zTLJQIIc781JwQ2TTwTGnx5wZLbffhCasowJKd2EVcyMJyhz6ru0PvXWJ4hUdkARJs3Xu8dus9a86N8Xk6aAPzBDqzYb1vyFIfBxP0oO8xFHgd30Cgmz8UrSE3qeWRrF8ftrI6xQnFjHBGWD/JWSvd6YMcQED0aVuQkuNW9ST/DzQThPzRfPUoiL10yAmV7Ytu4fR3x2sF0Yfi87YhHFuCMpV/DsqxmUizyiJuD938eRcH8hzR/VO53Qo3UIsqOLcyXtTv6THjSlTopQ+JOLOnHm1w8dzYbLN44OG44rRsbihMUQp+wUZ6bsI8rrOnm9WErzkbQFbrfAINdoCiNa6cimYIjvvnMTaFWNymqY1vZxGztQiMiHiHYwTfwHTXrb9j0uPM=|09J28iXv9oWzYtzK2LBT6Yht4IT4MijEkk0fwFdrVQ4=".parse::().unwrap(), - signing_key: None, - security_state: None, - method: InitUserCryptoMethod::Password { - password: "asdfasdfasdf".to_owned(), - user_key: "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=".parse().unwrap(), - } - }; - - let org_request = InitOrgCryptoRequest { - organization_keys: std::collections::HashMap::from([( - TEST_ORG_ID.parse().unwrap(), - "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==".parse().unwrap() - )]) - }; - - client - .crypto() - .initialize_user_crypto(user_request) - .await - .unwrap(); - client - .crypto() - .initialize_org_crypto(org_request) - .await - .unwrap(); - - client - .platform() - .state() - .register_client_managed(std::sync::Arc::new(MemoryRepository::::default())); - - client.vault().ciphers() - } fn generate_test_cipher() -> Cipher { Cipher { @@ -207,21 +190,24 @@ mod tests { #[tokio::test] async fn test_delete() { - let (mock_server, _config) = start_api_mock(vec![ - Mock::given(method("DELETE")) - .and(path_regex(r"/ciphers/[a-f0-9-]+")) - .respond_with(move |_req: &wiremock::Request| ResponseTemplate::new(200)), - ]) - .await; - - let client = create_client_with_wiremock(mock_server).await; + let api_client = ApiClient::new_mocked(move |mock| { + mock.ciphers_api + .expect_delete() + .returning(move |_model| Ok(())); + }); + + // let client = create_client_with_wiremock(mock_server).await; let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); - let repository = client.get_repository().unwrap(); + let repository = MemoryRepository::::default(); repository .set(cipher_id.to_string(), generate_test_cipher()) .await .unwrap(); - client.delete(cipher_id).await.unwrap(); + + delete_cipher(cipher_id, &api_client, &repository) + .await + .unwrap(); + let cipher = repository.get(cipher_id.to_string()).await.unwrap(); assert!( cipher.is_none(), @@ -229,38 +215,22 @@ mod tests { ); } - #[tokio::test] - async fn test_delete_as_admin() { - let (mock_server, _config) = start_api_mock(vec![ - Mock::given(method("DELETE")) - .and(path_regex(r"/ciphers/[a-f0-9-]+/admin")) - .respond_with(move |_req: &wiremock::Request| ResponseTemplate::new(200)), - ]) - .await; - - let client = create_client_with_wiremock(mock_server).await; - let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); - - client.delete_as_admin(cipher_id).await.unwrap(); - } - #[tokio::test] async fn test_delete_many() { - let (mock_server, _config) = start_api_mock(vec![ - Mock::given(method("DELETE")) - .and(path_regex(r"/ciphers")) - .respond_with(move |_req: &wiremock::Request| ResponseTemplate::new(200)), - ]) - .await; + let api_client = ApiClient::new_mocked(move |mock| { + mock.ciphers_api + .expect_delete_many() + .returning(move |_model| Ok(())); + }); + let repository = MemoryRepository::::default(); let cipher_1 = generate_test_cipher(); let mut cipher_2 = generate_test_cipher(); cipher_2.id = Some(TEST_CIPHER_ID_2.parse().unwrap()); - let client = create_client_with_wiremock(mock_server).await; let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap(); - let repository = client.get_repository().unwrap(); + repository .set(cipher_id.to_string(), cipher_1) .await @@ -269,10 +239,11 @@ mod tests { .set(TEST_CIPHER_ID_2.to_string(), cipher_2) .await .unwrap(); - client - .delete_many(vec![cipher_id, cipher_id_2], None) + + delete_ciphers(vec![cipher_id, cipher_id_2], None, &api_client, &repository) .await .unwrap(); + let cipher_1 = repository.get(cipher_id.to_string()).await.unwrap(); let cipher_2 = repository.get(cipher_id_2.to_string()).await.unwrap(); assert!( @@ -285,43 +256,25 @@ mod tests { ); } - #[tokio::test] - async fn test_delete_many_as_admin() { - let (mock_server, _config) = start_api_mock(vec![ - Mock::given(method("DELETE")) - .and(path_regex(r"/ciphers/admin")) - .respond_with(move |_req: &wiremock::Request| ResponseTemplate::new(200)), - ]) - .await; - - let client = create_client_with_wiremock(mock_server).await; - let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); - let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap(); - client - .delete_many_as_admin(vec![cipher_id, cipher_id_2], TEST_ORG_ID.parse().ok()) - .await - .unwrap(); - } - #[tokio::test] async fn test_soft_delete() { - let (mock_server, _config) = start_api_mock(vec![ - Mock::given(method("PUT")) - .and(path_regex(r"/ciphers/[a-f0-9-]+/delete")) - .respond_with(move |_req: &wiremock::Request| ResponseTemplate::new(200)), - ]) - .await; - - let client = create_client_with_wiremock(mock_server).await; + let api_client = ApiClient::new_mocked(move |mock| { + mock.ciphers_api + .expect_put_delete() + .returning(move |_model| Ok(())); + }); + let repository = MemoryRepository::::default(); + let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); - let repository = client.get_repository().unwrap(); repository .set(cipher_id.to_string(), generate_test_cipher()) .await .unwrap(); let start_time = Utc::now(); - client.soft_delete(cipher_id).await.unwrap(); + soft_delete(cipher_id, &api_client, &repository) + .await + .unwrap(); let end_time = Utc::now(); let cipher: Cipher = repository @@ -335,38 +288,21 @@ mod tests { ); } - #[tokio::test] - async fn test_soft_delete_as_admin() { - let (mock_server, _config) = start_api_mock(vec![ - Mock::given(method("PUT")) - .and(path_regex(r"/ciphers/[a-f0-9-]+/delete-admin")) - .respond_with(move |_req: &wiremock::Request| ResponseTemplate::new(200)), - ]) - .await; - - let client = create_client_with_wiremock(mock_server).await; - let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); - - client.soft_delete_as_admin(cipher_id).await.unwrap(); - } - #[tokio::test] async fn test_soft_delete_many() { - let (mock_server, _config) = start_api_mock(vec![ - Mock::given(method("PUT")) - .and(path_regex(r"/ciphers/delete")) - .respond_with(move |_req: &wiremock::Request| ResponseTemplate::new(200)), - ]) - .await; + let api_client = ApiClient::new_mocked(move |mock| { + mock.ciphers_api + .expect_put_delete_many() + .returning(move |_model| Ok(())); + }); + let repository = MemoryRepository::::default(); let cipher_1 = generate_test_cipher(); let mut cipher_2 = generate_test_cipher(); cipher_2.id = Some(TEST_CIPHER_ID_2.parse().unwrap()); - let client = create_client_with_wiremock(mock_server).await; let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap(); - let repository = client.get_repository().unwrap(); repository .set(cipher_id.to_string(), cipher_1) .await @@ -377,8 +313,8 @@ mod tests { .unwrap(); let start_time = Utc::now(); - client - .soft_delete_many(vec![cipher_id, cipher_id_2], None) + + soft_delete_many(vec![cipher_id, cipher_id_2], None, &api_client, &repository) .await .unwrap(); let end_time = Utc::now(); @@ -405,24 +341,4 @@ mod tests { "Cipher was flagged as deleted in the repository." ); } - - #[tokio::test] - async fn test_soft_delete_many_as_admin() { - let (mock_server, _config) = start_api_mock(vec![ - Mock::given(method("PUT")) - .and(path_regex(r"/ciphers/delete-admin")) - .respond_with(move |_req: &wiremock::Request| ResponseTemplate::new(200)), - ]) - .await; - - // Populate the repository with test ciphers. - let client = create_client_with_wiremock(mock_server).await; - let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); - let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap(); - - client - .soft_delete_many_as_admin(vec![cipher_id, cipher_id_2], TEST_ORG_ID.parse().ok()) - .await - .unwrap(); - } } From 7467c016130b836403f5a2200d72e065e0df467d Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Fri, 5 Dec 2025 10:38:58 -0800 Subject: [PATCH 28/38] Move restore functions to isolated functions, remove wiremock use --- .../src/cipher/cipher_client/restore.rs | 543 +++++++++--------- 1 file changed, 282 insertions(+), 261 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs b/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs index 46a28d596..ca62f5eb5 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs @@ -1,12 +1,15 @@ -use bitwarden_api_api::models::CipherBulkRestoreRequestModel; -use bitwarden_core::{ApiError, OrganizationId}; +use bitwarden_api_api::{apis::ApiClient, models::CipherBulkRestoreRequestModel}; +use bitwarden_core::{ + ApiError, OrganizationId, + key_management::{KeyIds, SymmetricKeyId}, +}; +use bitwarden_crypto::{CryptoError, KeyStore, SymmetricCryptoKey}; use bitwarden_error::bitwarden_error; -use bitwarden_state::repository::RepositoryError; +use bitwarden_state::repository::{Repository, RepositoryError}; use thiserror::Error; use crate::{ - Cipher, CipherId, CipherView, CiphersClient, DecryptCipherListResult, DecryptError, - VaultParseError, + Cipher, CipherId, CipherView, CiphersClient, DecryptCipherListResult, VaultParseError, }; #[allow(missing_docs)] @@ -18,9 +21,9 @@ pub enum RestoreCipherError { #[error(transparent)] VaultParse(#[from] VaultParseError), #[error(transparent)] - Decrypt(#[from] DecryptError), - #[error(transparent)] Repository(#[from] RepositoryError), + #[error(transparent)] + Crypto(#[from] CryptoError), } impl From> for RestoreCipherError { @@ -29,18 +32,111 @@ impl From> for RestoreCipherError { } } +/// Restores a soft-deleted cipher on the server. +pub async fn restore( + cipher_id: CipherId, + api_client: &ApiClient, + repository: &(impl Repository + ?Sized), + key_store: &KeyStore, +) -> Result { + let api = api_client.ciphers_api(); + + let cipher: Cipher = api.put_restore(cipher_id.into()).await?.try_into()?; + repository + .set(cipher_id.to_string(), cipher.clone()) + .await?; + + Ok(key_store.decrypt(&cipher)?) +} + +/// Restores a soft-deleted cipher on the server, using the admin endpoint. +pub async fn restore_as_admin( + cipher_id: CipherId, + api_client: &ApiClient, + key_store: &KeyStore, +) -> Result { + let api = api_client.ciphers_api(); + + let cipher: Cipher = api.put_restore_admin(cipher_id.into()).await?.try_into()?; + + Ok(key_store.decrypt(&cipher)?) +} + +/// Restores multiple soft-deleted ciphers on the server. +pub async fn restore_many( + cipher_ids: Vec, + api_client: &ApiClient, + repository: &(impl Repository + ?Sized), + key_store: &KeyStore, +) -> Result { + let api = api_client.ciphers_api(); + let store: KeyStore = KeyStore::default(); + #[allow(deprecated)] + let _ = store.context_mut().set_symmetric_key( + SymmetricKeyId::User, + SymmetricCryptoKey::make_aes256_cbc_hmac_key(), + ); + + let ciphers: Vec = api + .put_restore_many(Some(CipherBulkRestoreRequestModel { + ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), + organization_id: None, + })) + .await? + .data + .into_iter() + .flatten() + .map(|c| c.try_into()) + .collect::, _>>()?; + + for cipher in &ciphers { + if let Some(id) = &cipher.id { + repository.set(id.to_string(), cipher.clone()).await?; + } + } + + let (successes, failures) = key_store.decrypt_list_with_failures(&ciphers); + Ok(DecryptCipherListResult { + successes, + failures: failures.into_iter().cloned().collect(), + }) +} + +/// Restores multiple soft-deleted ciphers on the server. +pub async fn restore_many_as_admin( + cipher_ids: Vec, + org_id: OrganizationId, + api_client: &ApiClient, + key_store: &KeyStore, +) -> Result { + let api = api_client.ciphers_api(); + + let ciphers: Vec = api + .put_restore_many_admin(Some(CipherBulkRestoreRequestModel { + ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), + organization_id: Some(org_id.into()), + })) + .await? + .data + .into_iter() + .flatten() + .map(|c| c.try_into()) + .collect::, _>>()?; + + let (successes, failures) = key_store.decrypt_list_with_failures(&ciphers); + Ok(DecryptCipherListResult { + successes, + failures: failures.into_iter().cloned().collect(), + }) +} + impl CiphersClient { /// Restores a soft-deleted cipher on the server. pub async fn restore(&self, cipher_id: CipherId) -> Result { - let api_config = self.get_api_configurations().await; - let api = api_config.api_client.ciphers_api(); + let api_client = &self.get_api_configurations().await.api_client; + let key_store = self.client.internal.get_key_store(); - let cipher: Cipher = api.put_restore(cipher_id.into()).await?.try_into()?; - self.get_repository()? - .set(cipher_id.to_string(), cipher.clone()) - .await?; - - Ok(self.decrypt(cipher)?) + restore(cipher_id, api_client, &*self.get_repository()?, key_store).await } /// Restores a soft-deleted cipher on the server, using the admin endpoint. @@ -48,12 +144,10 @@ impl CiphersClient { &self, cipher_id: CipherId, ) -> Result { - let api_config = self.get_api_configurations().await; - let api = api_config.api_client.ciphers_api(); - - let cipher: Cipher = api.put_restore_admin(cipher_id.into()).await?.try_into()?; + let api_client = &self.get_api_configurations().await.api_client; + let key_store = self.client.internal.get_key_store(); - Ok(self.decrypt(cipher)?) + restore_as_admin(cipher_id, api_client, key_store).await } /// Restores multiple soft-deleted ciphers on the server. @@ -61,30 +155,11 @@ impl CiphersClient { &self, cipher_ids: Vec, ) -> Result { - let api_config = self.get_api_configurations().await; - let api = api_config.api_client.ciphers_api(); - - let ciphers: Vec = api - .put_restore_many(Some(CipherBulkRestoreRequestModel { - ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), - organization_id: None, - })) - .await? - .data - .into_iter() - .flatten() - .map(|c| c.try_into()) - .collect::, _>>()?; - - for cipher in &ciphers { - if let Some(id) = &cipher.id { - self.get_repository()? - .set(id.to_string(), cipher.clone()) - .await?; - } - } + let api_client = &self.get_api_configurations().await.api_client; + let key_store = self.client.internal.get_key_store(); + let repository = &*self.get_repository()?; - Ok(self.decrypt_list_with_failures(ciphers)) + restore_many(cipher_ids, api_client, repository, key_store).await } /// Restores multiple soft-deleted ciphers on the server. @@ -93,109 +168,34 @@ impl CiphersClient { cipher_ids: Vec, org_id: OrganizationId, ) -> Result { - let api_config = self.get_api_configurations().await; - let api = api_config.api_client.ciphers_api(); - - let ciphers: Vec = api - .put_restore_many_admin(Some(CipherBulkRestoreRequestModel { - ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), - organization_id: Some(org_id.into()), - })) - .await? - .data - .into_iter() - .flatten() - .map(|c| c.try_into()) - .collect::, _>>()?; - Ok(self.decrypt_list_with_failures(ciphers)) + let api_client = &self.get_api_configurations().await.api_client; + let key_store = self.client.internal.get_key_store(); + + restore_many_as_admin(cipher_ids, org_id, api_client, key_store).await } } #[cfg(test)] mod tests { - use bitwarden_api_api::models::{ - CipherMiniResponseModel, CipherMiniResponseModelListResponseModel, CipherResponseModel, - }; - use bitwarden_core::{ - Client, ClientSettings, DeviceType, UserId, - key_management::crypto::{ - InitOrgCryptoRequest, InitUserCryptoMethod, InitUserCryptoRequest, + use bitwarden_api_api::{ + apis::ApiClient, + models::{ + CipherMiniResponseModel, CipherMiniResponseModelListResponseModel, CipherResponseModel, }, }; - use bitwarden_crypto::{EncString, Kdf}; - use bitwarden_test::{MemoryRepository, start_api_mock}; + use bitwarden_core::key_management::{KeyIds, SymmetricKeyId}; + use bitwarden_crypto::{KeyStore, SymmetricCryptoKey}; + use bitwarden_state::repository::Repository; + use bitwarden_test::MemoryRepository; use chrono::Utc; - use wiremock::{ - Mock, MockServer, ResponseTemplate, - matchers::{method, path_regex}, - }; - use crate::{Cipher, CipherId, CiphersClient, Login, VaultClientExt}; + use super::*; + use crate::{Cipher, CipherId, Login}; const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097"; const TEST_CIPHER_ID_2: &str = "6faa9684-c793-4a2d-8a12-b33900187098"; - const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000"; const TEST_ORG_ID: &str = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8"; - async fn create_client_with_wiremock(mock_server: MockServer) -> CiphersClient { - let settings = ClientSettings { - identity_url: format!("http://{}", mock_server.address()), - api_url: format!("http://{}", mock_server.address()), - user_agent: "Bitwarden Test".into(), - device_type: DeviceType::SDK, - bitwarden_client_version: None, - }; - - let client = Client::new(Some(settings)); - - client - .internal - .load_flags(std::collections::HashMap::from([( - "enableCipherKeyEncryption".to_owned(), - true, - )])); - - let user_request = InitUserCryptoRequest { - user_id: Some(UserId::new(uuid::uuid!(TEST_USER_ID))), - kdf_params: Kdf::PBKDF2 { - iterations: 600_000.try_into().unwrap(), - }, - email: "test@bitwarden.com".to_owned(), - private_key: "2.yN7l00BOlUE0Sb0M//Q53w==|EwKG/BduQRQ33Izqc/ogoBROIoI5dmgrxSo82sgzgAMIBt3A2FZ9vPRMY+GWT85JiqytDitGR3TqwnFUBhKUpRRAq4x7rA6A1arHrFp5Tp1p21O3SfjtvB3quiOKbqWk6ZaU1Np9HwqwAecddFcB0YyBEiRX3VwF2pgpAdiPbSMuvo2qIgyob0CUoC/h4Bz1be7Qa7B0Xw9/fMKkB1LpOm925lzqosyMQM62YpMGkjMsbZz0uPopu32fxzDWSPr+kekNNyLt9InGhTpxLmq1go/pXR2uw5dfpXc5yuta7DB0EGBwnQ8Vl5HPdDooqOTD9I1jE0mRyuBpWTTI3FRnu3JUh3rIyGBJhUmHqGZvw2CKdqHCIrQeQkkEYqOeJRJVdBjhv5KGJifqT3BFRwX/YFJIChAQpebNQKXe/0kPivWokHWwXlDB7S7mBZzhaAPidZvnuIhalE2qmTypDwHy22FyqV58T8MGGMchcASDi/QXI6kcdpJzPXSeU9o+NC68QDlOIrMVxKFeE7w7PvVmAaxEo0YwmuAzzKy9QpdlK0aab/xEi8V4iXj4hGepqAvHkXIQd+r3FNeiLfllkb61p6WTjr5urcmDQMR94/wYoilpG5OlybHdbhsYHvIzYoLrC7fzl630gcO6t4nM24vdB6Ymg9BVpEgKRAxSbE62Tqacxqnz9AcmgItb48NiR/He3n3ydGjPYuKk/ihZMgEwAEZvSlNxYONSbYrIGDtOY+8Nbt6KiH3l06wjZW8tcmFeVlWv+tWotnTY9IqlAfvNVTjtsobqtQnvsiDjdEVtNy/s2ci5TH+NdZluca2OVEr91Wayxh70kpM6ib4UGbfdmGgCo74gtKvKSJU0rTHakQ5L9JlaSDD5FamBRyI0qfL43Ad9qOUZ8DaffDCyuaVyuqk7cz9HwmEmvWU3VQ+5t06n/5kRDXttcw8w+3qClEEdGo1KeENcnXCB32dQe3tDTFpuAIMLqwXs6FhpawfZ5kPYvLPczGWaqftIs/RXJ/EltGc0ugw2dmTLpoQhCqrcKEBDoYVk0LDZKsnzitOGdi9mOWse7Se8798ib1UsHFUjGzISEt6upestxOeupSTOh0v4+AjXbDzRUyogHww3V+Bqg71bkcMxtB+WM+pn1XNbVTyl9NR040nhP7KEf6e9ruXAtmrBC2ah5cFEpLIot77VFZ9ilLuitSz+7T8n1yAh1IEG6xxXxninAZIzi2qGbH69O5RSpOJuJTv17zTLJQIIc781JwQ2TTwTGnx5wZLbffhCasowJKd2EVcyMJyhz6ru0PvXWJ4hUdkARJs3Xu8dus9a86N8Xk6aAPzBDqzYb1vyFIfBxP0oO8xFHgd30Cgmz8UrSE3qeWRrF8ftrI6xQnFjHBGWD/JWSvd6YMcQED0aVuQkuNW9ST/DzQThPzRfPUoiL10yAmV7Ytu4fR3x2sF0Yfi87YhHFuCMpV/DsqxmUizyiJuD938eRcH8hzR/VO53Qo3UIsqOLcyXtTv6THjSlTopQ+JOLOnHm1w8dzYbLN44OG44rRsbihMUQp+wUZ6bsI8rrOnm9WErzkbQFbrfAINdoCiNa6cimYIjvvnMTaFWNymqY1vZxGztQiMiHiHYwTfwHTXrb9j0uPM=|09J28iXv9oWzYtzK2LBT6Yht4IT4MijEkk0fwFdrVQ4=".parse::().unwrap(), - signing_key: None, - security_state: None, - method: InitUserCryptoMethod::Password { - password: "asdfasdfasdf".to_owned(), - user_key: "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=".parse().unwrap(), - } - }; - - let org_request = InitOrgCryptoRequest { - organization_keys: std::collections::HashMap::from([( - TEST_ORG_ID.parse().unwrap(), - "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==".parse().unwrap() - )]) - }; - - client - .crypto() - .initialize_user_crypto(user_request) - .await - .unwrap(); - client - .crypto() - .initialize_org_crypto(org_request) - .await - .unwrap(); - - client - .platform() - .state() - .register_client_managed(std::sync::Arc::new(MemoryRepository::::default())); - - client.vault().ciphers() - } - fn generate_test_cipher() -> Cipher { Cipher { id: TEST_CIPHER_ID.parse().ok(), @@ -242,11 +242,11 @@ mod tests { let mut cipher_1 = generate_test_cipher(); cipher_1.deleted_date = Some(Utc::now()); - let (mock_server, _config) = start_api_mock(vec![ - Mock::given(method("PUT")) - .and(path_regex(r"/ciphers/[a-f0-9-]+/restore")) - .respond_with(move |_req: &wiremock::Request| { - ResponseTemplate::new(200).set_body_json(CipherResponseModel { + let api_client = ApiClient::new_mocked(move |mock| { + mock.ciphers_api + .expect_put_restore() + .returning(move |_model| { + Ok(CipherResponseModel { id: Some(TEST_CIPHER_ID.try_into().unwrap()), name: Some(cipher_1.name.to_string()), r#type: Some(cipher_1.r#type.into()), @@ -254,33 +254,42 @@ mod tests { revision_date: Some(Utc::now().to_string()), ..Default::default() }) - }), - ]) - .await; - let client = create_client_with_wiremock(mock_server).await; + }); + }); + + let repository: MemoryRepository = Default::default(); + let store: KeyStore = KeyStore::default(); + #[allow(deprecated)] + let _ = store.context_mut().set_symmetric_key( + SymmetricKeyId::User, + SymmetricCryptoKey::make_aes256_cbc_hmac_key(), + ); let mut cipher = generate_test_cipher(); cipher.deleted_date = Some(Utc::now()); - let repository = client.get_repository().unwrap(); + repository .set(TEST_CIPHER_ID.to_string(), cipher) .await .unwrap(); let start_time = Utc::now(); - let updated_cipher = client - .restore(TEST_CIPHER_ID.parse().unwrap()) - .await - .unwrap(); + let updated_cipher = restore( + TEST_CIPHER_ID.parse().unwrap(), + &api_client, + &repository, + &store, + ) + .await + .unwrap(); + let end_time = Utc::now(); assert!(updated_cipher.deleted_date.is_none()); assert!( updated_cipher.revision_date >= start_time && updated_cipher.revision_date <= end_time ); - let repo_cipher = client - .get_repository() - .unwrap() + let repo_cipher = repository .get(TEST_CIPHER_ID.to_string()) .await .unwrap() @@ -295,28 +304,34 @@ mod tests { async fn test_restore_as_admin() { let mut cipher = generate_test_cipher(); cipher.deleted_date = Some(Utc::now()); - let (mock_server, _config) = start_api_mock(vec![ - Mock::given(method("PUT")) - .and(path_regex(r"/ciphers/[a-f0-9-]+/restore-admin")) - .respond_with(move |_req: &wiremock::Request| { - ResponseTemplate::new(200).set_body_json(CipherResponseModel { - id: Some(TEST_CIPHER_ID.try_into().unwrap()), - name: Some(cipher.name.to_string()), - r#type: Some(cipher.r#type.into()), - creation_date: Some(cipher.creation_date.to_string()), - revision_date: Some(Utc::now().to_string()), - login: cipher.login.clone().map(|l| Box::new(l.into())), - ..Default::default() - }) - }), - ]) - .await; - let client = create_client_with_wiremock(mock_server).await; + let api_client = { + let cipher = cipher.clone(); + ApiClient::new_mocked(move |mock| { + mock.ciphers_api + .expect_put_restore_admin() + .returning(move |_model| { + Ok(CipherMiniResponseModel { + id: Some(TEST_CIPHER_ID.try_into().unwrap()), + name: Some(cipher.name.to_string()), + r#type: Some(cipher.r#type.into()), + creation_date: Some(cipher.creation_date.to_string()), + revision_date: Some(Utc::now().to_string()), + login: cipher.login.clone().map(|l| Box::new(l.into())), + ..Default::default() + }) + }); + }) + }; + let store: KeyStore = KeyStore::default(); + #[allow(deprecated)] + let _ = store.context_mut().set_symmetric_key( + SymmetricKeyId::User, + SymmetricCryptoKey::make_aes256_cbc_hmac_key(), + ); let start_time = Utc::now(); - let updated_cipher = client - .restore_as_admin(TEST_CIPHER_ID.parse().unwrap()) + let updated_cipher = restore_as_admin(TEST_CIPHER_ID.parse().unwrap(), &api_client, &store) .await .unwrap(); let end_time = Utc::now(); @@ -337,48 +352,51 @@ mod tests { cipher_2.deleted_date = Some(Utc::now()); cipher_2.id = Some(cipher_id_2); - let (mock_server, _config) = { - let (cipher_1, cipher_2) = (cipher_1.clone(), cipher_2.clone()); - start_api_mock(vec![ - Mock::given(method("PUT")) - .and(path_regex(r"/ciphers/restore")) - .respond_with(move |_req: &wiremock::Request| { - let (cipher_1, cipher_2) = (cipher_1.clone(), cipher_2.clone()); - ResponseTemplate::new(200).set_body_json( - CipherMiniResponseModelListResponseModel { - object: None, - data: Some(vec![ - CipherMiniResponseModel { - id: cipher_1.id.map(|id| id.into()), - name: Some(cipher_1.name.to_string()), - r#type: Some(cipher_1.r#type.into()), - login: cipher_1.login.clone().map(|l| Box::new(l.into())), - creation_date: cipher_1.creation_date.to_string().into(), - deleted_date: None, - revision_date: Some(Utc::now().to_string()), - ..Default::default() - }, - CipherMiniResponseModel { - id: cipher_2.id.map(|id| id.into()), - name: Some(cipher_2.name.to_string()), - r#type: Some(cipher_2.r#type.into()), - login: cipher_2.login.clone().map(|l| Box::new(l.into())), - creation_date: cipher_2.creation_date.to_string().into(), - deleted_date: None, - revision_date: Some(Utc::now().to_string()), - ..Default::default() - }, - ]), - continuation_token: None, - }, - ) - }), - ]) - .await + let api_client = { + let cipher_1 = cipher_1.clone(); + let cipher_2 = cipher_2.clone(); + ApiClient::new_mocked(move |mock| { + mock.ciphers_api.expect_put_restore_many().returning({ + move |_model| { + Ok(CipherMiniResponseModelListResponseModel { + object: None, + data: Some(vec![ + CipherMiniResponseModel { + id: cipher_1.id.map(|id| id.into()), + name: Some(cipher_1.name.to_string()), + r#type: Some(cipher_1.r#type.into()), + login: cipher_1.login.clone().map(|l| Box::new(l.into())), + creation_date: cipher_1.creation_date.to_string().into(), + deleted_date: None, + revision_date: Some(Utc::now().to_string()), + ..Default::default() + }, + CipherMiniResponseModel { + id: cipher_2.id.map(|id| id.into()), + name: Some(cipher_2.name.to_string()), + r#type: Some(cipher_2.r#type.into()), + login: cipher_2.login.clone().map(|l| Box::new(l.into())), + creation_date: cipher_2.creation_date.to_string().into(), + deleted_date: None, + revision_date: Some(Utc::now().to_string()), + ..Default::default() + }, + ]), + continuation_token: None, + }) + } + }); + }) }; - let client = create_client_with_wiremock(mock_server).await; - let repository = client.get_repository().unwrap(); + let repository: MemoryRepository = Default::default(); + let store: KeyStore = KeyStore::default(); + #[allow(deprecated)] + let _ = store.context_mut().set_symmetric_key( + SymmetricKeyId::User, + SymmetricCryptoKey::make_aes256_cbc_hmac_key(), + ); + repository .set(cipher_id.to_string(), cipher_1) .await @@ -389,10 +407,14 @@ mod tests { .unwrap(); let start_time = Utc::now(); - let ciphers = client - .restore_many(vec![cipher_id, cipher_id_2]) - .await - .unwrap(); + let ciphers = restore_many( + vec![cipher_id, cipher_id_2], + &api_client, + &repository, + &store, + ) + .await + .unwrap(); let end_time = Utc::now(); assert_eq!(ciphers.successes.len(), 2,); @@ -426,58 +448,57 @@ mod tests { cipher_2.deleted_date = Some(Utc::now()); cipher_2.id = Some(cipher_id_2); - let (mock_server, _config) = { - let (cipher_1, cipher_2) = (cipher_1.clone(), cipher_2.clone()); - start_api_mock(vec![ - Mock::given(method("PUT")) - .and(path_regex(r"/ciphers/restore-admin")) - .respond_with(move |_req: &wiremock::Request| { - let (cipher_1, cipher_2) = (cipher_1.clone(), cipher_2.clone()); - ResponseTemplate::new(200).set_body_json( - CipherMiniResponseModelListResponseModel { - object: None, - data: Some(vec![ - CipherMiniResponseModel { - id: cipher_1.id.map(|id| id.into()), - name: Some(cipher_1.name.to_string()), - r#type: Some(cipher_1.r#type.into()), - login: cipher_1.login.clone().map(|l| Box::new(l.into())), - creation_date: cipher_1.creation_date.to_string().into(), - deleted_date: None, - revision_date: Some(Utc::now().to_string()), - ..Default::default() - }, - CipherMiniResponseModel { - id: cipher_2.id.map(|id| id.into()), - name: Some(cipher_2.name.to_string()), - r#type: Some(cipher_2.r#type.into()), - login: cipher_2.login.clone().map(|l| Box::new(l.into())), - creation_date: cipher_2.creation_date.to_string().into(), - deleted_date: None, - revision_date: Some(Utc::now().to_string()), - ..Default::default() - }, - ]), - continuation_token: None, + let api_client = ApiClient::new_mocked(move |mock| { + mock.ciphers_api + .expect_put_restore_many_admin() + .returning(move |_model| { + Ok(CipherMiniResponseModelListResponseModel { + object: None, + data: Some(vec![ + CipherMiniResponseModel { + id: cipher_1.id.map(|id| id.into()), + name: Some(cipher_1.name.to_string()), + r#type: Some(cipher_1.r#type.into()), + login: cipher_1.login.clone().map(|l| Box::new(l.into())), + creation_date: cipher_1.creation_date.to_string().into(), + deleted_date: None, + revision_date: Some(Utc::now().to_string()), + ..Default::default() }, - ) - }), - ]) - .await - }; - let client = create_client_with_wiremock(mock_server).await; + CipherMiniResponseModel { + id: cipher_2.id.map(|id| id.into()), + name: Some(cipher_2.name.to_string()), + r#type: Some(cipher_2.r#type.into()), + login: cipher_2.login.clone().map(|l| Box::new(l.into())), + creation_date: cipher_2.creation_date.to_string().into(), + deleted_date: None, + revision_date: Some(Utc::now().to_string()), + ..Default::default() + }, + ]), + continuation_token: None, + }) + }); + }); + let store: KeyStore = KeyStore::default(); + #[allow(deprecated)] + let _ = store.context_mut().set_symmetric_key( + SymmetricKeyId::User, + SymmetricCryptoKey::make_aes256_cbc_hmac_key(), + ); let start_time = Utc::now(); - let ciphers = client - .restore_many_as_admin( - vec![ - TEST_CIPHER_ID.parse().unwrap(), - TEST_CIPHER_ID_2.parse().unwrap(), - ], - TEST_ORG_ID.parse().unwrap(), - ) - .await - .unwrap(); + let ciphers = restore_many_as_admin( + vec![ + TEST_CIPHER_ID.parse().unwrap(), + TEST_CIPHER_ID_2.parse().unwrap(), + ], + TEST_ORG_ID.parse().unwrap(), + &api_client, + &store, + ) + .await + .unwrap(); let end_time = Utc::now(); assert_eq!(ciphers.successes.len(), 2,); From 6d5959055545659d703e6ec67dfcb8e2b03a0fb3 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Fri, 5 Dec 2025 11:22:37 -0800 Subject: [PATCH 29/38] Move admin delete ops to new CipherAdminClient --- .../src/cipher/cipher_client/admin/delete.rs | 225 ++++++++++++++++++ .../{cipher_admin_client.rs => admin/mod.rs} | 103 ++------ .../src/cipher/cipher_client/mod.rs | 2 +- 3 files changed, 248 insertions(+), 82 deletions(-) create mode 100644 crates/bitwarden-vault/src/cipher/cipher_client/admin/delete.rs rename crates/bitwarden-vault/src/cipher/cipher_client/{cipher_admin_client.rs => admin/mod.rs} (61%) diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/admin/delete.rs b/crates/bitwarden-vault/src/cipher/cipher_client/admin/delete.rs new file mode 100644 index 000000000..1480448b2 --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin/delete.rs @@ -0,0 +1,225 @@ +use bitwarden_api_api::models::CipherBulkDeleteRequestModel; +use bitwarden_core::{ApiError, OrganizationId}; + +use crate::{CipherId, cipher_client::admin::CipherAdminClient}; + +async fn delete_cipher( + cipher_id: CipherId, + api_client: &bitwarden_api_api::apis::ApiClient, +) -> Result<(), ApiError> { + let api = api_client.ciphers_api(); + api.delete_admin(cipher_id.into()).await?; + Ok(()) +} + +async fn delete_ciphers( + cipher_ids: Vec, + organization_id: Option, + api_client: &bitwarden_api_api::apis::ApiClient, +) -> Result<(), ApiError> { + let api = api_client.ciphers_api(); + + api.delete_many_admin(Some(CipherBulkDeleteRequestModel { + ids: cipher_ids.iter().map(|id| id.to_string()).collect(), + organization_id: organization_id.map(|id| id.to_string()), + })) + .await?; + + Ok(()) +} + +async fn soft_delete( + cipher_id: CipherId, + api_client: &bitwarden_api_api::apis::ApiClient, +) -> Result<(), ApiError> { + let api = api_client.ciphers_api(); + api.put_delete_admin(cipher_id.into()).await?; + Ok(()) +} + +async fn soft_delete_many( + cipher_ids: Vec, + organization_id: Option, + api_client: &bitwarden_api_api::apis::ApiClient, +) -> Result<(), ApiError> { + let api = api_client.ciphers_api(); + + api.put_delete_many_admin(Some(CipherBulkDeleteRequestModel { + ids: cipher_ids.iter().map(|id| id.to_string()).collect(), + organization_id: organization_id.map(|id| id.to_string()), + })) + .await?; + Ok(()) +} + +impl CipherAdminClient { + /// Deletes the [Cipher] with the matching [CipherId] from the server, using the admin endpoint. + pub async fn delete(&self, cipher_id: CipherId) -> Result<(), ApiError> { + delete_cipher( + cipher_id, + &self + .client + .internal + .get_api_configurations() + .await + .api_client, + ) + .await + } + + /// Soft-deletes the [Cipher] with the matching [CipherId] from the server, using the admin + /// endpoint. + pub async fn soft_delete(&self, cipher_id: CipherId) -> Result<(), ApiError> { + soft_delete( + cipher_id, + &self + .client + .internal + .get_api_configurations() + .await + .api_client, + ) + .await + } + + /// Deletes all [Cipher] objects with a matching [CipherId] from the server, using the admin + /// endpoint. + pub async fn delete_many( + &self, + cipher_ids: Vec, + organization_id: Option, + ) -> Result<(), ApiError> { + delete_ciphers( + cipher_ids, + organization_id, + &self + .client + .internal + .get_api_configurations() + .await + .api_client, + ) + .await + } + + /// Soft-deletes all [Cipher] objects for the given [CipherId]s from the server, using the admin + /// endpoint. + pub async fn soft_delete_many( + &self, + cipher_ids: Vec, + organization_id: Option, + ) -> Result<(), ApiError> { + soft_delete_many( + cipher_ids, + organization_id, + &self + .client + .internal + .get_api_configurations() + .await + .api_client, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097"; + const TEST_CIPHER_ID_2: &str = "6faa9684-c793-4a2d-8a12-b33900187098"; + const TEST_ORG_ID: &str = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8"; + + #[tokio::test] + async fn test_delete_as_admin() { + delete_cipher( + TEST_CIPHER_ID.parse().unwrap(), + &bitwarden_api_api::apis::ApiClient::new_mocked(|mock| { + mock.ciphers_api.expect_delete_admin().returning(move |id| { + assert_eq!(&id.to_string(), TEST_CIPHER_ID); + Ok(()) + }); + }), + ) + .await + .unwrap() + } + + #[tokio::test] + async fn test_soft_delete_as_admin() { + soft_delete( + TEST_CIPHER_ID.parse().unwrap(), + &bitwarden_api_api::apis::ApiClient::new_mocked(|mock| { + mock.ciphers_api + .expect_put_delete_admin() + .returning(move |id| { + assert_eq!(&id.to_string(), TEST_CIPHER_ID); + Ok(()) + }); + }), + ) + .await + .unwrap() + } + + #[tokio::test] + async fn test_delete_many_as_admin() { + delete_ciphers( + vec![ + TEST_CIPHER_ID.parse().unwrap(), + TEST_CIPHER_ID_2.parse().unwrap(), + ], + TEST_ORG_ID.parse().ok(), + &bitwarden_api_api::apis::ApiClient::new_mocked(|mock| { + mock.ciphers_api + .expect_delete_many_admin() + .returning(move |request| { + let CipherBulkDeleteRequestModel { + ids, + organization_id, + } = request.unwrap(); + + assert_eq!( + ids, + vec![TEST_CIPHER_ID.to_string(), TEST_CIPHER_ID_2.to_string(),], + ); + assert_eq!(organization_id, Some(TEST_ORG_ID.to_string())); + Ok(()) + }); + }), + ) + .await + .unwrap() + } + + #[tokio::test] + async fn test_soft_delete_many_as_admin() { + soft_delete_many( + vec![ + TEST_CIPHER_ID.parse().unwrap(), + TEST_CIPHER_ID_2.parse().unwrap(), + ], + TEST_ORG_ID.parse().ok(), + &bitwarden_api_api::apis::ApiClient::new_mocked(|mock| { + mock.ciphers_api + .expect_put_delete_many_admin() + .returning(move |request| { + let CipherBulkDeleteRequestModel { + ids, + organization_id, + } = request.unwrap(); + + assert_eq!( + ids, + vec![TEST_CIPHER_ID.to_string(), TEST_CIPHER_ID_2.to_string()], + ); + assert_eq!(organization_id, Some(TEST_ORG_ID.to_string())); + Ok(()) + }); + }), + ) + .await + .unwrap() + } +} diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/cipher_admin_client.rs b/crates/bitwarden-vault/src/cipher/cipher_client/admin/mod.rs similarity index 61% rename from crates/bitwarden-vault/src/cipher/cipher_client/cipher_admin_client.rs rename to crates/bitwarden-vault/src/cipher/cipher_client/admin/mod.rs index 12688e820..05b92f6f2 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/cipher_admin_client.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin/mod.rs @@ -1,95 +1,35 @@ -use bitwarden_api_api::models::CipherBulkDeleteRequestModel; -use bitwarden_collections::collection::CollectionId; -use bitwarden_core::{ApiError, OrganizationId}; +use bitwarden_core::Client; use wasm_bindgen::prelude::*; -use crate::{ - CipherId, CipherView, CiphersClient, - cipher_client::{ - create::{CipherCreateRequest, CreateCipherError}, - delete::DeleteCipherError, - edit::{CipherEditRequest, EditCipherError}, - }, -}; +// mod create; +mod delete; +mod restore; +// mod edit; #[allow(missing_docs)] #[cfg_attr(feature = "wasm", wasm_bindgen)] pub struct CipherAdminClient { - pub(crate) client: CiphersClient, + pub(crate) client: Client, } impl CipherAdminClient { - /// Creates a new [Cipher] for an organization, using the admin server endpoints endpoints. - pub async fn create_as_admin( - &self, - request: CipherCreateRequest, - collection_ids: Vec, - ) -> Result { - self.client - .create_cipher(request, collection_ids, true) - .await - } + // /// Creates a new [Cipher] for an organization, using the admin server endpoints endpoints. + // pub async fn create( + // &self, + // request: CipherCreateRequest, + // collection_ids: Vec, + // ) -> Result { + // self.client + // .create_cipher(request, collection_ids, true) + // .await + // } // putCipherAdmin(id, request: CipherRequest) // ciphers_id_admin_put - #[allow(missing_docs)] // - pub async fn edit_as_admin( - &self, - request: CipherEditRequest, - ) -> Result { - self.client.edit_internal(request, true).await - } - - /// Deletes the [Cipher] with the matching [CipherId] from the server, using the admin endpoint. - pub async fn delete_as_admin(&self, cipher_id: CipherId) -> Result<(), ApiError> { - let configs = self.client.get_api_configurations().await; - let api = configs.api_client.ciphers_api(); - api.delete_admin(cipher_id.into()).await?; - Ok(()) - } - - /// Soft-deletes the [Cipher] with the matching [CipherId] from the server, using the admin - /// endpoint. - pub async fn soft_delete_as_admin(&self, cipher_id: CipherId) -> Result<(), DeleteCipherError> { - let configs = self.client.get_api_configurations().await; - let api = configs.api_client.ciphers_api(); - api.put_delete_admin(cipher_id.into()).await?; - Ok(()) - } - - /// Deletes all [Cipher] objects with a matching [CipherId] from the server, using the admin - /// endpoint. - pub async fn delete_many_as_admin( - &self, - cipher_ids: Vec, - organization_id: Option, - ) -> Result<(), DeleteCipherError> { - let configs = self.client.get_api_configurations().await; - let api = configs.api_client.ciphers_api(); - api.delete_many_admin(Some(CipherBulkDeleteRequestModel { - ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), - organization_id: organization_id.map(|id| id.to_string()), - })) - .await?; - Ok(()) - } - - /// Soft-deletes all [Cipher] objects for the given [CipherId]s from the server, using the admin - /// endpoint. - pub async fn soft_delete_many_as_admin( - &self, - cipher_ids: Vec, - organization_id: Option, - ) -> Result<(), DeleteCipherError> { - let configs = self.client.get_api_configurations().await; - let api = configs.api_client.ciphers_api(); - api.put_delete_many_admin(Some(CipherBulkDeleteRequestModel { - ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), - organization_id: organization_id.map(|id| id.to_string()), - })) - .await?; - Ok(()) - } + // #[allow(missing_docs)] // + // pub async fn edit(&self, request: CipherEditRequest) -> Result { + // self.client.edit_internal(request, true).await + // } } // #[cfg(test)] @@ -179,7 +119,7 @@ impl CipherAdminClient { // let response = client // .create_as_admin( // CipherCreateRequest { -// organization_id: Some(TEST_ORG_ID.parse().unwrap()), +// organization_id: SomeTEST_ORG_ID.parse().unwrap()), // folder_id: None, // name: "Test Cipher".into(), // notes: None, @@ -209,3 +149,4 @@ impl CipherAdminClient { // assert_eq!(response.id, Some(TEST_CIPHER_ID.parse().unwrap())); // assert_eq!(response.organization_id, Some(TEST_ORG_ID.parse().unwrap())); // } +// diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs b/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs index 2f1403824..7ce354974 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs @@ -18,7 +18,7 @@ use crate::{ cipher::cipher::DecryptCipherListResult, }; -mod cipher_admin_client; +mod admin; mod create; mod delete; mod edit; From ef3fef321bfb588541b8f931080cd506257a87c3 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Fri, 5 Dec 2025 11:38:04 -0800 Subject: [PATCH 30/38] Move restore operations to CipherAdminClient --- .../src/cipher/cipher_client/admin/restore.rs | 288 ++++++++++++++++++ .../src/cipher/cipher_client/restore.rs | 194 +----------- 2 files changed, 289 insertions(+), 193 deletions(-) create mode 100644 crates/bitwarden-vault/src/cipher/cipher_client/admin/restore.rs diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/admin/restore.rs b/crates/bitwarden-vault/src/cipher/cipher_client/admin/restore.rs new file mode 100644 index 000000000..1eb81343e --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin/restore.rs @@ -0,0 +1,288 @@ +use bitwarden_api_api::{apis::ApiClient, models::CipherBulkRestoreRequestModel}; +use bitwarden_core::{ApiError, OrganizationId, key_management::KeyIds}; +use bitwarden_crypto::{CryptoError, KeyStore}; +use bitwarden_error::bitwarden_error; +use thiserror::Error; + +use crate::{ + Cipher, CipherId, CipherView, DecryptCipherListResult, VaultParseError, + cipher_client::admin::CipherAdminClient, +}; + +#[allow(missing_docs)] +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum RestoreCipherAdminError { + #[error(transparent)] + Api(#[from] ApiError), + #[error(transparent)] + VaultParse(#[from] VaultParseError), + #[error(transparent)] + Crypto(#[from] CryptoError), +} + +impl From> for RestoreCipherAdminError { + fn from(val: bitwarden_api_api::apis::Error) -> Self { + Self::Api(val.into()) + } +} + +/// Restores a soft-deleted cipher on the server, using the admin endpoint. +pub async fn restore_as_admin( + cipher_id: CipherId, + api_client: &ApiClient, + key_store: &KeyStore, +) -> Result { + let api = api_client.ciphers_api(); + + let cipher: Cipher = api.put_restore_admin(cipher_id.into()).await?.try_into()?; + + Ok(key_store.decrypt(&cipher)?) +} + +/// Restores multiple soft-deleted ciphers on the server. +pub async fn restore_many_as_admin( + cipher_ids: Vec, + org_id: OrganizationId, + api_client: &ApiClient, + key_store: &KeyStore, +) -> Result { + let api = api_client.ciphers_api(); + + let ciphers: Vec = api + .put_restore_many_admin(Some(CipherBulkRestoreRequestModel { + ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), + organization_id: Some(org_id.into()), + })) + .await? + .data + .into_iter() + .flatten() + .map(|c| c.try_into()) + .collect::, _>>()?; + + let (successes, failures) = key_store.decrypt_list_with_failures(&ciphers); + Ok(DecryptCipherListResult { + successes, + failures: failures.into_iter().cloned().collect(), + }) +} + +impl CipherAdminClient { + /// Restores a soft-deleted cipher on the server, using the admin endpoint. + pub async fn restore_as_admin( + &self, + cipher_id: CipherId, + ) -> Result { + let api_client = &self + .client + .internal + .get_api_configurations() + .await + .api_client; + let key_store = self.client.internal.get_key_store(); + + restore_as_admin(cipher_id, api_client, key_store).await + } + /// Restores multiple soft-deleted ciphers on the server. + pub async fn restore_many_as_admin( + &self, + cipher_ids: Vec, + org_id: OrganizationId, + ) -> Result { + let api_client = &self + .client + .internal + .get_api_configurations() + .await + .api_client; + let key_store = self.client.internal.get_key_store(); + + restore_many_as_admin(cipher_ids, org_id, api_client, key_store).await + } +} + +#[cfg(test)] +mod tests { + use bitwarden_api_api::{ + apis::ApiClient, + models::{CipherMiniResponseModel, CipherMiniResponseModelListResponseModel}, + }; + use bitwarden_core::key_management::{KeyIds, SymmetricKeyId}; + use bitwarden_crypto::{KeyStore, SymmetricCryptoKey}; + use chrono::Utc; + + use super::*; + use crate::{Cipher, CipherId, Login}; + + const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097"; + const TEST_CIPHER_ID_2: &str = "6faa9684-c793-4a2d-8a12-b33900187098"; + const TEST_ORG_ID: &str = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8"; + + fn generate_test_cipher() -> Cipher { + Cipher { + id: TEST_CIPHER_ID.parse().ok(), + name: "2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8=".parse().unwrap(), + r#type: crate::CipherType::Login, + notes: Default::default(), + organization_id: Default::default(), + folder_id: Default::default(), + favorite: Default::default(), + reprompt: Default::default(), + fields: Default::default(), + collection_ids: Default::default(), + key: Default::default(), + login: Some(Login{ + username: None, + password: None, + password_revision_date: None, + uris: None, totp: None, + autofill_on_page_load: None, + fido2_credentials: None, + }), + identity: Default::default(), + card: Default::default(), + secure_note: Default::default(), + ssh_key: Default::default(), + organization_use_totp: Default::default(), + edit: Default::default(), + permissions: Default::default(), + view_password: Default::default(), + local_data: Default::default(), + attachments: Default::default(), + password_history: Default::default(), + creation_date: Default::default(), + deleted_date: Default::default(), + revision_date: Default::default(), + archived_date: Default::default(), + data: Default::default(), + } + } + + #[tokio::test] + async fn test_restore_as_admin() { + let mut cipher = generate_test_cipher(); + cipher.deleted_date = Some(Utc::now()); + + let api_client = { + let cipher = cipher.clone(); + ApiClient::new_mocked(move |mock| { + mock.ciphers_api + .expect_put_restore_admin() + .returning(move |_model| { + Ok(CipherMiniResponseModel { + id: Some(TEST_CIPHER_ID.try_into().unwrap()), + name: Some(cipher.name.to_string()), + r#type: Some(cipher.r#type.into()), + creation_date: Some(cipher.creation_date.to_string()), + revision_date: Some(Utc::now().to_string()), + login: cipher.login.clone().map(|l| Box::new(l.into())), + ..Default::default() + }) + }); + }) + }; + + let store: KeyStore = KeyStore::default(); + #[allow(deprecated)] + let _ = store.context_mut().set_symmetric_key( + SymmetricKeyId::User, + SymmetricCryptoKey::make_aes256_cbc_hmac_key(), + ); + let start_time = Utc::now(); + let updated_cipher = restore_as_admin(TEST_CIPHER_ID.parse().unwrap(), &api_client, &store) + .await + .unwrap(); + let end_time = Utc::now(); + + assert!(updated_cipher.deleted_date.is_none()); + assert!( + updated_cipher.revision_date >= start_time && updated_cipher.revision_date <= end_time + ); + } + + #[tokio::test] + async fn test_restore_many_as_admin() { + let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap(); + let mut cipher_1 = generate_test_cipher(); + cipher_1.deleted_date = Some(Utc::now()); + let mut cipher_2 = generate_test_cipher(); + cipher_2.deleted_date = Some(Utc::now()); + cipher_2.id = Some(cipher_id_2); + + let api_client = ApiClient::new_mocked(move |mock| { + mock.ciphers_api + .expect_put_restore_many_admin() + .returning(move |_model| { + Ok(CipherMiniResponseModelListResponseModel { + object: None, + data: Some(vec![ + CipherMiniResponseModel { + id: cipher_1.id.map(|id| id.into()), + name: Some(cipher_1.name.to_string()), + r#type: Some(cipher_1.r#type.into()), + login: cipher_1.login.clone().map(|l| Box::new(l.into())), + creation_date: cipher_1.creation_date.to_string().into(), + deleted_date: None, + revision_date: Some(Utc::now().to_string()), + ..Default::default() + }, + CipherMiniResponseModel { + id: cipher_2.id.map(|id| id.into()), + name: Some(cipher_2.name.to_string()), + r#type: Some(cipher_2.r#type.into()), + login: cipher_2.login.clone().map(|l| Box::new(l.into())), + creation_date: cipher_2.creation_date.to_string().into(), + deleted_date: None, + revision_date: Some(Utc::now().to_string()), + ..Default::default() + }, + ]), + continuation_token: None, + }) + }); + }); + let store: KeyStore = KeyStore::default(); + #[allow(deprecated)] + let _ = store.context_mut().set_symmetric_key( + SymmetricKeyId::User, + SymmetricCryptoKey::make_aes256_cbc_hmac_key(), + ); + + let start_time = Utc::now(); + let ciphers = restore_many_as_admin( + vec![ + TEST_CIPHER_ID.parse().unwrap(), + TEST_CIPHER_ID_2.parse().unwrap(), + ], + TEST_ORG_ID.parse().unwrap(), + &api_client, + &store, + ) + .await + .unwrap(); + let end_time = Utc::now(); + + assert_eq!(ciphers.successes.len(), 2,); + assert_eq!(ciphers.failures.len(), 0,); + assert_eq!( + ciphers.successes[0].id, + Some(TEST_CIPHER_ID.parse().unwrap()), + ); + assert_eq!( + ciphers.successes[1].id, + Some(TEST_CIPHER_ID_2.parse().unwrap()), + ); + assert_eq!(ciphers.successes[0].deleted_date, None,); + assert_eq!(ciphers.successes[1].deleted_date, None,); + + assert!( + ciphers.successes[0].revision_date >= start_time + && ciphers.successes[0].revision_date <= end_time + ); + assert!( + ciphers.successes[1].revision_date >= start_time + && ciphers.successes[1].revision_date <= end_time + ); + } +} diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs b/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs index ca62f5eb5..ebeae6b89 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs @@ -1,6 +1,6 @@ use bitwarden_api_api::{apis::ApiClient, models::CipherBulkRestoreRequestModel}; use bitwarden_core::{ - ApiError, OrganizationId, + ApiError, key_management::{KeyIds, SymmetricKeyId}, }; use bitwarden_crypto::{CryptoError, KeyStore, SymmetricCryptoKey}; @@ -49,19 +49,6 @@ pub async fn restore( Ok(key_store.decrypt(&cipher)?) } -/// Restores a soft-deleted cipher on the server, using the admin endpoint. -pub async fn restore_as_admin( - cipher_id: CipherId, - api_client: &ApiClient, - key_store: &KeyStore, -) -> Result { - let api = api_client.ciphers_api(); - - let cipher: Cipher = api.put_restore_admin(cipher_id.into()).await?.try_into()?; - - Ok(key_store.decrypt(&cipher)?) -} - /// Restores multiple soft-deleted ciphers on the server. pub async fn restore_many( cipher_ids: Vec, @@ -102,34 +89,6 @@ pub async fn restore_many( }) } -/// Restores multiple soft-deleted ciphers on the server. -pub async fn restore_many_as_admin( - cipher_ids: Vec, - org_id: OrganizationId, - api_client: &ApiClient, - key_store: &KeyStore, -) -> Result { - let api = api_client.ciphers_api(); - - let ciphers: Vec = api - .put_restore_many_admin(Some(CipherBulkRestoreRequestModel { - ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(), - organization_id: Some(org_id.into()), - })) - .await? - .data - .into_iter() - .flatten() - .map(|c| c.try_into()) - .collect::, _>>()?; - - let (successes, failures) = key_store.decrypt_list_with_failures(&ciphers); - Ok(DecryptCipherListResult { - successes, - failures: failures.into_iter().cloned().collect(), - }) -} - impl CiphersClient { /// Restores a soft-deleted cipher on the server. pub async fn restore(&self, cipher_id: CipherId) -> Result { @@ -139,17 +98,6 @@ impl CiphersClient { restore(cipher_id, api_client, &*self.get_repository()?, key_store).await } - /// Restores a soft-deleted cipher on the server, using the admin endpoint. - pub async fn restore_as_admin( - &self, - cipher_id: CipherId, - ) -> Result { - let api_client = &self.get_api_configurations().await.api_client; - let key_store = self.client.internal.get_key_store(); - - restore_as_admin(cipher_id, api_client, key_store).await - } - /// Restores multiple soft-deleted ciphers on the server. pub async fn restore_many( &self, @@ -161,18 +109,6 @@ impl CiphersClient { restore_many(cipher_ids, api_client, repository, key_store).await } - - /// Restores multiple soft-deleted ciphers on the server. - pub async fn restore_many_as_admin( - &self, - cipher_ids: Vec, - org_id: OrganizationId, - ) -> Result { - let api_client = &self.get_api_configurations().await.api_client; - let key_store = self.client.internal.get_key_store(); - - restore_many_as_admin(cipher_ids, org_id, api_client, key_store).await - } } #[cfg(test)] @@ -194,7 +130,6 @@ mod tests { const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097"; const TEST_CIPHER_ID_2: &str = "6faa9684-c793-4a2d-8a12-b33900187098"; - const TEST_ORG_ID: &str = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8"; fn generate_test_cipher() -> Cipher { Cipher { @@ -300,48 +235,6 @@ mod tests { ); } - #[tokio::test] - async fn test_restore_as_admin() { - let mut cipher = generate_test_cipher(); - cipher.deleted_date = Some(Utc::now()); - - let api_client = { - let cipher = cipher.clone(); - ApiClient::new_mocked(move |mock| { - mock.ciphers_api - .expect_put_restore_admin() - .returning(move |_model| { - Ok(CipherMiniResponseModel { - id: Some(TEST_CIPHER_ID.try_into().unwrap()), - name: Some(cipher.name.to_string()), - r#type: Some(cipher.r#type.into()), - creation_date: Some(cipher.creation_date.to_string()), - revision_date: Some(Utc::now().to_string()), - login: cipher.login.clone().map(|l| Box::new(l.into())), - ..Default::default() - }) - }); - }) - }; - - let store: KeyStore = KeyStore::default(); - #[allow(deprecated)] - let _ = store.context_mut().set_symmetric_key( - SymmetricKeyId::User, - SymmetricCryptoKey::make_aes256_cbc_hmac_key(), - ); - let start_time = Utc::now(); - let updated_cipher = restore_as_admin(TEST_CIPHER_ID.parse().unwrap(), &api_client, &store) - .await - .unwrap(); - let end_time = Utc::now(); - - assert!(updated_cipher.deleted_date.is_none()); - assert!( - updated_cipher.revision_date >= start_time && updated_cipher.revision_date <= end_time - ); - } - #[tokio::test] async fn test_restore_many() { let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); @@ -438,89 +331,4 @@ mod tests { assert!(cipher_1.revision_date >= start_time && cipher_1.revision_date <= end_time); assert!(cipher_2.revision_date >= start_time && cipher_2.revision_date <= end_time); } - - #[tokio::test] - async fn test_restore_many_as_admin() { - let cipher_id_2: CipherId = TEST_CIPHER_ID_2.parse().unwrap(); - let mut cipher_1 = generate_test_cipher(); - cipher_1.deleted_date = Some(Utc::now()); - let mut cipher_2 = generate_test_cipher(); - cipher_2.deleted_date = Some(Utc::now()); - cipher_2.id = Some(cipher_id_2); - - let api_client = ApiClient::new_mocked(move |mock| { - mock.ciphers_api - .expect_put_restore_many_admin() - .returning(move |_model| { - Ok(CipherMiniResponseModelListResponseModel { - object: None, - data: Some(vec![ - CipherMiniResponseModel { - id: cipher_1.id.map(|id| id.into()), - name: Some(cipher_1.name.to_string()), - r#type: Some(cipher_1.r#type.into()), - login: cipher_1.login.clone().map(|l| Box::new(l.into())), - creation_date: cipher_1.creation_date.to_string().into(), - deleted_date: None, - revision_date: Some(Utc::now().to_string()), - ..Default::default() - }, - CipherMiniResponseModel { - id: cipher_2.id.map(|id| id.into()), - name: Some(cipher_2.name.to_string()), - r#type: Some(cipher_2.r#type.into()), - login: cipher_2.login.clone().map(|l| Box::new(l.into())), - creation_date: cipher_2.creation_date.to_string().into(), - deleted_date: None, - revision_date: Some(Utc::now().to_string()), - ..Default::default() - }, - ]), - continuation_token: None, - }) - }); - }); - let store: KeyStore = KeyStore::default(); - #[allow(deprecated)] - let _ = store.context_mut().set_symmetric_key( - SymmetricKeyId::User, - SymmetricCryptoKey::make_aes256_cbc_hmac_key(), - ); - - let start_time = Utc::now(); - let ciphers = restore_many_as_admin( - vec![ - TEST_CIPHER_ID.parse().unwrap(), - TEST_CIPHER_ID_2.parse().unwrap(), - ], - TEST_ORG_ID.parse().unwrap(), - &api_client, - &store, - ) - .await - .unwrap(); - let end_time = Utc::now(); - - assert_eq!(ciphers.successes.len(), 2,); - assert_eq!(ciphers.failures.len(), 0,); - assert_eq!( - ciphers.successes[0].id, - Some(TEST_CIPHER_ID.parse().unwrap()), - ); - assert_eq!( - ciphers.successes[1].id, - Some(TEST_CIPHER_ID_2.parse().unwrap()), - ); - assert_eq!(ciphers.successes[0].deleted_date, None,); - assert_eq!(ciphers.successes[1].deleted_date, None,); - - assert!( - ciphers.successes[0].revision_date >= start_time - && ciphers.successes[0].revision_date <= end_time - ); - assert!( - ciphers.successes[1].revision_date >= start_time - && ciphers.successes[1].revision_date <= end_time - ); - } } From 715f75caf3aea7123ad1a1cd27c3e8ed9e30feb7 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Fri, 5 Dec 2025 12:26:23 -0800 Subject: [PATCH 31/38] Move create admin operations to CreateAdminClient --- .../src/cipher/cipher_client/admin/create.rs | 188 ++++++++++++++++++ .../src/cipher/cipher_client/admin/mod.rs | 2 +- .../src/cipher/cipher_client/create.rs | 26 +-- 3 files changed, 195 insertions(+), 21 deletions(-) create mode 100644 crates/bitwarden-vault/src/cipher/cipher_client/admin/create.rs diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/admin/create.rs b/crates/bitwarden-vault/src/cipher/cipher_client/admin/create.rs new file mode 100644 index 000000000..3667f39b7 --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin/create.rs @@ -0,0 +1,188 @@ +use bitwarden_api_api::models::CipherCreateRequestModel; +use bitwarden_collections::collection::CollectionId; +use bitwarden_core::{ + ApiError, MissingFieldError, NotAuthenticatedError, UserId, key_management::KeyIds, +}; +use bitwarden_crypto::{CryptoError, IdentifyKey, KeyStore}; +use bitwarden_error::bitwarden_error; +use thiserror::Error; +#[cfg(feature = "wasm")] +use wasm_bindgen::prelude::*; + +use crate::{ + Cipher, CipherView, VaultParseError, + cipher_client::{ + admin::CipherAdminClient, + create::{CipherCreateRequest, CipherCreateRequestInternal, CreateCipherError}, + }, +}; + +#[allow(missing_docs)] +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum CreateCipherAdminError { + #[error(transparent)] + Crypto(#[from] CryptoError), + #[error(transparent)] + Api(#[from] ApiError), + #[error(transparent)] + VaultParse(#[from] VaultParseError), + #[error(transparent)] + MissingField(#[from] MissingFieldError), + #[error(transparent)] + NotAuthenticated(#[from] NotAuthenticatedError), +} + +impl From> for CreateCipherAdminError { + fn from(val: bitwarden_api_api::apis::Error) -> Self { + Self::Api(val.into()) + } +} + +/// Wraps the API call to create a cipher using the admin endpoint, for easier testing. +async fn create_cipher( + request: CipherCreateRequestInternal, + collection_ids: Vec, + encrypted_for: UserId, + api_client: &bitwarden_api_api::apis::ApiClient, + key_store: &KeyStore, +) -> Result { + let mut cipher_request = key_store.encrypt(request)?; + cipher_request.encrypted_for = Some(encrypted_for.into()); + + let cipher: Cipher = api_client + .ciphers_api() + .post_admin(Some(CipherCreateRequestModel { + collection_ids: Some(collection_ids.into_iter().map(Into::into).collect()), + cipher: Box::new(cipher_request), + })) + .await? + .try_into()?; + + Ok(key_store.decrypt(&cipher)?) +} + +#[cfg_attr(feature = "wasm", wasm_bindgen)] +impl CipherAdminClient { + /// Creates a new [Cipher] for an organization, using the admin server endpoints endpoints. + /// Creates the Cipher on the server only, does not store it to local state. + pub async fn create( + &self, + request: CipherCreateRequest, + collection_ids: Vec, + ) -> Result { + let key_store = self.client.internal.get_key_store(); + let config = self.client.internal.get_api_configurations().await; + let mut internal_request: CipherCreateRequestInternal = request.into(); + + let user_id = self + .client + .internal + .get_user_id() + .ok_or(NotAuthenticatedError)?; + + // TODO: Once this flag is removed, the key generation logic should + // be moved closer to the actual encryption logic. + if self + .client + .internal + .get_flags() + .enable_cipher_key_encryption + { + let key = internal_request.key_identifier(); + internal_request.generate_cipher_key(&mut key_store.context(), key)?; + } + + create_cipher( + internal_request, + collection_ids, + user_id, + &config.api_client, + key_store, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use bitwarden_api_api::models::CipherMiniResponseModel; + use bitwarden_core::key_management::SymmetricKeyId; + use bitwarden_crypto::SymmetricCryptoKey; + use chrono::Utc; + + use super::*; + use crate::{CipherRepromptType, CipherViewType, LoginView}; + + const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097"; + const TEST_COLLECTION_ID: &str = "73546b86-8802-4449-ad2a-69ea981b4ffd"; + const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000"; + const TEST_ORG_ID: &str = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8"; + + #[tokio::test] + async fn test_create_org_cipher() { + let api_client = bitwarden_api_api::apis::ApiClient::new_mocked(|mock| { + mock.ciphers_api + .expect_post_admin() + .returning(move |request| { + let request = request.unwrap(); + + Ok(CipherMiniResponseModel { + id: Some(TEST_CIPHER_ID.try_into().unwrap()), + organization_id: request + .cipher + .organization_id + .and_then(|id| id.parse().ok()), + name: Some(request.cipher.name.clone()), + r#type: request.cipher.r#type, + creation_date: Some(Utc::now().to_string()), + revision_date: Some(Utc::now().to_string()), + ..Default::default() + }) + }); + }); + + let store: KeyStore = KeyStore::default(); + #[allow(deprecated)] + let _ = store.context_mut().set_symmetric_key( + SymmetricKeyId::User, + SymmetricCryptoKey::make_aes256_cbc_hmac_key(), + ); + + let cipher_request: CipherCreateRequestInternal = CipherCreateRequest { + organization_id: Some(TEST_ORG_ID.parse().unwrap()), + folder_id: None, + name: "Test Cipher".into(), + notes: None, + favorite: false, + reprompt: CipherRepromptType::None, + r#type: CipherViewType::Login(LoginView { + username: None, + password: None, + password_revision_date: None, + uris: None, + totp: None, + autofill_on_page_load: None, + fido2_credentials: None, + }), + fields: vec![], + } + .into(); + + let response = create_cipher( + cipher_request.clone(), + vec![TEST_COLLECTION_ID.parse().unwrap()], + TEST_USER_ID.parse().unwrap(), + &api_client, + &store, + ) + .await + .unwrap(); + + assert_eq!(response.id, Some(TEST_CIPHER_ID.parse().unwrap())); + assert_eq!( + response.organization_id, + cipher_request.create_request.organization_id + ); + } +} diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/admin/mod.rs b/crates/bitwarden-vault/src/cipher/cipher_client/admin/mod.rs index 05b92f6f2..0a579b9bc 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/admin/mod.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin/mod.rs @@ -1,7 +1,7 @@ use bitwarden_core::Client; use wasm_bindgen::prelude::*; -// mod create; +mod create; mod delete; mod restore; // mod edit; diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/create.rs b/crates/bitwarden-vault/src/cipher/cipher_client/create.rs index b65762d3e..df9b1ea3d 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/create.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/create.rs @@ -67,8 +67,8 @@ pub struct CipherCreateRequest { /// Used as an intermediary between the public-facing [CipherCreateRequest], and the encrypted /// value. This allows us to manage the cipher key creation internally. #[derive(Clone, Debug)] -struct CipherCreateRequestInternal { - create_request: CipherCreateRequest, +pub(super) struct CipherCreateRequestInternal { + pub create_request: CipherCreateRequest, key: Option, } @@ -226,22 +226,12 @@ async fn create_cipher + ?Sized>( encrypted_for: UserId, request: CipherCreateRequestInternal, collection_ids: Vec, - as_admin: bool, ) -> Result { let mut cipher_request = key_store.encrypt(request)?; cipher_request.encrypted_for = Some(encrypted_for.into()); let cipher: Cipher; - if as_admin && cipher_request.organization_id.is_some() { - cipher = api_client - .ciphers_api() - .post_admin(Some(CipherCreateRequestModel { - collection_ids: Some(collection_ids.into_iter().map(Into::into).collect()), - cipher: Box::new(cipher_request), - })) - .await? - .try_into()?; - } else if !collection_ids.is_empty() { + if !collection_ids.is_empty() { cipher = api_client .ciphers_api() .post_create(Some(CipherCreateRequestModel { @@ -271,11 +261,10 @@ async fn create_cipher + ?Sized>( #[cfg_attr(feature = "wasm", wasm_bindgen)] impl CiphersClient { - pub(super) async fn create_cipher( + async fn create_cipher( &self, request: CipherCreateRequest, collection_ids: Vec, - as_admin: bool, ) -> Result { let key_store = self.client.internal.get_key_store(); let config = self.client.internal.get_api_configurations().await; @@ -307,7 +296,6 @@ impl CiphersClient { user_id, internal_request, collection_ids, - as_admin, ) .await } @@ -317,7 +305,7 @@ impl CiphersClient { &self, request: CipherCreateRequest, ) -> Result { - self.create_cipher(request, vec![], false).await + self.create_cipher(request, vec![]).await } /// Creates a new [Cipher] for an organization, and saves it to the server. @@ -326,7 +314,7 @@ impl CiphersClient { request: CipherCreateRequest, collection_ids: Vec, ) -> Result { - self.create_cipher(request, collection_ids, false).await + self.create_cipher(request, collection_ids).await } } @@ -500,7 +488,6 @@ mod tests { TEST_USER_ID.parse().unwrap(), request.into(), vec![], - false, ) .await .unwrap(); @@ -565,7 +552,6 @@ mod tests { TEST_USER_ID.parse().unwrap(), request.into(), vec![], - false, ) .await; From feb335fd510295aaf75b8bb2795a9e4c19c63ba6 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Fri, 5 Dec 2025 13:08:52 -0800 Subject: [PATCH 32/38] Improve docs on delete.rs --- .../src/cipher/cipher_client/admin/delete.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/admin/delete.rs b/crates/bitwarden-vault/src/cipher/cipher_client/admin/delete.rs index 1480448b2..da9901335 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/admin/delete.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin/delete.rs @@ -54,6 +54,7 @@ async fn soft_delete_many( impl CipherAdminClient { /// Deletes the [Cipher] with the matching [CipherId] from the server, using the admin endpoint. + /// Affects server data only, does not modify local state. pub async fn delete(&self, cipher_id: CipherId) -> Result<(), ApiError> { delete_cipher( cipher_id, @@ -68,7 +69,7 @@ impl CipherAdminClient { } /// Soft-deletes the [Cipher] with the matching [CipherId] from the server, using the admin - /// endpoint. + /// endpoint. Affects server data only, does not modify local state. pub async fn soft_delete(&self, cipher_id: CipherId) -> Result<(), ApiError> { soft_delete( cipher_id, @@ -83,7 +84,7 @@ impl CipherAdminClient { } /// Deletes all [Cipher] objects with a matching [CipherId] from the server, using the admin - /// endpoint. + /// endpoint. Affects server data only, does not modify local state. pub async fn delete_many( &self, cipher_ids: Vec, @@ -103,7 +104,7 @@ impl CipherAdminClient { } /// Soft-deletes all [Cipher] objects for the given [CipherId]s from the server, using the admin - /// endpoint. + /// endpoint. Affects server data only, does not modify local state. pub async fn soft_delete_many( &self, cipher_ids: Vec, From 1f6c8ab1a859fe3cf975949b91b781e7af16c8a6 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Fri, 5 Dec 2025 13:39:21 -0800 Subject: [PATCH 33/38] Move admin edit operations to CipherAdminController --- .../src/cipher/cipher_client/admin/edit.rs | 339 ++++++++++++++++++ .../src/cipher/cipher_client/admin/mod.rs | 2 +- .../src/cipher/cipher_client/edit.rs | 134 ++----- 3 files changed, 360 insertions(+), 115 deletions(-) create mode 100644 crates/bitwarden-vault/src/cipher/cipher_client/admin/edit.rs diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/admin/edit.rs b/crates/bitwarden-vault/src/cipher/cipher_client/admin/edit.rs new file mode 100644 index 000000000..057485bc5 --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin/edit.rs @@ -0,0 +1,339 @@ +use bitwarden_api_api::{apis::ApiClient, models::CipherCollectionsRequestModel}; +use bitwarden_collections::collection::CollectionId; +use bitwarden_core::{ + ApiError, MissingFieldError, NotAuthenticatedError, UserId, key_management::KeyIds, +}; +use bitwarden_crypto::{CryptoError, IdentifyKey, KeyStore}; +use bitwarden_error::bitwarden_error; +use bitwarden_state::repository::RepositoryError; +use thiserror::Error; +#[cfg(feature = "wasm")] +use wasm_bindgen::prelude::*; + +use super::CipherAdminClient; +use crate::{ + Cipher, CipherId, CipherView, DecryptError, ItemNotFoundError, VaultParseError, + cipher_client::edit::{CipherEditRequest, CipherEditRequestInternal}, +}; + +#[allow(missing_docs)] +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum EditCipherAdminError { + #[error(transparent)] + ItemNotFound(#[from] ItemNotFoundError), + #[error(transparent)] + Crypto(#[from] CryptoError), + #[error(transparent)] + Api(#[from] ApiError), + #[error(transparent)] + VaultParse(#[from] VaultParseError), + #[error(transparent)] + MissingField(#[from] MissingFieldError), + #[error(transparent)] + NotAuthenticated(#[from] NotAuthenticatedError), + #[error(transparent)] + Repository(#[from] RepositoryError), + #[error(transparent)] + Uuid(#[from] uuid::Error), + #[error(transparent)] + Decrypt(#[from] DecryptError), +} + +impl From> for EditCipherAdminError { + fn from(val: bitwarden_api_api::apis::Error) -> Self { + Self::Api(val.into()) + } +} + +async fn edit_cipher( + key_store: &KeyStore, + api_client: &bitwarden_api_api::apis::ApiClient, + encrypted_for: UserId, + original_cipher_view: &CipherView, + request: CipherEditRequest, +) -> Result { + let cipher_id = request.id; + let request = CipherEditRequestInternal::new(request, original_cipher_view); + + let mut cipher_request = key_store.encrypt(request)?; + cipher_request.encrypted_for = Some(encrypted_for.into()); + + let cipher: Cipher = api_client + .ciphers_api() + .put_admin(cipher_id.into(), Some(cipher_request)) + .await + .map_err(ApiError::from)? + .try_into()?; + + Ok(key_store.decrypt(&cipher)?) +} + +/// Adds the cipher matched by [CipherId] to any number of collections on the server. +pub async fn add_to_collections( + cipher_id: CipherId, + collection_ids: Vec, + api_client: &ApiClient, + key_store: &KeyStore, +) -> Result { + let req = CipherCollectionsRequestModel { + collection_ids: collection_ids + .into_iter() + .map(|id| id.to_string()) + .collect(), + }; + + let api = api_client.ciphers_api(); + let cipher: Cipher = api + .put_collections_admin(&cipher_id.to_string(), Some(req)) + .await? + .try_into()?; + + Ok(key_store.decrypt(&cipher)?) +} + +#[cfg_attr(feature = "wasm", wasm_bindgen)] +impl CipherAdminClient { + /// Edit an existing [Cipher] and save it to the server. + pub async fn edit( + &self, + mut request: CipherEditRequest, + original_cipher_view: CipherView, + ) -> Result { + let key_store = self.client.internal.get_key_store(); + let config = self.client.internal.get_api_configurations().await; + + let user_id = self + .client + .internal + .get_user_id() + .ok_or(NotAuthenticatedError)?; + + // TODO: Once this flag is removed, the key generation logic should + // be moved closer to the actual encryption logic. + if request.key.is_none() + && self + .client + .internal + .get_flags() + .enable_cipher_key_encryption + { + let key = request.key_identifier(); + request.generate_cipher_key(&mut key_store.context(), key)?; + } + + edit_cipher( + key_store, + &config.api_client, + user_id, + &original_cipher_view, + request, + ) + .await + } + + /// Adds the cipher matched by [CipherId] to any number of collections on the server. + pub async fn update_collection( + &self, + cipher_id: CipherId, + collection_ids: Vec, + ) -> Result { + add_to_collections( + cipher_id, + collection_ids, + &self + .client + .internal + .get_api_configurations() + .await + .api_client, + &self.client.internal.get_key_store(), + ) + .await + } +} + +#[cfg(test)] +mod tests { + use bitwarden_api_api::{apis::ApiClient, models::CipherResponseModel}; + use bitwarden_core::key_management::SymmetricKeyId; + use bitwarden_crypto::{KeyStore, SymmetricCryptoKey}; + + use super::*; + use crate::{CipherId, CipherRepromptType, CipherType, LoginView}; + + const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097"; + const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000"; + + fn generate_test_cipher() -> CipherView { + CipherView { + id: Some(TEST_CIPHER_ID.parse().unwrap()), + organization_id: None, + folder_id: None, + collection_ids: vec![], + key: None, + name: "Test Login".to_string(), + notes: None, + r#type: CipherType::Login, + login: Some(LoginView { + username: Some("test@example.com".to_string()), + password: Some("password123".to_string()), + password_revision_date: None, + uris: None, + totp: None, + autofill_on_page_load: None, + fido2_credentials: None, + }), + identity: None, + card: None, + secure_note: None, + ssh_key: None, + favorite: false, + reprompt: CipherRepromptType::None, + organization_use_totp: true, + edit: true, + permissions: None, + view_password: true, + local_data: None, + attachments: None, + fields: None, + password_history: None, + creation_date: "2025-01-01T00:00:00Z".parse().unwrap(), + deleted_date: None, + revision_date: "2025-01-01T00:00:00Z".parse().unwrap(), + archived_date: None, + } + } + + #[tokio::test] + async fn test_edit_cipher() { + let store: KeyStore = KeyStore::default(); + #[allow(deprecated)] + let _ = store.context_mut().set_symmetric_key( + SymmetricKeyId::User, + SymmetricCryptoKey::make_aes256_cbc_hmac_key(), + ); + + let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap(); + + let api_client = ApiClient::new_mocked(move |mock| { + mock.ciphers_api + .expect_put() + .returning(move |_id, body| { + let body = body.unwrap(); + Ok(CipherResponseModel { + object: Some("cipher".to_string()), + id: Some(cipher_id.into()), + name: Some(body.name), + r#type: body.r#type, + organization_id: body + .organization_id + .as_ref() + .and_then(|id| uuid::Uuid::parse_str(id).ok()), + folder_id: body + .folder_id + .as_ref() + .and_then(|id| uuid::Uuid::parse_str(id).ok()), + favorite: body.favorite, + reprompt: body.reprompt, + key: body.key, + notes: body.notes, + view_password: Some(true), + edit: Some(true), + organization_use_totp: Some(true), + revision_date: Some("2025-01-01T00:00:00Z".to_string()), + creation_date: Some("2025-01-01T00:00:00Z".to_string()), + deleted_date: None, + login: body.login, + card: body.card, + identity: body.identity, + secure_note: body.secure_note, + ssh_key: body.ssh_key, + fields: body.fields, + password_history: body.password_history, + attachments: None, + permissions: None, + data: None, + archived_date: None, + }) + }) + .once(); + }); + + let original_cipher_view = generate_test_cipher(); + let mut cipher_view = original_cipher_view.clone(); + cipher_view.name = "New Cipher Name".to_string(); + + let request: CipherEditRequest = cipher_view.try_into().unwrap(); + + let result = edit_cipher( + &store, + &api_client, + TEST_USER_ID.parse().unwrap(), + &original_cipher_view, + request, + ) + .await + .unwrap(); + + assert_eq!(result.id, Some(cipher_id)); + assert_eq!(result.name, "New Cipher Name"); + } + + #[tokio::test] + async fn test_edit_cipher_does_not_exist() { + let store: KeyStore = KeyStore::default(); + let api_client = ApiClient::new_mocked(|_| {}); + + let original_cipher_view = generate_test_cipher(); + let cipher_view = original_cipher_view.clone(); + + let request: CipherEditRequest = cipher_view.try_into().unwrap(); + let result = edit_cipher( + &store, + &api_client, + TEST_USER_ID.parse().unwrap(), + &original_cipher_view, + request, + ) + .await; + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + EditCipherAdminError::ItemNotFound(_) + )); + } + + #[tokio::test] + async fn test_edit_cipher_http_error() { + let store: KeyStore = KeyStore::default(); + #[allow(deprecated)] + let _ = store.context_mut().set_symmetric_key( + SymmetricKeyId::User, + SymmetricCryptoKey::make_aes256_cbc_hmac_key(), + ); + + let api_client = ApiClient::new_mocked(move |mock| { + mock.ciphers_api.expect_put().returning(move |_id, _body| { + Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other( + "Simulated error", + ))) + }); + }); + let orig_cipher_view = generate_test_cipher(); + let cipher_view = orig_cipher_view.clone(); + let request: CipherEditRequest = cipher_view.try_into().unwrap(); + let result = edit_cipher( + &store, + &api_client, + TEST_USER_ID.parse().unwrap(), + &orig_cipher_view, + request, + ) + .await; + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), EditCipherAdminError::Api(_))); + } +} diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/admin/mod.rs b/crates/bitwarden-vault/src/cipher/cipher_client/admin/mod.rs index 0a579b9bc..1ce5dd988 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/admin/mod.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin/mod.rs @@ -3,8 +3,8 @@ use wasm_bindgen::prelude::*; mod create; mod delete; +mod edit; mod restore; -// mod edit; #[allow(missing_docs)] #[cfg_attr(feature = "wasm", wasm_bindgen)] diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs b/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs index 3f6c50b23..468bf8b51 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs @@ -108,7 +108,7 @@ impl TryFrom for CipherEditRequest { } impl CipherEditRequest { - pub(crate) fn generate_cipher_key( + pub(super) fn generate_cipher_key( &mut self, ctx: &mut KeyStoreContext, key: SymmetricKeyId, @@ -130,14 +130,13 @@ impl CipherEditRequest { /// Used as an intermediary between the public-facing [CipherEditRequest], and the encrypted /// value. This allows us to calculate password history safely, without risking misuse. #[derive(Clone, Debug)] - -struct CipherEditRequestInternal { - edit_request: CipherEditRequest, - password_history: Vec, +pub(super) struct CipherEditRequestInternal { + pub(super) edit_request: CipherEditRequest, + pub(super) password_history: Vec, } impl CipherEditRequestInternal { - fn new(edit_request: CipherEditRequest, orig_cipher: &CipherView) -> Self { + pub(super) fn new(edit_request: CipherEditRequest, orig_cipher: &CipherView) -> Self { let mut internal_req = Self { edit_request, password_history: vec![], @@ -328,7 +327,6 @@ async fn edit_cipher + ?Sized>( repository: &R, encrypted_for: UserId, request: CipherEditRequest, - is_admin: bool, ) -> Result { let cipher_id = request.id; @@ -343,28 +341,16 @@ async fn edit_cipher + ?Sized>( let mut cipher_request = key_store.encrypt(request)?; cipher_request.encrypted_for = Some(encrypted_for.into()); - let cipher = if is_admin { - api_client - .ciphers_api() - .put_admin(cipher_id.into(), Some(cipher_request)) - .await - .map_err(ApiError::from)? - .try_into()? - } else { - let cipher: Cipher = api_client - .ciphers_api() - .put(cipher_id.into(), Some(cipher_request)) - .await - .map_err(ApiError::from)? - .try_into()?; - - debug_assert!(cipher.id.unwrap_or_default() == cipher_id); - - repository - .set(cipher_id.to_string(), cipher.clone()) - .await?; - cipher - }; + let cipher: Cipher = api_client + .ciphers_api() + .put(cipher_id.into(), Some(cipher_request)) + .await + .map_err(ApiError::from)? + .try_into()?; + debug_assert!(cipher.id.unwrap_or_default() == cipher_id); + repository + .set(cipher_id.to_string(), cipher.clone()) + .await?; Ok(key_store.decrypt(&cipher)?) } @@ -372,15 +358,9 @@ async fn edit_cipher + ?Sized>( #[cfg_attr(feature = "wasm", wasm_bindgen)] impl CiphersClient { /// Edit an existing [Cipher] and save it to the server. - pub async fn edit(&self, request: CipherEditRequest) -> Result { - self.edit_internal(request, false).await - } - - /// A helper function to wrap all of the cipher edit routing logic. - pub(super) async fn edit_internal( + pub async fn edit( &self, mut request: CipherEditRequest, - is_admin: bool, ) -> Result { let key_store = self.client.internal.get_key_store(); let config = self.client.internal.get_api_configurations().await; @@ -411,7 +391,6 @@ impl CiphersClient { repository.as_ref(), user_id, request, - is_admin, ) .await } @@ -454,30 +433,19 @@ impl CiphersClient { #[cfg(test)] mod tests { use bitwarden_api_api::{apis::ApiClient, models::CipherResponseModel}; - use bitwarden_core::{ - Client, ClientSettings, DeviceType, - key_management::{ - SymmetricKeyId, - crypto::{InitOrgCryptoRequest, InitUserCryptoMethod, InitUserCryptoRequest}, - }, - }; - use bitwarden_crypto::{Kdf, KeyStore, PrimitiveEncryptable, SymmetricCryptoKey}; - use bitwarden_test::{MemoryRepository, start_api_mock}; + use bitwarden_core::key_management::SymmetricKeyId; + use bitwarden_crypto::{KeyStore, PrimitiveEncryptable, SymmetricCryptoKey}; + use bitwarden_test::MemoryRepository; use chrono::TimeZone; - use wiremock::{ - Mock, ResponseTemplate, - matchers::{method, path_regex}, - }; use super::*; use crate::{ Cipher, CipherId, CipherRepromptType, CipherType, FieldType, Login, LoginView, - PasswordHistoryView, VaultClientExt, + PasswordHistoryView, }; const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097"; const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000"; - const TEST_ORG_ID: &str = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8"; fn generate_test_cipher() -> CipherView { CipherView { @@ -519,65 +487,6 @@ mod tests { } } - async fn create_client_with_wiremock(mock_server: &wiremock::MockServer) -> CiphersClient { - let settings = ClientSettings { - identity_url: format!("http://{}", mock_server.address()), - api_url: format!("http://{}", mock_server.address()), - user_agent: "Bitwarden Test".into(), - device_type: DeviceType::SDK, - bitwarden_client_version: None, - }; - - let client = Client::new(Some(settings)); - - client - .internal - .load_flags(std::collections::HashMap::from([( - "enableCipherKeyEncryption".to_owned(), - true, - )])); - - let user_request = InitUserCryptoRequest { - user_id: Some(UserId::new(uuid::uuid!(TEST_USER_ID))), - kdf_params: Kdf::PBKDF2 { - iterations: 600_000.try_into().unwrap(), - }, - email: "test@bitwarden.com".to_owned(), - private_key: "2.yN7l00BOlUE0Sb0M//Q53w==|EwKG/BduQRQ33Izqc/ogoBROIoI5dmgrxSo82sgzgAMIBt3A2FZ9vPRMY+GWT85JiqytDitGR3TqwnFUBhKUpRRAq4x7rA6A1arHrFp5Tp1p21O3SfjtvB3quiOKbqWk6ZaU1Np9HwqwAecddFcB0YyBEiRX3VwF2pgpAdiPbSMuvo2qIgyob0CUoC/h4Bz1be7Qa7B0Xw9/fMKkB1LpOm925lzqosyMQM62YpMGkjMsbZz0uPopu32fxzDWSPr+kekNNyLt9InGhTpxLmq1go/pXR2uw5dfpXc5yuta7DB0EGBwnQ8Vl5HPdDooqOTD9I1jE0mRyuBpWTTI3FRnu3JUh3rIyGBJhUmHqGZvw2CKdqHCIrQeQkkEYqOeJRJVdBjhv5KGJifqT3BFRwX/YFJIChAQpebNQKXe/0kPivWokHWwXlDB7S7mBZzhaAPidZvnuIhalE2qmTypDwHy22FyqV58T8MGGMchcASDi/QXI6kcdpJzPXSeU9o+NC68QDlOIrMVxKFeE7w7PvVmAaxEo0YwmuAzzKy9QpdlK0aab/xEi8V4iXj4hGepqAvHkXIQd+r3FNeiLfllkb61p6WTjr5urcmDQMR94/wYoilpG5OlybHdbhsYHvIzYoLrC7fzl630gcO6t4nM24vdB6Ymg9BVpEgKRAxSbE62Tqacxqnz9AcmgItb48NiR/He3n3ydGjPYuKk/ihZMgEwAEZvSlNxYONSbYrIGDtOY+8Nbt6KiH3l06wjZW8tcmFeVlWv+tWotnTY9IqlAfvNVTjtsobqtQnvsiDjdEVtNy/s2ci5TH+NdZluca2OVEr91Wayxh70kpM6ib4UGbfdmGgCo74gtKvKSJU0rTHakQ5L9JlaSDD5FamBRyI0qfL43Ad9qOUZ8DaffDCyuaVyuqk7cz9HwmEmvWU3VQ+5t06n/5kRDXttcw8w+3qClEEdGo1KeENcnXCB32dQe3tDTFpuAIMLqwXs6FhpawfZ5kPYvLPczGWaqftIs/RXJ/EltGc0ugw2dmTLpoQhCqrcKEBDoYVk0LDZKsnzitOGdi9mOWse7Se8798ib1UsHFUjGzISEt6upestxOeupSTOh0v4+AjXbDzRUyogHww3V+Bqg71bkcMxtB+WM+pn1XNbVTyl9NR040nhP7KEf6e9ruXAtmrBC2ah5cFEpLIot77VFZ9ilLuitSz+7T8n1yAh1IEG6xxXxninAZIzi2qGbH69O5RSpOJuJTv17zTLJQIIc781JwQ2TTwTGnx5wZLbffhCasowJKd2EVcyMJyhz6ru0PvXWJ4hUdkARJs3Xu8dus9a86N8Xk6aAPzBDqzYb1vyFIfBxP0oO8xFHgd30Cgmz8UrSE3qeWRrF8ftrI6xQnFjHBGWD/JWSvd6YMcQED0aVuQkuNW9ST/DzQThPzRfPUoiL10yAmV7Ytu4fR3x2sF0Yfi87YhHFuCMpV/DsqxmUizyiJuD938eRcH8hzR/VO53Qo3UIsqOLcyXtTv6THjSlTopQ+JOLOnHm1w8dzYbLN44OG44rRsbihMUQp+wUZ6bsI8rrOnm9WErzkbQFbrfAINdoCiNa6cimYIjvvnMTaFWNymqY1vZxGztQiMiHiHYwTfwHTXrb9j0uPM=|09J28iXv9oWzYtzK2LBT6Yht4IT4MijEkk0fwFdrVQ4=".parse::().unwrap(), - signing_key: None, - security_state: None, - method: InitUserCryptoMethod::Password { - password: "asdfasdfasdf".to_owned(), - user_key: "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=".parse().unwrap(), - } - }; - - let org_request = InitOrgCryptoRequest { - organization_keys: std::collections::HashMap::from([( - TEST_ORG_ID.parse().unwrap(), - "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==".parse().unwrap() - )]) - }; - - client - .crypto() - .initialize_user_crypto(user_request) - .await - .unwrap(); - client - .crypto() - .initialize_org_crypto(org_request) - .await - .unwrap(); - - client - .platform() - .state() - .register_client_managed(std::sync::Arc::new(MemoryRepository::::default())); - - client.vault().ciphers() - } - fn create_test_login_cipher(password: &str) -> CipherView { let mut cipher_view = generate_test_cipher(); if let Some(ref mut login) = cipher_view.login { @@ -711,7 +620,6 @@ mod tests { &repository, TEST_USER_ID.parse().unwrap(), request, - false, ) .await .unwrap(); @@ -737,7 +645,6 @@ mod tests { &repository, TEST_USER_ID.parse().unwrap(), request, - false, ) .await; @@ -779,7 +686,6 @@ mod tests { &repository, TEST_USER_ID.parse().unwrap(), request, - false, ) .await; From a1c61ecaef1408f514c7675cfe3111a9a654127d Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Fri, 5 Dec 2025 13:48:52 -0800 Subject: [PATCH 34/38] Fix cipher admin create tests --- .../src/cipher/cipher_client/admin/create.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/admin/create.rs b/crates/bitwarden-vault/src/cipher/cipher_client/admin/create.rs index 3667f39b7..5f2305156 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/admin/create.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin/create.rs @@ -107,7 +107,7 @@ impl CipherAdminClient { #[cfg(test)] mod tests { use bitwarden_api_api::models::CipherMiniResponseModel; - use bitwarden_core::key_management::SymmetricKeyId; + use bitwarden_core::{OrganizationId, key_management::SymmetricKeyId}; use bitwarden_crypto::SymmetricCryptoKey; use chrono::Utc; @@ -148,6 +148,11 @@ mod tests { SymmetricKeyId::User, SymmetricCryptoKey::make_aes256_cbc_hmac_key(), ); + #[allow(deprecated)] + let _ = store.context_mut().set_symmetric_key( + SymmetricKeyId::Organization(TEST_ORG_ID.parse::().unwrap().into()), + SymmetricCryptoKey::make_aes256_cbc_hmac_key(), + ); let cipher_request: CipherCreateRequestInternal = CipherCreateRequest { organization_id: Some(TEST_ORG_ID.parse().unwrap()), From f029f56a6816c380ebac6c5ea38e4a5eab283cc3 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Fri, 5 Dec 2025 13:53:05 -0800 Subject: [PATCH 35/38] Fix edit cipher admin tests --- .../src/cipher/cipher_client/admin/edit.rs | 51 ++++--------------- 1 file changed, 10 insertions(+), 41 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/admin/edit.rs b/crates/bitwarden-vault/src/cipher/cipher_client/admin/edit.rs index 057485bc5..ce5886b6c 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/admin/edit.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin/edit.rs @@ -155,7 +155,7 @@ impl CipherAdminClient { #[cfg(test)] mod tests { - use bitwarden_api_api::{apis::ApiClient, models::CipherResponseModel}; + use bitwarden_api_api::{apis::ApiClient, models::CipherMiniResponseModel}; use bitwarden_core::key_management::SymmetricKeyId; use bitwarden_crypto::{KeyStore, SymmetricCryptoKey}; @@ -218,10 +218,10 @@ mod tests { let api_client = ApiClient::new_mocked(move |mock| { mock.ciphers_api - .expect_put() + .expect_put_admin() .returning(move |_id, body| { let body = body.unwrap(); - Ok(CipherResponseModel { + Ok(CipherMiniResponseModel { object: Some("cipher".to_string()), id: Some(cipher_id.into()), name: Some(body.name), @@ -230,16 +230,9 @@ mod tests { .organization_id .as_ref() .and_then(|id| uuid::Uuid::parse_str(id).ok()), - folder_id: body - .folder_id - .as_ref() - .and_then(|id| uuid::Uuid::parse_str(id).ok()), - favorite: body.favorite, reprompt: body.reprompt, key: body.key, notes: body.notes, - view_password: Some(true), - edit: Some(true), organization_use_totp: Some(true), revision_date: Some("2025-01-01T00:00:00Z".to_string()), creation_date: Some("2025-01-01T00:00:00Z".to_string()), @@ -252,7 +245,6 @@ mod tests { fields: body.fields, password_history: body.password_history, attachments: None, - permissions: None, data: None, archived_date: None, }) @@ -280,31 +272,6 @@ mod tests { assert_eq!(result.name, "New Cipher Name"); } - #[tokio::test] - async fn test_edit_cipher_does_not_exist() { - let store: KeyStore = KeyStore::default(); - let api_client = ApiClient::new_mocked(|_| {}); - - let original_cipher_view = generate_test_cipher(); - let cipher_view = original_cipher_view.clone(); - - let request: CipherEditRequest = cipher_view.try_into().unwrap(); - let result = edit_cipher( - &store, - &api_client, - TEST_USER_ID.parse().unwrap(), - &original_cipher_view, - request, - ) - .await; - - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - EditCipherAdminError::ItemNotFound(_) - )); - } - #[tokio::test] async fn test_edit_cipher_http_error() { let store: KeyStore = KeyStore::default(); @@ -315,11 +282,13 @@ mod tests { ); let api_client = ApiClient::new_mocked(move |mock| { - mock.ciphers_api.expect_put().returning(move |_id, _body| { - Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other( - "Simulated error", - ))) - }); + mock.ciphers_api + .expect_put_admin() + .returning(move |_id, _body| { + Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other( + "Simulated error", + ))) + }); }); let orig_cipher_view = generate_test_cipher(); let cipher_view = orig_cipher_view.clone(); From 8cbf8c82839fd10ba07a6615b4d699dfb934adde Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Fri, 5 Dec 2025 14:02:03 -0800 Subject: [PATCH 36/38] Remove helper function for get_api_configurations in CiphersClient --- .../src/cipher/cipher_client/admin/delete.rs | 6 +++--- .../src/cipher/cipher_client/delete.rs | 13 +++++++++---- .../src/cipher/cipher_client/edit.rs | 2 +- .../src/cipher/cipher_client/get.rs | 2 +- .../src/cipher/cipher_client/mod.rs | 6 +----- .../src/cipher/cipher_client/restore.rs | 14 ++++++++++++-- 6 files changed, 27 insertions(+), 16 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/admin/delete.rs b/crates/bitwarden-vault/src/cipher/cipher_client/admin/delete.rs index da9901335..14e7a75e4 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/admin/delete.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin/delete.rs @@ -12,7 +12,7 @@ async fn delete_cipher( Ok(()) } -async fn delete_ciphers( +async fn delete_ciphers_many( cipher_ids: Vec, organization_id: Option, api_client: &bitwarden_api_api::apis::ApiClient, @@ -90,7 +90,7 @@ impl CipherAdminClient { cipher_ids: Vec, organization_id: Option, ) -> Result<(), ApiError> { - delete_ciphers( + delete_ciphers_many( cipher_ids, organization_id, &self @@ -166,7 +166,7 @@ mod tests { #[tokio::test] async fn test_delete_many_as_admin() { - delete_ciphers( + delete_ciphers_many( vec![ TEST_CIPHER_ID.parse().unwrap(), TEST_CIPHER_ID_2.parse().unwrap(), diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs index e3b29f48b..21c76677d 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/delete.rs @@ -98,7 +98,7 @@ async fn process_soft_delete( impl CiphersClient { /// Deletes the [Cipher] with the matching [CipherId] from the server. pub async fn delete(&self, cipher_id: CipherId) -> Result<(), DeleteCipherError> { - let configs = self.get_api_configurations().await; + let configs = self.client.internal.get_api_configurations().await; delete_cipher(cipher_id, &configs.api_client, &*self.get_repository()?).await } @@ -108,7 +108,7 @@ impl CiphersClient { cipher_ids: Vec, organization_id: Option, ) -> Result<(), DeleteCipherError> { - let configs = self.get_api_configurations().await; + let configs = self.client.internal.get_api_configurations().await; delete_ciphers( cipher_ids, organization_id, @@ -120,7 +120,7 @@ impl CiphersClient { /// Soft-deletes the [Cipher] with the matching [CipherId] from the server. pub async fn soft_delete(&self, cipher_id: CipherId) -> Result<(), DeleteCipherError> { - let configs = self.get_api_configurations().await; + let configs = self.client.internal.get_api_configurations().await; soft_delete(cipher_id, &configs.api_client, &*self.get_repository()?).await } @@ -133,7 +133,12 @@ impl CiphersClient { soft_delete_many( cipher_ids, organization_id, - &self.get_api_configurations().await.api_client, + &self + .client + .internal + .get_api_configurations() + .await + .api_client, &*self.get_repository()?, ) .await diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs b/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs index 468bf8b51..1fe4921f1 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/edit.rs @@ -409,7 +409,7 @@ impl CiphersClient { .collect(), }; - let api_config = self.get_api_configurations().await; + let api_config = self.client.internal.get_api_configurations().await; let api = api_config.api_client.ciphers_api(); let cipher = if is_admin { api.put_collections_admin(&cipher_id.to_string(), Some(req)) diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/get.rs b/crates/bitwarden-vault/src/cipher/cipher_client/get.rs index 19f30c937..5a5c45665 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/get.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/get.rs @@ -70,7 +70,7 @@ impl CiphersClient { org_id: OrganizationId, include_member_items: bool, ) -> Result { - let configs = self.get_api_configurations().await; + let configs = self.client.internal.get_api_configurations().await; let api = configs.api_client.ciphers_api(); let response: CipherMiniDetailsResponseModelListResponseModel = api .get_organization_ciphers(Some(org_id.into()), Some(include_member_items)) diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs b/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs index 7ce354974..6d6a12eae 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/mod.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use bitwarden_core::{Client, OrganizationId, client::ApiConfigurations}; +use bitwarden_core::{Client, OrganizationId}; use bitwarden_crypto::IdentifyKey; #[cfg(feature = "wasm")] use bitwarden_crypto::{CompositeEncryptable, SymmetricCryptoKey}; @@ -197,10 +197,6 @@ impl CiphersClient { .state() .get_client_managed::()?) } - - async fn get_api_configurations(&self) -> Arc { - self.client.internal.get_api_configurations().await - } } #[cfg(test)] diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs b/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs index ebeae6b89..cb15a91c4 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/restore.rs @@ -92,7 +92,12 @@ pub async fn restore_many( impl CiphersClient { /// Restores a soft-deleted cipher on the server. pub async fn restore(&self, cipher_id: CipherId) -> Result { - let api_client = &self.get_api_configurations().await.api_client; + let api_client = &self + .client + .internal + .get_api_configurations() + .await + .api_client; let key_store = self.client.internal.get_key_store(); restore(cipher_id, api_client, &*self.get_repository()?, key_store).await @@ -103,7 +108,12 @@ impl CiphersClient { &self, cipher_ids: Vec, ) -> Result { - let api_client = &self.get_api_configurations().await.api_client; + let api_client = &self + .client + .internal + .get_api_configurations() + .await + .api_client; let key_store = self.client.internal.get_key_store(); let repository = &*self.get_repository()?; From 8aa369207a8e337d5fcaaa26d97a57f18772dcef Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Fri, 5 Dec 2025 14:10:15 -0800 Subject: [PATCH 37/38] Move list_org_ciphers operation to admin client --- .../src/cipher/cipher_client/admin/get.rs | 69 +++++++++++++++++++ .../src/cipher/cipher_client/admin/mod.rs | 1 + .../src/cipher/cipher_client/get.rs | 27 +------- 3 files changed, 71 insertions(+), 26 deletions(-) create mode 100644 crates/bitwarden-vault/src/cipher/cipher_client/admin/get.rs diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/admin/get.rs b/crates/bitwarden-vault/src/cipher/cipher_client/admin/get.rs new file mode 100644 index 000000000..70ba80448 --- /dev/null +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin/get.rs @@ -0,0 +1,69 @@ +use bitwarden_api_api::models::CipherMiniDetailsResponseModelListResponseModel; +use bitwarden_core::{ApiError, OrganizationId, key_management::KeyIds}; +use bitwarden_crypto::{CryptoError, KeyStore}; +use bitwarden_error::bitwarden_error; +use thiserror::Error; + +use crate::{ + Cipher, VaultParseError, cipher::cipher::DecryptCipherListResult, + cipher_client::admin::CipherAdminClient, +}; + +#[allow(missing_docs)] +#[bitwarden_error(flat)] +#[derive(Debug, Error)] +pub enum GetOrganizationCiphersError { + #[error(transparent)] + Crypto(#[from] CryptoError), + #[error(transparent)] + VaultParse(#[from] VaultParseError), + #[error(transparent)] + Api(#[from] ApiError), +} + +/// Get all ciphers for an organization. +pub async fn list_org_ciphers( + org_id: OrganizationId, + include_member_items: bool, + api_client: &bitwarden_api_api::apis::ApiClient, + key_store: &KeyStore, +) -> Result { + let api = api_client.ciphers_api(); + let response: CipherMiniDetailsResponseModelListResponseModel = api + .get_organization_ciphers(Some(org_id.into()), Some(include_member_items)) + .await + .map_err(Into::::into)?; + let ciphers = response + .data + .into_iter() + .flatten() + .map(TryInto::::try_into) + .collect::, _>>()?; + + let (successes, failures) = key_store.decrypt_list_with_failures(&ciphers); + Ok(DecryptCipherListResult { + successes, + failures: failures.into_iter().cloned().collect(), + }) +} + +impl CipherAdminClient { + pub async fn list_org_ciphers( + &self, + org_id: OrganizationId, + include_member_items: bool, + ) -> Result { + list_org_ciphers( + org_id, + include_member_items, + &self + .client + .internal + .get_api_configurations() + .await + .api_client, + &self.client.internal.get_key_store(), + ) + .await + } +} diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/admin/mod.rs b/crates/bitwarden-vault/src/cipher/cipher_client/admin/mod.rs index 1ce5dd988..a897ad52f 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/admin/mod.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin/mod.rs @@ -4,6 +4,7 @@ use wasm_bindgen::prelude::*; mod create; mod delete; mod edit; +mod get; mod restore; #[allow(missing_docs)] diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/get.rs b/crates/bitwarden-vault/src/cipher/cipher_client/get.rs index 5a5c45665..fe051ddb8 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/get.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/get.rs @@ -1,5 +1,4 @@ -use bitwarden_api_api::models::CipherMiniDetailsResponseModelListResponseModel; -use bitwarden_core::{ApiError, MissingFieldError, OrganizationId, key_management::KeyIds}; +use bitwarden_core::{ApiError, key_management::KeyIds}; use bitwarden_crypto::{CryptoError, KeyStore}; use bitwarden_error::bitwarden_error; use bitwarden_state::repository::{Repository, RepositoryError}; @@ -21,8 +20,6 @@ pub enum GetCipherError { #[error(transparent)] VaultParse(#[from] VaultParseError), #[error(transparent)] - MissingField(#[from] MissingFieldError), - #[error(transparent)] RepositoryError(#[from] RepositoryError), #[error(transparent)] Api(#[from] ApiError), @@ -64,28 +61,6 @@ impl CiphersClient { list_ciphers(key_store, repository.as_ref()).await } - /// Get all ciphers for an organization. - pub async fn list_org_ciphers( - &self, - org_id: OrganizationId, - include_member_items: bool, - ) -> Result { - let configs = self.client.internal.get_api_configurations().await; - let api = configs.api_client.ciphers_api(); - let response: CipherMiniDetailsResponseModelListResponseModel = api - .get_organization_ciphers(Some(org_id.into()), Some(include_member_items)) - .await - .map_err(Into::::into)?; - let ciphers = response - .data - .into_iter() - .flatten() - .map(TryInto::::try_into) - .collect::, _>>()?; - - Ok(self.decrypt_list_with_failures(ciphers)) - } - /// Get [Cipher] by ID from state and decrypt it to a [CipherView]. pub async fn get(&self, cipher_id: &str) -> Result { let key_store = self.client.internal.get_key_store(); From 16e28b0d1bb1626ad22b5b1ad1b9958bbfe1b941 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Fri, 5 Dec 2025 14:13:28 -0800 Subject: [PATCH 38/38] Housekeeping: Remove commented code, change ::into -> ::from --- .../src/cipher/cipher_client/admin/get.rs | 4 +- .../src/cipher/cipher_client/admin/mod.rs | 139 ------------------ 2 files changed, 2 insertions(+), 141 deletions(-) diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/admin/get.rs b/crates/bitwarden-vault/src/cipher/cipher_client/admin/get.rs index 70ba80448..1c8772e8f 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/admin/get.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin/get.rs @@ -32,12 +32,12 @@ pub async fn list_org_ciphers( let response: CipherMiniDetailsResponseModelListResponseModel = api .get_organization_ciphers(Some(org_id.into()), Some(include_member_items)) .await - .map_err(Into::::into)?; + .map_err(ApiError::from)?; let ciphers = response .data .into_iter() .flatten() - .map(TryInto::::try_into) + .map(Cipher::try_from) .collect::, _>>()?; let (successes, failures) = key_store.decrypt_list_with_failures(&ciphers); diff --git a/crates/bitwarden-vault/src/cipher/cipher_client/admin/mod.rs b/crates/bitwarden-vault/src/cipher/cipher_client/admin/mod.rs index a897ad52f..0eb51896e 100644 --- a/crates/bitwarden-vault/src/cipher/cipher_client/admin/mod.rs +++ b/crates/bitwarden-vault/src/cipher/cipher_client/admin/mod.rs @@ -12,142 +12,3 @@ mod restore; pub struct CipherAdminClient { pub(crate) client: Client, } - -impl CipherAdminClient { - // /// Creates a new [Cipher] for an organization, using the admin server endpoints endpoints. - // pub async fn create( - // &self, - // request: CipherCreateRequest, - // collection_ids: Vec, - // ) -> Result { - // self.client - // .create_cipher(request, collection_ids, true) - // .await - // } - - // putCipherAdmin(id, request: CipherRequest) - // ciphers_id_admin_put - // #[allow(missing_docs)] // - // pub async fn edit(&self, request: CipherEditRequest) -> Result { - // self.client.edit_internal(request, true).await - // } -} - -// #[cfg(test)] -// mod tests { -// #[tokio::test] -// async fn test_edit_cipher_as_admin() { -// let (mock_server, _config) = start_api_mock(vec![ -// Mock::given(method("PUT")) -// .and(path_regex(r"/ciphers/[a-f0-9-]+")) -// .respond_with(move |req: &wiremock::Request| { -// let body_bytes = req.body.as_slice(); -// let request_body: CipherRequestModel = -// serde_json::from_slice(body_bytes).expect("Failed to parse request -// body"); - -// let response = CipherResponseModel { -// id: Some(TEST_CIPHER_ID.try_into().unwrap()), -// organization_id: request_body -// .organization_id -// .and_then(|id| id.parse().ok()), -// name: Some(request_body.name.clone()), -// r#type: request_body.r#type, -// creation_date: Some(Utc::now().to_string()), -// revision_date: Some(Utc::now().to_string()), -// ..Default::default() -// }; - -// ResponseTemplate::new(200).set_body_json(&response) -// }), -// ]) -// .await; -// let client = create_client_with_wiremock(&mock_server).await; -// let repository = client.get_repository().unwrap(); - -// let cipher_view = generate_test_cipher(); -// repository -// .set( -// TEST_CIPHER_ID.to_string(), -// client.encrypt(cipher_view.clone()).unwrap().cipher, -// ) -// .await -// .unwrap(); - -// let request = cipher_view.try_into().unwrap(); -// let start_time = Utc::now(); -// let result = client.edit_as_admin(request).await.unwrap(); - -// let cipher = repository.get(TEST_CIPHER_ID.to_string()).await.unwrap(); -// // Should not update local repository for admin endpoints. -// assert!(result.revision_date > start_time); -// assert!(cipher.unwrap().revision_date < start_time); - -// assert_eq!(result.id, TEST_CIPHER_ID.parse().ok()); -// assert_eq!(result.name, "Test Login"); -// } -// } - -// #[tokio::test] -// async fn test_create_cipher_as_admin() { -// let (mock_server, _config) = start_api_mock(vec![ -// Mock::given(method("POST")) -// .and(path(r"/ciphers/admin")) -// .respond_with(move |req: &wiremock::Request| { -// let body_bytes = req.body.as_slice(); -// let request_body: CipherCreateRequestModel = -// serde_json::from_slice(body_bytes).expect("Failed to parse request body"); - -// let response = CipherResponseModel { -// id: Some(TEST_CIPHER_ID.try_into().unwrap()), -// organization_id: request_body -// .cipher -// .organization_id -// .and_then(|id| id.parse().ok()), -// name: Some(request_body.cipher.name.clone()), -// r#type: request_body.cipher.r#type, -// creation_date: Some(Utc::now().to_string()), -// revision_date: Some(Utc::now().to_string()), -// ..Default::default() -// }; - -// ResponseTemplate::new(200).set_body_json(&response) -// }), -// ]) -// .await; - -// let client = create_client_with_wiremock(&mock_server).await; -// let response = client -// .create_as_admin( -// CipherCreateRequest { -// organization_id: SomeTEST_ORG_ID.parse().unwrap()), -// folder_id: None, -// name: "Test Cipher".into(), -// notes: None, -// favorite: false, -// reprompt: CipherRepromptType::None, -// r#type: CipherViewType::Login(LoginView { -// username: None, -// password: None, -// password_revision_date: None, -// uris: None, -// totp: None, -// autofill_on_page_load: None, -// fido2_credentials: None, -// }), -// fields: vec![], -// }, -// vec![TEST_COLLECTION_ID.parse().unwrap()], -// ) -// .await -// .unwrap(); - -// let repository = client.get_repository().unwrap(); -// let cipher = repository.get(TEST_CIPHER_ID.to_string()).await.unwrap(); -// // Should not update local repository for admin endpoints. -// assert!(cipher.is_none()); - -// assert_eq!(response.id, Some(TEST_CIPHER_ID.parse().unwrap())); -// assert_eq!(response.organization_id, Some(TEST_ORG_ID.parse().unwrap())); -// } -//