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, 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/// The lifecycle state of an access request.
26///
27/// Activation does not change the status; it shows in
28/// [`produced_lease_id`](AccessRequestView::produced_lease_id) and
29/// [`produced_lease_status`](AccessRequestView::produced_lease_status).
30#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
31#[bitwarden_ffi::wasm_record]
32#[serde(rename_all = "snake_case")]
33pub enum AccessRequestStatus {
34    /// Awaiting an approver's decision.
35    Pending,
36    /// Approved; the requester may activate it to mint a lease.
37    Approved,
38    /// Denied by an approver, or automatically; terminal.
39    Denied,
40    /// Withdrawn by the requester before activation; terminal.
41    Canceled,
42    /// The window lapsed unanswered or unactivated; [`decisions`](AccessRequestView::decisions)
43    /// tells which. Terminal.
44    Expired,
45    /// A status value this SDK version does not recognize. Kept as a distinct variant so listing
46    /// requests never fails on a newer server's status.
47    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/// An approver's verdict on an access request decision.
64#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
65#[bitwarden_ffi::wasm_record]
66#[serde(rename_all = "snake_case")]
67pub enum AccessDecisionVerdict {
68    /// The request was denied.
69    Deny,
70    /// The request was approved.
71    Approve,
72    /// A verdict value this SDK version does not recognize. Kept as a distinct variant so reading
73    /// a request's decision log never fails on a newer server's verdict.
74    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/// A single decision recorded on an access request's decision log.
88#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
89#[bitwarden_ffi::wasm_record]
90#[serde(rename_all = "camelCase")]
91pub struct AccessRequestDecisionView {
92    /// Who made the decision.
93    pub decider: AccessDecider,
94    /// The decision's verdict.
95    pub verdict: AccessDecisionVerdict,
96    /// The optional note recorded with the decision.
97    pub comment: Option<String>,
98    /// When the decision was recorded (UTC).
99    pub decided_at: DateTime<Utc>,
100}
101
102/// Who made a decision on an access request.
103#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
104#[bitwarden_ffi::wasm_record]
105#[serde(rename_all = "camelCase")]
106pub enum AccessDecider {
107    /// Decided automatically by the governing access rule.
108    Automatic,
109    /// The decision was made by a human approver, whose identity is denormalized by the server.
110    Human(AccessApprover),
111}
112
113/// The identity of a human approver, denormalized by the server.
114#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
115#[bitwarden_ffi::wasm_record]
116#[serde(rename_all = "camelCase")]
117pub struct AccessApprover {
118    /// The approver's user id; `None` when the server omitted it.
119    pub id: Option<UserId>,
120    /// The approver's display name; `None` when the user could not be resolved.
121    pub name: Option<String>,
122    /// The approver's email; `None` when the user could not be resolved.
123    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/// An access request, as returned by the server.
152#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
153#[bitwarden_ffi::wasm_record]
154#[serde(rename_all = "camelCase")]
155pub struct AccessRequestView {
156    /// The request's unique identifier.
157    pub id: AccessRequestId,
158    /// The cipher access was requested for.
159    pub cipher_id: CipherId,
160    /// The collection the cipher belongs to, through which the request is governed.
161    pub collection_id: CollectionId,
162    /// The organization that owns the cipher. None when the server omits it.
163    pub organization_id: Option<OrganizationId>,
164    /// The member who opened the request.
165    pub requester_id: UserId,
166    /// The access rule pinned to the request at submit, if any.
167    pub rule_id: Option<AccessRuleId>,
168    /// The request's lifecycle state.
169    pub status: AccessRequestStatus,
170    /// The start of the activation window resolved at submit (UTC), the earliest the request may
171    /// be activated.
172    pub lease_not_before: DateTime<Utc>,
173    /// The end of the activation window resolved at submit (UTC).
174    pub lease_not_after: DateTime<Utc>,
175    /// The optional justification the requester supplied when opening the request.
176    pub reason: Option<String>,
177    /// When the request was opened (UTC).
178    pub submitted_at: DateTime<Utc>,
179    /// When the request was approved, denied, or cancelled (UTC); None while pending or expired.
180    pub resolved_at: Option<DateTime<Utc>>,
181    /// The request's decision log, oldest first.
182    pub decisions: Vec<AccessRequestDecisionView>,
183    /// The lease this request produced on activation. None until activation.
184    pub produced_lease_id: Option<AccessLeaseId>,
185    /// The status of the produced lease at the time this view was fetched. None until activation.
186    pub produced_lease_status: Option<AccessLeaseStatus>,
187    /// The produced lease's current end (UTC); None until activation. Unlike
188    /// [`lease_not_after`](Self::lease_not_after), this moves when the lease is extended.
189    pub produced_lease_not_after: Option<DateTime<Utc>>,
190    /// The parent lease, if this is an extension request.
191    pub extension_of_lease_id: Option<AccessLeaseId>,
192    /// The requester's display name, denormalized by the server. None only when the user could
193    /// not be resolved.
194    pub requester_name: Option<String>,
195    /// The requester's email, denormalized by the server. None only when the user could not be
196    /// resolved.
197    pub requester_email: Option<String>,
198    /// True while approved but not yet activated into a lease.
199    pub awaiting_activation: bool,
200    /// The approver's or holder's decision, if any. None for automatic decisions.
201    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/// The approval path an access request takes.
253#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
254#[bitwarden_ffi::wasm_record]
255#[serde(rename_all = "snake_case")]
256pub enum AccessApprovalMode {
257    /// Approved on submit; the requester picks a duration.
258    Automatic,
259    /// Needs an approver; the requester picks a window and gives a reason.
260    Human,
261    /// An approval mode value this SDK version does not recognize.
262    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/// The resolved approval outcome for a cipher, read without submitting a request.
276#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
277#[bitwarden_ffi::wasm_record]
278#[serde(rename_all = "camelCase")]
279pub struct AccessPreCheckView {
280    /// The cipher this pre-check was resolved for.
281    pub cipher_id: CipherId,
282    /// The approval path a request for this cipher would take.
283    pub approval_mode: AccessApprovalMode,
284    /// True when the caller already holds an active lease, so no request is needed.
285    pub has_active_lease: bool,
286    /// Duration to pre-select, in seconds; never above
287    /// [`max_duration_seconds`](Self::max_duration_seconds).
288    pub default_duration_seconds: u32,
289    /// Longest duration or window span, in seconds, that submit will accept for this cipher.
290    pub max_duration_seconds: u32,
291    /// False while another member holds this cipher's single active lease. A hint; the server
292    /// re-checks at start.
293    pub can_start_lease: bool,
294    /// When the lease holding the slot ends; None while
295    /// [`can_start_lease`](Self::can_start_lease) is true.
296    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        // Absent and non-positive both mean "no cap", resolving to the ceiling as the server's
304        // `EffectiveMax` does.
305        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            // Re-clamped here and server-side: the two bounds arrive as independent fields,
315            // so a default above the cap would pre-fill a value submit refuses.
316            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            // Fails open: reading absence as false would block every gated cipher.
321            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
327/// Reads an optional wire-side duration, mapping absent, zero, and negative alike onto `None` so
328/// the caller applies its fallback.
329fn positive_u32(value: Option<i32>) -> Option<u32> {
330    value.filter(|v| *v > 0).map(|v| v as u32)
331}
332
333/// A just-submitted access request, as its requester sees it; a lighter sibling of
334/// [`AccessRequestView`].
335#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
336#[bitwarden_ffi::wasm_record]
337#[serde(rename_all = "camelCase")]
338pub struct AccessRequestSummaryView {
339    /// The request's unique identifier.
340    pub id: AccessRequestId,
341    /// The cipher access was requested for.
342    pub cipher_id: CipherId,
343    /// The collection the cipher belongs to, through which the request is governed.
344    pub collection_id: CollectionId,
345    /// The organization that owns the cipher. None when the server omits it.
346    pub organization_id: Option<OrganizationId>,
347    /// The request's lifecycle state.
348    pub status: AccessRequestStatus,
349    /// The start of the activation window resolved at submit (UTC).
350    pub lease_not_before: DateTime<Utc>,
351    /// The end of the activation window resolved at submit (UTC).
352    pub lease_not_after: DateTime<Utc>,
353    /// The optional justification the requester supplied when opening the request.
354    pub reason: Option<String>,
355    /// When the request was opened (UTC).
356    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/// The result of submitting a cipher-lease request.
378///
379/// No lease is minted at submit on either path; the requester
380/// [`activate`](crate::AccessRequestsClient::activate)s the request to start the lease.
381#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
382#[bitwarden_ffi::wasm_record]
383#[serde(rename_all = "camelCase")]
384pub struct AccessRequestResultView {
385    /// [`Automatic`](AccessApprovalMode::Automatic) when [`request`](Self::request) was approved
386    /// on submit and is ready to activate, [`Human`](AccessApprovalMode::Human) when it is
387    /// pending an approver.
388    pub approval_mode: AccessApprovalMode,
389    /// The request that was just submitted.
390    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/// The single badge to show for a gated cipher: an active lease outranks an approved request,
405/// which outranks a pending one. Has no `unavailable` or `expired` state, since the access-state
406/// response covers only the caller.
407#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
408#[bitwarden_ffi::wasm_record]
409#[serde(rename_all = "camelCase")]
410pub enum AccessBadgeState {
411    /// The caller holds an active lease; the cipher is unlocked until it expires.
412    Active {
413        /// The active lease's access window close time (UTC).
414        #[serde(rename = "expiresAt")]
415        expires_at: DateTime<Utc>,
416    },
417    /// The caller's request was approved and is ready to be activated into a lease.
418    Ready,
419    /// The caller has a request awaiting a decision.
420    Pending,
421    /// No active lease, approved request, or pending request; the item is gated and resting.
422    Privileged,
423}
424
425/// A single-snapshot read of the caller's access state for one cipher, powering the cipher-view
426/// banner and the vault-row badge.
427#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
428#[bitwarden_ffi::wasm_record]
429#[serde(rename_all = "camelCase")]
430pub struct CipherAccessStateView {
431    /// The cipher this state was resolved for.
432    pub cipher_id: CipherId,
433    /// The caller's active lease over this cipher, if any.
434    pub active_lease: Option<AccessLeaseView>,
435    /// The caller's request awaiting a decision on this cipher, if any.
436    pub pending_request: Option<AccessRequestView>,
437    /// The caller's approved-but-not-yet-activated request on this cipher, if any. Lapsed
438    /// approvals are never surfaced here.
439    pub approved_request: Option<AccessRequestView>,
440    /// The single badge to show for this cipher; see [`AccessBadgeState`] for precedence.
441    pub badge_state: AccessBadgeState,
442    /// Whether the active lease can still be extended.
443    pub extensions_allowed: bool,
444    /// The longest a single extension of the active lease may run, in seconds; None without an
445    /// active lease or when its rule does not allow extensions.
446    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/// Request to lease a cipher.
491///
492/// Supply [`duration_seconds`](Self::duration_seconds) for the automatic path, or
493/// [`start`](Self::start)/[`end`](Self::end) + [`reason`](Self::reason) for the human path.
494#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
495#[bitwarden_ffi::wasm_record]
496#[serde(rename_all = "camelCase")]
497pub struct AccessRequestCreateRequest {
498    /// How long the automatic path's lease should run, in seconds. None on the human path.
499    pub duration_seconds: Option<NonZeroU32>,
500    /// The start of the requested window (UTC). Required on the human path.
501    pub start: Option<DateTime<Utc>>,
502    /// The end of the requested window (UTC). Required on the human path.
503    pub end: Option<DateTime<Utc>>,
504    /// The justification recorded with the request. Required on the human path.
505    pub reason: Option<String>,
506}
507
508impl TryFrom<AccessRequestCreateRequest> for AccessRequestCreateRequestModel {
509    type Error = AccessRequestWindowError;
510
511    /// Validates the request's activation window on the way to the wire model, so no path to the
512    /// server skips it.
513    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        // A rule left at a 1h default but capped at 15m; pre-filling the default
802        // would hand the requester a duration submit refuses.
803        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            // A multi-day cap under the ceiling must survive unclamped.
814            (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        // Zero means "no cap" and a negative only comes from a malformed response; neither should
826        // collapse the picker.
827        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        // An active lease outranks the (also-populated) approved and pending requests.
926        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    /// Far future because `validate` rejects an already-ended window; only the rendering is
1050    /// under test here.
1051    #[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    /// Activation does not change the status, so only the minted lease separates "still to start"
1114    /// from "already running".
1115    #[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}