Skip to main content

bitwarden_pam/access_requests/
client.rs

1use std::sync::Arc;
2
3use bitwarden_core::{FromClient, client::ApiConfigurations};
4#[cfg(feature = "wasm")]
5use wasm_bindgen::prelude::wasm_bindgen;
6
7use super::models::AccessRequestView;
8use crate::{AccessRequestId, error::LeasingError, leases::AccessLeaseView};
9
10/// Client for a requester's PAM access requests.
11///
12/// Covers the requester side of the request lifecycle: listing and reading the caller's own
13/// requests, [`activate`](AccessRequestsClient::activate)ing an approved request to mint a lease,
14/// and [`cancel`](AccessRequestsClient::cancel)ling a request that is still pending.
15///
16/// Creating a request (`POST /leases/ciphers/{id}`) is not yet exposed here - its binding lives on
17/// the not-yet-generated `cipher_lease_api` surface and lands with a later change.
18#[cfg_attr(feature = "wasm", wasm_bindgen)]
19#[derive(FromClient)]
20pub struct AccessRequestsClient {
21    pub(crate) api_configurations: Arc<ApiConfigurations>,
22}
23
24#[cfg_attr(feature = "wasm", wasm_bindgen)]
25impl AccessRequestsClient {
26    /// Lists the caller's own access requests.
27    pub async fn list_mine(&self) -> Result<Vec<AccessRequestView>, LeasingError> {
28        let response = self
29            .api_configurations
30            .api_client
31            .access_requests_api()
32            .get_mine()
33            .await?;
34
35        response
36            .data
37            .unwrap_or_default()
38            .into_iter()
39            .map(AccessRequestView::try_from)
40            .collect()
41    }
42
43    /// Retrieves a single access request by ID.
44    pub async fn get(&self, id: AccessRequestId) -> Result<AccessRequestView, LeasingError> {
45        let response = self
46            .api_configurations
47            .api_client
48            .access_requests_api()
49            .get_details(id.into())
50            .await?;
51
52        AccessRequestView::try_from(response)
53    }
54
55    /// Activates an approved request, minting and returning the resulting lease.
56    ///
57    /// This is the second half of the automatic flow: once a request reaches
58    /// [`Approved`](super::AccessRequestStatus::Approved), the requester activates it to obtain a
59    /// short-lived [`AccessLease`](AccessLeaseView) over the cipher.
60    pub async fn activate(&self, id: AccessRequestId) -> Result<AccessLeaseView, LeasingError> {
61        let response = self
62            .api_configurations
63            .api_client
64            .access_requests_api()
65            .activate(id.into())
66            .await?;
67
68        AccessLeaseView::try_from(response)
69    }
70
71    /// Cancels the caller's own request while it is still pending.
72    pub async fn cancel(&self, id: AccessRequestId) -> Result<(), LeasingError> {
73        self.api_configurations
74            .api_client
75            .access_requests_api()
76            .revoke(id.into())
77            .await?;
78
79        Ok(())
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use bitwarden_api_api::{
86        apis::ApiClient,
87        models::{
88            AccessLeaseResponseModel, AccessLeaseStatus as ApiAccessLeaseStatus,
89            AccessRequestDetailsResponseModel, AccessRequestDetailsResponseModelListResponseModel,
90            AccessRequestStatus as ApiAccessRequestStatus,
91        },
92    };
93    use uuid::uuid;
94
95    use super::*;
96    use crate::{AccessLeaseStatus, AccessRequestStatus};
97
98    fn request_id() -> AccessRequestId {
99        AccessRequestId::new(uuid!("44444444-4444-4444-4444-444444444444"))
100    }
101
102    fn client(api_client: ApiClient) -> AccessRequestsClient {
103        AccessRequestsClient {
104            api_configurations: Arc::new(ApiConfigurations::from_api_client(api_client)),
105        }
106    }
107
108    fn sample_request() -> AccessRequestDetailsResponseModel {
109        AccessRequestDetailsResponseModel {
110            id: Some(request_id().into()),
111            cipher_id: Some(uuid!("55555555-5555-5555-5555-555555555555")),
112            collection_id: Some(uuid!("66666666-6666-6666-6666-666666666666")),
113            requester_id: Some(uuid!("88888888-8888-8888-8888-888888888888")),
114            status: Some(ApiAccessRequestStatus::Approved),
115            lease_not_before: Some("2025-01-01T00:00:00Z".to_string()),
116            lease_not_after: Some("2025-01-01T01:00:00Z".to_string()),
117            submitted_at: Some("2025-01-01T00:00:00Z".to_string()),
118            ..Default::default()
119        }
120    }
121
122    fn sample_lease() -> AccessLeaseResponseModel {
123        AccessLeaseResponseModel {
124            id: Some(uuid!("33333333-3333-3333-3333-333333333333")),
125            request_id: Some(request_id().into()),
126            cipher_id: Some(uuid!("55555555-5555-5555-5555-555555555555")),
127            collection_id: Some(uuid!("66666666-6666-6666-6666-666666666666")),
128            requester_id: Some(uuid!("88888888-8888-8888-8888-888888888888")),
129            status: Some(ApiAccessLeaseStatus::Active),
130            not_before: Some("2025-01-01T00:00:00Z".to_string()),
131            not_after: Some("2025-01-01T01:00:00Z".to_string()),
132            ..Default::default()
133        }
134    }
135
136    #[tokio::test]
137    async fn list_mine_returns_views() {
138        let api_client = ApiClient::new_mocked(move |mock| {
139            mock.access_requests_api
140                .expect_get_mine()
141                .returning(move || {
142                    let mut list = AccessRequestDetailsResponseModelListResponseModel::new();
143                    list.data = Some(vec![sample_request()]);
144                    Ok(list)
145                })
146                .once();
147        });
148
149        let result = client(api_client).list_mine().await.unwrap();
150
151        assert_eq!(result.len(), 1);
152        assert_eq!(result[0].id, request_id());
153        assert_eq!(result[0].status, AccessRequestStatus::Approved);
154    }
155
156    #[tokio::test]
157    async fn get_returns_view() {
158        let api_client = ApiClient::new_mocked(move |mock| {
159            mock.access_requests_api
160                .expect_get_details()
161                .returning(move |_id| Ok(sample_request()))
162                .once();
163        });
164
165        let result = client(api_client).get(request_id()).await.unwrap();
166
167        assert_eq!(result.id, request_id());
168    }
169
170    #[tokio::test]
171    async fn activate_mints_a_lease() {
172        let api_client = ApiClient::new_mocked(move |mock| {
173            mock.access_requests_api
174                .expect_activate()
175                .returning(move |_id| Ok(sample_lease()))
176                .once();
177        });
178
179        let lease = client(api_client).activate(request_id()).await.unwrap();
180
181        assert_eq!(lease.request_id, request_id());
182        assert_eq!(lease.status, AccessLeaseStatus::Active);
183    }
184
185    #[tokio::test]
186    async fn activate_surfaces_api_error() {
187        let api_client = ApiClient::new_mocked(move |mock| {
188            mock.access_requests_api
189                .expect_activate()
190                .returning(move |_id| {
191                    Err(bitwarden_api_api::apis::Error::Response(
192                        bitwarden_api_api::apis::ResponseContent {
193                            status: reqwest::StatusCode::CONFLICT,
194                            message: "Not approved".to_string(),
195                        },
196                    ))
197                })
198                .once();
199        });
200
201        let result = client(api_client).activate(request_id()).await;
202
203        assert!(matches!(result, Err(LeasingError::Api(_))));
204    }
205
206    #[tokio::test]
207    async fn cancel_succeeds() {
208        let api_client = ApiClient::new_mocked(move |mock| {
209            mock.access_requests_api
210                .expect_revoke()
211                .returning(move |_id| Ok(()))
212                .once();
213        });
214
215        let result = client(api_client).cancel(request_id()).await;
216
217        assert!(result.is_ok());
218    }
219}