bitwarden_collections/
collection.rs

1use bitwarden_api_api::models::CollectionDetailsResponseModel;
2use bitwarden_core::{
3    key_management::{KeyIds, SymmetricKeyId},
4    require, OrganizationId,
5};
6use bitwarden_crypto::{CryptoError, Decryptable, EncString, IdentifyKey, KeyStoreContext};
7use bitwarden_uuid::uuid_newtype;
8use serde::{Deserialize, Serialize};
9use serde_repr::{Deserialize_repr, Serialize_repr};
10use uuid::Uuid;
11#[cfg(feature = "wasm")]
12use {tsify::Tsify, wasm_bindgen::prelude::*};
13
14use crate::{error::CollectionsParseError, tree::TreeItem};
15
16uuid_newtype!(pub CollectionId);
17
18#[allow(missing_docs)]
19#[derive(Serialize, Deserialize, Debug)]
20#[serde(rename_all = "camelCase", deny_unknown_fields)]
21#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
22#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
23pub struct Collection {
24    pub id: Option<CollectionId>,
25    pub organization_id: OrganizationId,
26    pub name: EncString,
27    pub external_id: Option<String>,
28    pub hide_passwords: bool,
29    pub read_only: bool,
30    pub manage: bool,
31    pub default_user_collection_email: Option<String>,
32    pub r#type: CollectionType,
33}
34
35#[allow(missing_docs)]
36#[derive(Serialize, Deserialize, Debug, Clone)]
37#[serde(rename_all = "camelCase", deny_unknown_fields)]
38#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
39#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
40pub struct CollectionView {
41    pub id: Option<CollectionId>,
42    pub organization_id: OrganizationId,
43    pub name: String,
44    pub external_id: Option<String>,
45    pub hide_passwords: bool,
46    pub read_only: bool,
47    pub manage: bool,
48    pub r#type: CollectionType,
49}
50
51/// Type of collection
52#[derive(Serialize_repr, Deserialize_repr, Debug, Clone, Eq, PartialEq)]
53#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
54#[cfg_attr(
55    feature = "wasm",
56    derive(tsify::Tsify),
57    tsify(into_wasm_abi, from_wasm_abi)
58)]
59#[repr(u8)]
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<KeyIds, SymmetricKeyId, CollectionView> for Collection {
70    fn decrypt(
71        &self,
72        ctx: &mut KeyStoreContext<KeyIds>,
73        key: SymmetricKeyId,
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).into(),
109        })
110    }
111}
112
113#[allow(missing_docs)]
114impl IdentifyKey<SymmetricKeyId> for Collection {
115    fn key_identifier(&self) -> SymmetricKeyId {
116        SymmetricKeyId::Organization(self.organization_id)
117    }
118}
119
120#[allow(missing_docs)]
121impl TreeItem for CollectionView {
122    fn id(&self) -> Uuid {
123        self.id.map(|id| id.0).unwrap_or_default()
124    }
125
126    fn short_name(&self) -> &str {
127        self.path().last().unwrap_or(&"")
128    }
129
130    fn path(&self) -> Vec<&str> {
131        self.name
132            .split(Self::DELIMITER)
133            .filter(|s| !s.is_empty())
134            .collect::<Vec<&str>>()
135    }
136
137    const DELIMITER: char = '/';
138}
139
140impl From<bitwarden_api_api::models::CollectionType> for CollectionType {
141    fn from(collection_type: bitwarden_api_api::models::CollectionType) -> Self {
142        match collection_type {
143            bitwarden_api_api::models::CollectionType::SharedCollection => Self::SharedCollection,
144            bitwarden_api_api::models::CollectionType::DefaultUserCollection => {
145                Self::DefaultUserCollection
146            }
147        }
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use bitwarden_core::key_management::{KeyIds, SymmetricKeyId};
154    use bitwarden_crypto::{KeyStore, PrimitiveEncryptable, SymmetricCryptoKey};
155
156    use super::*;
157
158    const ORGANIZATION_ID: &str = "12345678-1234-1234-1234-123456789012";
159    const COLLECTION_ID: &str = "87654321-4321-4321-4321-210987654321";
160
161    // Helper function to create a test key store with a symmetric key
162    fn create_test_key_store() -> KeyStore<KeyIds> {
163        let store = KeyStore::<KeyIds>::default();
164        let key = SymmetricCryptoKey::make_aes256_cbc_hmac_key();
165        let org_id = ORGANIZATION_ID.parse().unwrap();
166
167        #[allow(deprecated)]
168        store
169            .context_mut()
170            .set_symmetric_key(SymmetricKeyId::Organization(org_id), key)
171            .unwrap();
172
173        store
174    }
175
176    #[test]
177    fn test_decrypt_with_name_only() {
178        let store = create_test_key_store();
179        let mut ctx = store.context();
180        let org_id = ORGANIZATION_ID.parse().unwrap();
181        let key = SymmetricKeyId::Organization(org_id);
182
183        let collection_name: &str = "Collection Name";
184
185        let collection = Collection {
186            id: Some(COLLECTION_ID.parse().unwrap()),
187            organization_id: org_id,
188            name: collection_name.encrypt(&mut ctx, key).unwrap(),
189            external_id: Some("external-id".to_string()),
190            hide_passwords: true,
191            read_only: false,
192            manage: true,
193            default_user_collection_email: None,
194            r#type: CollectionType::SharedCollection,
195        };
196
197        let decrypted = collection.decrypt(&mut ctx, key).unwrap();
198
199        assert_eq!(decrypted.name, collection_name);
200    }
201
202    #[test]
203    fn test_decrypt_with_default_user_collection_email() {
204        let store = create_test_key_store();
205        let mut ctx = store.context();
206        let org_id = ORGANIZATION_ID.parse().unwrap();
207        let key = SymmetricKeyId::Organization(org_id);
208
209        let collection_name: &str = "Collection Name";
210        let default_user_collection_email = String::from("[email protected]");
211
212        let collection = Collection {
213            id: Some(COLLECTION_ID.parse().unwrap()),
214            organization_id: org_id,
215            name: collection_name.encrypt(&mut ctx, key).unwrap(),
216            external_id: None,
217            hide_passwords: false,
218            read_only: true,
219            manage: false,
220            default_user_collection_email: Some(default_user_collection_email.clone()),
221            r#type: CollectionType::SharedCollection,
222        };
223
224        let decrypted = collection.decrypt(&mut ctx, key).unwrap();
225
226        assert_ne!(decrypted.name, collection_name);
227        assert_eq!(decrypted.name, default_user_collection_email);
228    }
229
230    #[test]
231    fn test_decrypt_all_fields_preserved() {
232        let store = create_test_key_store();
233        let mut ctx = store.context();
234        let org_id = ORGANIZATION_ID.parse().unwrap();
235        let key = SymmetricKeyId::Organization(org_id);
236
237        let collection_id = Some(COLLECTION_ID.parse().unwrap());
238        let external_id = Some("external-test-id".to_string());
239        let collection_name: &str = "Collection Name";
240        let collection_type = CollectionType::SharedCollection;
241
242        let collection = Collection {
243            id: collection_id,
244            organization_id: org_id,
245            name: collection_name.encrypt(&mut ctx, key).unwrap(),
246            external_id: external_id.clone(),
247            hide_passwords: true,
248            read_only: true,
249            manage: true,
250            default_user_collection_email: None,
251            r#type: collection_type.clone(),
252        };
253
254        let decrypted = collection.decrypt(&mut ctx, key).unwrap();
255
256        // Verify all fields are correctly transferred
257        assert_eq!(decrypted.id, collection.id);
258        assert_eq!(decrypted.organization_id, collection.organization_id);
259        assert_eq!(decrypted.name, collection_name);
260        assert_eq!(decrypted.external_id, external_id);
261        assert_eq!(decrypted.hide_passwords, collection.hide_passwords);
262        assert_eq!(decrypted.read_only, collection.read_only);
263        assert_eq!(decrypted.manage, collection.manage);
264        assert_eq!(decrypted.r#type, collection_type);
265    }
266}