bitwarden_vault/cipher/cipher_client/admin/
edit.rs1use bitwarden_api_api::{apis::ApiClient, models::CipherCollectionsRequestModel};
2use bitwarden_collections::collection::CollectionId;
3use bitwarden_core::{
4 ApiError, MissingFieldError, NotAuthenticatedError, UserId, key_management::KeySlotIds,
5};
6use bitwarden_crypto::{CryptoError, IdentifyKey, KeyStore};
7use bitwarden_error::bitwarden_error;
8use bitwarden_state::repository::RepositoryError;
9use thiserror::Error;
10#[cfg(feature = "wasm")]
11use wasm_bindgen::prelude::*;
12
13use super::CipherAdminClient;
14use crate::{
15 Cipher, CipherId, CipherView, DecryptError, ItemNotFoundError, VaultParseError,
16 cipher::cipher::{PartialCipher, StrictDecrypt},
17 cipher_client::edit::{CipherEditRequest, CipherEditRequestInternal},
18};
19
20#[allow(missing_docs)]
21#[bitwarden_error(flat)]
22#[derive(Debug, Error)]
23pub enum EditCipherAdminError {
24 #[error(transparent)]
25 ItemNotFound(#[from] ItemNotFoundError),
26 #[error(transparent)]
27 Crypto(#[from] CryptoError),
28 #[error(transparent)]
29 Api(#[from] ApiError),
30 #[error(transparent)]
31 VaultParse(#[from] VaultParseError),
32 #[error(transparent)]
33 MissingField(#[from] MissingFieldError),
34 #[error(transparent)]
35 NotAuthenticated(#[from] NotAuthenticatedError),
36 #[error(transparent)]
37 Repository(#[from] RepositoryError),
38 #[error(transparent)]
39 Uuid(#[from] uuid::Error),
40 #[error(transparent)]
41 Decrypt(#[from] DecryptError),
42}
43
44impl<T> From<bitwarden_api_api::apis::Error<T>> for EditCipherAdminError {
45 fn from(val: bitwarden_api_api::apis::Error<T>) -> Self {
46 Self::Api(val.into())
47 }
48}
49
50async fn edit_cipher(
51 key_store: &KeyStore<KeySlotIds>,
52 api_client: &bitwarden_api_api::apis::ApiClient,
53 encrypted_for: UserId,
54 original_cipher_view: CipherView,
55 request: CipherEditRequest,
56 use_strict_decryption: bool,
57) -> Result<CipherView, EditCipherAdminError> {
58 let cipher_id = request.id;
59 let folder_id = request.folder_id;
62 let favorite = request.favorite;
63 let request = CipherEditRequestInternal::new(request, &original_cipher_view);
64
65 let mut cipher_request = key_store.encrypt(request)?;
66 cipher_request.encrypted_for = Some(encrypted_for.into());
67
68 let orig_cipher = key_store.encrypt(original_cipher_view)?;
69
70 let mut cipher: Cipher = api_client
71 .ciphers_api()
72 .put_admin(cipher_id.into(), Some(cipher_request))
73 .await
74 .map_err(ApiError::from)?
75 .merge_with_cipher(Some(orig_cipher))?;
76
77 cipher.folder_id = folder_id;
78 cipher.favorite = favorite;
79
80 if use_strict_decryption {
81 Ok(key_store.decrypt(&StrictDecrypt(cipher))?)
82 } else {
83 Ok(key_store.decrypt(&cipher)?)
84 }
85}
86
87pub async fn add_to_collections(
89 cipher_id: CipherId,
90 collection_ids: Vec<CollectionId>,
91 api_client: &ApiClient,
92 key_store: &KeyStore<KeySlotIds>,
93 use_strict_decryption: bool,
94) -> Result<CipherView, EditCipherAdminError> {
95 let req = CipherCollectionsRequestModel {
96 collection_ids: collection_ids
97 .into_iter()
98 .map(|id| id.to_string())
99 .collect(),
100 };
101
102 let api = api_client.ciphers_api();
103 let cipher: Cipher = api
104 .put_collections_admin(&cipher_id.to_string(), Some(req))
105 .await?
106 .merge_with_cipher(None)?;
107
108 if use_strict_decryption {
109 Ok(key_store.decrypt(&StrictDecrypt(cipher))?)
110 } else {
111 Ok(key_store.decrypt(&cipher)?)
112 }
113}
114
115#[cfg_attr(feature = "wasm", wasm_bindgen)]
116impl CipherAdminClient {
117 pub async fn edit(
119 &self,
120 mut request: CipherEditRequest,
121 original_cipher_view: CipherView,
122 ) -> Result<CipherView, EditCipherAdminError> {
123 let key_store = self.client.internal.get_key_store();
124 let config = self.client.internal.get_api_configurations();
125
126 let user_id = self
127 .client
128 .internal
129 .get_user_id()
130 .ok_or(NotAuthenticatedError)?;
131
132 if request.key.is_none()
135 && self
136 .client
137 .internal
138 .get_flags()
139 .await
140 .enable_cipher_key_encryption
141 {
142 let key = request.key_identifier();
143 request.generate_cipher_key(&mut key_store.context(), key)?;
144 }
145
146 edit_cipher(
147 key_store,
148 &config.api_client,
149 user_id,
150 original_cipher_view,
151 request,
152 self.is_strict_decrypt().await,
153 )
154 .await
155 }
156
157 pub async fn update_collection(
159 &self,
160 cipher_id: CipherId,
161 collection_ids: Vec<CollectionId>,
162 ) -> Result<CipherView, EditCipherAdminError> {
163 add_to_collections(
164 cipher_id,
165 collection_ids,
166 &self.client.internal.get_api_configurations().api_client,
167 self.client.internal.get_key_store(),
168 self.is_strict_decrypt().await,
169 )
170 .await
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use bitwarden_api_api::{apis::ApiClient, models::CipherMiniResponseModel};
177 use bitwarden_core::key_management::SymmetricKeySlotId;
178 use bitwarden_crypto::{KeyStore, SymmetricCryptoKey};
179
180 use super::*;
181 use crate::{CipherId, CipherRepromptType, CipherType, LoginView};
182
183 const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
184 const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000";
185
186 fn generate_test_cipher() -> CipherView {
187 CipherView {
188 id: Some(TEST_CIPHER_ID.parse().unwrap()),
189 organization_id: None,
190 folder_id: None,
191 collection_ids: vec![],
192 key: None,
193 name: "Test Login".to_string(),
194 notes: None,
195 r#type: CipherType::Login,
196 login: Some(LoginView {
197 username: Some("[email protected]".to_string()),
198 password: Some("password123".to_string()),
199 password_revision_date: None,
200 uris: None,
201 totp: None,
202 autofill_on_page_load: None,
203 fido2_credentials: None,
204 }),
205 identity: None,
206 card: None,
207 secure_note: None,
208 ssh_key: None,
209 bank_account: None,
210 favorite: false,
211 reprompt: CipherRepromptType::None,
212 organization_use_totp: true,
213 edit: true,
214 permissions: None,
215 view_password: true,
216 local_data: None,
217 attachments: None,
218 attachment_decryption_failures: None,
219 fields: None,
220 password_history: None,
221 creation_date: "2025-01-01T00:00:00Z".parse().unwrap(),
222 deleted_date: None,
223 revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
224 archived_date: None,
225 }
226 }
227
228 #[tokio::test]
229 async fn test_edit_cipher() {
230 let store: KeyStore<KeySlotIds> = KeyStore::default();
231 #[allow(deprecated)]
232 let _ = store.context_mut().set_symmetric_key(
233 SymmetricKeySlotId::User,
234 SymmetricCryptoKey::make_aes256_cbc_hmac_key(),
235 );
236
237 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
238
239 let api_client = ApiClient::new_mocked(move |mock| {
240 mock.ciphers_api
241 .expect_put_admin()
242 .returning(move |_id, body| {
243 let body = body.unwrap();
244 Ok(CipherMiniResponseModel {
245 object: Some("cipher".to_string()),
246 id: Some(cipher_id.into()),
247 name: Some(body.name),
248 r#type: body.r#type,
249 organization_id: body
250 .organization_id
251 .as_ref()
252 .and_then(|id| uuid::Uuid::parse_str(id).ok()),
253 reprompt: body.reprompt,
254 key: body.key,
255 notes: body.notes,
256 organization_use_totp: Some(true),
257 revision_date: Some("2025-01-01T00:00:00Z".to_string()),
258 creation_date: Some("2025-01-01T00:00:00Z".to_string()),
259 deleted_date: None,
260 login: body.login,
261 card: body.card,
262 identity: body.identity,
263 secure_note: body.secure_note,
264 ssh_key: body.ssh_key,
265 bank_account: body.bank_account,
266 fields: body.fields,
267 password_history: body.password_history,
268 attachments: None,
269 data: None,
270 })
271 })
272 .once();
273 });
274
275 let folder_a: crate::FolderId = "a4e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
276 let folder_b: crate::FolderId = "b5e13cc0-1234-5678-abcd-b181009709b8".parse().unwrap();
277
278 let mut original_cipher_view = generate_test_cipher();
279 original_cipher_view.folder_id = Some(folder_a);
280 let mut cipher_view = original_cipher_view.clone();
281 cipher_view.name = "New Cipher Name".to_string();
282 cipher_view.folder_id = Some(folder_b);
284
285 let request: CipherEditRequest = cipher_view.try_into().unwrap();
286
287 let result = edit_cipher(
288 &store,
289 &api_client,
290 TEST_USER_ID.parse().unwrap(),
291 original_cipher_view,
292 request,
293 false,
294 )
295 .await
296 .unwrap();
297
298 assert_eq!(result.id, Some(cipher_id));
299 assert_eq!(result.name, "New Cipher Name");
300 assert_eq!(result.folder_id, Some(folder_b));
302 }
303
304 #[tokio::test]
305 async fn test_edit_cipher_http_error() {
306 let store: KeyStore<KeySlotIds> = KeyStore::default();
307 #[allow(deprecated)]
308 let _ = store.context_mut().set_symmetric_key(
309 SymmetricKeySlotId::User,
310 SymmetricCryptoKey::make_aes256_cbc_hmac_key(),
311 );
312
313 let api_client = ApiClient::new_mocked(move |mock| {
314 mock.ciphers_api
315 .expect_put_admin()
316 .returning(move |_id, _body| {
317 Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other(
318 "Simulated error",
319 )))
320 });
321 });
322 let orig_cipher_view = generate_test_cipher();
323 let cipher_view = orig_cipher_view.clone();
324 let request: CipherEditRequest = cipher_view.try_into().unwrap();
325 let result = edit_cipher(
326 &store,
327 &api_client,
328 TEST_USER_ID.parse().unwrap(),
329 orig_cipher_view,
330 request,
331 false,
332 )
333 .await;
334
335 assert!(result.is_err());
336 assert!(matches!(result.unwrap_err(), EditCipherAdminError::Api(_)));
337 }
338}