Skip to main content

bitwarden_pam/access_rules/
client.rs

1use 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/// Client for PAM access rule CRUD operations.
14#[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    /// Lists all access rules for an organization.
23    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    /// Retrieves a single access rule by ID. Fails with
43    /// [`NotFound`](AccessRuleError::NotFound) for a rule not visible to the caller.
44    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    /// Validates and creates a new access rule.
61    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    /// Validates and updates an existing access rule. Fails with
79    /// [`NotFound`](AccessRuleError::NotFound) for a rule deleted before the write landed.
80    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    /// The ungated collections that expose this rule's ciphers without a lease.
100    ///
101    /// Returns collections, not ciphers, since the caller may be unable to decrypt them. A missing
102    /// rule answers empty, so this never fails with [`NotFound`](AccessRuleError::NotFound).
103    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    /// Enables or disables a rule; no other field changes.
124    ///
125    /// Takes the rule as the caller already holds it, saving a read before the write.
126    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    /// Deletes an access rule. Fails with [`NotFound`](AccessRuleError::NotFound) for an
142    /// already-gone rule.
143    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    /// This endpoint never 404s a missing rule, so a 404 here is infrastructural.
316    #[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    /// Org-scoped calls don't map `404` onto a missing rule the way by-id calls do.
414    #[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    /// Flipping `enabled` must not disturb any other field.
530    #[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}