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#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
17#[bitwarden_ffi::wasm_record]
18#[serde(rename_all = "snake_case")]
19pub enum AccessLeaseStatus {
20 Active,
22 Expired,
24 Revoked,
26 Canceled,
28 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#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
47#[bitwarden_ffi::wasm_record]
48#[serde(rename_all = "camelCase")]
49pub struct AccessLeaseView {
50 pub id: AccessLeaseId,
52 pub request_id: AccessRequestId,
54 pub cipher_id: CipherId,
56 pub collection_id: CollectionId,
58 pub organization_id: Option<OrganizationId>,
60 pub requester_id: UserId,
62 pub status: AccessLeaseStatus,
64 pub not_before: DateTime<Utc>,
66 pub not_after: DateTime<Utc>,
68 pub termination: Option<AccessLeaseTermination>,
70}
71
72#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
74#[bitwarden_ffi::wasm_record]
75#[serde(tag = "kind", rename_all = "snake_case")]
76pub enum AccessLeaseTermination {
77 EndedByHolder {
79 at: DateTime<Utc>,
81 },
82 Revoked {
84 at: DateTime<Utc>,
86 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 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#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
131#[bitwarden_ffi::wasm_record]
132#[serde(rename_all = "camelCase")]
133pub struct AccessLeaseExtensionRequest {
134 pub duration_seconds: Option<NonZeroU32>,
137 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#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
152#[bitwarden_ffi::wasm_record]
153#[serde(rename_all = "camelCase")]
154pub struct AccessLeaseRevokeRequest {
155 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}