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| 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 assert_eq!(req.triggered_actions.len(), 1);
195 }
196}
197
198pub 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}