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