1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::policy::ScopedPolicy;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct CostEstimate {
10 pub services: Vec<ServiceEstimate>,
12 pub total_min: f64,
14 pub total_max: f64,
15 pub risk_level: String,
17 pub warnings: Vec<String>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ServiceEstimate {
23 pub service: String,
24 pub actions: Vec<String>,
25 pub min_cost: f64,
26 pub max_cost: f64,
27 pub notes: String,
28}
29
30fn cost_profiles() -> HashMap<&'static str, ServiceCostProfile> {
34 HashMap::from([
35 ("s3", ServiceCostProfile {
36 read_actions: &["GetObject", "ListBucket", "ListAllMyBuckets", "GetBucketLocation", "HeadObject"],
37 write_actions: &["PutObject", "DeleteObject", "CreateBucket", "CopyObject"],
38 read_cost_per_1k: 0.0004, write_cost_per_1k: 0.005, data_cost_per_gb: 0.023, typical_requests: 100,
42 notes: "S3 pricing: GET $0.0004/1K, PUT $0.005/1K, storage $0.023/GB/mo",
43 }),
44 ("lambda", ServiceCostProfile {
45 read_actions: &["GetFunction", "ListFunctions", "GetFunctionConfiguration"],
46 write_actions: &["InvokeFunction", "UpdateFunctionCode", "UpdateFunctionConfiguration", "CreateFunction"],
47 read_cost_per_1k: 0.0,
48 write_cost_per_1k: 0.20, data_cost_per_gb: 0.0000167, typical_requests: 10,
51 notes: "Lambda: $0.20/1M requests + $0.0000166667/GB-s compute",
52 }),
53 ("dynamodb", ServiceCostProfile {
54 read_actions: &["GetItem", "Query", "Scan", "BatchGetItem", "DescribeTable", "ListTables"],
55 write_actions: &["PutItem", "UpdateItem", "DeleteItem", "BatchWriteItem"],
56 read_cost_per_1k: 0.00025, write_cost_per_1k: 0.00125, data_cost_per_gb: 0.25,
59 typical_requests: 100,
60 notes: "DynamoDB on-demand: $0.25/1M RRU, $1.25/1M WRU",
61 }),
62 ("ec2", ServiceCostProfile {
63 read_actions: &["DescribeInstances", "DescribeSecurityGroups", "DescribeSubnets", "DescribeVpcs", "DescribeImages"],
64 write_actions: &["RunInstances", "TerminateInstances", "StartInstances", "StopInstances", "CreateSecurityGroup"],
65 read_cost_per_1k: 0.0,
66 write_cost_per_1k: 0.0, data_cost_per_gb: 0.0,
68 typical_requests: 10,
69 notes: "EC2 API calls are free. Instance costs: $0.0116/hr (t3.micro) to $3.84/hr (p3.16xlarge)",
70 }),
71 ("iam", ServiceCostProfile {
72 read_actions: &["GetRole", "GetPolicy", "GetPolicyVersion", "ListRolePolicies", "ListAttachedRolePolicies"],
73 write_actions: &["CreateRole", "DeleteRole", "AttachRolePolicy", "DetachRolePolicy", "PutRolePolicy", "DeleteRolePolicy", "PassRole"],
74 read_cost_per_1k: 0.0,
75 write_cost_per_1k: 0.0,
76 data_cost_per_gb: 0.0,
77 typical_requests: 10,
78 notes: "IAM API calls are free. WARNING: IAM changes affect account security",
79 }),
80 ("sqs", ServiceCostProfile {
81 read_actions: &["ReceiveMessage", "GetQueueAttributes", "ListQueues"],
82 write_actions: &["SendMessage", "DeleteMessage", "CreateQueue"],
83 read_cost_per_1k: 0.0004,
84 write_cost_per_1k: 0.0004,
85 data_cost_per_gb: 0.0,
86 typical_requests: 100,
87 notes: "SQS: $0.40/1M requests (first 1M free)",
88 }),
89 ("sns", ServiceCostProfile {
90 read_actions: &["ListTopics", "GetTopicAttributes"],
91 write_actions: &["Publish"],
92 read_cost_per_1k: 0.0,
93 write_cost_per_1k: 0.0005,
94 data_cost_per_gb: 0.0,
95 typical_requests: 10,
96 notes: "SNS: $0.50/1M publishes",
97 }),
98 ("ecr", ServiceCostProfile {
99 read_actions: &["GetAuthorizationToken", "BatchCheckLayerAvailability", "GetDownloadUrlForLayer", "BatchGetImage", "DescribeRepositories"],
100 write_actions: &["PutImage", "InitiateLayerUpload", "UploadLayerPart", "CompleteLayerUpload", "CreateRepository"],
101 read_cost_per_1k: 0.0,
102 write_cost_per_1k: 0.0,
103 data_cost_per_gb: 0.10,
104 typical_requests: 10,
105 notes: "ECR: $0.10/GB/month storage. Data transfer charges apply",
106 }),
107 ("logs", ServiceCostProfile {
108 read_actions: &["GetLogEvents", "DescribeLogGroups", "DescribeLogStreams", "FilterLogEvents"],
109 write_actions: &["PutLogEvents", "CreateLogGroup", "CreateLogStream"],
110 read_cost_per_1k: 0.005,
111 write_cost_per_1k: 0.0,
112 data_cost_per_gb: 0.50,
113 typical_requests: 50,
114 notes: "CloudWatch Logs: $0.50/GB ingested, $0.005/1K queries",
115 }),
116 ("sts", ServiceCostProfile {
117 read_actions: &["GetCallerIdentity"],
118 write_actions: &["AssumeRole"],
119 read_cost_per_1k: 0.0,
120 write_cost_per_1k: 0.0,
121 data_cost_per_gb: 0.0,
122 typical_requests: 1,
123 notes: "STS API calls are free",
124 }),
125 ("cloudformation", ServiceCostProfile {
126 read_actions: &["DescribeStacks", "ListStacks", "GetTemplate"],
127 write_actions: &["CreateStack", "UpdateStack", "DeleteStack"],
128 read_cost_per_1k: 0.0,
129 write_cost_per_1k: 0.0,
130 data_cost_per_gb: 0.0,
131 typical_requests: 10,
132 notes: "CloudFormation: free for AWS resources, $0.0009/handler operation for third-party",
133 }),
134 ])
135}
136
137struct ServiceCostProfile {
138 read_actions: &'static [&'static str],
139 write_actions: &'static [&'static str],
140 read_cost_per_1k: f64,
141 write_cost_per_1k: f64,
142 data_cost_per_gb: f64,
143 typical_requests: u32,
144 notes: &'static str,
145}
146
147pub fn estimate(policy: &ScopedPolicy, ttl_seconds: u64) -> CostEstimate {
149 let profiles = cost_profiles();
150 let mut services = Vec::new();
151 let mut warnings = Vec::new();
152 let mut total_min = 0.0;
153 let mut total_max = 0.0;
154
155 let mut by_service: HashMap<String, Vec<String>> = HashMap::new();
157 for action in &policy.actions {
158 by_service
159 .entry(action.service.clone())
160 .or_default()
161 .push(action.action.clone());
162 }
163
164 let ttl_hours = ttl_seconds as f64 / 3600.0;
165
166 for (service, actions) in &by_service {
167 if let Some(profile) = profiles.get(service.as_str()) {
168 let mut read_count = 0;
169 let mut write_count = 0;
170
171 for action in actions {
172 if action == "*" {
173 read_count += profile.typical_requests;
175 write_count += profile.typical_requests;
176 warnings.push(format!(
177 "{}:* grants all actions — cost depends on actual usage",
178 service
179 ));
180 } else if profile.read_actions.iter().any(|r| action.starts_with(r) || *r == action.as_str()) {
181 read_count += profile.typical_requests;
182 } else if profile.write_actions.iter().any(|w| action.starts_with(w) || *w == action.as_str()) {
183 write_count += profile.typical_requests;
184 } else {
185 write_count += profile.typical_requests / 2;
187 }
188 }
189
190 let scale = (ttl_hours * 0.5).max(1.0); let scaled_reads = (read_count as f64 * scale) as u32;
193 let scaled_writes = (write_count as f64 * scale) as u32;
194
195 let read_cost = (scaled_reads as f64 / 1000.0) * profile.read_cost_per_1k;
196 let write_cost = (scaled_writes as f64 / 1000.0) * profile.write_cost_per_1k;
197 let min_cost = read_cost + write_cost;
198 let max_cost = min_cost * 10.0 + profile.data_cost_per_gb * 0.1;
200
201 total_min += min_cost;
202 total_max += max_cost;
203
204 services.push(ServiceEstimate {
205 service: service.clone(),
206 actions: actions.clone(),
207 min_cost,
208 max_cost,
209 notes: profile.notes.to_string(),
210 });
211
212 if service == "ec2" && actions.iter().any(|a| a.contains("RunInstances") || a == "*") {
214 warnings.push("ec2:RunInstances can launch instances costing up to $3.84/hr".to_string());
215 }
216 } else {
217 services.push(ServiceEstimate {
218 service: service.clone(),
219 actions: actions.clone(),
220 min_cost: 0.0,
221 max_cost: 0.0,
222 notes: "No cost estimate available for this service".to_string(),
223 });
224 }
225 }
226
227 let risk_level = if total_max > 10.0 || !warnings.is_empty() {
229 "high"
230 } else if total_max > 1.0 {
231 "medium"
232 } else {
233 "low"
234 }
235 .to_string();
236
237 if by_service.contains_key("iam") {
239 warnings.push("IAM actions can modify account security — review carefully".to_string());
240 }
241
242 services.sort_by(|a, b| b.max_cost.partial_cmp(&a.max_cost).unwrap_or(std::cmp::Ordering::Equal));
243
244 CostEstimate {
245 services,
246 total_min,
247 total_max,
248 risk_level,
249 warnings,
250 }
251}
252
253pub fn format_text(est: &CostEstimate) -> String {
255 let mut out = String::new();
256
257 out.push_str(&format!(
258 "Estimated cost: ${:.4} — ${:.4}\n",
259 est.total_min, est.total_max
260 ));
261 out.push_str(&format!("Risk level: {}\n\n", est.risk_level));
262
263 for svc in &est.services {
264 out.push_str(&format!(
265 " {} (${:.4} — ${:.4})\n",
266 svc.service, svc.min_cost, svc.max_cost
267 ));
268 out.push_str(&format!(" Actions: {}\n", svc.actions.join(", ")));
269 out.push_str(&format!(" {}\n", svc.notes));
270 }
271
272 if !est.warnings.is_empty() {
273 out.push_str("\nWarnings:\n");
274 for w in &est.warnings {
275 out.push_str(&format!(" ! {}\n", w));
276 }
277 }
278
279 out
280}
281
282pub fn format_json(est: &CostEstimate) -> String {
284 serde_json::to_string_pretty(est).unwrap_or_else(|_| "{}".to_string())
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn test_estimate_s3_readonly() {
293 let policy = ScopedPolicy::from_allow_str("s3:GetObject,s3:ListBucket").unwrap();
294 let est = estimate(&policy, 900);
295 assert_eq!(est.services.len(), 1);
296 assert_eq!(est.services[0].service, "s3");
297 assert!(est.total_min >= 0.0);
298 assert_eq!(est.risk_level, "low");
299 }
300
301 #[test]
302 fn test_estimate_ec2_high_risk() {
303 let policy = ScopedPolicy::from_allow_str("ec2:RunInstances,ec2:DescribeInstances").unwrap();
304 let est = estimate(&policy, 3600);
305 assert_eq!(est.risk_level, "high");
306 assert!(est.warnings.iter().any(|w| w.contains("RunInstances")));
307 }
308
309 #[test]
310 fn test_estimate_iam_warning() {
311 let policy = ScopedPolicy::from_allow_str("iam:CreateRole").unwrap();
312 let est = estimate(&policy, 900);
313 assert!(est.warnings.iter().any(|w| w.contains("IAM")));
314 }
315
316 #[test]
317 fn test_estimate_wildcard() {
318 let policy = ScopedPolicy::from_allow_str("s3:*").unwrap();
319 let est = estimate(&policy, 900);
320 assert!(est.warnings.iter().any(|w| w.contains("s3:*")));
321 }
322
323 #[test]
324 fn test_estimate_multi_service() {
325 let policy = ScopedPolicy::from_allow_str("s3:GetObject,lambda:InvokeFunction,dynamodb:Query").unwrap();
326 let est = estimate(&policy, 900);
327 assert_eq!(est.services.len(), 3);
328 }
329
330 #[test]
331 fn test_estimate_unknown_service() {
332 let policy = ScopedPolicy::from_allow_str("xray:GetTraceSummaries").unwrap();
333 let est = estimate(&policy, 900);
334 assert_eq!(est.services.len(), 1);
335 assert!(est.services[0].notes.contains("No cost estimate"));
336 }
337
338 #[test]
339 fn test_format_text() {
340 let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
341 let est = estimate(&policy, 900);
342 let text = format_text(&est);
343 assert!(text.contains("Estimated cost"));
344 assert!(text.contains("s3"));
345 }
346
347 #[test]
348 fn test_format_json() {
349 let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
350 let est = estimate(&policy, 900);
351 let json = format_json(&est);
352 assert!(json.contains("total_min"));
353 assert!(json.contains("risk_level"));
354 }
355
356 #[test]
357 fn test_longer_ttl_scales_cost() {
358 let policy = ScopedPolicy::from_allow_str("s3:GetObject,s3:PutObject").unwrap();
359 let short = estimate(&policy, 900); let long = estimate(&policy, 14400); assert!(long.total_max >= short.total_max);
363 }
364
365 #[test]
366 fn test_free_services() {
367 let policy = ScopedPolicy::from_allow_str("sts:GetCallerIdentity").unwrap();
368 let est = estimate(&policy, 900);
369 assert_eq!(est.total_min, 0.0);
370 }
371}