Skip to main content

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}