ironflow_store/entities/approval_requirement.rs
1//! Approval requirement -- how many votes, and from whom, a gate needs.
2//!
3//! The workflow handler computes the approvers of a gate in Rust when the gate
4//! opens; the engine records them as an [`ApprovalRequirement`] on the step.
5//! Every vote cast on the gate is then appended as a [`StepApproval`]. The gate
6//! resolves once the number of distinct votes reaches
7//! [`ApprovalRequirement::required_approvers`].
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13/// The approval requirement recorded when a gate opened.
14///
15/// A gate opened without approvers carries no requirement at all; the default
16/// value (one approval from anyone allowed to answer the gate) applies.
17///
18/// # Examples
19///
20/// ```
21/// use ironflow_store::entities::ApprovalRequirement;
22///
23/// let requirement = ApprovalRequirement {
24/// reason: Some("amount > 10k".to_string()),
25/// required_approvers: 2,
26/// approver_groups: vec!["finance".to_string()],
27/// };
28/// assert!(!requirement.is_satisfied_by(1));
29/// assert!(requirement.is_satisfied_by(2));
30/// assert!(!requirement.allows_everyone());
31/// ```
32#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34pub struct ApprovalRequirement {
35 /// Why the handler asked for these approvers, for the audit trail. Never
36 /// evaluated.
37 #[serde(default)]
38 pub reason: Option<String>,
39 /// Number of distinct approvals needed to resolve the gate.
40 pub required_approvers: u32,
41 /// Groups whose members may vote. Empty means anyone allowed to answer
42 /// the gate may vote.
43 #[serde(default)]
44 pub approver_groups: Vec<String>,
45}
46
47impl Default for ApprovalRequirement {
48 /// One approval, from anyone, no reason.
49 fn default() -> Self {
50 Self {
51 reason: None,
52 required_approvers: 1,
53 approver_groups: Vec::new(),
54 }
55 }
56}
57
58impl ApprovalRequirement {
59 /// Whether `received` distinct approvals are enough to resolve the gate.
60 ///
61 /// # Examples
62 ///
63 /// ```
64 /// use ironflow_store::entities::ApprovalRequirement;
65 ///
66 /// let requirement = ApprovalRequirement::default();
67 /// assert!(!requirement.is_satisfied_by(0));
68 /// assert!(requirement.is_satisfied_by(1));
69 /// ```
70 pub fn is_satisfied_by(&self, received: usize) -> bool {
71 received >= self.required_approvers as usize
72 }
73
74 /// Whether the requirement puts no group restriction on voters.
75 ///
76 /// # Examples
77 ///
78 /// ```
79 /// use ironflow_store::entities::ApprovalRequirement;
80 ///
81 /// assert!(ApprovalRequirement::default().allows_everyone());
82 /// ```
83 pub fn allows_everyone(&self) -> bool {
84 self.approver_groups.is_empty()
85 }
86}
87
88/// One vote cast on an approval gate.
89///
90/// Votes are unique per [`user_id`](StepApproval::user_id): an API key votes as
91/// its owner, and a user voting twice is ignored by the store.
92///
93/// # Examples
94///
95/// ```
96/// use chrono::Utc;
97/// use ironflow_store::entities::StepApproval;
98/// use uuid::Uuid;
99///
100/// let vote = StepApproval {
101/// user_id: Uuid::now_v7(),
102/// approved_by: "alice".to_string(),
103/// at: Utc::now(),
104/// };
105/// assert_eq!(vote.approved_by, "alice");
106/// ```
107#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109pub struct StepApproval {
110 /// The user who voted.
111 pub user_id: Uuid,
112 /// Display name of the voter at vote time.
113 pub approved_by: String,
114 /// When the vote was cast.
115 pub at: DateTime<Utc>,
116}
117
118#[cfg(test)]
119mod tests {
120 use serde_json::{from_str, from_value, json, to_string};
121
122 use super::*;
123
124 fn requirement(required: u32) -> ApprovalRequirement {
125 ApprovalRequirement {
126 reason: Some("production deploy".to_string()),
127 required_approvers: required,
128 approver_groups: vec!["sre".to_string()],
129 }
130 }
131
132 #[test]
133 fn requirement_serde_roundtrip() {
134 let req = requirement(3);
135 let json = to_string(&req).expect("serialize");
136 let back: ApprovalRequirement = from_str(&json).expect("deserialize");
137 assert_eq!(back, req);
138 }
139
140 #[test]
141 fn requirement_defaults_missing_reason_and_groups() {
142 let back: ApprovalRequirement =
143 from_value(json!({"required_approvers": 1})).expect("deserialize");
144 assert_eq!(back, ApprovalRequirement::default());
145 }
146
147 #[test]
148 fn requirement_written_by_approval_rules_still_deserializes() {
149 // Shape stored before approval rules were replaced by `Approvers`.
150 let back: ApprovalRequirement = from_value(json!({
151 "rule_index": 0,
152 "condition": "payload.amount > 10000",
153 "required_approvers": 2,
154 "approver_groups": ["finance"],
155 "evaluated": [{"index": 0, "condition": "payload.amount > 10000", "matched": true}]
156 }))
157 .expect("deserialize");
158 assert_eq!(back.reason, None);
159 assert_eq!(back.required_approvers, 2);
160 assert_eq!(back.approver_groups, vec!["finance"]);
161 }
162
163 #[test]
164 fn default_requires_one_approval_from_anyone() {
165 let req = ApprovalRequirement::default();
166 assert_eq!(req.reason, None);
167 assert_eq!(req.required_approvers, 1);
168 assert!(req.approver_groups.is_empty());
169 assert!(req.allows_everyone());
170 }
171
172 #[test]
173 fn is_satisfied_by_boundaries() {
174 let req = requirement(3);
175 assert!(!req.is_satisfied_by(0));
176 assert!(!req.is_satisfied_by(2));
177 assert!(req.is_satisfied_by(3));
178 assert!(req.is_satisfied_by(4));
179 assert!(!req.allows_everyone());
180 }
181
182 #[test]
183 fn step_approval_serde_roundtrip() {
184 let vote = StepApproval {
185 user_id: Uuid::now_v7(),
186 approved_by: "élodie".to_string(),
187 at: Utc::now(),
188 };
189 let json = to_string(&vote).expect("serialize");
190 let back: StepApproval = from_str(&json).expect("deserialize");
191 assert_eq!(back, vote);
192 }
193}