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