11use bitwarden_api_api:: models:: {
2+ AccessDecisionVerdict as ApiAccessDecisionVerdict , AccessRequestDecisionResponseModel ,
23 AccessRequestDetailsResponseModel , AccessRequestStatus as ApiAccessRequestStatus ,
4+ DeciderKind as ApiDeciderKind ,
35} ;
46use bitwarden_collections:: collection:: CollectionId ;
57use bitwarden_core:: { OrganizationId , UserId , require} ;
@@ -9,7 +11,7 @@ use serde::{Deserialize, Serialize};
911#[ cfg( feature = "wasm" ) ]
1012use tsify:: Tsify ;
1113
12- use crate :: { AccessRequestId , AccessRuleId , error:: LeasingError } ;
14+ use crate :: { AccessLeaseId , AccessLeaseStatus , AccessRequestId , AccessRuleId , error:: LeasingError } ;
1315
1416/// The lifecycle state of an access request.
1517///
@@ -51,6 +53,98 @@ impl From<ApiAccessRequestStatus> for AccessRequestStatus {
5153 }
5254}
5355
56+ /// What produced a decision on an access request.
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" ) ]
60+ pub enum AccessDeciderKind {
61+ /// The decision was made automatically by the governing access rule; no human approval was
62+ /// required.
63+ Automatic ,
64+ /// The decision was made by a human approver.
65+ Human ,
66+ /// A decider kind value this SDK version does not recognize. Kept as a distinct variant so
67+ /// reading a request's decision log never fails on a newer server's decider kind.
68+ Unknown ,
69+ }
70+
71+ impl From < ApiDeciderKind > for AccessDeciderKind {
72+ fn from ( kind : ApiDeciderKind ) -> Self {
73+ match kind {
74+ ApiDeciderKind :: Automatic => Self :: Automatic ,
75+ ApiDeciderKind :: Human => Self :: Human ,
76+ ApiDeciderKind :: __Unknown( _) => Self :: Unknown ,
77+ }
78+ }
79+ }
80+
81+ /// An approver's verdict on an access request decision.
82+ #[ derive( Serialize , Deserialize , Debug , Clone , Copy , PartialEq , Eq ) ]
83+ #[ cfg_attr( feature = "wasm" , derive( Tsify ) , tsify( into_wasm_abi, from_wasm_abi) ) ]
84+ #[ serde( rename_all = "snake_case" ) ]
85+ pub enum AccessDecisionVerdict {
86+ /// The request was denied.
87+ Deny ,
88+ /// The request was approved.
89+ Approve ,
90+ /// A verdict value this SDK version does not recognize. Kept as a distinct variant so reading
91+ /// a request's decision log never fails on a newer server's verdict.
92+ Unknown ,
93+ }
94+
95+ impl From < ApiAccessDecisionVerdict > for AccessDecisionVerdict {
96+ fn from ( verdict : ApiAccessDecisionVerdict ) -> Self {
97+ match verdict {
98+ ApiAccessDecisionVerdict :: Deny => Self :: Deny ,
99+ ApiAccessDecisionVerdict :: Approve => Self :: Approve ,
100+ ApiAccessDecisionVerdict :: __Unknown( _) => Self :: Unknown ,
101+ }
102+ }
103+ }
104+
105+ /// A single decision recorded on an access request's decision log.
106+ ///
107+ /// For an [`Automatic`](AccessDeciderKind::Automatic) decision, `id`, `name`, and `email` are
108+ /// `None`; for a [`Human`](AccessDeciderKind::Human) decision they carry the approver's identity,
109+ /// denormalized by the server (`None` only when the user could not be resolved).
110+ #[ derive( Serialize , Deserialize , Debug , Clone , PartialEq ) ]
111+ #[ cfg_attr( feature = "wasm" , derive( Tsify ) , tsify( into_wasm_abi, from_wasm_abi) ) ]
112+ #[ serde( rename_all = "camelCase" ) ]
113+ pub struct AccessRequestDecisionView {
114+ /// Who made the decision.
115+ pub decider_kind : AccessDeciderKind ,
116+ /// The human approver's user id; `None` for an automatic decision.
117+ pub id : Option < UserId > ,
118+ /// The human approver's display name; `None` for an automatic decision, or when the user
119+ /// could not be resolved.
120+ pub name : Option < String > ,
121+ /// The human approver's email; `None` for an automatic decision, or when the user could not
122+ /// be resolved.
123+ pub email : Option < String > ,
124+ /// The optional note the approver left with the decision.
125+ pub comment : Option < String > ,
126+ /// The decision's verdict.
127+ pub verdict : AccessDecisionVerdict ,
128+ /// When the decision was recorded (UTC).
129+ pub decided_at : DateTime < Utc > ,
130+ }
131+
132+ impl TryFrom < AccessRequestDecisionResponseModel > for AccessRequestDecisionView {
133+ type Error = LeasingError ;
134+
135+ fn try_from ( response : AccessRequestDecisionResponseModel ) -> Result < Self , Self :: Error > {
136+ Ok ( Self {
137+ decider_kind : AccessDeciderKind :: from ( require ! ( response. decider_kind) ) ,
138+ id : response. id . map ( UserId :: new) ,
139+ name : response. name ,
140+ email : response. email ,
141+ comment : response. comment ,
142+ verdict : AccessDecisionVerdict :: from ( require ! ( response. verdict) ) ,
143+ decided_at : require ! ( response. decided_at) . parse ( ) ?,
144+ } )
145+ }
146+ }
147+
54148/// A decrypted view of an access request, as its requester sees it.
55149///
56150/// An access request is a member's ask to open a PAM-gated cipher. Once approved, the requester
@@ -86,6 +180,20 @@ pub struct AccessRequestView {
86180 pub submitted_at : DateTime < Utc > ,
87181 /// When the request was approved, denied, or cancelled (UTC); None while pending.
88182 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 > ,
89197}
90198
91199impl TryFrom < AccessRequestDetailsResponseModel > for AccessRequestView {
@@ -105,6 +213,138 @@ impl TryFrom<AccessRequestDetailsResponseModel> for AccessRequestView {
105213 reason : response. reason ,
106214 submitted_at : require ! ( response. submitted_at) . parse ( ) ?,
107215 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 ,
108227 } )
109228 }
110229}
230+
231+ #[ cfg( test) ]
232+ mod 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 ( "ana@example.com" . 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 ( "rea@example.com" . 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 ( "rea@example.com" . to_string( ) ) ) ;
306+
307+ let human = & view. decisions [ 1 ] ;
308+ assert_eq ! ( human. decider_kind, AccessDeciderKind :: Human ) ;
309+ assert_eq ! ( human. name, Some ( "Ana Approver" . to_string( ) ) ) ;
310+ assert_eq ! ( human. email, Some ( "ana@example.com" . to_string( ) ) ) ;
311+ assert_eq ! ( human. comment, Some ( "Looks fine" . to_string( ) ) ) ;
312+ assert_eq ! ( human. verdict, AccessDecisionVerdict :: Approve ) ;
313+ }
314+
315+ #[ test]
316+ fn automatic_decision_has_no_approver_identity ( ) {
317+ let view = AccessRequestDecisionView :: try_from ( automatic_decision ( ) ) . unwrap ( ) ;
318+
319+ assert_eq ! ( view. decider_kind, AccessDeciderKind :: Automatic ) ;
320+ assert_eq ! ( view. id, None ) ;
321+ assert_eq ! ( view. name, None ) ;
322+ assert_eq ! ( view. email, None ) ;
323+ }
324+
325+ #[ test]
326+ fn missing_decisions_becomes_empty_vec ( ) {
327+ let response = AccessRequestDetailsResponseModel {
328+ decisions : None ,
329+ ..full_response ( )
330+ } ;
331+
332+ let view = AccessRequestView :: try_from ( response) . unwrap ( ) ;
333+
334+ assert_eq ! ( view. decisions, Vec :: new( ) ) ;
335+ }
336+
337+ #[ test]
338+ fn unknown_decider_kind_and_verdict_map_to_unknown ( ) {
339+ let response = AccessRequestDecisionResponseModel {
340+ decider_kind : Some ( DeciderKind :: __Unknown ( 99 ) ) ,
341+ verdict : Some ( ApiAccessDecisionVerdict :: __Unknown ( 99 ) ) ,
342+ ..human_decision ( )
343+ } ;
344+
345+ let view = AccessRequestDecisionView :: try_from ( response) . unwrap ( ) ;
346+
347+ assert_eq ! ( view. decider_kind, AccessDeciderKind :: Unknown ) ;
348+ assert_eq ! ( view. verdict, AccessDecisionVerdict :: Unknown ) ;
349+ }
350+ }
0 commit comments