Skip to main content

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}