Skip to main content

bitwarden_collections/
collection.rs

1use bitwarden_api_api::models::CollectionDetailsResponseModel;
2use bitwarden_core::{
3    OrganizationId,
4    key_management::{KeySlotIds, SymmetricKeySlotId},
5    require,
6};
7use bitwarden_crypto::{
8    CompositeEncryptable, CryptoError, Decryptable, EncString, IdentifyKey, KeyStoreContext,
9    PrimitiveEncryptable,
10};
11use bitwarden_uuid::uuid_newtype;
12use serde::{Deserialize, Serialize};
13use serde_repr::{Deserialize_repr, Serialize_repr};
14use uuid::Uuid;
15#[cfg(feature = "wasm")]
16use {tsify::Tsify, wasm_bindgen::prelude::*};
17
18use crate::{error::CollectionsParseError, tree::TreeItem};
19
20uuid_newtype!(pub CollectionId);
21
22#[allow(missing_docs)]
23#[derive(Serialize, Deserialize, Debug)]
24#[serde(rename_all = "camelCase", deny_unknown_fields)]
25#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
26#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
27pub struct Collection {
28    pub id: Option<CollectionId>,
29    pub organization_id: OrganizationId,
30    pub name: EncString,
31    pub external_id: Option<String>,
32    pub hide_passwords: bool,
33    pub read_only: bool,
34    pub manage: bool,
35    pub default_user_collection_email: Option<String>,
36    pub r#type: CollectionType,
37}
38
39#[allow(missing_docs)]
40#[derive(Serialize, Deserialize, Debug, Clone)]
41#[serde(rename_all = "camelCase", deny_unknown_fields)]
42#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
43#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
44pub struct CollectionView {
45    pub id: Option<CollectionId>,
46    pub organization_id: OrganizationId,
47    pub name: String,
48    pub external_id: Option<String>,
49    pub hide_passwords: bool,
50    pub read_only: bool,
51    pub manage: bool,
52    pub r#type: CollectionType,
53}
54
55/// Type of collection
56#[derive(Serialize_repr, Deserialize_repr, Debug, Clone, Eq, PartialEq)]
57#[repr(u8)]
58#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
59#[cfg_attr(feature = "wasm", wasm_bindgen)]
60pub enum CollectionType {
61    /// Default collection type. Can be assigned by an organization to user(s) or group(s)
62    SharedCollection = 0,
63    /// Default collection assigned to a user for an organization that has
64    /// OrganizationDataOwnership (formerly PersonalOwnership) policy enabled.
65    DefaultUserCollection = 1,
66}
67
68#[allow(missing_docs)]
69impl Decryptable<KeySlotIds, SymmetricKeySlotId, CollectionView> for Collection {
70    fn decrypt(
71        &self,
72        ctx: &mut KeyStoreContext<KeySlotIds>,
73        key: SymmetricKeySlotId,
74    ) -> Result<CollectionView, CryptoError> {
75        let name = self
76            .default_user_collection_email
77            .as_ref()
78            .unwrap_or(&self.name.decrypt(ctx, key)?)
79            .clone();
80
81        Ok(CollectionView {
82            id: self.id,
83            organization_id: self.organization_id,
84            name,
85            external_id: self.external_id.clone(),
86            hide_passwords: self.hide_passwords,
87            read_only: self.read_only,
88            manage: self.manage,
89            r#type: self.r#type.clone(),
90        })
91    }
92}
93
94#[allow(missing_docs)]
95impl TryFrom<CollectionDetailsResponseModel> for Collection {
96    type Error = CollectionsParseError;
97
98    fn try_from(collection: CollectionDetailsResponseModel) -> Result<Self, Self::Error> {
99        Ok(Collection {
100            id: collection.id.map(CollectionId::new),
101            organization_id: OrganizationId::new(require!(collection.organization_id)),
102            name: require!(collection.name).parse()?,
103            external_id: collection.external_id,
104            hide_passwords: collection.hide_passwords.unwrap_or(false),
105            read_only: collection.read_only.unwrap_or(false),
106            manage: collection.manage.unwrap_or(false),
107            default_user_collection_email: collection.default_user_collection_email,
108            r#type: require!(collection.r#type).try_into()?,
109        })
110    }
111}
112
113#[allow(missing_docs)]
114impl IdentifyKey<SymmetricKeySlotId> for Collection {
115    fn key_identifier(&self) -> SymmetricKeySlotId {
116        SymmetricKeySlotId::Organization(self.organization_id)
117    }
118}
119
120impl IdentifyKey<SymmetricKeySlotId> for CollectionView {
121    fn key_identifier(&self) -> SymmetricKeySlotId {
122        SymmetricKeySlotId::Organization(self.organization_id)
123    }
124}
125
126impl CompositeEncryptable<KeySlotIds, SymmetricKeySlotId, Collection> for CollectionView {
127    fn encrypt_composite(
128        &self,
129        ctx: &mut KeyStoreContext<KeySlotIds>,
130        key: SymmetricKeySlotId,
131    ) -> Result<Collection, CryptoError> {
132        Ok(Collection {
133            id: self.id,
134            organization_id: self.organization_id,
135            name: self.name.encrypt(ctx, key)?,
136            external_id: self.external_id.clone(),
137            hide_passwords: self.hide_passwords,
138            read_only: self.read_only,
139            manage: self.manage,
140            // defaultUserCollectionEmail is not stored in CollectionView; it is handled
141            // server-side and is not modified during a client-side encrypt round-trip.
142            default_user_collection_email: None,
143            r#type: self.r#type.clone(),
144        })
145    }
146}
147
148#[allow(missing_docs)]
149impl TreeItem for CollectionView {
150    fn id(&self) -> Uuid {
151        self.id.map(|id| id.0).unwrap_or_default()
152    }
153
154    fn short_name(&self) -> &str {
155        self.path().last().unwrap_or(&"")
156    }
157
158    fn path(&self) -> Vec<&str> {
159        self.name
160            .split(Self::DELIMITER)
161            .filter(|s| !s.is_empty())
162            .collect::<Vec<&str>>()
163    }
164
165    const DELIMITER: char = '/';
166}
167
168impl TryFrom<bitwarden_api_api::models::CollectionType> for CollectionType {
169    type Error = bitwarden_core::MissingFieldError;
170
171    fn try_from(
172        collection_type: bitwarden_api_api::models::CollectionType,
173    ) -> Result<Self, Self::Error> {
174        Ok(match collection_type {
175            bitwarden_api_api::models::CollectionType::SharedCollection => Self::SharedCollection,
176            bitwarden_api_api::models::CollectionType::DefaultUserCollection => {
177                Self::DefaultUserCollection
178            }
179            bitwarden_api_api::models::CollectionType::__Unknown(_) => {
180                return Err(bitwarden_core::MissingFieldError("type"));
181            }
182        })
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use bitwarden_core::key_management::{KeySlotIds, SymmetricKeySlotId};
189    use bitwarden_crypto::{KeyStore, PrimitiveEncryptable, SymmetricKeyAlgorithm};
190
191    use super::*;
192
193    const ORGANIZATION_ID: &str = "12345678-1234-1234-1234-123456789012";
194    const COLLECTION_ID: &str = "87654321-4321-4321-4321-210987654321";
195
196    // Helper function to create a test key store with a symmetric key
197    fn create_test_key_store() -> KeyStore<KeySlotIds> {
198        let store = KeyStore::<KeySlotIds>::default();
199        let org_id = ORGANIZATION_ID.parse().unwrap();
200
201        let mut ctx = store.context_mut();
202
203        let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
204        ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::Organization(org_id))
205            .unwrap();
206        drop(ctx);
207
208        store
209    }
210
211    #[test]
212    fn test_decrypt_with_name_only() {
213        let store = create_test_key_store();
214        let mut ctx = store.context();
215        let org_id = ORGANIZATION_ID.parse().unwrap();
216        let key = SymmetricKeySlotId::Organization(org_id);
217
218        let collection_name: &str = "Collection Name";
219
220        let collection = Collection {
221            id: Some(COLLECTION_ID.parse().unwrap()),
222            organization_id: org_id,
223            name: collection_name.encrypt(&mut ctx, key).unwrap(),
224            external_id: Some("external-id".to_string()),
225            hide_passwords: true,
226            read_only: false,
227            manage: true,
228            default_user_collection_email: None,
229            r#type: CollectionType::SharedCollection,
230        };
231
232        let decrypted = collection.decrypt(&mut ctx, key).unwrap();
233
234        assert_eq!(decrypted.name, collection_name);
235    }
236
237    #[test]
238    fn test_decrypt_with_default_user_collection_email() {
239        let store = create_test_key_store();
240        let mut ctx = store.context();
241        let org_id = ORGANIZATION_ID.parse().unwrap();
242        let key = SymmetricKeySlotId::Organization(org_id);
243
244        let collection_name: &str = "Collection Name";
245        let default_user_collection_email = String::from("[email protected]");
246
247        let collection = Collection {
248            id: Some(COLLECTION_ID.parse().unwrap()),
249            organization_id: org_id,
250            name: collection_name.encrypt(&mut ctx, key).unwrap(),
251            external_id: None,
252            hide_passwords: false,
253            read_only: true,
254            manage: false,
255            default_user_collection_email: Some(default_user_collection_email.clone()),
256            r#type: CollectionType::SharedCollection,
257        };
258
259        let decrypted = collection.decrypt(&mut ctx, key).unwrap();
260
261        assert_ne!(decrypted.name, collection_name);
262        assert_eq!(decrypted.name, default_user_collection_email);
263    }
264
265    #[test]
266    fn test_decrypt_all_fields_preserved() {
267        let store = create_test_key_store();
268        let mut ctx = store.context();
269        let org_id = ORGANIZATION_ID.parse().unwrap();
270        let key = SymmetricKeySlotId::Organization(org_id);
271
272        let collection_id = Some(COLLECTION_ID.parse().unwrap());
273        let external_id = Some("external-test-id".to_string());
274        let collection_name: &str = "Collection Name";
275        let collection_type = CollectionType::SharedCollection;
276
277        let collection = Collection {
278            id: collection_id,
279            organization_id: org_id,
280            name: collection_name.encrypt(&mut ctx, key).unwrap(),
281            external_id: external_id.clone(),
282            hide_passwords: true,
283            read_only: true,
284            manage: true,
285            default_user_collection_email: None,
286            r#type: collection_type.clone(),
287        };
288
289        let decrypted = collection.decrypt(&mut ctx, key).unwrap();
290
291        // Verify all fields are correctly transferred
292        assert_eq!(decrypted.id, collection.id);
293        assert_eq!(decrypted.organization_id, collection.organization_id);
294        assert_eq!(decrypted.name, collection_name);
295        assert_eq!(decrypted.external_id, external_id);
296        assert_eq!(decrypted.hide_passwords, collection.hide_passwords);
297        assert_eq!(decrypted.read_only, collection.read_only);
298        assert_eq!(decrypted.manage, collection.manage);
299        assert_eq!(decrypted.r#type, collection_type);
300    }
301}