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