Skip to main content

bitwarden_pam/access_requests/
client.rs

1use std::sync::Arc;
2
3use bitwarden_core::{FromClient, client::ApiConfigurations};
4use bitwarden_vault::CipherId;
5
6use super::{
7    error::AccessRequestError,
8    models::{
9        AccessPreCheckView, AccessRequestCreateRequest, AccessRequestResultView, AccessRequestView,
10        CipherAccessStateView,
11    },
12};
13use crate::{AccessRequestId, leases::AccessLeaseView};
14
15/// Client for a requester's PAM access requests.
16#[bitwarden_ffi::wasm_object]
17#[derive(FromClient)]
18pub struct AccessRequestsClient {
19    pub(crate) api_configurations: Arc<ApiConfigurations>,
20}
21
22#[bitwarden_ffi::wasm_export]
23impl AccessRequestsClient {
24    /// Resolves the approval outcome for a cipher without submitting a request, so the client can
25    /// show the right form before the requester commits.
26    pub async fn pre_check(
27        &self,
28        cipher_id: CipherId,
29    ) -> Result<AccessPreCheckView, AccessRequestError> {
30        let response = self
31            .api_configurations
32            .api_client
33            .cipher_lease_api()
34            .pre_check(cipher_id.into())
35            .await?;
36
37        Ok(AccessPreCheckView::try_from(response)?)
38    }
39
40    /// Reads the caller's current access state for a cipher, for the cipher-view banner and the
41    /// vault-row badge.
42    pub async fn cipher_access_state(
43        &self,
44        cipher_id: CipherId,
45    ) -> Result<CipherAccessStateView, AccessRequestError> {
46        let response = self
47            .api_configurations
48            .api_client
49            .cipher_lease_api()
50            .state(cipher_id.into())
51            .await?;
52
53        Ok(CipherAccessStateView::try_from(response)?)
54    }
55
56    /// Validates and opens an access request for a cipher. Run [`pre_check`](Self::pre_check)
57    /// first to know which fields [`AccessRequestCreateRequest`] expects.
58    pub async fn request(
59        &self,
60        cipher_id: CipherId,
61        request: AccessRequestCreateRequest,
62    ) -> Result<AccessRequestResultView, AccessRequestError> {
63        let response = self
64            .api_configurations
65            .api_client
66            .cipher_lease_api()
67            .post(cipher_id.into(), request.try_into()?)
68            .await?;
69
70        Ok(AccessRequestResultView::try_from(response)?)
71    }
72
73    /// Lists the caller's own access requests.
74    pub async fn list_mine(&self) -> Result<Vec<AccessRequestView>, AccessRequestError> {
75        let response = self
76            .api_configurations
77            .api_client
78            .access_requests_api()
79            .get_mine()
80            .await?;
81
82        response
83            .data
84            .unwrap_or_default()
85            .into_iter()
86            .map(AccessRequestView::try_from)
87            .collect::<Result<Vec<_>, _>>()
88            .map_err(Into::into)
89    }
90
91    /// Retrieves a single access request by ID.
92    pub async fn get(&self, id: AccessRequestId) -> Result<AccessRequestView, AccessRequestError> {
93        let response = self
94            .api_configurations
95            .api_client
96            .access_requests_api()
97            .get_details(id.into())
98            .await?;
99
100        Ok(AccessRequestView::try_from(response)?)
101    }
102
103    /// Activates an approved request, minting and returning the resulting lease.
104    pub async fn activate(
105        &self,
106        id: AccessRequestId,
107    ) -> Result<AccessLeaseView, AccessRequestError> {
108        let response = self
109            .api_configurations
110            .api_client
111            .access_requests_api()
112            .activate(id.into())
113            .await?;
114
115        Ok(AccessLeaseView::try_from(response)?)
116    }
117
118    /// Cancels the caller's own request while it is pending, or approved but not yet activated.
119    pub async fn cancel(&self, id: AccessRequestId) -> Result<(), AccessRequestError> {
120        self.api_configurations
121            .api_client
122            .access_requests_api()
123            .revoke(id.into(), None)
124            .await?;
125
126        Ok(())
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use std::num::NonZeroU32;
133
134    use bitwarden_api_api::{
135        apis::ApiClient,
136        models::{
137            AccessApprovalMode as ApiAccessApprovalMode, AccessLeaseResponseModel,
138            AccessLeaseStatus as ApiAccessLeaseStatus, AccessPreCheckResponseModel,
139            AccessRequestDetailsResponseModel, AccessRequestDetailsResponseModelListResponseModel,
140            AccessRequestResultResponseModel, AccessRequestStatus as ApiAccessRequestStatus,
141            CipherAccessStateResponseModel,
142        },
143    };
144    use uuid::uuid;
145
146    use super::*;
147    use crate::{
148        AccessApprovalMode, AccessLeaseStatus, AccessRequestStatus,
149        access_requests::MAX_REQUEST_ACCESS_WINDOW_SECONDS,
150    };
151
152    fn request_id() -> AccessRequestId {
153        AccessRequestId::new(uuid!("44444444-4444-4444-4444-444444444444"))
154    }
155
156    fn cipher_id() -> CipherId {
157        CipherId::new(uuid!("55555555-5555-5555-5555-555555555555"))
158    }
159
160    fn client(api_client: ApiClient) -> AccessRequestsClient {
161        AccessRequestsClient {
162            api_configurations: Arc::new(ApiConfigurations::from_api_client(api_client)),
163        }
164    }
165
166    fn sample_request() -> AccessRequestDetailsResponseModel {
167        AccessRequestDetailsResponseModel {
168            id: Some(request_id().into()),
169            cipher_id: Some(uuid!("55555555-5555-5555-5555-555555555555")),
170            collection_id: Some(uuid!("66666666-6666-6666-6666-666666666666")),
171            requester_id: Some(uuid!("88888888-8888-8888-8888-888888888888")),
172            status: Some(ApiAccessRequestStatus::Approved),
173            lease_not_before: Some("2025-01-01T00:00:00Z".to_string()),
174            lease_not_after: Some("2025-01-01T01:00:00Z".to_string()),
175            submitted_at: Some("2025-01-01T00:00:00Z".to_string()),
176            ..Default::default()
177        }
178    }
179
180    fn sample_lease() -> AccessLeaseResponseModel {
181        AccessLeaseResponseModel {
182            id: Some(uuid!("33333333-3333-3333-3333-333333333333")),
183            request_id: Some(request_id().into()),
184            cipher_id: Some(uuid!("55555555-5555-5555-5555-555555555555")),
185            collection_id: Some(uuid!("66666666-6666-6666-6666-666666666666")),
186            requester_id: Some(uuid!("88888888-8888-8888-8888-888888888888")),
187            status: Some(ApiAccessLeaseStatus::Active),
188            not_before: Some("2025-01-01T00:00:00Z".to_string()),
189            not_after: Some("2025-01-01T01:00:00Z".to_string()),
190            ..Default::default()
191        }
192    }
193
194    #[tokio::test]
195    async fn list_mine_returns_views() {
196        let api_client = ApiClient::new_mocked(move |mock| {
197            mock.access_requests_api
198                .expect_get_mine()
199                .returning(move || {
200                    let mut list = AccessRequestDetailsResponseModelListResponseModel::new();
201                    list.data = Some(vec![sample_request()]);
202                    Ok(list)
203                })
204                .once();
205        });
206
207        let result = client(api_client).list_mine().await.unwrap();
208
209        assert_eq!(result.len(), 1);
210        assert_eq!(result[0].id, request_id());
211        assert_eq!(result[0].status, AccessRequestStatus::Approved);
212    }
213
214    #[tokio::test]
215    async fn get_returns_view() {
216        let api_client = ApiClient::new_mocked(move |mock| {
217            mock.access_requests_api
218                .expect_get_details()
219                .returning(move |_id| Ok(sample_request()))
220                .once();
221        });
222
223        let result = client(api_client).get(request_id()).await.unwrap();
224
225        assert_eq!(result.id, request_id());
226    }
227
228    #[tokio::test]
229    async fn activate_mints_a_lease() {
230        let api_client = ApiClient::new_mocked(move |mock| {
231            mock.access_requests_api
232                .expect_activate()
233                .returning(move |_id| Ok(sample_lease()))
234                .once();
235        });
236
237        let lease = client(api_client).activate(request_id()).await.unwrap();
238
239        assert_eq!(lease.request_id, request_id());
240        assert_eq!(lease.status, AccessLeaseStatus::Active);
241    }
242
243    #[tokio::test]
244    async fn activate_surfaces_api_error() {
245        let api_client = ApiClient::new_mocked(move |mock| {
246            mock.access_requests_api
247                .expect_activate()
248                .returning(move |_id| {
249                    Err(bitwarden_api_api::apis::Error::Response(
250                        bitwarden_api_api::apis::ResponseContent {
251                            status: reqwest::StatusCode::CONFLICT,
252                            message: "Not approved".to_string(),
253                        },
254                    ))
255                })
256                .once();
257        });
258
259        let result = client(api_client).activate(request_id()).await;
260
261        assert!(matches!(result, Err(AccessRequestError::Api(_))));
262    }
263
264    #[tokio::test]
265    async fn cancel_succeeds() {
266        let api_client = ApiClient::new_mocked(move |mock| {
267            mock.access_requests_api
268                .expect_revoke()
269                .returning(move |_id, _body| Ok(()))
270                .once();
271        });
272
273        let result = client(api_client).cancel(request_id()).await;
274
275        assert!(result.is_ok());
276    }
277
278    #[tokio::test]
279    async fn pre_check_returns_view() {
280        let api_client = ApiClient::new_mocked(move |mock| {
281            mock.cipher_lease_api
282                .expect_pre_check()
283                .returning(move |_id| {
284                    Ok(AccessPreCheckResponseModel {
285                        cipher_id: Some(cipher_id().into()),
286                        approval_mode: Some(ApiAccessApprovalMode::Automatic),
287                        has_active_lease: Some(false),
288                        ..Default::default()
289                    })
290                })
291                .once();
292        });
293
294        let result = client(api_client).pre_check(cipher_id()).await.unwrap();
295
296        assert_eq!(result.approval_mode, AccessApprovalMode::Automatic);
297        assert!(!result.has_active_lease);
298    }
299
300    #[tokio::test]
301    async fn pre_check_surfaces_api_error() {
302        let api_client = ApiClient::new_mocked(move |mock| {
303            mock.cipher_lease_api
304                .expect_pre_check()
305                .returning(move |_id| {
306                    Err(bitwarden_api_api::apis::Error::Response(
307                        bitwarden_api_api::apis::ResponseContent {
308                            status: reqwest::StatusCode::NOT_FOUND,
309                            message: "Not found".to_string(),
310                        },
311                    ))
312                })
313                .once();
314        });
315
316        let result = client(api_client).pre_check(cipher_id()).await;
317
318        assert!(matches!(result, Err(AccessRequestError::Api(_))));
319    }
320
321    #[tokio::test]
322    async fn cipher_access_state_returns_view() {
323        let api_client = ApiClient::new_mocked(move |mock| {
324            mock.cipher_lease_api
325                .expect_state()
326                .returning(move |_id| {
327                    Ok(CipherAccessStateResponseModel {
328                        cipher_id: Some(cipher_id().into()),
329                        active_lease: None,
330                        pending_request: None,
331                        approved_request: None,
332                        extensions_allowed: Some(true),
333                        max_extension_duration_seconds: Some(1800),
334                        ..Default::default()
335                    })
336                })
337                .once();
338        });
339
340        let result = client(api_client)
341            .cipher_access_state(cipher_id())
342            .await
343            .unwrap();
344
345        assert_eq!(result.active_lease, None);
346        assert!(result.extensions_allowed);
347        assert_eq!(result.max_extension_duration_seconds, Some(1800));
348    }
349
350    #[tokio::test]
351    async fn request_submits_and_returns_result() {
352        let api_client = ApiClient::new_mocked(move |mock| {
353            mock.cipher_lease_api
354                .expect_post()
355                .returning(move |_id, _request| {
356                    Ok(AccessRequestResultResponseModel {
357                        approval_mode: Some(ApiAccessApprovalMode::Automatic),
358                        request: Some(Box::new(AccessRequestDetailsResponseModel {
359                            id: Some(request_id().into()),
360                            cipher_id: Some(cipher_id().into()),
361                            collection_id: Some(uuid!("66666666-6666-6666-6666-666666666666")),
362                            status: Some(ApiAccessRequestStatus::Approved),
363                            lease_not_before: Some("2025-01-01T00:00:00Z".to_string()),
364                            lease_not_after: Some("2025-01-01T01:00:00Z".to_string()),
365                            submitted_at: Some("2025-01-01T00:00:00Z".to_string()),
366                            ..Default::default()
367                        })),
368                        ..Default::default()
369                    })
370                })
371                .once();
372        });
373
374        let request = AccessRequestCreateRequest {
375            duration_seconds: NonZeroU32::new(3600),
376            ..Default::default()
377        };
378
379        let result = client(api_client)
380            .request(cipher_id(), request)
381            .await
382            .unwrap();
383
384        assert_eq!(result.approval_mode, AccessApprovalMode::Automatic);
385        assert_eq!(result.request.id, request_id());
386    }
387
388    #[tokio::test]
389    async fn request_rejects_an_over_long_window_without_calling_the_api() {
390        let api_client = ApiClient::new_mocked(|mock| {
391            mock.cipher_lease_api.expect_post().never();
392        });
393
394        let request = AccessRequestCreateRequest {
395            duration_seconds: NonZeroU32::new(MAX_REQUEST_ACCESS_WINDOW_SECONDS + 1),
396            ..Default::default()
397        };
398
399        let result = client(api_client).request(cipher_id(), request).await;
400
401        assert!(matches!(result, Err(AccessRequestError::Validation(_))));
402    }
403
404    #[tokio::test]
405    async fn request_surfaces_api_error() {
406        let api_client = ApiClient::new_mocked(move |mock| {
407            mock.cipher_lease_api
408                .expect_post()
409                .returning(move |_id, _request| {
410                    Err(bitwarden_api_api::apis::Error::Response(
411                        bitwarden_api_api::apis::ResponseContent {
412                            status: reqwest::StatusCode::BAD_REQUEST,
413                            message: "Invalid window".to_string(),
414                        },
415                    ))
416                })
417                .once();
418        });
419
420        let result = client(api_client)
421            .request(cipher_id(), AccessRequestCreateRequest::default())
422            .await;
423
424        assert!(matches!(result, Err(AccessRequestError::Api(_))));
425    }
426}