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| AvError::InvalidPolicy(format!("Failed to send Slack approval request: {}", e)))?;
117
118    if !resp.status().is_success() {
119        return Err(AvError::InvalidPolicy(format!(
120            "Slack webhook returned {}",
121            resp.status()
122        )));
123    }
124
125    Ok(())
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    fn make_policy(actions: &[&str]) -> ScopedPolicy {
133        ScopedPolicy::from_allow_str(&actions.join(",")).unwrap()
134    }
135
136    fn make_config(patterns: &[&str]) -> ApprovalConfig {
137        ApprovalConfig {
138            require_for: patterns.iter().map(|s| s.to_string()).collect(),
139            slack_webhook: None,
140            auto_deny: None,
141        }
142    }
143
144    #[test]
145    fn test_no_approval_when_empty_config() {
146        let policy = make_policy(&["s3:GetObject", "s3:PutObject"]);
147        let config = ApprovalConfig::default();
148        assert!(check(&policy, &config).is_none());
149    }
150
151    #[test]
152    fn test_no_approval_when_no_match() {
153        let policy = make_policy(&["s3:GetObject", "s3:PutObject"]);
154        let config = make_config(&["iam:*", "billing:*"]);
155        assert!(check(&policy, &config).is_none());
156    }
157
158    #[test]
159    fn test_approval_required_exact_match() {
160        let policy = make_policy(&["iam:CreateRole", "s3:GetObject"]);
161        let config = make_config(&["iam:*"]);
162        let result = check(&policy, &config);
163        assert!(result.is_some());
164        let req = result.unwrap();
165        assert!(req.triggered_actions.contains(&"iam:CreateRole".to_string()));
166        assert_eq!(req.triggered_actions.len(), 1);
167    }
168
169    #[test]
170    fn test_approval_required_multiple_matches() {
171        let policy = make_policy(&["iam:CreateRole", "iam:DeleteRole", "s3:GetObject"]);
172        let config = make_config(&["iam:*"]);
173        let req = check(&policy, &config).unwrap();
174        assert_eq!(req.triggered_actions.len(), 2);
175        assert!(req.triggered_actions.contains(&"iam:CreateRole".to_string()));
176        assert!(req.triggered_actions.contains(&"iam:DeleteRole".to_string()));
177    }
178
179    #[test]
180    fn test_approval_multiple_rules() {
181        let policy = make_policy(&["iam:CreateRole", "organizations:ListAccounts"]);
182        let config = make_config(&["iam:*", "organizations:*"]);
183        let req = check(&policy, &config).unwrap();
184        assert_eq!(req.triggered_actions.len(), 2);
185        assert_eq!(req.matched_rules.len(), 2);
186    }
187
188    #[test]
189    fn test_approval_deduplicates() {
190        let policy = make_policy(&["iam:CreateRole"]);
191        let config = make_config(&["iam:*", "iam:CreateRole"]);
192        let req = check(&policy, &config).unwrap();
193        // triggered_actions should be deduped
194        assert_eq!(req.triggered_actions.len(), 1);
195    }
196}
197
198/// Prompt for interactive approval on the CLI. Returns true if approved.
199pub fn prompt_cli(required: &ApprovalRequired) -> bool {
200    use std::io::Write;
201
202    eprintln!("\n⚠  APPROVAL REQUIRED");
203    eprintln!("The following high-risk actions need approval:\n");
204    for action in &required.triggered_actions {
205        eprintln!("  • {}", action);
206    }
207    eprintln!("\nMatched approval rules: {}", required.matched_rules.join(", "));
208    eprint!("\nProceed? [y/N] ");
209    let _ = std::io::stderr().flush();
210
211    let mut input = String::new();
212    if std::io::stdin().read_line(&mut input).is_ok() {
213        let trimmed = input.trim().to_lowercase();
214        trimmed == "y" || trimmed == "yes"
215    } else {
216        false
217    }
218}