Skip to content

Commit 31ea817

Browse files
committed
feat(pam): enrich AccessRequestView with decision log and lease linkage
Surface fields the "My requests" UI needs, mapped from AccessRequestDetailsResponseModel: a decision log (new AccessRequestDecisionView + AccessDeciderKind / AccessDecisionVerdict enums), produced/parent lease linkage (producedLeaseId / producedLeaseStatus / extensionOfLeaseId), and the denormalized requester name/email.
1 parent 38d173a commit 31ea817

3 files changed

Lines changed: 249 additions & 3 deletions

File tree

bitwarden_license/bitwarden-pam/src/access_requests/mod.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,7 @@ mod client;
2121
mod models;
2222

2323
pub use client::AccessRequestsClient;
24-
pub use models::{AccessRequestStatus, AccessRequestView};
24+
pub use models::{
25+
AccessDeciderKind, AccessDecisionVerdict, AccessRequestDecisionView, AccessRequestStatus,
26+
AccessRequestView,
27+
};

bitwarden_license/bitwarden-pam/src/access_requests/models.rs

Lines changed: 241 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
use bitwarden_api_api::models::{
2+
AccessDecisionVerdict as ApiAccessDecisionVerdict, AccessRequestDecisionResponseModel,
23
AccessRequestDetailsResponseModel, AccessRequestStatus as ApiAccessRequestStatus,
4+
DeciderKind as ApiDeciderKind,
35
};
46
use bitwarden_collections::collection::CollectionId;
57
use bitwarden_core::{OrganizationId, UserId, require};
@@ -9,7 +11,7 @@ use serde::{Deserialize, Serialize};
911
#[cfg(feature = "wasm")]
1012
use 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

91199
impl 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+
}

bitwarden_license/bitwarden-pam/src/lib.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ uuid_newtype!(pub AccessLeaseId);
1212
uuid_newtype!(pub AccessRequestId);
1313
uuid_newtype!(pub AccessRuleId);
1414

15-
pub use access_requests::{AccessRequestStatus, AccessRequestView, AccessRequestsClient};
15+
pub use access_requests::{
16+
AccessDeciderKind, AccessDecisionVerdict, AccessRequestDecisionView, AccessRequestStatus,
17+
AccessRequestView, AccessRequestsClient,
18+
};
1619
pub use access_rules::{
1720
AccessCondition, AccessRuleAddEditRequest, AccessRuleError, AccessRuleValidationError,
1821
AccessRuleView, AccessRulesClient, is_valid_cidr,

0 commit comments

Comments
 (0)