bitwarden_vault/cipher/cipher_client/admin/
edit.rs

1use bitwarden_api_api::{apis::ApiClient, models::CipherCollectionsRequestModel};
2use bitwarden_collections::collection::CollectionId;
3use bitwarden_core::{
4    ApiError, MissingFieldError, NotAuthenticatedError, UserId, key_management::KeyIds,
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,
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<KeyIds>,
52    api_client: &bitwarden_api_api::apis::ApiClient,
53    encrypted_for: UserId,
54    original_cipher_view: CipherView,
55    request: CipherEditRequest,
56) -> Result<CipherView, EditCipherAdminError> {
57    let cipher_id = request.id;
58    let request = CipherEditRequestInternal::new(request, &original_cipher_view);
59
60    let mut cipher_request = key_store.encrypt(request)?;
61    cipher_request.encrypted_for = Some(encrypted_for.into());
62
63    let orig_cipher = key_store.encrypt(original_cipher_view)?;
64
65    let cipher: Cipher = api_client
66        .ciphers_api()
67        .put_admin(cipher_id.into(), Some(cipher_request))
68        .await
69        .map_err(ApiError::from)?
70        .merge_with_cipher(Some(orig_cipher))?;
71
72    Ok(key_store.decrypt(&cipher)?)
73}
74
75/// Adds the cipher matched by [CipherId] to any number of collections on the server.
76pub async fn add_to_collections(
77    cipher_id: CipherId,
78    collection_ids: Vec<CollectionId>,
79    api_client: &ApiClient,
80    key_store: &KeyStore<KeyIds>,
81) -> Result<CipherView, EditCipherAdminError> {
82    let req = CipherCollectionsRequestModel {
83        collection_ids: collection_ids
84            .into_iter()
85            .map(|id| id.to_string())
86            .collect(),
87    };
88
89    let api = api_client.ciphers_api();
90    let cipher: Cipher = api
91        .put_collections_admin(&cipher_id.to_string(), Some(req))
92        .await?
93        .merge_with_cipher(None)?;
94
95    Ok(key_store.decrypt(&cipher)?)
96}
97
98#[cfg_attr(feature = "wasm", wasm_bindgen)]
99impl CipherAdminClient {
100    /// Edit an existing [Cipher] and save it to the server.
101    pub async fn edit(
102        &self,
103        mut request: CipherEditRequest,
104        original_cipher_view: CipherView,
105    ) -> Result<CipherView, EditCipherAdminError> {
106        let key_store = self.client.internal.get_key_store();
107        let config = self.client.internal.get_api_configurations().await;
108
109        let user_id = self
110            .client
111            .internal
112            .get_user_id()
113            .ok_or(NotAuthenticatedError)?;
114
115        // TODO: Once this flag is removed, the key generation logic should
116        // be moved closer to the actual encryption logic.
117        if request.key.is_none()
118            && self
119                .client
120                .internal
121                .get_flags()
122                .enable_cipher_key_encryption
123        {
124            let key = request.key_identifier();
125            request.generate_cipher_key(&mut key_store.context(), key)?;
126        }
127
128        edit_cipher(
129            key_store,
130            &config.api_client,
131            user_id,
132            original_cipher_view,
133            request,
134        )
135        .await
136    }
137
138    /// Adds the cipher matched by [CipherId] to any number of collections on the server.
139    pub async fn update_collection(
140        &self,
141        cipher_id: CipherId,
142        collection_ids: Vec<CollectionId>,
143    ) -> Result<CipherView, EditCipherAdminError> {
144        add_to_collections(
145            cipher_id,
146            collection_ids,
147            &self
148                .client
149                .internal
150                .get_api_configurations()
151                .await
152                .api_client,
153            self.client.internal.get_key_store(),
154        )
155        .await
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use bitwarden_api_api::{apis::ApiClient, models::CipherMiniResponseModel};
162    use bitwarden_core::key_management::SymmetricKeyId;
163    use bitwarden_crypto::{KeyStore, SymmetricCryptoKey};
164
165    use super::*;
166    use crate::{CipherId, CipherRepromptType, CipherType, LoginView};
167
168    const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
169    const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000";
170
171    fn generate_test_cipher() -> CipherView {
172        CipherView {
173            id: Some(TEST_CIPHER_ID.parse().unwrap()),
174            organization_id: None,
175            folder_id: None,
176            collection_ids: vec![],
177            key: None,
178            name: "Test Login".to_string(),
179            notes: None,
180            r#type: CipherType::Login,
181            login: Some(LoginView {
182                username: Some("[email protected]".to_string()),
183                password: Some("password123".to_string()),
184                password_revision_date: None,
185                uris: None,
186                totp: None,
187                autofill_on_page_load: None,
188                fido2_credentials: None,
189            }),
190            identity: None,
191            card: None,
192            secure_note: None,
193            ssh_key: None,
194            favorite: false,
195            reprompt: CipherRepromptType::None,
196            organization_use_totp: true,
197            edit: true,
198            permissions: None,
199            view_password: true,
200            local_data: None,
201            attachments: None,
202            fields: None,
203            password_history: None,
204            creation_date: "2025-01-01T00:00:00Z".parse().unwrap(),
205            deleted_date: None,
206            revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
207            archived_date: None,
208        }
209    }
210
211    #[tokio::test]
212    async fn test_edit_cipher() {
213        let store: KeyStore<KeyIds> = KeyStore::default();
214        #[allow(deprecated)]
215        let _ = store.context_mut().set_symmetric_key(
216            SymmetricKeyId::User,
217            SymmetricCryptoKey::make_aes256_cbc_hmac_key(),
218        );
219
220        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
221
222        let api_client = ApiClient::new_mocked(move |mock| {
223            mock.ciphers_api
224                .expect_put_admin()
225                .returning(move |_id, body| {
226                    let body = body.unwrap();
227                    Ok(CipherMiniResponseModel {
228                        object: Some("cipher".to_string()),
229                        id: Some(cipher_id.into()),
230                        name: Some(body.name),
231                        r#type: body.r#type,
232                        organization_id: body
233                            .organization_id
234                            .as_ref()
235                            .and_then(|id| uuid::Uuid::parse_str(id).ok()),
236                        reprompt: body.reprompt,
237                        key: body.key,
238                        notes: body.notes,
239                        organization_use_totp: Some(true),
240                        revision_date: Some("2025-01-01T00:00:00Z".to_string()),
241                        creation_date: Some("2025-01-01T00:00:00Z".to_string()),
242                        deleted_date: None,
243                        login: body.login,
244                        card: body.card,
245                        identity: body.identity,
246                        secure_note: body.secure_note,
247                        ssh_key: body.ssh_key,
248                        fields: body.fields,
249                        password_history: body.password_history,
250                        attachments: None,
251                        data: None,
252                        archived_date: None,
253                    })
254                })
255                .once();
256        });
257
258        let original_cipher_view = generate_test_cipher();
259        let mut cipher_view = original_cipher_view.clone();
260        cipher_view.name = "New Cipher Name".to_string();
261
262        let request: CipherEditRequest = cipher_view.try_into().unwrap();
263
264        let result = edit_cipher(
265            &store,
266            &api_client,
267            TEST_USER_ID.parse().unwrap(),
268            original_cipher_view,
269            request,
270        )
271        .await
272        .unwrap();
273
274        assert_eq!(result.id, Some(cipher_id));
275        assert_eq!(result.name, "New Cipher Name");
276    }
277
278    #[tokio::test]
279    async fn test_edit_cipher_http_error() {
280        let store: KeyStore<KeyIds> = KeyStore::default();
281        #[allow(deprecated)]
282        let _ = store.context_mut().set_symmetric_key(
283            SymmetricKeyId::User,
284            SymmetricCryptoKey::make_aes256_cbc_hmac_key(),
285        );
286
287        let api_client = ApiClient::new_mocked(move |mock| {
288            mock.ciphers_api
289                .expect_put_admin()
290                .returning(move |_id, _body| {
291                    Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other(
292                        "Simulated error",
293                    )))
294                });
295        });
296        let orig_cipher_view = generate_test_cipher();
297        let cipher_view = orig_cipher_view.clone();
298        let request: CipherEditRequest = cipher_view.try_into().unwrap();
299        let result = edit_cipher(
300            &store,
301            &api_client,
302            TEST_USER_ID.parse().unwrap(),
303            orig_cipher_view,
304            request,
305        )
306        .await;
307
308        assert!(result.is_err());
309        assert!(matches!(result.unwrap_err(), EditCipherAdminError::Api(_)));
310    }
311}