Skip to main content

bitwarden_pam/leases/
client.rs

1use 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/// Client for reading and managing a requester's PAM access leases. Leases are minted by
14/// [`AccessRequestsClient::activate`](crate::AccessRequestsClient::activate).
15#[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    /// Lists the caller's currently active leases.
25    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    /// Lists all of the caller's leases, active or not.
43    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    /// Extends an active lease, returning the updated originating request.
61    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    /// Ends a lease before it expires, re-locking the cipher.
77    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    /// Reads the full cipher a lease unlocks from the server, or `None` if the server still
92    /// restricts it. The result is never stored, so a lapsed lease leaves no decryptable secrets
93    /// in state.
94    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    /// A full (unrestricted) cipher payload, as the server answers a caller holding a lease. Its
153    /// `name` is encrypted under a key the test store lacks, which lenient decryption tolerates.
154    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        // Returning the partial would show an empty credential as if it were the real one.
291        assert!(result.is_none());
292    }
293
294    /// A non-restricted payload takes the decrypt branch and comes back marked `partial: false`.
295    #[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}