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