tryaudex_core/
approval.rs1use serde::{Deserialize, Serialize};
2
3use crate::error::{AvError, Result};
4use crate::policy::{ActionPattern, ScopedPolicy};
5
6#[derive(Debug, Clone, Serialize, Deserialize, Default)]
8pub struct ApprovalConfig {
9 #[serde(default)]
11 pub require_for: Vec<String>,
12 pub slack_webhook: Option<String>,
14 pub auto_deny: Option<bool>,
16}
17
18#[derive(Debug)]
20pub struct ApprovalRequired {
21 pub triggered_actions: Vec<String>,
23 pub matched_rules: Vec<String>,
25}
26
27pub 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
66pub 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
130pub 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 assert_eq!(req.triggered_actions.len(), 1);
237 }
238}