Skip to main content

tryaudex_core/
approval.rs

1use serde::{Deserialize, Serialize};
2
3use crate::error::{AvError, Result};
4use crate::policy::{ActionPattern, ScopedPolicy};
5
6/// Approval workflow configuration in `[approval]` config section.
7#[derive(Debug, Clone, Serialize, Deserialize, Default)]
8pub struct ApprovalConfig {
9    /// Action patterns that require approval (e.g. ["iam:*", "billing:*", "organizations:*"])
10    #[serde(default)]
11    pub require_for: Vec<String>,
12    /// Slack webhook URL to send approval requests to
13    pub slack_webhook: Option<String>,
14    /// Whether to auto-deny without prompting (for CI environments)
15    pub auto_deny: Option<bool>,
16}
17
18/// Result of an approval check.
19#[derive(Debug)]
20pub struct ApprovalRequired {
21    /// The actions that triggered the approval requirement
22    pub triggered_actions: Vec<String>,
23    /// The rules that were matched
24    pub matched_rules: Vec<String>,
25}
26
27/// Check if a policy contains actions that require approval.
28/// Returns None if no approval needed, Some with details if approval is required.
29pub fn check(policy: &ScopedPolicy, config: &ApprovalConfig) -> Option<ApprovalRequired> {
30    if config.require_for.is_empty() {
31        return None;
32    }
33
34    let approval_patterns: Vec<ActionPattern> = config
35        .require_for
36        .iter()
37        .filter_map(|p| ActionPattern::parse(p).ok())
38        .collect();
39
40    let mut triggered = Vec::new();
41    let mut matched = Vec::new();
42
43    for action in &policy.actions {
44        for pattern in &approval_patterns {
45            if pattern.matches(action) {
46                triggered.push(action.to_iam_action());
47                matched.push(pattern.to_iam_action());
48            }
49        }
50    }
51
52    if triggered.is_empty() {
53        None
54    } else {
55        triggered.sort();
56        triggered.dedup();
57        matched.sort();
58        matched.dedup();
59        Some(ApprovalRequired {
60            triggered_actions: triggered,
61            matched_rules: matched,
62        })
63    }
64}
65
66/// Send an approval request to Slack and return the message text.
67pub async fn notify_slack(
68    webhook_url: &str,
69    session_id: &str,
70    actions: &[String],
71    command: &[String],
72) -> Result<()> {
73    let action_list = actions
74        .iter()
75        .map(|a| format!("  • `{}`", a))
76        .collect::<Vec<_>>()
77        .join("\n");
78
79    let payload = serde_json::json!({
80        "blocks": [
81            {
82                "type": "header",
83                "text": {
84                    "type": "plain_text",
85                    "text": "šŸ” Audex Approval Required"
86                }
87            },
88            {
89                "type": "section",
90                "text": {
91                    "type": "mrkdwn",
92                    "text": format!(
93                        "*Session:* `{}`\n*Command:* `{}`\n\n*High-risk actions requested:*\n{}",
94                        &session_id[..8],
95                        command.join(" "),
96                        action_list
97                    )
98                }
99            },
100            {
101                "type": "context",
102                "elements": [{
103                    "type": "mrkdwn",
104                    "text": "Approve by running: `audex approve {}`"
105                }]
106            }
107        ]
108    });
109
110    let client = reqwest::Client::new();
111    let resp = client
112        .post(webhook_url)
113        .json(&payload)
114        .send()
115        .await
116        .map_err(|e| {
117            AvError::InvalidPolicy(format!("Failed to send Slack approval request: {}", e))
118        })?;
119
120    if !resp.status().is_success() {
121        return Err(AvError::InvalidPolicy(format!(
122            "Slack webhook returned {}",
123            resp.status()
124        )));
125    }
126
127    Ok(())
128}
129
130/// Prompt for interactive approval on the CLI. Returns true if approved.
131pub fn prompt_cli(required: &ApprovalRequired) -> bool {
132    use std::io::Write;
133
134    eprintln!("\n⚠  APPROVAL REQUIRED");
135    eprintln!("The following high-risk actions need approval:\n");
136    for action in &required.triggered_actions {
137        eprintln!("  • {}", action);
138    }
139    eprintln!(
140        "\nMatched approval rules: {}",
141        required.matched_rules.join(", ")
142    );
143    eprint!("\nProceed? [y/N] ");
144    let _ = std::io::stderr().flush();
145
146    let mut input = String::new();
147    if std::io::stdin().read_line(&mut input).is_ok() {
148        let trimmed = input.trim().to_lowercase();
149        trimmed == "y" || trimmed == "yes"
150    } else {
151        false
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    fn make_policy(actions: &[&str]) -> ScopedPolicy {
160        ScopedPolicy::from_allow_str(&actions.join(",")).unwrap()
161    }
162
163    fn make_config(patterns: &[&str]) -> ApprovalConfig {
164        ApprovalConfig {
165            require_for: patterns.iter().map(|s| s.to_string()).collect(),
166            slack_webhook: None,
167            auto_deny: None,
168        }
169    }
170
171    #[test]
172    fn test_no_approval_when_empty_config() {
173        let policy = make_policy(&["s3:GetObject", "s3:PutObject"]);
174        let config = ApprovalConfig::default();
175        assert!(check(&policy, &config).is_none());
176    }
177
178    #[test]
179    fn test_no_approval_when_no_match() {
180        let policy = make_policy(&["s3:GetObject", "s3:PutObject"]);
181        let config = make_config(&["iam:*", "billing:*"]);
182        assert!(check(&policy, &config).is_none());
183    }
184
185    #[test]
186    fn test_approval_required_exact_match() {
187        let policy = make_policy(&["iam:CreateRole", "s3:GetObject"]);
188        let config = make_config(&["iam:*"]);
189        let result = check(&policy, &config);
190        assert!(result.is_some());
191        let req = result.unwrap();
192        assert!(req
193            .triggered_actions
194            .contains(&"iam:CreateRole".to_string()));
195        assert_eq!(req.triggered_actions.len(), 1);
196    }
197
198    #[test]
199    fn test_approval_required_multiple_matches() {
200        let policy = make_policy(&["iam:CreateRole", "iam:DeleteRole", "s3:GetObject"]);
201        let config = make_config(&["iam:*"]);
202        let req = check(&policy, &config).unwrap();
203        assert_eq!(req.triggered_actions.len(), 2);
204        assert!(req
205            .triggered_actions
206            .contains(&"iam:CreateRole".to_string()));
207        assert!(req
208            .triggered_actions
209            .contains(&"iam:DeleteRole".to_string()));
210    }
211
212    #[test]
213    fn test_approval_multiple_rules() {
214        let policy = make_policy(&["iam:CreateRole", "organizations:ListAccounts"]);
215        let config = make_config(&["iam:*", "organizations:*"]);
216        let req = check(&policy, &config).unwrap();
217        assert_eq!(req.triggered_actions.len(), 2);
218        assert_eq!(req.matched_rules.len(), 2);
219    }
220
221    #[test]
222    fn test_approval_deduplicates() {
223        let policy = make_policy(&["iam:CreateRole"]);
224        let config = make_config(&["iam:*", "iam:CreateRole"]);
225        let req = check(&policy, &config).unwrap();
226        // triggered_actions should be deduped
227        assert_eq!(req.triggered_actions.len(), 1);
228    }
229}