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 #[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 #[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#[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 SharedCollection = 0,
73 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 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 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 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}