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 {
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 assert_eq!(req.triggered_actions.len(), 1);
228 }
229}