Skip to main content

bitwarden_pam/access_requests/
models.rs

1use bitwarden_api_api::models::{
2    AccessDecisionVerdict as ApiAccessDecisionVerdict, AccessRequestDecisionResponseModel,
3    AccessRequestDetailsResponseModel, AccessRequestStatus as ApiAccessRequestStatus,
4    DeciderKind as ApiDeciderKind,
5};
6use bitwarden_collections::collection::CollectionId;
7use bitwarden_core::{OrganizationId, UserId, require};
8use bitwarden_vault::CipherId;
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11#[cfg(feature = "wasm")]
12use tsify::Tsify;
13
14use crate::{AccessLeaseId, AccessLeaseStatus, AccessRequestId, AccessRuleId, error::LeasingError};
15
16/// The lifecycle state of an access request.
17///
18/// The automatic (no human approval) path moves `Pending -> Approved -> Activated`; the requester
19/// activates the approved request to mint a lease. `Denied`, `Canceled`, and `Expired` are terminal
20/// states in which no lease exists.
21#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
22#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
23#[serde(rename_all = "snake_case")]
24pub enum AccessRequestStatus {
25    /// Awaiting a decision (or, on the automatic path, awaiting the server's auto-approval).
26    Pending,
27    /// Approved but not yet activated; the requester may activate it to mint a lease.
28    Approved,
29    /// Activated - a lease has been minted from this request.
30    Activated,
31    /// Denied by an approver; terminal.
32    Denied,
33    /// Cancelled by the requester before resolution; terminal.
34    Canceled,
35    /// Approved but lapsed before the requester activated it; terminal.
36    Expired,
37    /// A status value this SDK version does not recognize. Kept as a distinct variant so listing
38    /// requests never fails on a newer server's status.
39    Unknown,
40}
41
42impl From<ApiAccessRequestStatus> for AccessRequestStatus {
43    fn from(status: ApiAccessRequestStatus) -> Self {
44        match status {
45            ApiAccessRequestStatus::Pending => Self::Pending,
46            ApiAccessRequestStatus::Approved => Self::Approved,
47            ApiAccessRequestStatus::Activated => Self::Activated,
48            ApiAccessRequestStatus::Denied => Self::Denied,
49            ApiAccessRequestStatus::Canceled => Self::Canceled,
50            ApiAccessRequestStatus::Expired => Self::Expired,
51            ApiAccessRequestStatus::__Unknown(_) => Self::Unknown,
52        }
53    }
54}
55
56/// An approver's verdict on an access request decision.
57#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
58#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
59#[serde(rename_all = "snake_case")]
60pub enum AccessDecisionVerdict {
61    /// The request was denied.
62    Deny,
63    /// The request was approved.
64    Approve,
65    /// A verdict value this SDK version does not recognize. Kept as a distinct variant so reading
66    /// a request's decision log never fails on a newer server's verdict.
67    Unknown,
68}
69
70impl From<ApiAccessDecisionVerdict> for AccessDecisionVerdict {
71    fn from(verdict: ApiAccessDecisionVerdict) -> Self {
72        match verdict {
73            ApiAccessDecisionVerdict::Deny => Self::Deny,
74            ApiAccessDecisionVerdict::Approve => Self::Approve,
75            ApiAccessDecisionVerdict::__Unknown(_) => Self::Unknown,
76        }
77    }
78}
79
80/// A single decision recorded on an access request's decision log.
81///
82/// Every decision carries a [`verdict`](Self::verdict), an optional [`comment`](Self::comment), and
83/// the time it was [`decided_at`](Self::decided_at). [`decider`](Self::decider) distinguishes an
84/// automatic (access-rule) decision from a human one and, for a human, carries the approver's
85/// identity.
86#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
87#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
88#[serde(rename_all = "camelCase")]
89pub struct AccessRequestDecisionView {
90    /// Who made the decision.
91    pub decider: AccessDecider,
92    /// The decision's verdict.
93    pub verdict: AccessDecisionVerdict,
94    /// The optional note recorded with the decision.
95    pub comment: Option<String>,
96    /// When the decision was recorded (UTC).
97    pub decided_at: DateTime<Utc>,
98}
99
100/// Who made a decision on an access request.
101#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
102#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
103#[serde(rename_all = "camelCase")]
104pub enum AccessDecider {
105    /// The decision was made automatically by the governing access rule; no human approval was
106    /// required.
107    Automatic,
108    /// The decision was made by a human approver, whose identity is denormalized by the server.
109    Human(AccessApprover),
110}
111
112/// The identity of a human approver, denormalized by the server.
113#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
114#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
115#[serde(rename_all = "camelCase")]
116pub struct AccessApprover {
117    /// The approver's user id; `None` when the server omitted it.
118    pub id: Option<UserId>,
119    /// The approver's display name; `None` when the user could not be resolved.
120    pub name: Option<String>,
121    /// The approver's email; `None` when the user could not be resolved.
122    pub email: Option<String>,
123}
124
125impl TryFrom<AccessRequestDecisionResponseModel> for AccessRequestDecisionView {
126    type Error = LeasingError;
127
128    fn try_from(response: AccessRequestDecisionResponseModel) -> Result<Self, Self::Error> {
129        let decider = match require!(response.decider_kind) {
130            ApiDeciderKind::Automatic => AccessDecider::Automatic,
131            ApiDeciderKind::Human => AccessDecider::Human(AccessApprover {
132                id: response.id.map(UserId::new),
133                name: response.name,
134                email: response.email,
135            }),
136            ApiDeciderKind::__Unknown(_) => return Err(LeasingError::UnrecognizedDeciderKind),
137        };
138
139        Ok(Self {
140            decider,
141            verdict: AccessDecisionVerdict::from(require!(response.verdict)),
142            comment: response.comment,
143            decided_at: require!(response.decided_at).parse()?,
144        })
145    }
146}
147
148/// A decrypted view of an access request, as its requester sees it.
149///
150/// An access request is a member's ask to open a PAM-gated cipher. Once approved, the requester
151/// [`activate`](crate::AccessRequestsClient::activate)s it to mint an
152/// [`AccessLease`](crate::AccessLeaseView).
153#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
154#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
155#[serde(rename_all = "camelCase")]
156pub struct AccessRequestView {
157    /// The request's unique identifier.
158    pub id: AccessRequestId,
159    /// The cipher access was requested for.
160    pub cipher_id: CipherId,
161    /// The collection the cipher belongs to, through which the request is governed.
162    pub collection_id: CollectionId,
163    /// The organization that owns the cipher. None when the server omits it.
164    pub organization_id: Option<OrganizationId>,
165    /// The member who opened the request.
166    pub requester_id: UserId,
167    /// The access rule pinned to the request at submit time. None for requests created before rule
168    /// pinning existed.
169    pub rule_id: Option<AccessRuleId>,
170    /// The request's lifecycle state.
171    pub status: AccessRequestStatus,
172    /// The start of the activation window resolved at submit (UTC) - the earliest the request may
173    /// be promoted to a lease.
174    pub lease_not_before: DateTime<Utc>,
175    /// The end of the activation window resolved at submit (UTC).
176    pub lease_not_after: DateTime<Utc>,
177    /// The optional justification the requester supplied when opening the request.
178    pub reason: Option<String>,
179    /// When the request was opened (UTC).
180    pub submitted_at: DateTime<Utc>,
181    /// When the request was approved, denied, or cancelled (UTC); None while pending.
182    pub resolved_at: Option<DateTime<Utc>>,
183    /// The request's decision log, oldest first. Empty only while pending.
184    pub decisions: Vec<AccessRequestDecisionView>,
185    /// The lease produced once this (approved) request was activated. None until activation.
186    pub produced_lease_id: Option<AccessLeaseId>,
187    /// The status of the produced lease at the time this view was fetched. None until activation.
188    pub produced_lease_status: Option<AccessLeaseStatus>,
189    /// The parent lease this request extends, if it is an extension request. None otherwise.
190    pub extension_of_lease_id: Option<AccessLeaseId>,
191    /// The requester's display name, denormalized by the server. None only when the user could
192    /// not be resolved.
193    pub requester_name: Option<String>,
194    /// The requester's email, denormalized by the server. None only when the user could not be
195    /// resolved.
196    pub requester_email: Option<String>,
197}
198
199impl TryFrom<AccessRequestDetailsResponseModel> for AccessRequestView {
200    type Error = LeasingError;
201
202    fn try_from(response: AccessRequestDetailsResponseModel) -> Result<Self, Self::Error> {
203        Ok(Self {
204            id: AccessRequestId::new(require!(response.id)),
205            cipher_id: CipherId::new(require!(response.cipher_id)),
206            collection_id: CollectionId::new(require!(response.collection_id)),
207            organization_id: response.organization_id.map(OrganizationId::new),
208            requester_id: UserId::new(require!(response.requester_id)),
209            rule_id: response.rule_id.map(AccessRuleId::new),
210            status: AccessRequestStatus::from(require!(response.status)),
211            lease_not_before: require!(response.lease_not_before).parse()?,
212            lease_not_after: require!(response.lease_not_after).parse()?,
213            reason: response.reason,
214            submitted_at: require!(response.submitted_at).parse()?,
215            resolved_at: response.resolved_at.map(|d| d.parse()).transpose()?,
216            decisions: response
217                .decisions
218                .unwrap_or_default()
219                .into_iter()
220                .map(AccessRequestDecisionView::try_from)
221                .collect::<Result<Vec<_>, _>>()?,
222            produced_lease_id: response.produced_lease_id.map(AccessLeaseId::new),
223            produced_lease_status: response.produced_lease_status.map(AccessLeaseStatus::from),
224            extension_of_lease_id: response.extension_of_lease_id.map(AccessLeaseId::new),
225            requester_name: response.requester_name,
226            requester_email: response.requester_email,
227        })
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use bitwarden_api_api::models::{AccessLeaseStatus as ApiAccessLeaseStatus, DeciderKind};
234    use uuid::Uuid;
235
236    use super::*;
237
238    fn human_decision() -> AccessRequestDecisionResponseModel {
239        AccessRequestDecisionResponseModel {
240            decider_kind: Some(DeciderKind::Human),
241            id: Some(Uuid::new_v4()),
242            name: Some("Ana Approver".to_string()),
243            email: Some("[email protected]".to_string()),
244            comment: Some("Looks fine".to_string()),
245            verdict: Some(ApiAccessDecisionVerdict::Approve),
246            decided_at: Some("2025-01-01T00:30:00Z".to_string()),
247        }
248    }
249
250    fn automatic_decision() -> AccessRequestDecisionResponseModel {
251        AccessRequestDecisionResponseModel {
252            decider_kind: Some(DeciderKind::Automatic),
253            id: None,
254            name: None,
255            email: None,
256            comment: None,
257            verdict: Some(ApiAccessDecisionVerdict::Approve),
258            decided_at: Some("2025-01-01T00:00:05Z".to_string()),
259        }
260    }
261
262    fn full_response() -> AccessRequestDetailsResponseModel {
263        AccessRequestDetailsResponseModel {
264            id: Some(Uuid::new_v4()),
265            cipher_id: Some(Uuid::new_v4()),
266            collection_id: Some(Uuid::new_v4()),
267            organization_id: Some(Uuid::new_v4()),
268            requester_id: Some(Uuid::new_v4()),
269            rule_id: Some(Uuid::new_v4()),
270            status: Some(ApiAccessRequestStatus::Activated),
271            lease_not_before: Some("2025-01-01T00:00:00Z".to_string()),
272            lease_not_after: Some("2025-01-01T01:00:00Z".to_string()),
273            reason: Some("Need to fix an incident".to_string()),
274            submitted_at: Some("2025-01-01T00:00:00Z".to_string()),
275            resolved_at: Some("2025-01-01T00:30:00Z".to_string()),
276            decisions: Some(vec![automatic_decision(), human_decision()]),
277            produced_lease_id: Some(Uuid::new_v4()),
278            produced_lease_status: Some(ApiAccessLeaseStatus::Active),
279            extension_of_lease_id: Some(Uuid::new_v4()),
280            requester_name: Some("Rea Quester".to_string()),
281            requester_email: Some("[email protected]".to_string()),
282            ..Default::default()
283        }
284    }
285
286    #[test]
287    fn full_response_converts_decisions_and_lease_linkage() {
288        let response = full_response();
289        let expected_produced_lease_id = response.produced_lease_id.unwrap();
290        let expected_extension_of_lease_id = response.extension_of_lease_id.unwrap();
291
292        let view = AccessRequestView::try_from(response).unwrap();
293
294        assert_eq!(view.decisions.len(), 2);
295        assert_eq!(
296            view.produced_lease_id,
297            Some(AccessLeaseId::new(expected_produced_lease_id))
298        );
299        assert_eq!(view.produced_lease_status, Some(AccessLeaseStatus::Active));
300        assert_eq!(
301            view.extension_of_lease_id,
302            Some(AccessLeaseId::new(expected_extension_of_lease_id))
303        );
304        assert_eq!(view.requester_name, Some("Rea Quester".to_string()));
305        assert_eq!(view.requester_email, Some("[email protected]".to_string()));
306
307        assert!(matches!(
308            view.decisions[0].decider,
309            AccessDecider::Automatic
310        ));
311
312        let decision = &view.decisions[1];
313        let AccessDecider::Human(approver) = &decision.decider else {
314            panic!("expected a human decision, got {:?}", decision.decider);
315        };
316        assert_eq!(approver.name.as_deref(), Some("Ana Approver"));
317        assert_eq!(approver.email.as_deref(), Some("[email protected]"));
318        assert_eq!(decision.comment.as_deref(), Some("Looks fine"));
319        assert_eq!(decision.verdict, AccessDecisionVerdict::Approve);
320    }
321
322    #[test]
323    fn automatic_decision_has_no_approver_identity() {
324        let view = AccessRequestDecisionView::try_from(automatic_decision()).unwrap();
325
326        assert!(matches!(view.decider, AccessDecider::Automatic));
327    }
328
329    #[test]
330    fn missing_decisions_becomes_empty_vec() {
331        let response = AccessRequestDetailsResponseModel {
332            decisions: None,
333            ..full_response()
334        };
335
336        let view = AccessRequestView::try_from(response).unwrap();
337
338        assert_eq!(view.decisions, Vec::new());
339    }
340
341    #[test]
342    fn unknown_decider_kind_is_rejected() {
343        let response = AccessRequestDecisionResponseModel {
344            decider_kind: Some(DeciderKind::__Unknown(99)),
345            ..human_decision()
346        };
347
348        assert!(AccessRequestDecisionView::try_from(response).is_err());
349    }
350
351    #[test]
352    fn unknown_verdict_maps_to_unknown() {
353        let response = AccessRequestDecisionResponseModel {
354            verdict: Some(ApiAccessDecisionVerdict::__Unknown(99)),
355            ..human_decision()
356        };
357
358        let view = AccessRequestDecisionView::try_from(response).unwrap();
359
360        assert_eq!(view.verdict, AccessDecisionVerdict::Unknown);
361    }
362
363    #[test]
364    fn human_decision_serializes_with_nested_approver() {
365        let view = AccessRequestDecisionView::try_from(human_decision()).unwrap();
366
367        let json = serde_json::to_value(&view).unwrap();
368
369        assert_eq!(json["decider"]["human"]["name"], "Ana Approver");
370        assert_eq!(json["decider"]["human"]["email"], "[email protected]");
371        assert_eq!(json["verdict"], "approve");
372        assert!(json.get("decidedAt").is_some());
373    }
374
375    #[test]
376    fn automatic_decision_serializes_without_approver() {
377        let view = AccessRequestDecisionView::try_from(automatic_decision()).unwrap();
378
379        let json = serde_json::to_value(&view).unwrap();
380
381        assert_eq!(json["decider"], "automatic");
382        assert_eq!(json["verdict"], "approve");
383    }
384}