Skip to main content

bitwarden_pam/approvals/
client.rs

1use std::sync::Arc;
2
3use bitwarden_api_api::models::{AccessDecisionRequestModel, AccessRequestRevokeRequestModel};
4use bitwarden_core::{FromClient, client::ApiConfigurations};
5
6use super::{
7    error::ApprovalError,
8    models::{AccessDecisionRequest, AccessRequestRevokeRequest},
9};
10use crate::{AccessRequestId, access_requests::AccessRequestView};
11
12/// Client for a PAM approver's queue. The requester side is
13/// [`AccessRequestsClient`](crate::AccessRequestsClient).
14#[bitwarden_ffi::wasm_object]
15#[derive(FromClient)]
16pub struct ApprovalsClient {
17    pub(crate) api_configurations: Arc<ApiConfigurations>,
18}
19
20#[bitwarden_ffi::wasm_export]
21impl ApprovalsClient {
22    /// Lists the pending access requests awaiting the caller's decision, in collections the caller
23    /// can manage.
24    pub async fn list_inbox(&self) -> Result<Vec<AccessRequestView>, ApprovalError> {
25        let response = self
26            .api_configurations
27            .api_client
28            .access_requests_api()
29            .get_inbox()
30            .await?;
31
32        response
33            .data
34            .unwrap_or_default()
35            .into_iter()
36            .map(AccessRequestView::try_from)
37            .collect::<Result<Vec<_>, _>>()
38            .map_err(Into::into)
39    }
40
41    /// Lists the decided access requests for collections the caller manages.
42    pub async fn list_history(&self) -> Result<Vec<AccessRequestView>, ApprovalError> {
43        let response = self
44            .api_configurations
45            .api_client
46            .access_requests_api()
47            .get_history()
48            .await?;
49
50        response
51            .data
52            .unwrap_or_default()
53            .into_iter()
54            .map(AccessRequestView::try_from)
55            .collect::<Result<Vec<_>, _>>()
56            .map_err(Into::into)
57    }
58
59    /// Records a decision on a pending access request.
60    ///
61    /// The response guarantees only `status`, `resolved_at` and the new decision, so merge it onto
62    /// the row already held instead of replacing it.
63    pub async fn decide(
64        &self,
65        id: AccessRequestId,
66        request: AccessDecisionRequest,
67    ) -> Result<AccessRequestView, ApprovalError> {
68        let model = AccessDecisionRequestModel::try_from(request)?;
69
70        let response = self
71            .api_configurations
72            .api_client
73            .access_requests_api()
74            .decide(id.into(), model)
75            .await?;
76
77        Ok(AccessRequestView::try_from(response)?)
78    }
79
80    /// Revokes an approved access request the requester has not started. The server records it as
81    /// the approver's deny decision, with the reason shown to the requester.
82    pub async fn revoke(
83        &self,
84        id: AccessRequestId,
85        request: AccessRequestRevokeRequest,
86    ) -> Result<(), ApprovalError> {
87        self.api_configurations
88            .api_client
89            .access_requests_api()
90            .revoke(
91                id.into(),
92                Some(AccessRequestRevokeRequestModel::from(request)),
93            )
94            .await?;
95
96        Ok(())
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use bitwarden_api_api::{
103        apis::ApiClient,
104        models::{
105            AccessDecisionVerdict as ApiAccessDecisionVerdict, AccessRequestDetailsResponseModel,
106            AccessRequestDetailsResponseModelListResponseModel,
107            AccessRequestStatus as ApiAccessRequestStatus,
108        },
109    };
110    use uuid::uuid;
111
112    use super::*;
113    use crate::{AccessDecisionVerdict, AccessRequestStatus};
114
115    fn request_id() -> AccessRequestId {
116        AccessRequestId::new(uuid!("44444444-4444-4444-4444-444444444444"))
117    }
118
119    fn client(api_client: ApiClient) -> ApprovalsClient {
120        ApprovalsClient {
121            api_configurations: Arc::new(ApiConfigurations::from_api_client(api_client)),
122        }
123    }
124
125    fn sample_request() -> AccessRequestDetailsResponseModel {
126        AccessRequestDetailsResponseModel {
127            id: Some(request_id().into()),
128            cipher_id: Some(uuid!("55555555-5555-5555-5555-555555555555")),
129            collection_id: Some(uuid!("66666666-6666-6666-6666-666666666666")),
130            requester_id: Some(uuid!("88888888-8888-8888-8888-888888888888")),
131            status: Some(ApiAccessRequestStatus::Pending),
132            lease_not_before: Some("2025-01-01T00:00:00Z".to_string()),
133            lease_not_after: Some("2025-01-01T01:00:00Z".to_string()),
134            submitted_at: Some("2025-01-01T00:00:00Z".to_string()),
135            ..Default::default()
136        }
137    }
138
139    #[tokio::test]
140    async fn list_inbox_returns_views() {
141        let api_client = ApiClient::new_mocked(move |mock| {
142            mock.access_requests_api
143                .expect_get_inbox()
144                .returning(move || {
145                    let mut list = AccessRequestDetailsResponseModelListResponseModel::new();
146                    list.data = Some(vec![sample_request()]);
147                    Ok(list)
148                })
149                .once();
150        });
151
152        let result = client(api_client).list_inbox().await.unwrap();
153
154        assert_eq!(result.len(), 1);
155        assert_eq!(result[0].id, request_id());
156        assert_eq!(result[0].status, AccessRequestStatus::Pending);
157    }
158
159    #[tokio::test]
160    async fn list_inbox_returns_empty_vec_when_server_sends_no_data() {
161        let api_client = ApiClient::new_mocked(move |mock| {
162            mock.access_requests_api
163                .expect_get_inbox()
164                .returning(move || Ok(AccessRequestDetailsResponseModelListResponseModel::new()))
165                .once();
166        });
167
168        let result = client(api_client).list_inbox().await.unwrap();
169
170        assert_eq!(result, Vec::new());
171    }
172
173    #[tokio::test]
174    async fn list_history_returns_views() {
175        let api_client = ApiClient::new_mocked(move |mock| {
176            mock.access_requests_api
177                .expect_get_history()
178                .returning(move || {
179                    let mut list = AccessRequestDetailsResponseModelListResponseModel::new();
180                    list.data = Some(vec![sample_request()]);
181                    Ok(list)
182                })
183                .once();
184        });
185
186        let result = client(api_client).list_history().await.unwrap();
187
188        assert_eq!(result.len(), 1);
189        assert_eq!(result[0].id, request_id());
190    }
191
192    #[tokio::test]
193    async fn decide_returns_updated_view_and_sends_approve_verdict() {
194        let api_client = ApiClient::new_mocked(move |mock| {
195            mock.access_requests_api
196                .expect_decide()
197                .returning(move |_id, model| {
198                    assert_eq!(model.verdict, ApiAccessDecisionVerdict::Approve);
199                    assert_eq!(model.comment.as_deref(), Some("Looks fine"));
200
201                    Ok(AccessRequestDetailsResponseModel {
202                        status: Some(ApiAccessRequestStatus::Approved),
203                        resolved_at: Some("2025-01-01T00:30:00Z".to_string()),
204                        ..sample_request()
205                    })
206                })
207                .once();
208        });
209
210        let request = AccessDecisionRequest {
211            verdict: AccessDecisionVerdict::Approve,
212            comment: Some("Looks fine".to_string()),
213        };
214
215        let result = client(api_client)
216            .decide(request_id(), request)
217            .await
218            .unwrap();
219
220        assert_eq!(result.id, request_id());
221        assert_eq!(result.status, AccessRequestStatus::Approved);
222    }
223
224    #[tokio::test]
225    async fn decide_sends_deny_verdict() {
226        let api_client = ApiClient::new_mocked(move |mock| {
227            mock.access_requests_api
228                .expect_decide()
229                .returning(move |_id, model| {
230                    assert_eq!(model.verdict, ApiAccessDecisionVerdict::Deny);
231                    assert_eq!(model.comment.as_deref(), Some("Not authorized"));
232
233                    Ok(AccessRequestDetailsResponseModel {
234                        status: Some(ApiAccessRequestStatus::Denied),
235                        resolved_at: Some("2025-01-01T00:30:00Z".to_string()),
236                        ..sample_request()
237                    })
238                })
239                .once();
240        });
241
242        let request = AccessDecisionRequest {
243            verdict: AccessDecisionVerdict::Deny,
244            comment: Some("Not authorized".to_string()),
245        };
246
247        let result = client(api_client)
248            .decide(request_id(), request)
249            .await
250            .unwrap();
251
252        assert_eq!(result.status, AccessRequestStatus::Denied);
253    }
254
255    #[tokio::test]
256    async fn decide_rejects_unknown_verdict_without_calling_the_api() {
257        let api_client = ApiClient::new_mocked(|mock| {
258            mock.access_requests_api.expect_decide().never();
259        });
260
261        let request = AccessDecisionRequest {
262            verdict: AccessDecisionVerdict::Unknown,
263            comment: None,
264        };
265
266        let result = client(api_client).decide(request_id(), request).await;
267
268        assert!(matches!(result, Err(ApprovalError::UnsubmittableVerdict)));
269    }
270
271    #[tokio::test]
272    async fn decide_surfaces_api_error() {
273        let api_client = ApiClient::new_mocked(move |mock| {
274            mock.access_requests_api
275                .expect_decide()
276                .returning(move |_id, _model| {
277                    Err(bitwarden_api_api::apis::Error::Response(
278                        bitwarden_api_api::apis::ResponseContent {
279                            status: reqwest::StatusCode::CONFLICT,
280                            message: "Already decided".to_string(),
281                        },
282                    ))
283                })
284                .once();
285        });
286
287        let request = AccessDecisionRequest {
288            verdict: AccessDecisionVerdict::Approve,
289            comment: None,
290        };
291
292        let result = client(api_client).decide(request_id(), request).await;
293
294        assert!(matches!(result, Err(ApprovalError::Api(_))));
295    }
296
297    #[tokio::test]
298    async fn revoke_sends_the_reason() {
299        let api_client = ApiClient::new_mocked(move |mock| {
300            mock.access_requests_api
301                .expect_revoke()
302                .returning(move |id, body| {
303                    assert_eq!(id, uuid::Uuid::from(request_id()));
304                    assert_eq!(
305                        body.and_then(|b| b.reason).as_deref(),
306                        Some("Approved by mistake")
307                    );
308                    Ok(())
309                })
310                .once();
311        });
312
313        let request = AccessRequestRevokeRequest {
314            reason: "Approved by mistake".to_string(),
315        };
316
317        let result = client(api_client).revoke(request_id(), request).await;
318
319        assert!(result.is_ok());
320    }
321
322    #[tokio::test]
323    async fn revoke_surfaces_api_error() {
324        let api_client = ApiClient::new_mocked(move |mock| {
325            mock.access_requests_api
326                .expect_revoke()
327                .returning(move |_id, _body| {
328                    Err(bitwarden_api_api::apis::Error::Response(
329                        bitwarden_api_api::apis::ResponseContent {
330                            status: reqwest::StatusCode::CONFLICT,
331                            message: "This request has already been resolved.".to_string(),
332                        },
333                    ))
334                })
335                .once();
336        });
337
338        let request = AccessRequestRevokeRequest {
339            reason: "Approved by mistake".to_string(),
340        };
341
342        let result = client(api_client).revoke(request_id(), request).await;
343
344        assert!(matches!(result, Err(ApprovalError::Api(_))));
345    }
346}