bitwarden_pam/access_requests/
client.rs1use 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#[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 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 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 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 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 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 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 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}