1use std::num::NonZeroU32;
2
3use bitwarden_api_api::models::{
4 AccessApprovalMode as ApiAccessApprovalMode, AccessDeciderKind as ApiAccessDeciderKind,
5 AccessDecisionVerdict as ApiAccessDecisionVerdict, AccessPreCheckResponseModel,
6 AccessRequestCreateRequestModel, AccessRequestDecisionResponseModel,
7 AccessRequestDetailsResponseModel, AccessRequestResultResponseModel,
8 AccessRequestStatus as ApiAccessRequestStatus, CipherAccessStateResponseModel,
9};
10use bitwarden_collections::collection::CollectionId;
11use bitwarden_core::{OrganizationId, UserId, require};
12use bitwarden_vault::CipherId;
13use chrono::{DateTime, SecondsFormat, Utc};
14use serde::{Deserialize, Serialize};
15
16use super::validate::{
17 AccessRequestWindowError, DEFAULT_REQUEST_ACCESS_DURATION_SECONDS,
18 MAX_REQUEST_ACCESS_WINDOW_SECONDS,
19};
20use crate::{
21 AccessLeaseId, AccessLeaseStatus, AccessRequestId, AccessRuleId, error::PamDecodeError,
22 leases::AccessLeaseView,
23};
24
25#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
31#[bitwarden_ffi::wasm_record]
32#[serde(rename_all = "snake_case")]
33pub enum AccessRequestStatus {
34 Pending,
36 Approved,
38 Denied,
40 Canceled,
42 Expired,
45 Unknown,
48}
49
50impl From<ApiAccessRequestStatus> for AccessRequestStatus {
51 fn from(status: ApiAccessRequestStatus) -> Self {
52 match status {
53 ApiAccessRequestStatus::Pending => Self::Pending,
54 ApiAccessRequestStatus::Approved => Self::Approved,
55 ApiAccessRequestStatus::Denied => Self::Denied,
56 ApiAccessRequestStatus::Cancelled => Self::Canceled,
57 ApiAccessRequestStatus::Expired => Self::Expired,
58 ApiAccessRequestStatus::__Unknown(_) => Self::Unknown,
59 }
60 }
61}
62
63#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
65#[bitwarden_ffi::wasm_record]
66#[serde(rename_all = "snake_case")]
67pub enum AccessDecisionVerdict {
68 Deny,
70 Approve,
72 Unknown,
75}
76
77impl From<ApiAccessDecisionVerdict> for AccessDecisionVerdict {
78 fn from(verdict: ApiAccessDecisionVerdict) -> Self {
79 match verdict {
80 ApiAccessDecisionVerdict::Deny => Self::Deny,
81 ApiAccessDecisionVerdict::Approve => Self::Approve,
82 ApiAccessDecisionVerdict::__Unknown(_) => Self::Unknown,
83 }
84 }
85}
86
87#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
89#[bitwarden_ffi::wasm_record]
90#[serde(rename_all = "camelCase")]
91pub struct AccessRequestDecisionView {
92 pub decider: AccessDecider,
94 pub verdict: AccessDecisionVerdict,
96 pub comment: Option<String>,
98 pub decided_at: DateTime<Utc>,
100}
101
102#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
104#[bitwarden_ffi::wasm_record]
105#[serde(rename_all = "camelCase")]
106pub enum AccessDecider {
107 Automatic,
109 Human(AccessApprover),
111}
112
113#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
115#[bitwarden_ffi::wasm_record]
116#[serde(rename_all = "camelCase")]
117pub struct AccessApprover {
118 pub id: Option<UserId>,
120 pub name: Option<String>,
122 pub email: Option<String>,
124}
125
126impl TryFrom<AccessRequestDecisionResponseModel> for AccessRequestDecisionView {
127 type Error = PamDecodeError;
128
129 fn try_from(response: AccessRequestDecisionResponseModel) -> Result<Self, Self::Error> {
130 let decider = match require!(response.decider_kind) {
131 ApiAccessDeciderKind::Automatic => AccessDecider::Automatic,
132 ApiAccessDeciderKind::Human => AccessDecider::Human(AccessApprover {
133 id: response.id.map(UserId::new),
134 name: response.name,
135 email: response.email,
136 }),
137 ApiAccessDeciderKind::__Unknown(_) => {
138 return Err(PamDecodeError::UnrecognizedDeciderKind);
139 }
140 };
141
142 Ok(Self {
143 decider,
144 verdict: AccessDecisionVerdict::from(require!(response.verdict)),
145 comment: response.comment,
146 decided_at: require!(response.decided_at).parse()?,
147 })
148 }
149}
150
151#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
153#[bitwarden_ffi::wasm_record]
154#[serde(rename_all = "camelCase")]
155pub struct AccessRequestView {
156 pub id: AccessRequestId,
158 pub cipher_id: CipherId,
160 pub collection_id: CollectionId,
162 pub organization_id: Option<OrganizationId>,
164 pub requester_id: UserId,
166 pub rule_id: Option<AccessRuleId>,
168 pub status: AccessRequestStatus,
170 pub lease_not_before: DateTime<Utc>,
173 pub lease_not_after: DateTime<Utc>,
175 pub reason: Option<String>,
177 pub submitted_at: DateTime<Utc>,
179 pub resolved_at: Option<DateTime<Utc>>,
181 pub decisions: Vec<AccessRequestDecisionView>,
183 pub produced_lease_id: Option<AccessLeaseId>,
185 pub produced_lease_status: Option<AccessLeaseStatus>,
187 pub produced_lease_not_after: Option<DateTime<Utc>>,
190 pub extension_of_lease_id: Option<AccessLeaseId>,
192 pub requester_name: Option<String>,
195 pub requester_email: Option<String>,
198 pub awaiting_activation: bool,
200 pub human_decision: Option<AccessRequestDecisionView>,
202}
203
204impl TryFrom<AccessRequestDetailsResponseModel> for AccessRequestView {
205 type Error = PamDecodeError;
206
207 fn try_from(response: AccessRequestDetailsResponseModel) -> Result<Self, Self::Error> {
208 let status = AccessRequestStatus::from(require!(response.status));
209 let produced_lease_id = response.produced_lease_id.map(AccessLeaseId::new);
210 let decisions = response
211 .decisions
212 .unwrap_or_default()
213 .into_iter()
214 .map(AccessRequestDecisionView::try_from)
215 .collect::<Result<Vec<_>, _>>()?;
216
217 let human_decision = decisions
218 .iter()
219 .find(|decision| !matches!(decision.decider, AccessDecider::Automatic))
220 .cloned();
221
222 Ok(Self {
223 id: AccessRequestId::new(require!(response.id)),
224 cipher_id: CipherId::new(require!(response.cipher_id)),
225 collection_id: CollectionId::new(require!(response.collection_id)),
226 organization_id: response.organization_id.map(OrganizationId::new),
227 requester_id: UserId::new(require!(response.requester_id)),
228 rule_id: response.rule_id.map(AccessRuleId::new),
229 status,
230 lease_not_before: require!(response.lease_not_before).parse()?,
231 lease_not_after: require!(response.lease_not_after).parse()?,
232 reason: response.reason,
233 submitted_at: require!(response.submitted_at).parse()?,
234 resolved_at: response.resolved_at.map(|d| d.parse()).transpose()?,
235 awaiting_activation: status == AccessRequestStatus::Approved
236 && produced_lease_id.is_none(),
237 human_decision,
238 decisions,
239 produced_lease_id,
240 produced_lease_status: response.produced_lease_status.map(AccessLeaseStatus::from),
241 produced_lease_not_after: response
242 .produced_lease_not_after
243 .map(|d| d.parse())
244 .transpose()?,
245 extension_of_lease_id: response.extension_of_lease_id.map(AccessLeaseId::new),
246 requester_name: response.requester_name,
247 requester_email: response.requester_email,
248 })
249 }
250}
251
252#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
254#[bitwarden_ffi::wasm_record]
255#[serde(rename_all = "snake_case")]
256pub enum AccessApprovalMode {
257 Automatic,
259 Human,
261 Unknown,
263}
264
265impl From<ApiAccessApprovalMode> for AccessApprovalMode {
266 fn from(mode: ApiAccessApprovalMode) -> Self {
267 match mode {
268 ApiAccessApprovalMode::Automatic => Self::Automatic,
269 ApiAccessApprovalMode::Human => Self::Human,
270 ApiAccessApprovalMode::__Unknown(_) => Self::Unknown,
271 }
272 }
273}
274
275#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
277#[bitwarden_ffi::wasm_record]
278#[serde(rename_all = "camelCase")]
279pub struct AccessPreCheckView {
280 pub cipher_id: CipherId,
282 pub approval_mode: AccessApprovalMode,
284 pub has_active_lease: bool,
286 pub default_duration_seconds: u32,
289 pub max_duration_seconds: u32,
291 pub can_start_lease: bool,
294 pub slot_frees_at: Option<DateTime<Utc>>,
297}
298
299impl TryFrom<AccessPreCheckResponseModel> for AccessPreCheckView {
300 type Error = PamDecodeError;
301
302 fn try_from(response: AccessPreCheckResponseModel) -> Result<Self, Self::Error> {
303 let max_duration_seconds = positive_u32(response.max_duration_seconds)
306 .map_or(MAX_REQUEST_ACCESS_WINDOW_SECONDS, |max| {
307 max.min(MAX_REQUEST_ACCESS_WINDOW_SECONDS)
308 });
309
310 Ok(Self {
311 cipher_id: CipherId::new(require!(response.cipher_id)),
312 approval_mode: AccessApprovalMode::from(require!(response.approval_mode)),
313 has_active_lease: require!(response.has_active_lease),
314 default_duration_seconds: positive_u32(response.default_duration_seconds)
317 .unwrap_or(DEFAULT_REQUEST_ACCESS_DURATION_SECONDS)
318 .min(max_duration_seconds),
319 max_duration_seconds,
320 can_start_lease: response.can_start_lease.unwrap_or(true),
322 slot_frees_at: response.slot_frees_at.map(|d| d.parse()).transpose()?,
323 })
324 }
325}
326
327fn positive_u32(value: Option<i32>) -> Option<u32> {
330 value.filter(|v| *v > 0).map(|v| v as u32)
331}
332
333#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
336#[bitwarden_ffi::wasm_record]
337#[serde(rename_all = "camelCase")]
338pub struct AccessRequestSummaryView {
339 pub id: AccessRequestId,
341 pub cipher_id: CipherId,
343 pub collection_id: CollectionId,
345 pub organization_id: Option<OrganizationId>,
347 pub status: AccessRequestStatus,
349 pub lease_not_before: DateTime<Utc>,
351 pub lease_not_after: DateTime<Utc>,
353 pub reason: Option<String>,
355 pub submitted_at: DateTime<Utc>,
357}
358
359impl TryFrom<AccessRequestDetailsResponseModel> for AccessRequestSummaryView {
360 type Error = PamDecodeError;
361
362 fn try_from(response: AccessRequestDetailsResponseModel) -> Result<Self, Self::Error> {
363 Ok(Self {
364 id: AccessRequestId::new(require!(response.id)),
365 cipher_id: CipherId::new(require!(response.cipher_id)),
366 collection_id: CollectionId::new(require!(response.collection_id)),
367 organization_id: response.organization_id.map(OrganizationId::new),
368 status: AccessRequestStatus::from(require!(response.status)),
369 lease_not_before: require!(response.lease_not_before).parse()?,
370 lease_not_after: require!(response.lease_not_after).parse()?,
371 reason: response.reason,
372 submitted_at: require!(response.submitted_at).parse()?,
373 })
374 }
375}
376
377#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
382#[bitwarden_ffi::wasm_record]
383#[serde(rename_all = "camelCase")]
384pub struct AccessRequestResultView {
385 pub approval_mode: AccessApprovalMode,
389 pub request: AccessRequestSummaryView,
391}
392
393impl TryFrom<AccessRequestResultResponseModel> for AccessRequestResultView {
394 type Error = PamDecodeError;
395
396 fn try_from(response: AccessRequestResultResponseModel) -> Result<Self, Self::Error> {
397 Ok(Self {
398 approval_mode: AccessApprovalMode::from(require!(response.approval_mode)),
399 request: AccessRequestSummaryView::try_from(*require!(response.request))?,
400 })
401 }
402}
403
404#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
408#[bitwarden_ffi::wasm_record]
409#[serde(rename_all = "camelCase")]
410pub enum AccessBadgeState {
411 Active {
413 #[serde(rename = "expiresAt")]
415 expires_at: DateTime<Utc>,
416 },
417 Ready,
419 Pending,
421 Privileged,
423}
424
425#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
428#[bitwarden_ffi::wasm_record]
429#[serde(rename_all = "camelCase")]
430pub struct CipherAccessStateView {
431 pub cipher_id: CipherId,
433 pub active_lease: Option<AccessLeaseView>,
435 pub pending_request: Option<AccessRequestView>,
437 pub approved_request: Option<AccessRequestView>,
440 pub badge_state: AccessBadgeState,
442 pub extensions_allowed: bool,
444 pub max_extension_duration_seconds: Option<i32>,
447}
448
449impl TryFrom<CipherAccessStateResponseModel> for CipherAccessStateView {
450 type Error = PamDecodeError;
451
452 fn try_from(response: CipherAccessStateResponseModel) -> Result<Self, Self::Error> {
453 let active_lease = response
454 .active_lease
455 .map(|lease| AccessLeaseView::try_from(*lease))
456 .transpose()?;
457 let pending_request = response
458 .pending_request
459 .map(|request| AccessRequestView::try_from(*request))
460 .transpose()?;
461 let approved_request = response
462 .approved_request
463 .map(|request| AccessRequestView::try_from(*request))
464 .transpose()?;
465
466 let badge_state = if let Some(lease) = &active_lease {
467 AccessBadgeState::Active {
468 expires_at: lease.not_after,
469 }
470 } else if approved_request.is_some() {
471 AccessBadgeState::Ready
472 } else if pending_request.is_some() {
473 AccessBadgeState::Pending
474 } else {
475 AccessBadgeState::Privileged
476 };
477
478 Ok(Self {
479 cipher_id: CipherId::new(require!(response.cipher_id)),
480 active_lease,
481 pending_request,
482 approved_request,
483 badge_state,
484 extensions_allowed: require!(response.extensions_allowed),
485 max_extension_duration_seconds: response.max_extension_duration_seconds,
486 })
487 }
488}
489
490#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
495#[bitwarden_ffi::wasm_record]
496#[serde(rename_all = "camelCase")]
497pub struct AccessRequestCreateRequest {
498 pub duration_seconds: Option<NonZeroU32>,
500 pub start: Option<DateTime<Utc>>,
502 pub end: Option<DateTime<Utc>>,
504 pub reason: Option<String>,
506}
507
508impl TryFrom<AccessRequestCreateRequest> for AccessRequestCreateRequestModel {
509 type Error = AccessRequestWindowError;
510
511 fn try_from(request: AccessRequestCreateRequest) -> Result<Self, Self::Error> {
514 request.validate()?;
515
516 Ok(Self {
517 duration_seconds: request.duration_seconds.map(|d| d.get() as i32),
518 start: request
519 .start
520 .map(|d| d.to_rfc3339_opts(SecondsFormat::Millis, true)),
521 end: request
522 .end
523 .map(|d| d.to_rfc3339_opts(SecondsFormat::Millis, true)),
524 reason: request.reason,
525 })
526 }
527}
528
529#[cfg(test)]
530mod tests {
531 use bitwarden_api_api::models::{
532 AccessDeciderKind, AccessLeaseResponseModel, AccessLeaseStatus as ApiAccessLeaseStatus,
533 };
534 use uuid::{Uuid, uuid};
535
536 use super::*;
537
538 fn human_decision() -> AccessRequestDecisionResponseModel {
539 AccessRequestDecisionResponseModel {
540 decider_kind: Some(AccessDeciderKind::Human),
541 id: Some(Uuid::new_v4()),
542 name: Some("Ana Approver".to_string()),
543 email: Some("[email protected]".to_string()),
544 comment: Some("Looks fine".to_string()),
545 verdict: Some(ApiAccessDecisionVerdict::Approve),
546 decided_at: Some("2025-01-01T00:30:00Z".to_string()),
547 }
548 }
549
550 fn automatic_decision() -> AccessRequestDecisionResponseModel {
551 AccessRequestDecisionResponseModel {
552 decider_kind: Some(AccessDeciderKind::Automatic),
553 id: None,
554 name: None,
555 email: None,
556 comment: None,
557 verdict: Some(ApiAccessDecisionVerdict::Approve),
558 decided_at: Some("2025-01-01T00:00:05Z".to_string()),
559 }
560 }
561
562 fn full_response() -> AccessRequestDetailsResponseModel {
563 AccessRequestDetailsResponseModel {
564 id: Some(Uuid::new_v4()),
565 cipher_id: Some(Uuid::new_v4()),
566 collection_id: Some(Uuid::new_v4()),
567 organization_id: Some(Uuid::new_v4()),
568 requester_id: Some(Uuid::new_v4()),
569 rule_id: Some(Uuid::new_v4()),
570 status: Some(ApiAccessRequestStatus::Approved),
571 lease_not_before: Some("2025-01-01T00:00:00Z".to_string()),
572 lease_not_after: Some("2025-01-01T01:00:00Z".to_string()),
573 reason: Some("Need to fix an incident".to_string()),
574 submitted_at: Some("2025-01-01T00:00:00Z".to_string()),
575 resolved_at: Some("2025-01-01T00:30:00Z".to_string()),
576 decisions: Some(vec![automatic_decision(), human_decision()]),
577 produced_lease_id: Some(Uuid::new_v4()),
578 produced_lease_status: Some(ApiAccessLeaseStatus::Active),
579 extension_of_lease_id: Some(Uuid::new_v4()),
580 requester_name: Some("Rea Quester".to_string()),
581 requester_email: Some("[email protected]".to_string()),
582 ..Default::default()
583 }
584 }
585
586 #[test]
587 fn full_response_converts_decisions_and_lease_linkage() {
588 let response = full_response();
589 let expected_produced_lease_id = response.produced_lease_id.unwrap();
590 let expected_extension_of_lease_id = response.extension_of_lease_id.unwrap();
591
592 let view = AccessRequestView::try_from(response).unwrap();
593
594 assert_eq!(view.decisions.len(), 2);
595 assert_eq!(
596 view.produced_lease_id,
597 Some(AccessLeaseId::new(expected_produced_lease_id))
598 );
599 assert_eq!(view.produced_lease_status, Some(AccessLeaseStatus::Active));
600 assert_eq!(
601 view.extension_of_lease_id,
602 Some(AccessLeaseId::new(expected_extension_of_lease_id))
603 );
604 assert_eq!(view.requester_name, Some("Rea Quester".to_string()));
605 assert_eq!(view.requester_email, Some("[email protected]".to_string()));
606
607 assert!(matches!(
608 view.decisions[0].decider,
609 AccessDecider::Automatic
610 ));
611
612 let decision = &view.decisions[1];
613 let AccessDecider::Human(approver) = &decision.decider else {
614 panic!("expected a human decision, got {:?}", decision.decider);
615 };
616 assert_eq!(approver.name.as_deref(), Some("Ana Approver"));
617 assert_eq!(approver.email.as_deref(), Some("[email protected]"));
618 assert_eq!(decision.comment.as_deref(), Some("Looks fine"));
619 assert_eq!(decision.verdict, AccessDecisionVerdict::Approve);
620 }
621
622 #[test]
623 fn automatic_decision_has_no_approver_identity() {
624 let view = AccessRequestDecisionView::try_from(automatic_decision()).unwrap();
625
626 assert!(matches!(view.decider, AccessDecider::Automatic));
627 }
628
629 #[test]
630 fn missing_decisions_becomes_empty_vec() {
631 let response = AccessRequestDetailsResponseModel {
632 decisions: None,
633 ..full_response()
634 };
635
636 let view = AccessRequestView::try_from(response).unwrap();
637
638 assert_eq!(view.decisions, Vec::new());
639 }
640
641 #[test]
642 fn unknown_decider_kind_is_rejected() {
643 let response = AccessRequestDecisionResponseModel {
644 decider_kind: Some(AccessDeciderKind::__Unknown(99)),
645 ..human_decision()
646 };
647
648 assert!(AccessRequestDecisionView::try_from(response).is_err());
649 }
650
651 #[test]
652 fn unknown_verdict_maps_to_unknown() {
653 let response = AccessRequestDecisionResponseModel {
654 verdict: Some(ApiAccessDecisionVerdict::__Unknown(99)),
655 ..human_decision()
656 };
657
658 let view = AccessRequestDecisionView::try_from(response).unwrap();
659
660 assert_eq!(view.verdict, AccessDecisionVerdict::Unknown);
661 }
662
663 #[test]
664 fn human_decision_serializes_with_nested_approver() {
665 let view = AccessRequestDecisionView::try_from(human_decision()).unwrap();
666
667 let json = serde_json::to_value(&view).unwrap();
668
669 assert_eq!(json["decider"]["human"]["name"], "Ana Approver");
670 assert_eq!(json["decider"]["human"]["email"], "[email protected]");
671 assert_eq!(json["verdict"], "approve");
672 assert!(json.get("decidedAt").is_some());
673 }
674
675 #[test]
676 fn automatic_decision_serializes_without_approver() {
677 let view = AccessRequestDecisionView::try_from(automatic_decision()).unwrap();
678
679 let json = serde_json::to_value(&view).unwrap();
680
681 assert_eq!(json["decider"], "automatic");
682 assert_eq!(json["verdict"], "approve");
683 }
684
685 fn request_id() -> AccessRequestId {
686 AccessRequestId::new(uuid!("44444444-4444-4444-4444-444444444444"))
687 }
688
689 fn cipher_id() -> uuid::Uuid {
690 uuid!("55555555-5555-5555-5555-555555555555")
691 }
692
693 #[test]
694 fn pre_check_view_converts() {
695 let response = AccessPreCheckResponseModel {
696 cipher_id: Some(cipher_id()),
697 approval_mode: Some(ApiAccessApprovalMode::Automatic),
698 has_active_lease: Some(true),
699 ..Default::default()
700 };
701
702 let view = AccessPreCheckView::try_from(response).unwrap();
703
704 assert_eq!(view.cipher_id, CipherId::new(cipher_id()));
705 assert_eq!(view.approval_mode, AccessApprovalMode::Automatic);
706 assert!(view.has_active_lease);
707 }
708
709 #[test]
710 fn pre_check_view_maps_unknown_approval_mode() {
711 let response = AccessPreCheckResponseModel {
712 approval_mode: Some(ApiAccessApprovalMode::__Unknown(99)),
713 ..AccessPreCheckResponseModel {
714 cipher_id: Some(cipher_id()),
715 has_active_lease: Some(false),
716 ..Default::default()
717 }
718 };
719
720 let view = AccessPreCheckView::try_from(response).unwrap();
721
722 assert_eq!(view.approval_mode, AccessApprovalMode::Unknown);
723 }
724
725 #[test]
726 fn pre_check_view_defaults_can_start_lease_to_true_when_absent() {
727 let response = pre_check_response(None, None);
728
729 let view = AccessPreCheckView::try_from(response).unwrap();
730
731 assert!(view.can_start_lease);
732 assert_eq!(view.slot_frees_at, None);
733 }
734
735 #[test]
736 fn pre_check_view_maps_a_taken_slot_and_its_free_time() {
737 let response = AccessPreCheckResponseModel {
738 can_start_lease: Some(false),
739 slot_frees_at: Some("2026-08-31T10:52:00Z".to_string()),
740 ..pre_check_response(None, None)
741 };
742
743 let view = AccessPreCheckView::try_from(response).unwrap();
744
745 assert!(!view.can_start_lease);
746 assert_eq!(
747 view.slot_frees_at,
748 Some("2026-08-31T10:52:00Z".parse::<DateTime<Utc>>().unwrap())
749 );
750 }
751
752 #[test]
753 fn pre_check_view_maps_a_taken_slot_without_a_free_time() {
754 let response = AccessPreCheckResponseModel {
755 can_start_lease: Some(false),
756 slot_frees_at: None,
757 ..pre_check_response(None, None)
758 };
759
760 let view = AccessPreCheckView::try_from(response).unwrap();
761
762 assert!(!view.can_start_lease);
763 assert_eq!(view.slot_frees_at, None);
764 }
765
766 fn pre_check_response(
767 default_duration_seconds: Option<i32>,
768 max_duration_seconds: Option<i32>,
769 ) -> AccessPreCheckResponseModel {
770 AccessPreCheckResponseModel {
771 cipher_id: Some(cipher_id()),
772 approval_mode: Some(ApiAccessApprovalMode::Automatic),
773 has_active_lease: Some(false),
774 default_duration_seconds,
775 max_duration_seconds,
776 ..Default::default()
777 }
778 }
779
780 #[test]
781 fn pre_check_view_carries_the_rules_duration_bounds() {
782 let view = AccessPreCheckView::try_from(pre_check_response(Some(900), Some(1800))).unwrap();
783
784 assert_eq!(view.default_duration_seconds, 900);
785 assert_eq!(view.max_duration_seconds, 1800);
786 }
787
788 #[test]
789 fn pre_check_view_falls_back_when_bounds_are_absent() {
790 let view = AccessPreCheckView::try_from(pre_check_response(None, None)).unwrap();
791
792 assert_eq!(
793 view.default_duration_seconds,
794 DEFAULT_REQUEST_ACCESS_DURATION_SECONDS
795 );
796 assert_eq!(view.max_duration_seconds, MAX_REQUEST_ACCESS_WINDOW_SECONDS);
797 }
798
799 #[test]
800 fn pre_check_view_clamps_default_to_max() {
801 let view = AccessPreCheckView::try_from(pre_check_response(Some(3600), Some(900))).unwrap();
804
805 assert_eq!(view.default_duration_seconds, 900);
806 assert_eq!(view.max_duration_seconds, 900);
807 }
808
809 #[test]
810 fn pre_check_view_publishes_the_rule_cap_narrowed_only_by_the_global_ceiling() {
811 let ceiling = i32::try_from(MAX_REQUEST_ACCESS_WINDOW_SECONDS).unwrap();
812 for (sent, expected) in [
813 (7 * 86_400, 7 * 86_400),
815 (ceiling + 1, MAX_REQUEST_ACCESS_WINDOW_SECONDS),
816 ] {
817 let view = AccessPreCheckView::try_from(pre_check_response(None, Some(sent))).unwrap();
818
819 assert_eq!(view.max_duration_seconds, expected, "cap {sent}");
820 }
821 }
822
823 #[test]
824 fn pre_check_view_treats_non_positive_bounds_as_absent() {
825 let view = AccessPreCheckView::try_from(pre_check_response(Some(0), Some(-1))).unwrap();
828
829 assert_eq!(
830 view.default_duration_seconds,
831 DEFAULT_REQUEST_ACCESS_DURATION_SECONDS
832 );
833 assert_eq!(view.max_duration_seconds, MAX_REQUEST_ACCESS_WINDOW_SECONDS);
834 }
835
836 fn sample_created_request() -> AccessRequestDetailsResponseModel {
837 AccessRequestDetailsResponseModel {
838 id: Some(request_id().into()),
839 cipher_id: Some(cipher_id()),
840 collection_id: Some(uuid!("66666666-6666-6666-6666-666666666666")),
841 organization_id: Some(uuid!("77777777-7777-7777-7777-777777777777")),
842 status: Some(ApiAccessRequestStatus::Pending),
843 lease_not_before: Some("2025-01-01T00:00:00Z".to_string()),
844 lease_not_after: Some("2025-01-01T01:00:00Z".to_string()),
845 reason: Some("Need to fix an incident".to_string()),
846 submitted_at: Some("2025-01-01T00:00:00Z".to_string()),
847 ..Default::default()
848 }
849 }
850
851 #[test]
852 fn access_request_summary_view_converts() {
853 let view = AccessRequestSummaryView::try_from(sample_created_request()).unwrap();
854
855 assert_eq!(view.id, request_id());
856 assert_eq!(view.cipher_id, CipherId::new(cipher_id()));
857 assert_eq!(view.status, AccessRequestStatus::Pending);
858 assert_eq!(view.reason, Some("Need to fix an incident".to_string()));
859 }
860
861 #[test]
862 fn access_request_result_view_converts() {
863 let response = AccessRequestResultResponseModel {
864 approval_mode: Some(ApiAccessApprovalMode::Human),
865 request: Some(Box::new(sample_created_request())),
866 ..Default::default()
867 };
868
869 let view = AccessRequestResultView::try_from(response).unwrap();
870
871 assert_eq!(view.approval_mode, AccessApprovalMode::Human);
872 assert_eq!(view.request.id, request_id());
873 }
874
875 #[test]
876 fn cipher_access_state_view_converts_when_nothing_active() {
877 let response = CipherAccessStateResponseModel {
878 cipher_id: Some(cipher_id()),
879 active_lease: None,
880 pending_request: None,
881 approved_request: None,
882 extensions_allowed: Some(false),
883 max_extension_duration_seconds: None,
884 ..Default::default()
885 };
886
887 let view = CipherAccessStateView::try_from(response).unwrap();
888
889 assert_eq!(view.cipher_id, CipherId::new(cipher_id()));
890 assert_eq!(view.active_lease, None);
891 assert_eq!(view.pending_request, None);
892 assert_eq!(view.approved_request, None);
893 assert_eq!(view.badge_state, AccessBadgeState::Privileged);
894 assert!(!view.extensions_allowed);
895 assert_eq!(view.max_extension_duration_seconds, None);
896 }
897
898 #[test]
899 fn cipher_access_state_view_converts_when_all_branches_populated() {
900 let response = CipherAccessStateResponseModel {
901 cipher_id: Some(cipher_id()),
902 active_lease: Some(Box::new(AccessLeaseResponseModel {
903 id: Some(uuid!("33333333-3333-3333-3333-333333333333")),
904 request_id: Some(request_id().into()),
905 cipher_id: Some(cipher_id()),
906 collection_id: Some(uuid!("66666666-6666-6666-6666-666666666666")),
907 requester_id: Some(uuid!("88888888-8888-8888-8888-888888888888")),
908 status: Some(ApiAccessLeaseStatus::Active),
909 not_before: Some("2025-01-01T00:00:00Z".to_string()),
910 not_after: Some("2025-01-01T01:00:00Z".to_string()),
911 ..Default::default()
912 })),
913 pending_request: Some(Box::new(full_response())),
914 approved_request: Some(Box::new(full_response())),
915 extensions_allowed: Some(true),
916 max_extension_duration_seconds: Some(3600),
917 ..Default::default()
918 };
919
920 let view = CipherAccessStateView::try_from(response).unwrap();
921
922 assert!(view.active_lease.is_some());
923 assert!(view.pending_request.is_some());
924 assert!(view.approved_request.is_some());
925 assert_eq!(
927 view.badge_state,
928 AccessBadgeState::Active {
929 expires_at: "2025-01-01T01:00:00Z".parse().unwrap()
930 }
931 );
932 assert!(view.extensions_allowed);
933 assert_eq!(view.max_extension_duration_seconds, Some(3600));
934 }
935
936 fn cipher_access_state_response_with(
937 active_lease: Option<Box<AccessLeaseResponseModel>>,
938 pending_request: Option<Box<AccessRequestDetailsResponseModel>>,
939 approved_request: Option<Box<AccessRequestDetailsResponseModel>>,
940 ) -> CipherAccessStateResponseModel {
941 CipherAccessStateResponseModel {
942 cipher_id: Some(cipher_id()),
943 active_lease,
944 pending_request,
945 approved_request,
946 extensions_allowed: Some(false),
947 max_extension_duration_seconds: None,
948 ..Default::default()
949 }
950 }
951
952 fn sample_active_lease() -> Box<AccessLeaseResponseModel> {
953 Box::new(AccessLeaseResponseModel {
954 id: Some(uuid!("33333333-3333-3333-3333-333333333333")),
955 request_id: Some(request_id().into()),
956 cipher_id: Some(cipher_id()),
957 collection_id: Some(uuid!("66666666-6666-6666-6666-666666666666")),
958 requester_id: Some(uuid!("88888888-8888-8888-8888-888888888888")),
959 status: Some(ApiAccessLeaseStatus::Active),
960 not_before: Some("2025-01-01T00:00:00Z".to_string()),
961 not_after: Some("2025-01-01T01:00:00Z".to_string()),
962 ..Default::default()
963 })
964 }
965
966 #[test]
967 fn badge_state_is_active_when_only_an_active_lease_is_present() {
968 let response = cipher_access_state_response_with(Some(sample_active_lease()), None, None);
969
970 let view = CipherAccessStateView::try_from(response).unwrap();
971
972 assert_eq!(
973 view.badge_state,
974 AccessBadgeState::Active {
975 expires_at: "2025-01-01T01:00:00Z".parse().unwrap()
976 }
977 );
978 }
979
980 #[test]
981 fn badge_state_is_ready_when_approved_request_is_present_without_a_lease() {
982 let response =
983 cipher_access_state_response_with(None, None, Some(Box::new(full_response())));
984
985 let view = CipherAccessStateView::try_from(response).unwrap();
986
987 assert_eq!(view.badge_state, AccessBadgeState::Ready);
988 }
989
990 #[test]
991 fn badge_state_prefers_ready_over_pending() {
992 let response = cipher_access_state_response_with(
993 None,
994 Some(Box::new(full_response())),
995 Some(Box::new(full_response())),
996 );
997
998 let view = CipherAccessStateView::try_from(response).unwrap();
999
1000 assert_eq!(view.badge_state, AccessBadgeState::Ready);
1001 }
1002
1003 #[test]
1004 fn badge_state_is_pending_when_only_a_pending_request_is_present() {
1005 let response =
1006 cipher_access_state_response_with(None, Some(Box::new(full_response())), None);
1007
1008 let view = CipherAccessStateView::try_from(response).unwrap();
1009
1010 assert_eq!(view.badge_state, AccessBadgeState::Pending);
1011 }
1012
1013 #[test]
1014 fn badge_state_is_privileged_when_nothing_is_present() {
1015 let response = cipher_access_state_response_with(None, None, None);
1016
1017 let view = CipherAccessStateView::try_from(response).unwrap();
1018
1019 assert_eq!(view.badge_state, AccessBadgeState::Privileged);
1020 }
1021
1022 #[test]
1023 fn active_badge_state_serializes_with_camel_case_expires_at() {
1024 let state = AccessBadgeState::Active {
1025 expires_at: "2025-01-01T01:00:00Z".parse().unwrap(),
1026 };
1027
1028 let json = serde_json::to_value(&state).unwrap();
1029
1030 assert_eq!(json["active"]["expiresAt"], "2025-01-01T01:00:00Z");
1031 }
1032
1033 #[test]
1034 fn unit_badge_states_serialize_as_bare_strings() {
1035 assert_eq!(
1036 serde_json::to_value(AccessBadgeState::Ready).unwrap(),
1037 serde_json::json!("ready")
1038 );
1039 assert_eq!(
1040 serde_json::to_value(AccessBadgeState::Pending).unwrap(),
1041 serde_json::json!("pending")
1042 );
1043 assert_eq!(
1044 serde_json::to_value(AccessBadgeState::Privileged).unwrap(),
1045 serde_json::json!("privileged")
1046 );
1047 }
1048
1049 #[test]
1052 fn access_request_create_request_converts_to_model() {
1053 let request = AccessRequestCreateRequest {
1054 duration_seconds: NonZeroU32::new(3600),
1055 start: Some("2099-01-01T00:00:00Z".parse().unwrap()),
1056 end: Some("2099-01-01T01:00:00Z".parse().unwrap()),
1057 reason: Some("Need access".to_string()),
1058 };
1059
1060 let model = AccessRequestCreateRequestModel::try_from(request).unwrap();
1061
1062 assert_eq!(model.duration_seconds, Some(3600));
1063 assert_eq!(model.start, Some("2099-01-01T00:00:00.000Z".to_string()));
1064 assert_eq!(model.end, Some("2099-01-01T01:00:00.000Z".to_string()));
1065 assert_eq!(model.reason, Some("Need access".to_string()));
1066 }
1067
1068 #[test]
1069 fn access_request_create_request_window_is_serialized_as_utc_with_a_z_designator() {
1070 let request = AccessRequestCreateRequest {
1071 start: Some("2099-06-15T13:30:00.250Z".parse().unwrap()),
1072 end: Some("2099-06-15T14:30:00Z".parse().unwrap()),
1073 ..Default::default()
1074 };
1075
1076 let model = AccessRequestCreateRequestModel::try_from(request).unwrap();
1077
1078 assert_eq!(model.start, Some("2099-06-15T13:30:00.250Z".to_string()));
1079 assert_eq!(model.end, Some("2099-06-15T14:30:00.000Z".to_string()));
1080 assert!(model.start.unwrap().ends_with('Z'));
1081 assert!(model.end.unwrap().ends_with('Z'));
1082 }
1083
1084 #[test]
1085 fn access_request_create_request_conversion_enforces_validation() {
1086 let request = AccessRequestCreateRequest {
1087 start: Some("2025-01-01T01:00:00Z".parse().unwrap()),
1088 end: Some("2025-01-01T00:00:00Z".parse().unwrap()),
1089 ..Default::default()
1090 };
1091
1092 let result = AccessRequestCreateRequestModel::try_from(request);
1093
1094 assert_eq!(
1095 result.unwrap_err(),
1096 AccessRequestWindowError::EndBeforeStart
1097 );
1098 }
1099
1100 #[test]
1101 fn awaiting_activation_is_true_for_an_approved_request_with_no_lease_yet() {
1102 let response = AccessRequestDetailsResponseModel {
1103 status: Some(ApiAccessRequestStatus::Approved),
1104 produced_lease_id: None,
1105 ..full_response()
1106 };
1107
1108 let view = AccessRequestView::try_from(response).unwrap();
1109
1110 assert!(view.awaiting_activation);
1111 }
1112
1113 #[test]
1116 fn awaiting_activation_is_false_once_the_request_has_minted_a_lease() {
1117 let response = AccessRequestDetailsResponseModel {
1118 status: Some(ApiAccessRequestStatus::Approved),
1119 produced_lease_id: Some(Uuid::new_v4()),
1120 ..full_response()
1121 };
1122
1123 let view = AccessRequestView::try_from(response).unwrap();
1124
1125 assert!(!view.awaiting_activation);
1126 assert_eq!(view.status, AccessRequestStatus::Approved);
1127 }
1128
1129 #[test]
1130 fn awaiting_activation_is_false_for_a_request_still_pending() {
1131 let response = AccessRequestDetailsResponseModel {
1132 status: Some(ApiAccessRequestStatus::Pending),
1133 produced_lease_id: None,
1134 ..full_response()
1135 };
1136
1137 let view = AccessRequestView::try_from(response).unwrap();
1138
1139 assert!(!view.awaiting_activation);
1140 }
1141
1142 #[test]
1143 fn human_decision_skips_the_automatic_one() {
1144 let response = AccessRequestDetailsResponseModel {
1145 decisions: Some(vec![automatic_decision(), human_decision()]),
1146 ..full_response()
1147 };
1148
1149 let view = AccessRequestView::try_from(response).unwrap();
1150
1151 let decision = view.human_decision.expect("a human decided this request");
1152 assert!(matches!(decision.decider, AccessDecider::Human(_)));
1153 assert_eq!(decision.comment.as_deref(), Some("Looks fine"));
1154 }
1155
1156 #[test]
1157 fn human_decision_is_none_when_only_an_access_rule_decided() {
1158 let response = AccessRequestDetailsResponseModel {
1159 decisions: Some(vec![automatic_decision()]),
1160 ..full_response()
1161 };
1162
1163 let view = AccessRequestView::try_from(response).unwrap();
1164
1165 assert!(view.human_decision.is_none());
1166 }
1167
1168 #[test]
1169 fn human_decision_is_none_while_the_request_is_undecided() {
1170 let response = AccessRequestDetailsResponseModel {
1171 decisions: Some(vec![]),
1172 ..full_response()
1173 };
1174
1175 let view = AccessRequestView::try_from(response).unwrap();
1176
1177 assert!(view.human_decision.is_none());
1178 }
1179}