Skip to main content

bitwarden_pam/access_requests/
models.rs

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, Utc};
14use serde::{Deserialize, Serialize};
15#[cfg(feature = "wasm")]
16use tsify::Tsify;
17
18use crate::{
19    AccessLeaseId, AccessLeaseStatus, AccessRequestId, AccessRuleId, error::LeasingError,
20    leases::AccessLeaseView,
21};
22
23/// The lifecycle state of an access request.
24///
25/// The automatic (no human approval) path moves `Pending -> Approved`; the requester activates the
26/// approved request to mint a lease. Activation does not change the status — it is observed through
27/// [`produced_lease_id`](AccessRequestView::produced_lease_id) and
28/// [`produced_lease_status`](AccessRequestView::produced_lease_status). `Denied`, `Canceled`, and
29/// `Expired` are terminal states in which no lease exists.
30#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
31#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
32#[serde(rename_all = "snake_case")]
33pub enum AccessRequestStatus {
34    /// Awaiting a decision (or, on the automatic path, awaiting the server's auto-approval).
35    Pending,
36    /// Approved; the requester may activate it to mint a lease.
37    Approved,
38    /// Denied by an approver; terminal.
39    Denied,
40    /// Cancelled by the requester before resolution; terminal.
41    Canceled,
42    /// Approved but lapsed before the requester activated it; terminal.
43    Expired,
44    /// A status value this SDK version does not recognize. Kept as a distinct variant so listing
45    /// requests never fails on a newer server's status.
46    Unknown,
47}
48
49impl From<ApiAccessRequestStatus> for AccessRequestStatus {
50    fn from(status: ApiAccessRequestStatus) -> Self {
51        match status {
52            ApiAccessRequestStatus::Pending => Self::Pending,
53            ApiAccessRequestStatus::Approved => Self::Approved,
54            ApiAccessRequestStatus::Denied => Self::Denied,
55            ApiAccessRequestStatus::Cancelled => Self::Canceled,
56            ApiAccessRequestStatus::Expired => Self::Expired,
57            ApiAccessRequestStatus::__Unknown(_) => Self::Unknown,
58        }
59    }
60}
61
62/// An approver's verdict on an access request decision.
63#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
64#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
65#[serde(rename_all = "snake_case")]
66pub enum AccessDecisionVerdict {
67    /// The request was denied.
68    Deny,
69    /// The request was approved.
70    Approve,
71    /// A verdict value this SDK version does not recognize. Kept as a distinct variant so reading
72    /// a request's decision log never fails on a newer server's verdict.
73    Unknown,
74}
75
76impl From<ApiAccessDecisionVerdict> for AccessDecisionVerdict {
77    fn from(verdict: ApiAccessDecisionVerdict) -> Self {
78        match verdict {
79            ApiAccessDecisionVerdict::Deny => Self::Deny,
80            ApiAccessDecisionVerdict::Approve => Self::Approve,
81            ApiAccessDecisionVerdict::__Unknown(_) => Self::Unknown,
82        }
83    }
84}
85
86/// A single decision recorded on an access request's decision log.
87///
88/// Every decision carries a [`verdict`](Self::verdict), an optional [`comment`](Self::comment), and
89/// the time it was [`decided_at`](Self::decided_at). [`decider`](Self::decider) distinguishes an
90/// automatic (access-rule) decision from a human one and, for a human, carries the approver's
91/// identity.
92#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
93#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
94#[serde(rename_all = "camelCase")]
95pub struct AccessRequestDecisionView {
96    /// Who made the decision.
97    pub decider: AccessDecider,
98    /// The decision's verdict.
99    pub verdict: AccessDecisionVerdict,
100    /// The optional note recorded with the decision.
101    pub comment: Option<String>,
102    /// When the decision was recorded (UTC).
103    pub decided_at: DateTime<Utc>,
104}
105
106/// Who made a decision on an access request.
107#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
108#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
109#[serde(rename_all = "camelCase")]
110pub enum AccessDecider {
111    /// The decision was made automatically by the governing access rule; no human approval was
112    /// required.
113    Automatic,
114    /// The decision was made by a human approver, whose identity is denormalized by the server.
115    Human(AccessApprover),
116}
117
118/// The identity of a human approver, denormalized by the server.
119#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
120#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
121#[serde(rename_all = "camelCase")]
122pub struct AccessApprover {
123    /// The approver's user id; `None` when the server omitted it.
124    pub id: Option<UserId>,
125    /// The approver's display name; `None` when the user could not be resolved.
126    pub name: Option<String>,
127    /// The approver's email; `None` when the user could not be resolved.
128    pub email: Option<String>,
129}
130
131impl TryFrom<AccessRequestDecisionResponseModel> for AccessRequestDecisionView {
132    type Error = LeasingError;
133
134    fn try_from(response: AccessRequestDecisionResponseModel) -> Result<Self, Self::Error> {
135        let decider = match require!(response.decider_kind) {
136            ApiAccessDeciderKind::Automatic => AccessDecider::Automatic,
137            ApiAccessDeciderKind::Human => AccessDecider::Human(AccessApprover {
138                id: response.id.map(UserId::new),
139                name: response.name,
140                email: response.email,
141            }),
142            ApiAccessDeciderKind::__Unknown(_) => {
143                return Err(LeasingError::UnrecognizedDeciderKind);
144            }
145        };
146
147        Ok(Self {
148            decider,
149            verdict: AccessDecisionVerdict::from(require!(response.verdict)),
150            comment: response.comment,
151            decided_at: require!(response.decided_at).parse()?,
152        })
153    }
154}
155
156/// A decrypted view of an access request, as its requester sees it.
157///
158/// An access request is a member's ask to open a PAM-gated cipher. Once approved, the requester
159/// [`activate`](crate::AccessRequestsClient::activate)s it to mint an
160/// [`AccessLease`](crate::AccessLeaseView).
161#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
162#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
163#[serde(rename_all = "camelCase")]
164pub struct AccessRequestView {
165    /// The request's unique identifier.
166    pub id: AccessRequestId,
167    /// The cipher access was requested for.
168    pub cipher_id: CipherId,
169    /// The collection the cipher belongs to, through which the request is governed.
170    pub collection_id: CollectionId,
171    /// The organization that owns the cipher. None when the server omits it.
172    pub organization_id: Option<OrganizationId>,
173    /// The member who opened the request.
174    pub requester_id: UserId,
175    /// The access rule pinned to the request at submit time. None for requests created before rule
176    /// pinning existed.
177    pub rule_id: Option<AccessRuleId>,
178    /// The request's lifecycle state.
179    pub status: AccessRequestStatus,
180    /// The start of the activation window resolved at submit (UTC) - the earliest the request may
181    /// be promoted to a lease.
182    pub lease_not_before: DateTime<Utc>,
183    /// The end of the activation window resolved at submit (UTC).
184    pub lease_not_after: DateTime<Utc>,
185    /// The optional justification the requester supplied when opening the request.
186    pub reason: Option<String>,
187    /// When the request was opened (UTC).
188    pub submitted_at: DateTime<Utc>,
189    /// When the request was approved, denied, or cancelled (UTC); None while pending.
190    pub resolved_at: Option<DateTime<Utc>>,
191    /// The request's decision log, oldest first. Empty only while pending.
192    pub decisions: Vec<AccessRequestDecisionView>,
193    /// The lease produced once this (approved) request was activated. None until activation.
194    pub produced_lease_id: Option<AccessLeaseId>,
195    /// The status of the produced lease at the time this view was fetched. None until activation.
196    pub produced_lease_status: Option<AccessLeaseStatus>,
197    /// The parent lease this request extends, if it is an extension request. None otherwise.
198    pub extension_of_lease_id: Option<AccessLeaseId>,
199    /// The requester's display name, denormalized by the server. None only when the user could
200    /// not be resolved.
201    pub requester_name: Option<String>,
202    /// The requester's email, denormalized by the server. None only when the user could not be
203    /// resolved.
204    pub requester_email: Option<String>,
205}
206
207impl TryFrom<AccessRequestDetailsResponseModel> for AccessRequestView {
208    type Error = LeasingError;
209
210    fn try_from(response: AccessRequestDetailsResponseModel) -> Result<Self, Self::Error> {
211        Ok(Self {
212            id: AccessRequestId::new(require!(response.id)),
213            cipher_id: CipherId::new(require!(response.cipher_id)),
214            collection_id: CollectionId::new(require!(response.collection_id)),
215            organization_id: response.organization_id.map(OrganizationId::new),
216            requester_id: UserId::new(require!(response.requester_id)),
217            rule_id: response.rule_id.map(AccessRuleId::new),
218            status: AccessRequestStatus::from(require!(response.status)),
219            lease_not_before: require!(response.lease_not_before).parse()?,
220            lease_not_after: require!(response.lease_not_after).parse()?,
221            reason: response.reason,
222            submitted_at: require!(response.submitted_at).parse()?,
223            resolved_at: response.resolved_at.map(|d| d.parse()).transpose()?,
224            decisions: response
225                .decisions
226                .unwrap_or_default()
227                .into_iter()
228                .map(AccessRequestDecisionView::try_from)
229                .collect::<Result<Vec<_>, _>>()?,
230            produced_lease_id: response.produced_lease_id.map(AccessLeaseId::new),
231            produced_lease_status: response.produced_lease_status.map(AccessLeaseStatus::from),
232            extension_of_lease_id: response.extension_of_lease_id.map(AccessLeaseId::new),
233            requester_name: response.requester_name,
234            requester_email: response.requester_email,
235        })
236    }
237}
238
239/// The approval path a lease request will take, surfaced by
240/// [`pre_check`](crate::AccessRequestsClient::pre_check) so the client can present the right
241/// workflow before the requester commits.
242#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
243#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
244#[serde(rename_all = "snake_case")]
245pub enum AccessApprovalMode {
246    /// A request would be approved immediately - the client should let the requester pick a
247    /// duration.
248    Automatic,
249    /// A request would need an approver - the client should let the requester pick a window and
250    /// justify it.
251    Human,
252    /// An approval mode value this SDK version does not recognize.
253    Unknown,
254}
255
256impl From<ApiAccessApprovalMode> for AccessApprovalMode {
257    fn from(mode: ApiAccessApprovalMode) -> Self {
258        match mode {
259            ApiAccessApprovalMode::Automatic => Self::Automatic,
260            ApiAccessApprovalMode::Human => Self::Human,
261            ApiAccessApprovalMode::__Unknown(_) => Self::Unknown,
262        }
263    }
264}
265
266/// The resolved approval outcome for a cipher, read without submitting a request.
267#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
268#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
269#[serde(rename_all = "camelCase")]
270pub struct AccessPreCheckView {
271    /// The cipher this pre-check was resolved for.
272    pub cipher_id: CipherId,
273    /// The approval path a request for this cipher would take.
274    pub approval_mode: AccessApprovalMode,
275    /// True when the caller already holds an active lease: reveal the credential, no request
276    /// needed.
277    pub has_active_lease: bool,
278}
279
280impl TryFrom<AccessPreCheckResponseModel> for AccessPreCheckView {
281    type Error = LeasingError;
282
283    fn try_from(response: AccessPreCheckResponseModel) -> Result<Self, Self::Error> {
284        Ok(Self {
285            cipher_id: CipherId::new(require!(response.cipher_id)),
286            approval_mode: AccessApprovalMode::from(require!(response.approval_mode)),
287            has_active_lease: require!(response.has_active_lease),
288        })
289    }
290}
291
292/// A decrypted view of an access request as its requester sees it right after submitting it.
293///
294/// A lighter sibling of [`AccessRequestView`]: the create response doesn't carry a decision log,
295/// pinned rule, produced-lease linkage, or denormalized requester identity, since none of those
296/// exist yet for a request that was just opened.
297#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
298#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
299#[serde(rename_all = "camelCase")]
300pub struct AccessRequestSummaryView {
301    /// The request's unique identifier.
302    pub id: AccessRequestId,
303    /// The cipher access was requested for.
304    pub cipher_id: CipherId,
305    /// The collection the cipher belongs to, through which the request is governed.
306    pub collection_id: CollectionId,
307    /// The organization that owns the cipher. None when the server omits it.
308    pub organization_id: Option<OrganizationId>,
309    /// The request's lifecycle state.
310    pub status: AccessRequestStatus,
311    /// The start of the activation window resolved at submit (UTC).
312    pub lease_not_before: DateTime<Utc>,
313    /// The end of the activation window resolved at submit (UTC).
314    pub lease_not_after: DateTime<Utc>,
315    /// The optional justification the requester supplied when opening the request.
316    pub reason: Option<String>,
317    /// When the request was opened (UTC).
318    pub submitted_at: DateTime<Utc>,
319}
320
321impl TryFrom<AccessRequestDetailsResponseModel> for AccessRequestSummaryView {
322    type Error = LeasingError;
323
324    fn try_from(response: AccessRequestDetailsResponseModel) -> Result<Self, Self::Error> {
325        Ok(Self {
326            id: AccessRequestId::new(require!(response.id)),
327            cipher_id: CipherId::new(require!(response.cipher_id)),
328            collection_id: CollectionId::new(require!(response.collection_id)),
329            organization_id: response.organization_id.map(OrganizationId::new),
330            status: AccessRequestStatus::from(require!(response.status)),
331            lease_not_before: require!(response.lease_not_before).parse()?,
332            lease_not_after: require!(response.lease_not_after).parse()?,
333            reason: response.reason,
334            submitted_at: require!(response.submitted_at).parse()?,
335        })
336    }
337}
338
339/// The result of submitting a cipher-lease request.
340///
341/// No lease is minted at submit on either path - the requester
342/// [`activate`](crate::AccessRequestsClient::activate)s the request to start the lease.
343#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
344#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
345#[serde(rename_all = "camelCase")]
346pub struct AccessRequestResultView {
347    /// [`Automatic`](AccessApprovalMode::Automatic) when [`request`](Self::request) was approved
348    /// on submit and is ready to activate, [`Human`](AccessApprovalMode::Human) when it is
349    /// pending an approver.
350    pub approval_mode: AccessApprovalMode,
351    /// The request that was just submitted.
352    pub request: AccessRequestSummaryView,
353}
354
355impl TryFrom<AccessRequestResultResponseModel> for AccessRequestResultView {
356    type Error = LeasingError;
357
358    fn try_from(response: AccessRequestResultResponseModel) -> Result<Self, Self::Error> {
359        Ok(Self {
360            approval_mode: AccessApprovalMode::from(require!(response.approval_mode)),
361            request: AccessRequestSummaryView::try_from(*require!(response.request))?,
362        })
363    }
364}
365
366/// A single-snapshot read of the caller's access state for one cipher, powering the cipher-view
367/// banner and the vault-row badge.
368///
369/// At most one of [`active_lease`](Self::active_lease), [`pending_request`](Self::pending_request),
370/// and [`approved_request`](Self::approved_request) is meaningfully "next": an active lease
371/// authorizes access, a pending request awaits a decision, and an approved request awaits
372/// activation by the caller.
373#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
374#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
375#[serde(rename_all = "camelCase")]
376pub struct CipherAccessStateView {
377    /// The cipher this state was resolved for.
378    pub cipher_id: CipherId,
379    /// The caller's active lease over this cipher, if any.
380    pub active_lease: Option<AccessLeaseView>,
381    /// The caller's request awaiting a decision on this cipher, if any.
382    pub pending_request: Option<AccessRequestView>,
383    /// The caller's approved-but-not-yet-activated request on this cipher, if any. Lapsed
384    /// approvals are never surfaced here.
385    pub approved_request: Option<AccessRequestView>,
386    /// Whether the active lease can still be extended.
387    pub extensions_allowed: bool,
388    /// The longest a single extension of the active lease may run, in seconds; None when there is
389    /// no cap or no active lease.
390    pub max_extension_duration_seconds: Option<i32>,
391}
392
393impl TryFrom<CipherAccessStateResponseModel> for CipherAccessStateView {
394    type Error = LeasingError;
395
396    fn try_from(response: CipherAccessStateResponseModel) -> Result<Self, Self::Error> {
397        Ok(Self {
398            cipher_id: CipherId::new(require!(response.cipher_id)),
399            active_lease: response
400                .active_lease
401                .map(|lease| AccessLeaseView::try_from(*lease))
402                .transpose()?,
403            pending_request: response
404                .pending_request
405                .map(|request| AccessRequestView::try_from(*request))
406                .transpose()?,
407            approved_request: response
408                .approved_request
409                .map(|request| AccessRequestView::try_from(*request))
410                .transpose()?,
411            extensions_allowed: require!(response.extensions_allowed),
412            max_extension_duration_seconds: response.max_extension_duration_seconds,
413        })
414    }
415}
416
417/// Request to lease a cipher.
418///
419/// Supply [`duration_seconds`](Self::duration_seconds) for the automatic path, or
420/// [`start`](Self::start)/[`end`](Self::end) + [`reason`](Self::reason) for the human path. Run a
421/// [`pre_check`](crate::AccessRequestsClient::pre_check) first to know which shape the server
422/// expects.
423#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
424#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
425#[serde(rename_all = "camelCase")]
426pub struct AccessRequestCreateRequest {
427    /// How long the automatic path's lease should run, in seconds. None on the human path.
428    pub duration_seconds: Option<NonZeroU32>,
429    /// The start of the requested window (UTC). Required on the human path.
430    pub start: Option<DateTime<Utc>>,
431    /// The end of the requested window (UTC). Required on the human path.
432    pub end: Option<DateTime<Utc>>,
433    /// The justification recorded with the request. Required on the human path.
434    pub reason: Option<String>,
435}
436
437impl From<AccessRequestCreateRequest> for AccessRequestCreateRequestModel {
438    fn from(request: AccessRequestCreateRequest) -> Self {
439        Self {
440            duration_seconds: request.duration_seconds.map(|d| d.get() as i32),
441            start: request.start.map(|d| d.to_rfc3339()),
442            end: request.end.map(|d| d.to_rfc3339()),
443            reason: request.reason,
444        }
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use bitwarden_api_api::models::{
451        AccessDeciderKind, AccessLeaseResponseModel, AccessLeaseStatus as ApiAccessLeaseStatus,
452    };
453    use uuid::{Uuid, uuid};
454
455    use super::*;
456
457    fn human_decision() -> AccessRequestDecisionResponseModel {
458        AccessRequestDecisionResponseModel {
459            decider_kind: Some(AccessDeciderKind::Human),
460            id: Some(Uuid::new_v4()),
461            name: Some("Ana Approver".to_string()),
462            email: Some("[email protected]".to_string()),
463            comment: Some("Looks fine".to_string()),
464            verdict: Some(ApiAccessDecisionVerdict::Approve),
465            decided_at: Some("2025-01-01T00:30:00Z".to_string()),
466        }
467    }
468
469    fn automatic_decision() -> AccessRequestDecisionResponseModel {
470        AccessRequestDecisionResponseModel {
471            decider_kind: Some(AccessDeciderKind::Automatic),
472            id: None,
473            name: None,
474            email: None,
475            comment: None,
476            verdict: Some(ApiAccessDecisionVerdict::Approve),
477            decided_at: Some("2025-01-01T00:00:05Z".to_string()),
478        }
479    }
480
481    fn full_response() -> AccessRequestDetailsResponseModel {
482        AccessRequestDetailsResponseModel {
483            id: Some(Uuid::new_v4()),
484            cipher_id: Some(Uuid::new_v4()),
485            collection_id: Some(Uuid::new_v4()),
486            organization_id: Some(Uuid::new_v4()),
487            requester_id: Some(Uuid::new_v4()),
488            rule_id: Some(Uuid::new_v4()),
489            status: Some(ApiAccessRequestStatus::Approved),
490            lease_not_before: Some("2025-01-01T00:00:00Z".to_string()),
491            lease_not_after: Some("2025-01-01T01:00:00Z".to_string()),
492            reason: Some("Need to fix an incident".to_string()),
493            submitted_at: Some("2025-01-01T00:00:00Z".to_string()),
494            resolved_at: Some("2025-01-01T00:30:00Z".to_string()),
495            decisions: Some(vec![automatic_decision(), human_decision()]),
496            produced_lease_id: Some(Uuid::new_v4()),
497            produced_lease_status: Some(ApiAccessLeaseStatus::Active),
498            extension_of_lease_id: Some(Uuid::new_v4()),
499            requester_name: Some("Rea Quester".to_string()),
500            requester_email: Some("[email protected]".to_string()),
501            ..Default::default()
502        }
503    }
504
505    #[test]
506    fn full_response_converts_decisions_and_lease_linkage() {
507        let response = full_response();
508        let expected_produced_lease_id = response.produced_lease_id.unwrap();
509        let expected_extension_of_lease_id = response.extension_of_lease_id.unwrap();
510
511        let view = AccessRequestView::try_from(response).unwrap();
512
513        assert_eq!(view.decisions.len(), 2);
514        assert_eq!(
515            view.produced_lease_id,
516            Some(AccessLeaseId::new(expected_produced_lease_id))
517        );
518        assert_eq!(view.produced_lease_status, Some(AccessLeaseStatus::Active));
519        assert_eq!(
520            view.extension_of_lease_id,
521            Some(AccessLeaseId::new(expected_extension_of_lease_id))
522        );
523        assert_eq!(view.requester_name, Some("Rea Quester".to_string()));
524        assert_eq!(view.requester_email, Some("[email protected]".to_string()));
525
526        assert!(matches!(
527            view.decisions[0].decider,
528            AccessDecider::Automatic
529        ));
530
531        let decision = &view.decisions[1];
532        let AccessDecider::Human(approver) = &decision.decider else {
533            panic!("expected a human decision, got {:?}", decision.decider);
534        };
535        assert_eq!(approver.name.as_deref(), Some("Ana Approver"));
536        assert_eq!(approver.email.as_deref(), Some("[email protected]"));
537        assert_eq!(decision.comment.as_deref(), Some("Looks fine"));
538        assert_eq!(decision.verdict, AccessDecisionVerdict::Approve);
539    }
540
541    #[test]
542    fn automatic_decision_has_no_approver_identity() {
543        let view = AccessRequestDecisionView::try_from(automatic_decision()).unwrap();
544
545        assert!(matches!(view.decider, AccessDecider::Automatic));
546    }
547
548    #[test]
549    fn missing_decisions_becomes_empty_vec() {
550        let response = AccessRequestDetailsResponseModel {
551            decisions: None,
552            ..full_response()
553        };
554
555        let view = AccessRequestView::try_from(response).unwrap();
556
557        assert_eq!(view.decisions, Vec::new());
558    }
559
560    #[test]
561    fn unknown_decider_kind_is_rejected() {
562        let response = AccessRequestDecisionResponseModel {
563            decider_kind: Some(AccessDeciderKind::__Unknown(99)),
564            ..human_decision()
565        };
566
567        assert!(AccessRequestDecisionView::try_from(response).is_err());
568    }
569
570    #[test]
571    fn unknown_verdict_maps_to_unknown() {
572        let response = AccessRequestDecisionResponseModel {
573            verdict: Some(ApiAccessDecisionVerdict::__Unknown(99)),
574            ..human_decision()
575        };
576
577        let view = AccessRequestDecisionView::try_from(response).unwrap();
578
579        assert_eq!(view.verdict, AccessDecisionVerdict::Unknown);
580    }
581
582    #[test]
583    fn human_decision_serializes_with_nested_approver() {
584        let view = AccessRequestDecisionView::try_from(human_decision()).unwrap();
585
586        let json = serde_json::to_value(&view).unwrap();
587
588        assert_eq!(json["decider"]["human"]["name"], "Ana Approver");
589        assert_eq!(json["decider"]["human"]["email"], "[email protected]");
590        assert_eq!(json["verdict"], "approve");
591        assert!(json.get("decidedAt").is_some());
592    }
593
594    #[test]
595    fn automatic_decision_serializes_without_approver() {
596        let view = AccessRequestDecisionView::try_from(automatic_decision()).unwrap();
597
598        let json = serde_json::to_value(&view).unwrap();
599
600        assert_eq!(json["decider"], "automatic");
601        assert_eq!(json["verdict"], "approve");
602    }
603
604    fn request_id() -> AccessRequestId {
605        AccessRequestId::new(uuid!("44444444-4444-4444-4444-444444444444"))
606    }
607
608    fn cipher_id() -> uuid::Uuid {
609        uuid!("55555555-5555-5555-5555-555555555555")
610    }
611
612    #[test]
613    fn pre_check_view_converts() {
614        let response = AccessPreCheckResponseModel {
615            cipher_id: Some(cipher_id()),
616            approval_mode: Some(ApiAccessApprovalMode::Automatic),
617            has_active_lease: Some(true),
618            ..Default::default()
619        };
620
621        let view = AccessPreCheckView::try_from(response).unwrap();
622
623        assert_eq!(view.cipher_id, CipherId::new(cipher_id()));
624        assert_eq!(view.approval_mode, AccessApprovalMode::Automatic);
625        assert!(view.has_active_lease);
626    }
627
628    #[test]
629    fn pre_check_view_maps_unknown_approval_mode() {
630        let response = AccessPreCheckResponseModel {
631            approval_mode: Some(ApiAccessApprovalMode::__Unknown(99)),
632            ..AccessPreCheckResponseModel {
633                cipher_id: Some(cipher_id()),
634                has_active_lease: Some(false),
635                ..Default::default()
636            }
637        };
638
639        let view = AccessPreCheckView::try_from(response).unwrap();
640
641        assert_eq!(view.approval_mode, AccessApprovalMode::Unknown);
642    }
643
644    fn sample_created_request() -> AccessRequestDetailsResponseModel {
645        AccessRequestDetailsResponseModel {
646            id: Some(request_id().into()),
647            cipher_id: Some(cipher_id()),
648            collection_id: Some(uuid!("66666666-6666-6666-6666-666666666666")),
649            organization_id: Some(uuid!("77777777-7777-7777-7777-777777777777")),
650            status: Some(ApiAccessRequestStatus::Pending),
651            lease_not_before: Some("2025-01-01T00:00:00Z".to_string()),
652            lease_not_after: Some("2025-01-01T01:00:00Z".to_string()),
653            reason: Some("Need to fix an incident".to_string()),
654            submitted_at: Some("2025-01-01T00:00:00Z".to_string()),
655            ..Default::default()
656        }
657    }
658
659    #[test]
660    fn access_request_summary_view_converts() {
661        let view = AccessRequestSummaryView::try_from(sample_created_request()).unwrap();
662
663        assert_eq!(view.id, request_id());
664        assert_eq!(view.cipher_id, CipherId::new(cipher_id()));
665        assert_eq!(view.status, AccessRequestStatus::Pending);
666        assert_eq!(view.reason, Some("Need to fix an incident".to_string()));
667    }
668
669    #[test]
670    fn access_request_result_view_converts() {
671        let response = AccessRequestResultResponseModel {
672            approval_mode: Some(ApiAccessApprovalMode::Human),
673            request: Some(Box::new(sample_created_request())),
674            ..Default::default()
675        };
676
677        let view = AccessRequestResultView::try_from(response).unwrap();
678
679        assert_eq!(view.approval_mode, AccessApprovalMode::Human);
680        assert_eq!(view.request.id, request_id());
681    }
682
683    #[test]
684    fn cipher_access_state_view_converts_when_nothing_active() {
685        let response = CipherAccessStateResponseModel {
686            cipher_id: Some(cipher_id()),
687            active_lease: None,
688            pending_request: None,
689            approved_request: None,
690            extensions_allowed: Some(false),
691            max_extension_duration_seconds: None,
692            ..Default::default()
693        };
694
695        let view = CipherAccessStateView::try_from(response).unwrap();
696
697        assert_eq!(view.cipher_id, CipherId::new(cipher_id()));
698        assert_eq!(view.active_lease, None);
699        assert_eq!(view.pending_request, None);
700        assert_eq!(view.approved_request, None);
701        assert!(!view.extensions_allowed);
702        assert_eq!(view.max_extension_duration_seconds, None);
703    }
704
705    #[test]
706    fn cipher_access_state_view_converts_when_all_branches_populated() {
707        let response = CipherAccessStateResponseModel {
708            cipher_id: Some(cipher_id()),
709            active_lease: Some(Box::new(AccessLeaseResponseModel {
710                id: Some(uuid!("33333333-3333-3333-3333-333333333333")),
711                request_id: Some(request_id().into()),
712                cipher_id: Some(cipher_id()),
713                collection_id: Some(uuid!("66666666-6666-6666-6666-666666666666")),
714                requester_id: Some(uuid!("88888888-8888-8888-8888-888888888888")),
715                status: Some(ApiAccessLeaseStatus::Active),
716                not_before: Some("2025-01-01T00:00:00Z".to_string()),
717                not_after: Some("2025-01-01T01:00:00Z".to_string()),
718                ..Default::default()
719            })),
720            pending_request: Some(Box::new(full_response())),
721            approved_request: Some(Box::new(full_response())),
722            extensions_allowed: Some(true),
723            max_extension_duration_seconds: Some(3600),
724            ..Default::default()
725        };
726
727        let view = CipherAccessStateView::try_from(response).unwrap();
728
729        assert!(view.active_lease.is_some());
730        assert!(view.pending_request.is_some());
731        assert!(view.approved_request.is_some());
732        assert!(view.extensions_allowed);
733        assert_eq!(view.max_extension_duration_seconds, Some(3600));
734    }
735
736    #[test]
737    fn access_request_create_request_converts_to_model() {
738        let request = AccessRequestCreateRequest {
739            duration_seconds: NonZeroU32::new(3600),
740            start: Some("2025-01-01T00:00:00Z".parse().unwrap()),
741            end: Some("2025-01-01T01:00:00Z".parse().unwrap()),
742            reason: Some("Need access".to_string()),
743        };
744
745        let model = AccessRequestCreateRequestModel::from(request);
746
747        assert_eq!(model.duration_seconds, Some(3600));
748        assert_eq!(model.start, Some("2025-01-01T00:00:00+00:00".to_string()));
749        assert_eq!(model.end, Some("2025-01-01T01:00:00+00:00".to_string()));
750        assert_eq!(model.reason, Some("Need access".to_string()));
751    }
752}