1use bitwarden_api_api::models::{
2 AccessDecisionVerdict as ApiAccessDecisionVerdict, AccessRequestDecisionResponseModel,
3 AccessRequestDetailsResponseModel, AccessRequestStatus as ApiAccessRequestStatus,
4 DeciderKind as ApiDeciderKind,
5};
6use bitwarden_collections::collection::CollectionId;
7use bitwarden_core::{OrganizationId, UserId, require};
8use bitwarden_vault::CipherId;
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11#[cfg(feature = "wasm")]
12use tsify::Tsify;
13
14use crate::{AccessLeaseId, AccessLeaseStatus, AccessRequestId, AccessRuleId, error::LeasingError};
15
16#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
22#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
23#[serde(rename_all = "snake_case")]
24pub enum AccessRequestStatus {
25 Pending,
27 Approved,
29 Activated,
31 Denied,
33 Canceled,
35 Expired,
37 Unknown,
40}
41
42impl From<ApiAccessRequestStatus> for AccessRequestStatus {
43 fn from(status: ApiAccessRequestStatus) -> Self {
44 match status {
45 ApiAccessRequestStatus::Pending => Self::Pending,
46 ApiAccessRequestStatus::Approved => Self::Approved,
47 ApiAccessRequestStatus::Activated => Self::Activated,
48 ApiAccessRequestStatus::Denied => Self::Denied,
49 ApiAccessRequestStatus::Canceled => Self::Canceled,
50 ApiAccessRequestStatus::Expired => Self::Expired,
51 ApiAccessRequestStatus::__Unknown(_) => Self::Unknown,
52 }
53 }
54}
55
56#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
58#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
59#[serde(rename_all = "snake_case")]
60pub enum AccessDecisionVerdict {
61 Deny,
63 Approve,
65 Unknown,
68}
69
70impl From<ApiAccessDecisionVerdict> for AccessDecisionVerdict {
71 fn from(verdict: ApiAccessDecisionVerdict) -> Self {
72 match verdict {
73 ApiAccessDecisionVerdict::Deny => Self::Deny,
74 ApiAccessDecisionVerdict::Approve => Self::Approve,
75 ApiAccessDecisionVerdict::__Unknown(_) => Self::Unknown,
76 }
77 }
78}
79
80#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
87#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
88#[serde(rename_all = "camelCase")]
89pub struct AccessRequestDecisionView {
90 pub decider: AccessDecider,
92 pub verdict: AccessDecisionVerdict,
94 pub comment: Option<String>,
96 pub decided_at: DateTime<Utc>,
98}
99
100#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
102#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
103#[serde(rename_all = "camelCase")]
104pub enum AccessDecider {
105 Automatic,
108 Human(AccessApprover),
110}
111
112#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
114#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
115#[serde(rename_all = "camelCase")]
116pub struct AccessApprover {
117 pub id: Option<UserId>,
119 pub name: Option<String>,
121 pub email: Option<String>,
123}
124
125impl TryFrom<AccessRequestDecisionResponseModel> for AccessRequestDecisionView {
126 type Error = LeasingError;
127
128 fn try_from(response: AccessRequestDecisionResponseModel) -> Result<Self, Self::Error> {
129 let decider = match require!(response.decider_kind) {
130 ApiDeciderKind::Automatic => AccessDecider::Automatic,
131 ApiDeciderKind::Human => AccessDecider::Human(AccessApprover {
132 id: response.id.map(UserId::new),
133 name: response.name,
134 email: response.email,
135 }),
136 ApiDeciderKind::__Unknown(_) => return Err(LeasingError::UnrecognizedDeciderKind),
137 };
138
139 Ok(Self {
140 decider,
141 verdict: AccessDecisionVerdict::from(require!(response.verdict)),
142 comment: response.comment,
143 decided_at: require!(response.decided_at).parse()?,
144 })
145 }
146}
147
148#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
154#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
155#[serde(rename_all = "camelCase")]
156pub struct AccessRequestView {
157 pub id: AccessRequestId,
159 pub cipher_id: CipherId,
161 pub collection_id: CollectionId,
163 pub organization_id: Option<OrganizationId>,
165 pub requester_id: UserId,
167 pub rule_id: Option<AccessRuleId>,
170 pub status: AccessRequestStatus,
172 pub lease_not_before: DateTime<Utc>,
175 pub lease_not_after: DateTime<Utc>,
177 pub reason: Option<String>,
179 pub submitted_at: DateTime<Utc>,
181 pub resolved_at: Option<DateTime<Utc>>,
183 pub decisions: Vec<AccessRequestDecisionView>,
185 pub produced_lease_id: Option<AccessLeaseId>,
187 pub produced_lease_status: Option<AccessLeaseStatus>,
189 pub extension_of_lease_id: Option<AccessLeaseId>,
191 pub requester_name: Option<String>,
194 pub requester_email: Option<String>,
197}
198
199impl TryFrom<AccessRequestDetailsResponseModel> for AccessRequestView {
200 type Error = LeasingError;
201
202 fn try_from(response: AccessRequestDetailsResponseModel) -> Result<Self, Self::Error> {
203 Ok(Self {
204 id: AccessRequestId::new(require!(response.id)),
205 cipher_id: CipherId::new(require!(response.cipher_id)),
206 collection_id: CollectionId::new(require!(response.collection_id)),
207 organization_id: response.organization_id.map(OrganizationId::new),
208 requester_id: UserId::new(require!(response.requester_id)),
209 rule_id: response.rule_id.map(AccessRuleId::new),
210 status: AccessRequestStatus::from(require!(response.status)),
211 lease_not_before: require!(response.lease_not_before).parse()?,
212 lease_not_after: require!(response.lease_not_after).parse()?,
213 reason: response.reason,
214 submitted_at: require!(response.submitted_at).parse()?,
215 resolved_at: response.resolved_at.map(|d| d.parse()).transpose()?,
216 decisions: response
217 .decisions
218 .unwrap_or_default()
219 .into_iter()
220 .map(AccessRequestDecisionView::try_from)
221 .collect::<Result<Vec<_>, _>>()?,
222 produced_lease_id: response.produced_lease_id.map(AccessLeaseId::new),
223 produced_lease_status: response.produced_lease_status.map(AccessLeaseStatus::from),
224 extension_of_lease_id: response.extension_of_lease_id.map(AccessLeaseId::new),
225 requester_name: response.requester_name,
226 requester_email: response.requester_email,
227 })
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use bitwarden_api_api::models::{AccessLeaseStatus as ApiAccessLeaseStatus, DeciderKind};
234 use uuid::Uuid;
235
236 use super::*;
237
238 fn human_decision() -> AccessRequestDecisionResponseModel {
239 AccessRequestDecisionResponseModel {
240 decider_kind: Some(DeciderKind::Human),
241 id: Some(Uuid::new_v4()),
242 name: Some("Ana Approver".to_string()),
243 email: Some("[email protected]".to_string()),
244 comment: Some("Looks fine".to_string()),
245 verdict: Some(ApiAccessDecisionVerdict::Approve),
246 decided_at: Some("2025-01-01T00:30:00Z".to_string()),
247 }
248 }
249
250 fn automatic_decision() -> AccessRequestDecisionResponseModel {
251 AccessRequestDecisionResponseModel {
252 decider_kind: Some(DeciderKind::Automatic),
253 id: None,
254 name: None,
255 email: None,
256 comment: None,
257 verdict: Some(ApiAccessDecisionVerdict::Approve),
258 decided_at: Some("2025-01-01T00:00:05Z".to_string()),
259 }
260 }
261
262 fn full_response() -> AccessRequestDetailsResponseModel {
263 AccessRequestDetailsResponseModel {
264 id: Some(Uuid::new_v4()),
265 cipher_id: Some(Uuid::new_v4()),
266 collection_id: Some(Uuid::new_v4()),
267 organization_id: Some(Uuid::new_v4()),
268 requester_id: Some(Uuid::new_v4()),
269 rule_id: Some(Uuid::new_v4()),
270 status: Some(ApiAccessRequestStatus::Activated),
271 lease_not_before: Some("2025-01-01T00:00:00Z".to_string()),
272 lease_not_after: Some("2025-01-01T01:00:00Z".to_string()),
273 reason: Some("Need to fix an incident".to_string()),
274 submitted_at: Some("2025-01-01T00:00:00Z".to_string()),
275 resolved_at: Some("2025-01-01T00:30:00Z".to_string()),
276 decisions: Some(vec![automatic_decision(), human_decision()]),
277 produced_lease_id: Some(Uuid::new_v4()),
278 produced_lease_status: Some(ApiAccessLeaseStatus::Active),
279 extension_of_lease_id: Some(Uuid::new_v4()),
280 requester_name: Some("Rea Quester".to_string()),
281 requester_email: Some("[email protected]".to_string()),
282 ..Default::default()
283 }
284 }
285
286 #[test]
287 fn full_response_converts_decisions_and_lease_linkage() {
288 let response = full_response();
289 let expected_produced_lease_id = response.produced_lease_id.unwrap();
290 let expected_extension_of_lease_id = response.extension_of_lease_id.unwrap();
291
292 let view = AccessRequestView::try_from(response).unwrap();
293
294 assert_eq!(view.decisions.len(), 2);
295 assert_eq!(
296 view.produced_lease_id,
297 Some(AccessLeaseId::new(expected_produced_lease_id))
298 );
299 assert_eq!(view.produced_lease_status, Some(AccessLeaseStatus::Active));
300 assert_eq!(
301 view.extension_of_lease_id,
302 Some(AccessLeaseId::new(expected_extension_of_lease_id))
303 );
304 assert_eq!(view.requester_name, Some("Rea Quester".to_string()));
305 assert_eq!(view.requester_email, Some("[email protected]".to_string()));
306
307 assert!(matches!(
308 view.decisions[0].decider,
309 AccessDecider::Automatic
310 ));
311
312 let decision = &view.decisions[1];
313 let AccessDecider::Human(approver) = &decision.decider else {
314 panic!("expected a human decision, got {:?}", decision.decider);
315 };
316 assert_eq!(approver.name.as_deref(), Some("Ana Approver"));
317 assert_eq!(approver.email.as_deref(), Some("[email protected]"));
318 assert_eq!(decision.comment.as_deref(), Some("Looks fine"));
319 assert_eq!(decision.verdict, AccessDecisionVerdict::Approve);
320 }
321
322 #[test]
323 fn automatic_decision_has_no_approver_identity() {
324 let view = AccessRequestDecisionView::try_from(automatic_decision()).unwrap();
325
326 assert!(matches!(view.decider, AccessDecider::Automatic));
327 }
328
329 #[test]
330 fn missing_decisions_becomes_empty_vec() {
331 let response = AccessRequestDetailsResponseModel {
332 decisions: None,
333 ..full_response()
334 };
335
336 let view = AccessRequestView::try_from(response).unwrap();
337
338 assert_eq!(view.decisions, Vec::new());
339 }
340
341 #[test]
342 fn unknown_decider_kind_is_rejected() {
343 let response = AccessRequestDecisionResponseModel {
344 decider_kind: Some(DeciderKind::__Unknown(99)),
345 ..human_decision()
346 };
347
348 assert!(AccessRequestDecisionView::try_from(response).is_err());
349 }
350
351 #[test]
352 fn unknown_verdict_maps_to_unknown() {
353 let response = AccessRequestDecisionResponseModel {
354 verdict: Some(ApiAccessDecisionVerdict::__Unknown(99)),
355 ..human_decision()
356 };
357
358 let view = AccessRequestDecisionView::try_from(response).unwrap();
359
360 assert_eq!(view.verdict, AccessDecisionVerdict::Unknown);
361 }
362
363 #[test]
364 fn human_decision_serializes_with_nested_approver() {
365 let view = AccessRequestDecisionView::try_from(human_decision()).unwrap();
366
367 let json = serde_json::to_value(&view).unwrap();
368
369 assert_eq!(json["decider"]["human"]["name"], "Ana Approver");
370 assert_eq!(json["decider"]["human"]["email"], "[email protected]");
371 assert_eq!(json["verdict"], "approve");
372 assert!(json.get("decidedAt").is_some());
373 }
374
375 #[test]
376 fn automatic_decision_serializes_without_approver() {
377 let view = AccessRequestDecisionView::try_from(automatic_decision()).unwrap();
378
379 let json = serde_json::to_value(&view).unwrap();
380
381 assert_eq!(json["decider"], "automatic");
382 assert_eq!(json["verdict"], "approve");
383 }
384}