Skip to main content

bitwarden_pam/approvals/
client.rs

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