ironflow_store/entities/approval_requirement.rs
1//! Dynamic approval requirement -- how many votes, and from whom, a gate needs.
2//!
3//! When an approval gate opens, the engine evaluates the approval rules of the
4//! step in order and records the outcome as an [`ApprovalRequirement`] on the
5//! step. Every vote cast on the gate is then appended as a [`StepApproval`].
6//! The gate 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/// Outcome of evaluating one approval rule when a gate opens.
14///
15/// # Examples
16///
17/// ```
18/// use ironflow_store::entities::ApprovalRuleEvaluation;
19///
20/// let evaluation = ApprovalRuleEvaluation {
21/// index: 0,
22/// condition: "payload.amount > 10000".to_string(),
23/// matched: true,
24/// };
25/// assert!(evaluation.matched);
26/// ```
27#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub struct ApprovalRuleEvaluation {
30 /// Position of the rule in the step configuration (0-based).
31 pub index: u32,
32 /// Source of the rule condition.
33 pub condition: String,
34 /// Whether the condition evaluated to `true`.
35 pub matched: bool,
36}
37
38/// The approval requirement evaluated when a gate opened.
39///
40/// A step without approval rules carries no requirement at all; the default
41/// value (one approval from anyone allowed to answer the gate) applies.
42///
43/// # Examples
44///
45/// ```
46/// use ironflow_store::entities::ApprovalRequirement;
47///
48/// let requirement = ApprovalRequirement {
49/// rule_index: Some(0),
50/// condition: Some("payload.amount > 10000".to_string()),
51/// required_approvers: 2,
52/// approver_groups: vec!["finance".to_string()],
53/// evaluated: Vec::new(),
54/// };
55/// assert!(!requirement.is_satisfied_by(1));
56/// assert!(requirement.is_satisfied_by(2));
57/// assert!(!requirement.allows_everyone());
58/// ```
59#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct ApprovalRequirement {
62 /// Index of the matched rule. `None` means no rule matched and the default
63 /// requirement applies.
64 pub rule_index: Option<u32>,
65 /// Source of the matched rule condition.
66 pub condition: Option<String>,
67 /// Number of distinct approvals needed to resolve the gate.
68 pub required_approvers: u32,
69 /// Groups whose members may vote. Empty means anyone allowed to answer
70 /// the gate may vote.
71 #[serde(default)]
72 pub approver_groups: Vec<String>,
73 /// Rules evaluated in order, up to and including the matched one.
74 #[serde(default)]
75 pub evaluated: Vec<ApprovalRuleEvaluation>,
76}
77
78impl Default for ApprovalRequirement {
79 /// One approval, from anyone, no matched rule.
80 fn default() -> Self {
81 Self {
82 rule_index: None,
83 condition: None,
84 required_approvers: 1,
85 approver_groups: Vec::new(),
86 evaluated: Vec::new(),
87 }
88 }
89}
90
91impl ApprovalRequirement {
92 /// Whether `received` distinct approvals are enough to resolve the gate.
93 ///
94 /// # Examples
95 ///
96 /// ```
97 /// use ironflow_store::entities::ApprovalRequirement;
98 ///
99 /// let requirement = ApprovalRequirement::default();
100 /// assert!(!requirement.is_satisfied_by(0));
101 /// assert!(requirement.is_satisfied_by(1));
102 /// ```
103 pub fn is_satisfied_by(&self, received: usize) -> bool {
104 received >= self.required_approvers as usize
105 }
106
107 /// Whether the requirement puts no group restriction on voters.
108 ///
109 /// # Examples
110 ///
111 /// ```
112 /// use ironflow_store::entities::ApprovalRequirement;
113 ///
114 /// assert!(ApprovalRequirement::default().allows_everyone());
115 /// ```
116 pub fn allows_everyone(&self) -> bool {
117 self.approver_groups.is_empty()
118 }
119}
120
121/// One vote cast on an approval gate.
122///
123/// Votes are unique per [`user_id`](StepApproval::user_id): an API key votes as
124/// its owner, and a user voting twice is ignored by the store.
125///
126/// # Examples
127///
128/// ```
129/// use chrono::Utc;
130/// use ironflow_store::entities::StepApproval;
131/// use uuid::Uuid;
132///
133/// let vote = StepApproval {
134/// user_id: Uuid::now_v7(),
135/// approved_by: "alice".to_string(),
136/// at: Utc::now(),
137/// };
138/// assert_eq!(vote.approved_by, "alice");
139/// ```
140#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
141#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
142pub struct StepApproval {
143 /// The user who voted.
144 pub user_id: Uuid,
145 /// Display name of the voter at vote time.
146 pub approved_by: String,
147 /// When the vote was cast.
148 pub at: DateTime<Utc>,
149}
150
151#[cfg(test)]
152mod tests {
153 use serde_json::{from_str, from_value, json, to_string};
154
155 use super::*;
156
157 fn requirement(required: u32) -> ApprovalRequirement {
158 ApprovalRequirement {
159 rule_index: Some(1),
160 condition: Some("labels.env == \"production\"".to_string()),
161 required_approvers: required,
162 approver_groups: vec!["sre".to_string()],
163 evaluated: vec![
164 ApprovalRuleEvaluation {
165 index: 0,
166 condition: "payload.amount > 10000".to_string(),
167 matched: false,
168 },
169 ApprovalRuleEvaluation {
170 index: 1,
171 condition: "labels.env == \"production\"".to_string(),
172 matched: true,
173 },
174 ],
175 }
176 }
177
178 #[test]
179 fn requirement_serde_roundtrip() {
180 let req = requirement(3);
181 let json = to_string(&req).expect("serialize");
182 let back: ApprovalRequirement = from_str(&json).expect("deserialize");
183 assert_eq!(back, req);
184 }
185
186 #[test]
187 fn requirement_defaults_missing_lists() {
188 let back: ApprovalRequirement = from_value(json!({
189 "rule_index": null,
190 "condition": null,
191 "required_approvers": 1
192 }))
193 .expect("deserialize");
194 assert_eq!(back, ApprovalRequirement::default());
195 }
196
197 #[test]
198 fn default_requires_one_approval_from_anyone() {
199 let req = ApprovalRequirement::default();
200 assert_eq!(req.rule_index, None);
201 assert_eq!(req.condition, None);
202 assert_eq!(req.required_approvers, 1);
203 assert!(req.approver_groups.is_empty());
204 assert!(req.evaluated.is_empty());
205 assert!(req.allows_everyone());
206 }
207
208 #[test]
209 fn is_satisfied_by_boundaries() {
210 let req = requirement(3);
211 assert!(!req.is_satisfied_by(0));
212 assert!(!req.is_satisfied_by(2));
213 assert!(req.is_satisfied_by(3));
214 assert!(req.is_satisfied_by(4));
215 assert!(!req.allows_everyone());
216 }
217
218 #[test]
219 fn step_approval_serde_roundtrip() {
220 let vote = StepApproval {
221 user_id: Uuid::now_v7(),
222 approved_by: "élodie".to_string(),
223 at: Utc::now(),
224 };
225 let json = to_string(&vote).expect("serialize");
226 let back: StepApproval = from_str(&json).expect("deserialize");
227 assert_eq!(back, vote);
228 }
229}