Skip to main content

bitwarden_pam/leases/
models.rs

1use std::num::NonZeroU32;
2
3use bitwarden_api_api::models::{
4    AccessLeaseExtensionRequestModel, AccessLeaseResponseModel, AccessLeaseRevokeRequestModel,
5    AccessLeaseStatus as ApiAccessLeaseStatus,
6};
7use bitwarden_collections::collection::CollectionId;
8use bitwarden_core::{OrganizationId, UserId, require};
9use bitwarden_vault::CipherId;
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13use crate::{AccessLeaseId, AccessRequestId, error::PamDecodeError};
14
15/// The lifecycle state of an access lease.
16#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
17#[bitwarden_ffi::wasm_record]
18#[serde(rename_all = "snake_case")]
19pub enum AccessLeaseStatus {
20    /// The lease is currently within its access window and grants access.
21    Active,
22    /// The lease's access window has closed; it no longer grants access.
23    Expired,
24    /// An operator revoked the lease before its window closed.
25    Revoked,
26    /// The lease was cancelled by its requester before its window closed.
27    Canceled,
28    /// A status value this SDK version does not recognize. Kept as a distinct variant so listing
29    /// leases never fails on a newer server's status.
30    Unknown,
31}
32
33impl From<ApiAccessLeaseStatus> for AccessLeaseStatus {
34    fn from(status: ApiAccessLeaseStatus) -> Self {
35        match status {
36            ApiAccessLeaseStatus::Active => Self::Active,
37            ApiAccessLeaseStatus::Expired => Self::Expired,
38            ApiAccessLeaseStatus::Revoked => Self::Revoked,
39            ApiAccessLeaseStatus::Cancelled => Self::Canceled,
40            ApiAccessLeaseStatus::__Unknown(_) => Self::Unknown,
41        }
42    }
43}
44
45/// An access lease, as its requester sees it.
46#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
47#[bitwarden_ffi::wasm_record]
48#[serde(rename_all = "camelCase")]
49pub struct AccessLeaseView {
50    /// The lease's unique identifier.
51    pub id: AccessLeaseId,
52    /// The request this lease was minted from.
53    pub request_id: AccessRequestId,
54    /// The cipher the lease grants access to.
55    pub cipher_id: CipherId,
56    /// The collection the cipher belongs to.
57    pub collection_id: CollectionId,
58    /// The organization that owns the cipher. None when the server omits it.
59    pub organization_id: Option<OrganizationId>,
60    /// The user the lease was granted to (the original requester).
61    pub requester_id: UserId,
62    /// The lease's lifecycle state.
63    pub status: AccessLeaseStatus,
64    /// When the lease's access window opens (UTC).
65    pub not_before: DateTime<Utc>,
66    /// When the lease's access window closes (UTC).
67    pub not_after: DateTime<Utc>,
68    /// How the lease ended early; None if it expired or is still active.
69    pub termination: Option<AccessLeaseTermination>,
70}
71
72/// How an access lease's grant ended before its window closed.
73#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
74#[bitwarden_ffi::wasm_record]
75#[serde(tag = "kind", rename_all = "snake_case")]
76pub enum AccessLeaseTermination {
77    /// The holder ended their own lease.
78    EndedByHolder {
79        /// Time the holder ended it (UTC).
80        at: DateTime<Utc>,
81    },
82    /// An operator revoked the lease out from under the holder.
83    Revoked {
84        /// Time it was revoked (UTC).
85        at: DateTime<Utc>,
86        /// The operator who revoked it, or `None` when the server omits the revoker.
87        by_user_id: Option<UserId>,
88    },
89}
90
91impl TryFrom<AccessLeaseResponseModel> for AccessLeaseView {
92    type Error = PamDecodeError;
93
94    fn try_from(response: AccessLeaseResponseModel) -> Result<Self, Self::Error> {
95        let requester_id = UserId::new(require!(response.requester_id));
96        let revoked_by_user_id = response.revoked_by_user_id.map(UserId::new);
97        // `revoked_at` is the authoritative marker; a revoker id without a time is incoherent
98        // data, kept as "not terminated" rather than failing the whole list.
99        let termination = response
100            .revoked_at
101            .map(|at| at.parse())
102            .transpose()?
103            .map(|at| {
104                if revoked_by_user_id == Some(requester_id) {
105                    AccessLeaseTermination::EndedByHolder { at }
106                } else {
107                    AccessLeaseTermination::Revoked {
108                        at,
109                        by_user_id: revoked_by_user_id,
110                    }
111                }
112            });
113
114        Ok(Self {
115            id: AccessLeaseId::new(require!(response.id)),
116            request_id: AccessRequestId::new(require!(response.request_id)),
117            cipher_id: CipherId::new(require!(response.cipher_id)),
118            collection_id: CollectionId::new(require!(response.collection_id)),
119            organization_id: response.organization_id.map(OrganizationId::new),
120            requester_id,
121            status: AccessLeaseStatus::from(require!(response.status)),
122            not_before: require!(response.not_before).parse()?,
123            not_after: require!(response.not_after).parse()?,
124            termination,
125        })
126    }
127}
128
129/// Request to extend an active lease.
130#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
131#[bitwarden_ffi::wasm_record]
132#[serde(rename_all = "camelCase")]
133pub struct AccessLeaseExtensionRequest {
134    /// How far to push out the lease's end, in seconds, within the governing rule's maximum
135    /// extension. The server rejects `None`.
136    pub duration_seconds: Option<NonZeroU32>,
137    /// The justification recorded with the extension. Required by the server to be non-empty.
138    pub reason: String,
139}
140
141impl From<AccessLeaseExtensionRequest> for AccessLeaseExtensionRequestModel {
142    fn from(request: AccessLeaseExtensionRequest) -> Self {
143        Self {
144            duration_seconds: request.duration_seconds.map(|d| d.get() as i32),
145            reason: request.reason,
146        }
147    }
148}
149
150/// Request to revoke (end) a lease before it expires.
151#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
152#[bitwarden_ffi::wasm_record]
153#[serde(rename_all = "camelCase")]
154pub struct AccessLeaseRevokeRequest {
155    /// An optional note explaining the revocation. Recorded on the audit trail only.
156    pub reason: Option<String>,
157}
158
159impl From<AccessLeaseRevokeRequest> for AccessLeaseRevokeRequestModel {
160    fn from(request: AccessLeaseRevokeRequest) -> Self {
161        Self {
162            reason: request.reason,
163        }
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use std::num::NonZeroU32;
170
171    use uuid::uuid;
172
173    use super::*;
174
175    #[test]
176    fn access_lease_extension_request_converts_to_model() {
177        let request = AccessLeaseExtensionRequest {
178            duration_seconds: NonZeroU32::new(3600),
179            reason: "Need more time".to_string(),
180        };
181
182        let model = AccessLeaseExtensionRequestModel::from(request);
183
184        assert_eq!(model.duration_seconds, Some(3600));
185        assert_eq!(model.reason, "Need more time".to_string());
186    }
187
188    fn requester_id() -> uuid::Uuid {
189        uuid!("88888888-8888-8888-8888-888888888888")
190    }
191
192    fn base_lease_response() -> AccessLeaseResponseModel {
193        AccessLeaseResponseModel {
194            id: Some(uuid!("33333333-3333-3333-3333-333333333333")),
195            request_id: Some(uuid!("44444444-4444-4444-4444-444444444444")),
196            cipher_id: Some(uuid!("55555555-5555-5555-5555-555555555555")),
197            collection_id: Some(uuid!("66666666-6666-6666-6666-666666666666")),
198            requester_id: Some(requester_id()),
199            status: Some(ApiAccessLeaseStatus::Revoked),
200            not_before: Some("2025-01-01T00:00:00Z".to_string()),
201            not_after: Some("2025-01-01T01:00:00Z".to_string()),
202            ..Default::default()
203        }
204    }
205
206    #[test]
207    fn termination_is_ended_by_holder_when_revoker_is_the_requester() {
208        let response = AccessLeaseResponseModel {
209            revoked_at: Some("2025-01-01T00:30:00Z".to_string()),
210            revoked_by_user_id: Some(requester_id()),
211            ..base_lease_response()
212        };
213
214        let view = AccessLeaseView::try_from(response).unwrap();
215
216        assert_eq!(
217            view.termination,
218            Some(AccessLeaseTermination::EndedByHolder {
219                at: "2025-01-01T00:30:00Z".parse().unwrap()
220            })
221        );
222    }
223
224    #[test]
225    fn termination_is_revoked_when_revoker_is_an_operator() {
226        let response = AccessLeaseResponseModel {
227            revoked_at: Some("2025-01-01T00:30:00Z".to_string()),
228            revoked_by_user_id: Some(uuid!("99999999-9999-9999-9999-999999999999")),
229            ..base_lease_response()
230        };
231
232        let view = AccessLeaseView::try_from(response).unwrap();
233
234        assert_eq!(
235            view.termination,
236            Some(AccessLeaseTermination::Revoked {
237                at: "2025-01-01T00:30:00Z".parse().unwrap(),
238                by_user_id: Some(UserId::new(uuid!("99999999-9999-9999-9999-999999999999"))),
239            })
240        );
241    }
242
243    #[test]
244    fn termination_is_revoked_with_no_attribution_when_the_server_omits_the_revoker() {
245        let response = AccessLeaseResponseModel {
246            revoked_at: Some("2025-01-01T00:30:00Z".to_string()),
247            revoked_by_user_id: None,
248            ..base_lease_response()
249        };
250
251        let view = AccessLeaseView::try_from(response).unwrap();
252
253        assert_eq!(
254            view.termination,
255            Some(AccessLeaseTermination::Revoked {
256                at: "2025-01-01T00:30:00Z".parse().unwrap(),
257                by_user_id: None,
258            })
259        );
260    }
261
262    #[test]
263    fn a_revoker_without_a_revocation_time_is_not_a_termination() {
264        let response = AccessLeaseResponseModel {
265            revoked_at: None,
266            revoked_by_user_id: Some(requester_id()),
267            ..base_lease_response()
268        };
269
270        let view = AccessLeaseView::try_from(response).unwrap();
271
272        assert_eq!(view.termination, None);
273    }
274
275    #[test]
276    fn termination_is_none_when_the_lease_was_never_revoked() {
277        let response = AccessLeaseResponseModel {
278            status: Some(ApiAccessLeaseStatus::Active),
279            revoked_at: None,
280            revoked_by_user_id: None,
281            ..base_lease_response()
282        };
283
284        let view = AccessLeaseView::try_from(response).unwrap();
285
286        assert_eq!(view.termination, None);
287    }
288}