bitwarden_pam/leases/
client.rs1use std::sync::Arc;
2
3use bitwarden_core::{FromClient, client::ApiConfigurations, key_management::KeySlotIds};
4use bitwarden_crypto::KeyStore;
5use bitwarden_vault::{Cipher, CipherId, CipherView};
6
7use super::{
8 error::AccessLeaseError,
9 models::{AccessLeaseExtensionRequest, AccessLeaseRevokeRequest, AccessLeaseView},
10};
11use crate::{AccessLeaseId, access_requests::AccessRequestView};
12
13#[bitwarden_ffi::wasm_object]
16#[derive(FromClient)]
17pub struct LeasesClient {
18 pub(crate) key_store: KeyStore<KeySlotIds>,
19 pub(crate) api_configurations: Arc<ApiConfigurations>,
20}
21
22#[bitwarden_ffi::wasm_export]
23impl LeasesClient {
24 pub async fn list_active(&self) -> Result<Vec<AccessLeaseView>, AccessLeaseError> {
26 let response = self
27 .api_configurations
28 .api_client
29 .leases_api()
30 .get_active()
31 .await?;
32
33 response
34 .data
35 .unwrap_or_default()
36 .into_iter()
37 .map(AccessLeaseView::try_from)
38 .collect::<Result<Vec<_>, _>>()
39 .map_err(Into::into)
40 }
41
42 pub async fn list_mine(&self) -> Result<Vec<AccessLeaseView>, AccessLeaseError> {
44 let response = self
45 .api_configurations
46 .api_client
47 .leases_api()
48 .get_mine()
49 .await?;
50
51 response
52 .data
53 .unwrap_or_default()
54 .into_iter()
55 .map(AccessLeaseView::try_from)
56 .collect::<Result<Vec<_>, _>>()
57 .map_err(Into::into)
58 }
59
60 pub async fn extend(
62 &self,
63 lease_id: AccessLeaseId,
64 request: AccessLeaseExtensionRequest,
65 ) -> Result<AccessRequestView, AccessLeaseError> {
66 let response = self
67 .api_configurations
68 .api_client
69 .leases_api()
70 .extend(lease_id.into(), request.into())
71 .await?;
72
73 Ok(AccessRequestView::try_from(response)?)
74 }
75
76 pub async fn end(
78 &self,
79 lease_id: AccessLeaseId,
80 request: AccessLeaseRevokeRequest,
81 ) -> Result<(), AccessLeaseError> {
82 self.api_configurations
83 .api_client
84 .leases_api()
85 .revoke(lease_id.into(), request.into())
86 .await?;
87
88 Ok(())
89 }
90
91 pub async fn leased_cipher(
95 &self,
96 cipher_id: CipherId,
97 ) -> Result<Option<CipherView>, AccessLeaseError> {
98 let response = self
99 .api_configurations
100 .api_client
101 .ciphers_api()
102 .get_details(cipher_id.into())
103 .await?;
104
105 let cipher = Cipher::try_from(response)?;
106
107 if cipher.partial_data.is_some() {
108 return Ok(None);
109 }
110
111 Ok(Some(self.key_store.decrypt(&cipher)?))
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use std::num::NonZeroU32;
118
119 use bitwarden_api_api::{
120 apis::ApiClient,
121 models::{
122 AccessLeaseResponseModel, AccessLeaseResponseModelListResponseModel,
123 AccessLeaseStatus as ApiAccessLeaseStatus, AccessRequestDetailsResponseModel,
124 CipherDetailsResponseModel,
125 },
126 };
127 use bitwarden_core::key_management::create_test_crypto_with_user_key;
128 use bitwarden_crypto::{SymmetricCryptoKey, SymmetricKeyAlgorithm};
129 use chrono::{DateTime, Utc};
130 use uuid::uuid;
131
132 use super::*;
133 use crate::leases::models::AccessLeaseStatus;
134
135 fn lease_id() -> AccessLeaseId {
136 AccessLeaseId::new(uuid!("33333333-3333-3333-3333-333333333333"))
137 }
138
139 fn client(api_client: ApiClient) -> LeasesClient {
140 LeasesClient {
141 key_store: create_test_crypto_with_user_key(SymmetricCryptoKey::make(
142 SymmetricKeyAlgorithm::Aes256CbcHmac,
143 )),
144 api_configurations: Arc::new(ApiConfigurations::from_api_client(api_client)),
145 }
146 }
147
148 fn cipher_id() -> CipherId {
149 CipherId::new(uuid!("55555555-5555-5555-5555-555555555555"))
150 }
151
152 fn full_cipher_response() -> CipherDetailsResponseModel {
155 CipherDetailsResponseModel {
156 id: Some(cipher_id().into()),
157 name: Some("2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8=".to_string()),
158 r#type: Some(bitwarden_api_api::models::CipherType::Login),
159 login: Some(Box::new(bitwarden_api_api::models::CipherLoginModel::default())),
160 creation_date: Some("2025-01-01T00:00:00Z".to_string()),
161 revision_date: Some("2025-01-01T00:00:00Z".to_string()),
162 ..Default::default()
163 }
164 }
165
166 fn sample_lease() -> AccessLeaseResponseModel {
167 AccessLeaseResponseModel {
168 id: Some(lease_id().into()),
169 request_id: Some(uuid!("44444444-4444-4444-4444-444444444444")),
170 cipher_id: Some(uuid!("55555555-5555-5555-5555-555555555555")),
171 collection_id: Some(uuid!("66666666-6666-6666-6666-666666666666")),
172 organization_id: Some(uuid!("77777777-7777-7777-7777-777777777777")),
173 requester_id: Some(uuid!("88888888-8888-8888-8888-888888888888")),
174 status: Some(ApiAccessLeaseStatus::Active),
175 not_before: Some("2025-01-01T00:00:00Z".to_string()),
176 not_after: Some("2025-01-01T01:00:00Z".to_string()),
177 ..Default::default()
178 }
179 }
180
181 #[tokio::test]
182 async fn list_active_returns_views() {
183 let api_client = ApiClient::new_mocked(move |mock| {
184 mock.leases_api
185 .expect_get_active()
186 .returning(move || {
187 let mut list = AccessLeaseResponseModelListResponseModel::new();
188 list.data = Some(vec![sample_lease()]);
189 Ok(list)
190 })
191 .once();
192 });
193
194 let result = client(api_client).list_active().await.unwrap();
195
196 assert_eq!(result.len(), 1);
197 assert_eq!(result[0].id, lease_id());
198 assert_eq!(result[0].status, AccessLeaseStatus::Active);
199 }
200
201 #[tokio::test]
202 async fn list_mine_surfaces_api_error() {
203 let api_client = ApiClient::new_mocked(move |mock| {
204 mock.leases_api
205 .expect_get_mine()
206 .returning(move || {
207 Err(bitwarden_api_api::apis::Error::Response(
208 bitwarden_api_api::apis::ResponseContent {
209 status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
210 message: String::new(),
211 },
212 ))
213 })
214 .once();
215 });
216
217 let result = client(api_client).list_mine().await;
218
219 assert!(matches!(result, Err(AccessLeaseError::Api(_))));
220 }
221
222 #[tokio::test]
223 async fn extend_returns_updated_request() {
224 let api_client = ApiClient::new_mocked(move |mock| {
225 mock.leases_api
226 .expect_extend()
227 .returning(move |_id, _request| {
228 Ok(AccessRequestDetailsResponseModel {
229 id: Some(uuid!("44444444-4444-4444-4444-444444444444")),
230 cipher_id: Some(uuid!("55555555-5555-5555-5555-555555555555")),
231 collection_id: Some(uuid!("66666666-6666-6666-6666-666666666666")),
232 requester_id: Some(uuid!("88888888-8888-8888-8888-888888888888")),
233 status: Some(bitwarden_api_api::models::AccessRequestStatus::Approved),
234 lease_not_before: Some("2025-01-01T00:00:00Z".to_string()),
235 lease_not_after: Some("2025-01-01T02:00:00Z".to_string()),
236 submitted_at: Some("2025-01-01T00:00:00Z".to_string()),
237 ..Default::default()
238 })
239 })
240 .once();
241 });
242
243 let request = AccessLeaseExtensionRequest {
244 duration_seconds: NonZeroU32::new(3600),
245 reason: "Need more time".to_string(),
246 };
247 let result = client(api_client)
248 .extend(lease_id(), request)
249 .await
250 .unwrap();
251
252 assert_eq!(
253 result.lease_not_after,
254 "2025-01-01T02:00:00Z".parse::<DateTime<Utc>>().unwrap()
255 );
256 }
257
258 #[tokio::test]
259 async fn end_succeeds() {
260 let api_client = ApiClient::new_mocked(move |mock| {
261 mock.leases_api
262 .expect_revoke()
263 .returning(move |_id, _request| Ok(()))
264 .once();
265 });
266
267 let result = client(api_client)
268 .end(lease_id(), AccessLeaseRevokeRequest::default())
269 .await;
270
271 assert!(result.is_ok());
272 }
273
274 #[tokio::test]
275 async fn leased_cipher_reports_no_access_when_the_server_still_restricts_the_cipher() {
276 let api_client = ApiClient::new_mocked(move |mock| {
277 mock.ciphers_api
278 .expect_get_details()
279 .returning(move |_id| {
280 Ok(CipherDetailsResponseModel {
281 partial_data: Some(r#"{"name":"Prod DB root"}"#.to_string()),
282 ..full_cipher_response()
283 })
284 })
285 .once();
286 });
287
288 let result = client(api_client).leased_cipher(cipher_id()).await.unwrap();
289
290 assert!(result.is_none());
292 }
293
294 #[tokio::test]
296 async fn leased_cipher_returns_a_view_for_a_full_payload() {
297 let api_client = ApiClient::new_mocked(move |mock| {
298 mock.ciphers_api
299 .expect_get_details()
300 .returning(move |_id| Ok(full_cipher_response()))
301 .once();
302 });
303
304 let view = client(api_client)
305 .leased_cipher(cipher_id())
306 .await
307 .unwrap()
308 .expect("a full payload should resolve to a view");
309
310 assert_eq!(view.id, Some(cipher_id()));
311 assert!(!view.partial);
312 }
313
314 #[tokio::test]
315 async fn leased_cipher_surfaces_api_errors() {
316 let api_client = ApiClient::new_mocked(move |mock| {
317 mock.ciphers_api
318 .expect_get_details()
319 .returning(move |_id| {
320 Err(bitwarden_api_api::ApiError::Response(
321 bitwarden_api_api::ResponseContent {
322 status: reqwest::StatusCode::NOT_FOUND,
323 message: String::new(),
324 },
325 ))
326 })
327 .once();
328 });
329
330 let result = client(api_client).leased_cipher(cipher_id()).await;
331
332 assert!(matches!(result, Err(AccessLeaseError::Api(_))));
333 }
334}