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.
131/// In non-TTY environments (CI, piped stdin), always denies to prevent
132/// unattended execution of high-risk actions.
133pub fn prompt_cli(required: &ApprovalRequired) -> bool {
134    use std::io::{IsTerminal, Write};
135
136    eprintln!("\n⚠  APPROVAL REQUIRED");
137    eprintln!("The following high-risk actions need approval:\n");
138    for action in &required.triggered_actions {
139        eprintln!("  • {}", action);
140    }
141    eprintln!(
142        "\nMatched approval rules: {}",
143        required.matched_rules.join(", ")
144    );
145
146    if !std::io::stdin().is_terminal() {
147        eprintln!("\nNon-interactive environment detected — denying by default.");
148        eprintln!("Set [approval] auto_deny = false or use a TTY for interactive approval.");
149        return false;
150    }
151
152    eprint!("\nProceed? [y/N] ");
153    let _ = std::io::stderr().flush();
154
155    let mut input = String::new();
156    if std::io::stdin().read_line(&mut input).is_ok() {
157        let trimmed = input.trim().to_lowercase();
158        trimmed == "y" || trimmed == "yes"
159    } else {
160        false
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    fn make_policy(actions: &[&str]) -> ScopedPolicy {
169        ScopedPolicy::from_allow_str(&actions.join(",")).unwrap()
170    }
171
172    fn make_config(patterns: &[&str]) -> ApprovalConfig {
173        ApprovalConfig {
174            require_for: patterns.iter().map(|s| s.to_string()).collect(),
175            slack_webhook: None,
176            auto_deny: None,
177        }
178    }
179
180    #[test]
181    fn test_no_approval_when_empty_config() {
182        let policy = make_policy(&["s3:GetObject", "s3:PutObject"]);
183        let config = ApprovalConfig::default();
184        assert!(check(&policy, &config).is_none());
185    }
186
187    #[test]
188    fn test_no_approval_when_no_match() {
189        let policy = make_policy(&["s3:GetObject", "s3:PutObject"]);
190        let config = make_config(&["iam:*", "billing:*"]);
191        assert!(check(&policy, &config).is_none());
192    }
193
194    #[test]
195    fn test_approval_required_exact_match() {
196        let policy = make_policy(&["iam:CreateRole", "s3:GetObject"]);
197        let config = make_config(&["iam:*"]);
198        let result = check(&policy, &config);
199        assert!(result.is_some());
200        let req = result.unwrap();
201        assert!(req
202            .triggered_actions
203            .contains(&"iam:CreateRole".to_string()));
204        assert_eq!(req.triggered_actions.len(), 1);
205    }
206
207    #[test]
208    fn test_approval_required_multiple_matches() {
209        let policy = make_policy(&["iam:CreateRole", "iam:DeleteRole", "s3:GetObject"]);
210        let config = make_config(&["iam:*"]);
211        let req = check(&policy, &config).unwrap();
212        assert_eq!(req.triggered_actions.len(), 2);
213        assert!(req
214            .triggered_actions
215            .contains(&"iam:CreateRole".to_string()));
216        assert!(req
217            .triggered_actions
218            .contains(&"iam:DeleteRole".to_string()));
219    }
220
221    #[test]
222    fn test_approval_multiple_rules() {
223        let policy = make_policy(&["iam:CreateRole", "organizations:ListAccounts"]);
224        let config = make_config(&["iam:*", "organizations:*"]);
225        let req = check(&policy, &config).unwrap();
226        assert_eq!(req.triggered_actions.len(), 2);
227        assert_eq!(req.matched_rules.len(), 2);
228    }
229
230    #[test]
231    fn test_approval_deduplicates() {
232        let policy = make_policy(&["iam:CreateRole"]);
233        let config = make_config(&["iam:*", "iam:CreateRole"]);
234        let req = check(&policy, &config).unwrap();
235        // triggered_actions should be deduped
236        assert_eq!(req.triggered_actions.len(), 1);
237    }
238}