bitwarden_pam/access_rules/
client.rs1use std::sync::Arc;
2
3use bitwarden_collections::collection::CollectionId;
4use bitwarden_core::{FromClient, OrganizationId, client::ApiConfigurations};
5
6use super::{
7 error::AccessRuleError,
8 models::{AccessRuleAddEditRequest, AccessRuleView},
9 validate::validate_request,
10};
11use crate::AccessRuleId;
12
13#[bitwarden_ffi::wasm_object]
15#[derive(FromClient)]
16pub struct AccessRulesClient {
17 pub(crate) api_configurations: Arc<ApiConfigurations>,
18}
19
20#[bitwarden_ffi::wasm_export]
21impl AccessRulesClient {
22 pub async fn list(
24 &self,
25 organization_id: OrganizationId,
26 ) -> Result<Vec<AccessRuleView>, AccessRuleError> {
27 let response = self
28 .api_configurations
29 .api_client
30 .access_rules_api()
31 .get_all(organization_id.into())
32 .await?;
33
34 response
35 .data
36 .unwrap_or_default()
37 .into_iter()
38 .map(AccessRuleView::try_from)
39 .collect()
40 }
41
42 pub async fn get(
45 &self,
46 organization_id: OrganizationId,
47 id: AccessRuleId,
48 ) -> Result<AccessRuleView, AccessRuleError> {
49 let response = self
50 .api_configurations
51 .api_client
52 .access_rules_api()
53 .get(organization_id.into(), id.into())
54 .await
55 .map_err(AccessRuleError::from_api_error)?;
56
57 AccessRuleView::try_from(response)
58 }
59
60 pub async fn create(
62 &self,
63 organization_id: OrganizationId,
64 request: AccessRuleAddEditRequest,
65 ) -> Result<AccessRuleView, AccessRuleError> {
66 validate_request(&request)?;
67
68 let response = self
69 .api_configurations
70 .api_client
71 .access_rules_api()
72 .post(organization_id.into(), request.try_into()?)
73 .await?;
74
75 AccessRuleView::try_from(response)
76 }
77
78 pub async fn update(
81 &self,
82 organization_id: OrganizationId,
83 id: AccessRuleId,
84 request: AccessRuleAddEditRequest,
85 ) -> Result<AccessRuleView, AccessRuleError> {
86 validate_request(&request)?;
87
88 let response = self
89 .api_configurations
90 .api_client
91 .access_rules_api()
92 .put(organization_id.into(), id.into(), request.try_into()?)
93 .await
94 .map_err(AccessRuleError::from_api_error)?;
95
96 AccessRuleView::try_from(response)
97 }
98
99 pub async fn bypassable_ciphers(
104 &self,
105 organization_id: OrganizationId,
106 id: AccessRuleId,
107 ) -> Result<Vec<CollectionId>, AccessRuleError> {
108 let response = self
109 .api_configurations
110 .api_client
111 .access_rules_api()
112 .get_bypassable_ciphers(organization_id.into(), id.into())
113 .await?;
114
115 Ok(response
116 .ungated_collection_ids
117 .unwrap_or_default()
118 .into_iter()
119 .map(CollectionId::new)
120 .collect())
121 }
122
123 pub async fn set_enabled(
127 &self,
128 organization_id: OrganizationId,
129 rule: AccessRuleView,
130 enabled: bool,
131 ) -> Result<AccessRuleView, AccessRuleError> {
132 let id = rule.id;
133 let request = AccessRuleAddEditRequest {
134 enabled,
135 ..rule.into()
136 };
137
138 self.update(organization_id, id, request).await
139 }
140
141 pub async fn delete(
144 &self,
145 organization_id: OrganizationId,
146 id: AccessRuleId,
147 ) -> Result<(), AccessRuleError> {
148 self.api_configurations
149 .api_client
150 .access_rules_api()
151 .delete(organization_id.into(), id.into())
152 .await
153 .map_err(AccessRuleError::from_api_error)?;
154
155 Ok(())
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use bitwarden_api_api::{apis::ApiClient, models::AccessRuleResponseModel};
162 use uuid::uuid;
163
164 use super::*;
165 use crate::AccessCondition;
166
167 fn org_id() -> OrganizationId {
168 OrganizationId::new(uuid!("11111111-1111-1111-1111-111111111111"))
169 }
170
171 fn rule_id() -> AccessRuleId {
172 AccessRuleId::new(uuid!("22222222-2222-2222-2222-222222222222"))
173 }
174
175 fn client(api_client: ApiClient) -> AccessRulesClient {
176 AccessRulesClient {
177 api_configurations: Arc::new(ApiConfigurations::from_api_client(api_client)),
178 }
179 }
180
181 fn sample_response(id: uuid::Uuid, organization_id: uuid::Uuid) -> AccessRuleResponseModel {
182 let mut response = AccessRuleResponseModel::new();
183 response.id = Some(id);
184 response.organization_id = Some(organization_id);
185 response.name = Some("My rule".to_string());
186 response.conditions = Some(serde_json::json!([]));
187 response.creation_date = Some("2025-01-01T00:00:00Z".to_string());
188 response.revision_date = Some("2025-01-01T00:00:00Z".to_string());
189 response
190 }
191
192 fn sample_request() -> AccessRuleAddEditRequest {
193 AccessRuleAddEditRequest {
194 name: "My rule".to_string(),
195 description: None,
196 enabled: true,
197 conditions: vec![AccessCondition::HumanApproval],
198 single_active_lease: false,
199 default_lease_duration_seconds: None,
200 max_lease_duration_seconds: None,
201 allows_extensions: false,
202 max_extension_duration_seconds: None,
203 collections: Vec::new(),
204 }
205 }
206
207 #[tokio::test]
208 async fn list_returns_views() {
209 let organization_id = org_id();
210 let rule = rule_id();
211 let response = sample_response(rule.into(), organization_id.into());
212
213 let api_client = ApiClient::new_mocked(move |mock| {
214 mock.access_rules_api
215 .expect_get_all()
216 .returning(move |_org_id| {
217 let mut list_response =
218 bitwarden_api_api::models::AccessRuleResponseModelListResponseModel::new();
219 list_response.data = Some(vec![response.clone()]);
220 Ok(list_response)
221 })
222 .once();
223 });
224
225 let result = client(api_client).list(organization_id).await.unwrap();
226
227 assert_eq!(result.len(), 1);
228 assert_eq!(result[0].id, rule);
229 }
230
231 #[tokio::test]
232 async fn get_returns_view() {
233 let organization_id = org_id();
234 let rule = rule_id();
235 let response = sample_response(rule.into(), organization_id.into());
236
237 let api_client = ApiClient::new_mocked(move |mock| {
238 mock.access_rules_api
239 .expect_get()
240 .returning(move |_org_id, _id| Ok(response.clone()))
241 .once();
242 });
243
244 let result = client(api_client).get(organization_id, rule).await.unwrap();
245
246 assert_eq!(result.id, rule);
247 }
248
249 fn collection_id() -> uuid::Uuid {
250 uuid!("44444444-4444-4444-4444-444444444444")
251 }
252
253 fn bypassable_response(
254 ungated: Option<Vec<uuid::Uuid>>,
255 ) -> bitwarden_api_api::models::RuleBypassableCiphersResponseModel {
256 let mut response = bitwarden_api_api::models::RuleBypassableCiphersResponseModel::new();
257 response.rule_id = Some(rule_id().into());
258 response.ungated_collection_ids = ungated;
259 response
260 }
261
262 #[tokio::test]
263 async fn bypassable_ciphers_returns_the_reported_collections() {
264 let organization_id = org_id();
265 let rule = rule_id();
266 let other = uuid!("55555555-5555-5555-5555-555555555555");
267
268 let api_client = ApiClient::new_mocked(move |mock| {
269 mock.access_rules_api
270 .expect_get_bypassable_ciphers()
271 .returning(move |_org_id, _id| {
272 Ok(bypassable_response(Some(vec![collection_id(), other])))
273 })
274 .once();
275 });
276
277 let result = client(api_client)
278 .bypassable_ciphers(organization_id, rule)
279 .await
280 .unwrap();
281
282 assert_eq!(
283 result.into_iter().map(uuid::Uuid::from).collect::<Vec<_>>(),
284 vec![collection_id(), other]
285 );
286 }
287
288 #[tokio::test]
289 async fn bypassable_ciphers_treats_an_omitted_list_as_empty() {
290 let organization_id = org_id();
291 let rule = rule_id();
292
293 let api_client = ApiClient::new_mocked(move |mock| {
294 mock.access_rules_api
295 .expect_get_bypassable_ciphers()
296 .returning(move |_org_id, _id| Ok(bypassable_response(None)))
297 .once();
298 });
299
300 let result = client(api_client)
301 .bypassable_ciphers(organization_id, rule)
302 .await
303 .unwrap();
304
305 assert!(result.is_empty());
306 }
307
308 fn api_error(status: reqwest::StatusCode) -> bitwarden_api_api::ApiError {
309 bitwarden_api_api::ApiError::Response(bitwarden_api_api::ResponseContent {
310 status,
311 message: String::new(),
312 })
313 }
314
315 #[tokio::test]
317 async fn bypassable_ciphers_leaves_not_found_as_api() {
318 let organization_id = org_id();
319 let rule = rule_id();
320
321 let api_client = ApiClient::new_mocked(move |mock| {
322 mock.access_rules_api
323 .expect_get_bypassable_ciphers()
324 .returning(move |_org_id, _id| Err(api_error(reqwest::StatusCode::NOT_FOUND)))
325 .once();
326 });
327
328 let error = client(api_client)
329 .bypassable_ciphers(organization_id, rule)
330 .await
331 .unwrap_err();
332
333 assert!(
334 matches!(error, AccessRuleError::Api(_)),
335 "expected Api, got {error:?}"
336 );
337 }
338
339 #[tokio::test]
340 async fn get_maps_not_found_to_not_found() {
341 let organization_id = org_id();
342 let rule = rule_id();
343
344 let api_client = ApiClient::new_mocked(move |mock| {
345 mock.access_rules_api
346 .expect_get()
347 .returning(move |_org_id, _id| Err(api_error(reqwest::StatusCode::NOT_FOUND)))
348 .once();
349 });
350
351 let result = client(api_client).get(organization_id, rule).await;
352
353 assert!(matches!(result, Err(AccessRuleError::NotFound)));
354 }
355
356 #[tokio::test]
357 async fn get_surfaces_other_api_errors_as_api() {
358 let organization_id = org_id();
359 let rule = rule_id();
360
361 let api_client = ApiClient::new_mocked(move |mock| {
362 mock.access_rules_api
363 .expect_get()
364 .returning(move |_org_id, _id| {
365 Err(api_error(reqwest::StatusCode::INTERNAL_SERVER_ERROR))
366 })
367 .once();
368 });
369
370 let result = client(api_client).get(organization_id, rule).await;
371
372 assert!(matches!(result, Err(AccessRuleError::Api(_))));
373 }
374
375 #[tokio::test]
376 async fn update_maps_not_found_to_not_found() {
377 let organization_id = org_id();
378 let rule = rule_id();
379
380 let api_client = ApiClient::new_mocked(move |mock| {
381 mock.access_rules_api
382 .expect_put()
383 .returning(move |_org_id, _id, _request| {
384 Err(api_error(reqwest::StatusCode::NOT_FOUND))
385 })
386 .once();
387 });
388
389 let result = client(api_client)
390 .update(organization_id, rule, sample_request())
391 .await;
392
393 assert!(matches!(result, Err(AccessRuleError::NotFound)));
394 }
395
396 #[tokio::test]
397 async fn delete_maps_not_found_to_not_found() {
398 let organization_id = org_id();
399 let rule = rule_id();
400
401 let api_client = ApiClient::new_mocked(move |mock| {
402 mock.access_rules_api
403 .expect_delete()
404 .returning(move |_org_id, _id| Err(api_error(reqwest::StatusCode::NOT_FOUND)))
405 .once();
406 });
407
408 let result = client(api_client).delete(organization_id, rule).await;
409
410 assert!(matches!(result, Err(AccessRuleError::NotFound)));
411 }
412
413 #[tokio::test]
415 async fn list_leaves_not_found_as_api() {
416 let organization_id = org_id();
417
418 let api_client = ApiClient::new_mocked(move |mock| {
419 mock.access_rules_api
420 .expect_get_all()
421 .returning(move |_org_id| Err(api_error(reqwest::StatusCode::NOT_FOUND)))
422 .once();
423 });
424
425 let result = client(api_client).list(organization_id).await;
426
427 assert!(matches!(result, Err(AccessRuleError::Api(_))));
428 }
429
430 #[tokio::test]
431 async fn create_rejects_invalid_request_without_calling_the_api() {
432 let organization_id = org_id();
433 let mut request = sample_request();
434 request.name = String::new();
435
436 let api_client = ApiClient::new_mocked(|mock| {
437 mock.access_rules_api.expect_post().never();
438 });
439
440 let result = client(api_client).create(organization_id, request).await;
441
442 assert!(matches!(result, Err(AccessRuleError::Validation(_))));
443 }
444
445 #[tokio::test]
446 async fn create_returns_created_view() {
447 let organization_id = org_id();
448 let rule = rule_id();
449 let response = sample_response(rule.into(), organization_id.into());
450
451 let api_client = ApiClient::new_mocked(move |mock| {
452 mock.access_rules_api
453 .expect_post()
454 .returning(move |_org_id, _request| Ok(response.clone()))
455 .once();
456 });
457
458 let result = client(api_client)
459 .create(organization_id, sample_request())
460 .await
461 .unwrap();
462
463 assert_eq!(result.id, rule);
464 }
465
466 #[tokio::test]
467 async fn create_surfaces_api_error() {
468 let organization_id = org_id();
469
470 let api_client = ApiClient::new_mocked(move |mock| {
471 mock.access_rules_api
472 .expect_post()
473 .returning(move |_org_id, _request| {
474 Err(bitwarden_api_api::ApiError::Response(
475 bitwarden_api_api::ResponseContent {
476 status: reqwest::StatusCode::BAD_REQUEST,
477 message: "Invalid rule".to_string(),
478 },
479 ))
480 })
481 .once();
482 });
483
484 let result = client(api_client)
485 .create(organization_id, sample_request())
486 .await;
487
488 assert!(matches!(result, Err(AccessRuleError::Api(_))));
489 }
490
491 #[tokio::test]
492 async fn update_returns_updated_view() {
493 let organization_id = org_id();
494 let rule = rule_id();
495 let response = sample_response(rule.into(), organization_id.into());
496
497 let api_client = ApiClient::new_mocked(move |mock| {
498 mock.access_rules_api
499 .expect_put()
500 .returning(move |_org_id, _id, _request| Ok(response.clone()))
501 .once();
502 });
503
504 let result = client(api_client)
505 .update(organization_id, rule, sample_request())
506 .await
507 .unwrap();
508
509 assert_eq!(result.id, rule);
510 }
511
512 #[tokio::test]
513 async fn delete_succeeds() {
514 let organization_id = org_id();
515 let rule = rule_id();
516
517 let api_client = ApiClient::new_mocked(move |mock| {
518 mock.access_rules_api
519 .expect_delete()
520 .returning(move |_org_id, _id| Ok(()))
521 .once();
522 });
523
524 let result = client(api_client).delete(organization_id, rule).await;
525
526 assert!(result.is_ok());
527 }
528
529 #[tokio::test]
531 async fn set_enabled_flips_enabled_and_preserves_every_other_field() {
532 let organization_id = org_id();
533 let rule = rule_id();
534 let mut response = sample_response(rule.into(), organization_id.into());
535 response.enabled = Some(true);
536 response.allows_extensions = Some(true);
537 response.max_extension_duration_seconds = Some(3600);
538 response.single_active_lease = Some(true);
539 response.default_lease_duration_seconds = Some(1800);
540 response.max_lease_duration_seconds = Some(7200);
541 response.description = Some("Production database access".to_string());
542
543 let view = AccessRuleView::try_from(response.clone()).unwrap();
544 let returned = response.clone();
545
546 let api_client = ApiClient::new_mocked(move |mock| {
547 mock.access_rules_api
548 .expect_put()
549 .withf(|_org_id, _id, request| {
550 request.enabled == Some(false)
551 && request.allows_extensions == Some(true)
552 && request.max_extension_duration_seconds == Some(3600)
553 && request.single_active_lease == Some(true)
554 && request.default_lease_duration_seconds == Some(1800)
555 && request.max_lease_duration_seconds == Some(7200)
556 && request.name == "My rule"
557 && request.description.as_deref() == Some("Production database access")
558 })
559 .returning(move |_org_id, _id, _request| Ok(returned.clone()))
560 .once();
561 });
562
563 let result = client(api_client)
564 .set_enabled(organization_id, view, false)
565 .await
566 .unwrap();
567
568 assert_eq!(result.id, rule);
569 }
570
571 #[tokio::test]
572 async fn set_enabled_targets_the_rule_it_was_given() {
573 let organization_id = org_id();
574 let rule = rule_id();
575 let response = sample_response(rule.into(), organization_id.into());
576 let view = AccessRuleView::try_from(response.clone()).unwrap();
577
578 let api_client = ApiClient::new_mocked(move |mock| {
579 mock.access_rules_api
580 .expect_put()
581 .withf(move |_org_id, id, _request| *id == uuid::Uuid::from(rule_id()))
582 .returning(move |_org_id, _id, _request| Ok(response.clone()))
583 .once();
584 });
585
586 client(api_client)
587 .set_enabled(organization_id, view, true)
588 .await
589 .unwrap();
590 }
591}