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> {
39 HashMap::from([
40 ("s3", ServiceCostProfile {
42 read_actions: &["GetObject", "ListBucket", "ListAllMyBuckets", "GetBucketLocation", "HeadObject"],
43 write_actions: &["PutObject", "DeleteObject", "CreateBucket", "CopyObject"],
44 read_cost_per_1k: 0.0004, write_cost_per_1k: 0.005, data_cost_per_gb: 0.023, typical_requests: 100,
48 notes: "S3 pricing: GET $0.0004/1K, PUT $0.005/1K, storage $0.023/GB/mo",
49 }),
50 ("lambda", ServiceCostProfile {
51 read_actions: &["GetFunction", "ListFunctions", "GetFunctionConfiguration"],
52 write_actions: &["InvokeFunction", "UpdateFunctionCode", "UpdateFunctionConfiguration", "CreateFunction"],
53 read_cost_per_1k: 0.0,
54 write_cost_per_1k: 0.20, data_cost_per_gb: 0.0000167, typical_requests: 10,
57 notes: "Lambda: $0.20/1M requests + $0.0000166667/GB-s compute",
58 }),
59 ("dynamodb", ServiceCostProfile {
60 read_actions: &["GetItem", "Query", "Scan", "BatchGetItem", "DescribeTable", "ListTables"],
61 write_actions: &["PutItem", "UpdateItem", "DeleteItem", "BatchWriteItem"],
62 read_cost_per_1k: 0.00025, write_cost_per_1k: 0.00125, data_cost_per_gb: 0.25,
65 typical_requests: 100,
66 notes: "DynamoDB on-demand: $0.25/1M RRU, $1.25/1M WRU",
67 }),
68 ("ec2", ServiceCostProfile {
69 read_actions: &["DescribeInstances", "DescribeSecurityGroups", "DescribeSubnets", "DescribeVpcs", "DescribeImages"],
70 write_actions: &["RunInstances", "TerminateInstances", "StartInstances", "StopInstances", "CreateSecurityGroup"],
71 read_cost_per_1k: 0.0,
72 write_cost_per_1k: 0.0, data_cost_per_gb: 0.0,
74 typical_requests: 10,
75 notes: "EC2 API calls are free. Instance costs: $0.0116/hr (t3.micro) to $3.84/hr (p3.16xlarge)",
76 }),
77 ("iam", ServiceCostProfile {
78 read_actions: &["GetRole", "GetPolicy", "GetPolicyVersion", "ListRolePolicies", "ListAttachedRolePolicies"],
79 write_actions: &["CreateRole", "DeleteRole", "AttachRolePolicy", "DetachRolePolicy", "PutRolePolicy", "DeleteRolePolicy", "PassRole"],
80 read_cost_per_1k: 0.0,
81 write_cost_per_1k: 0.0,
82 data_cost_per_gb: 0.0,
83 typical_requests: 10,
84 notes: "IAM API calls are free. WARNING: IAM changes affect account security",
85 }),
86 ("sqs", ServiceCostProfile {
87 read_actions: &["ReceiveMessage", "GetQueueAttributes", "ListQueues"],
88 write_actions: &["SendMessage", "DeleteMessage", "CreateQueue"],
89 read_cost_per_1k: 0.0004,
90 write_cost_per_1k: 0.0004,
91 data_cost_per_gb: 0.0,
92 typical_requests: 100,
93 notes: "SQS: $0.40/1M requests (first 1M free)",
94 }),
95 ("sns", ServiceCostProfile {
96 read_actions: &["ListTopics", "GetTopicAttributes"],
97 write_actions: &["Publish"],
98 read_cost_per_1k: 0.0,
99 write_cost_per_1k: 0.0005,
100 data_cost_per_gb: 0.0,
101 typical_requests: 10,
102 notes: "SNS: $0.50/1M publishes",
103 }),
104 ("ecr", ServiceCostProfile {
105 read_actions: &["GetAuthorizationToken", "BatchCheckLayerAvailability", "GetDownloadUrlForLayer", "BatchGetImage", "DescribeRepositories"],
106 write_actions: &["PutImage", "InitiateLayerUpload", "UploadLayerPart", "CompleteLayerUpload", "CreateRepository"],
107 read_cost_per_1k: 0.0,
108 write_cost_per_1k: 0.0,
109 data_cost_per_gb: 0.10,
110 typical_requests: 10,
111 notes: "ECR: $0.10/GB/month storage. Data transfer charges apply",
112 }),
113 ("logs", ServiceCostProfile {
114 read_actions: &["GetLogEvents", "DescribeLogGroups", "DescribeLogStreams", "FilterLogEvents"],
115 write_actions: &["PutLogEvents", "CreateLogGroup", "CreateLogStream"],
116 read_cost_per_1k: 0.005,
117 write_cost_per_1k: 0.0,
118 data_cost_per_gb: 0.50,
119 typical_requests: 50,
120 notes: "CloudWatch Logs: $0.50/GB ingested, $0.005/1K queries",
121 }),
122 ("sts", ServiceCostProfile {
123 read_actions: &["GetCallerIdentity"],
124 write_actions: &["AssumeRole"],
125 read_cost_per_1k: 0.0,
126 write_cost_per_1k: 0.0,
127 data_cost_per_gb: 0.0,
128 typical_requests: 1,
129 notes: "STS API calls are free",
130 }),
131 ("cloudformation", ServiceCostProfile {
132 read_actions: &["DescribeStacks", "ListStacks", "GetTemplate"],
133 write_actions: &["CreateStack", "UpdateStack", "DeleteStack"],
134 read_cost_per_1k: 0.0,
135 write_cost_per_1k: 0.0,
136 data_cost_per_gb: 0.0,
137 typical_requests: 10,
138 notes: "CloudFormation: free for AWS resources, $0.0009/handler operation for third-party",
139 }),
140 ("bedrock", ServiceCostProfile {
146 read_actions: &["ListFoundationModels", "GetFoundationModel"],
147 write_actions: &["InvokeModel", "InvokeModelWithResponseStream"],
148 read_cost_per_1k: 0.0,
149 write_cost_per_1k: 3.0, data_cost_per_gb: 0.0,
151 typical_requests: 10,
152 notes: "Bedrock: $0.003-$0.06/1K input tokens depending on model. \
153 WARNING: This estimate is NOT token-volume aware — actual costs scale \
154 with prompt/completion length. Use for risk categorisation only, \
155 not budget enforcement.",
156 }),
157 ("sagemaker", ServiceCostProfile {
158 read_actions: &["DescribeEndpoint", "ListEndpoints", "DescribeTrainingJob"],
159 write_actions: &["CreateEndpoint", "CreateTrainingJob", "InvokeEndpoint"],
160 read_cost_per_1k: 0.0,
161 write_cost_per_1k: 0.0,
162 data_cost_per_gb: 0.0,
163 typical_requests: 5,
164 notes: "SageMaker: $0.046/hr (ml.t3.medium) to $32.77/hr (ml.p3.16xlarge). Training and inference billed separately",
165 }),
166 ("storage", ServiceCostProfile {
168 read_actions: &["objects.get", "objects.list", "buckets.get", "buckets.list"],
169 write_actions: &["objects.create", "objects.delete", "buckets.create"],
170 read_cost_per_1k: 0.0004, write_cost_per_1k: 0.005, data_cost_per_gb: 0.020, typical_requests: 100,
174 notes: "GCS pricing: Class A $0.05/10K, Class B $0.004/10K, storage $0.020/GB/mo",
175 }),
176 ("compute", ServiceCostProfile {
177 read_actions: &["instances.get", "instances.list", "zones.list"],
178 write_actions: &["instances.create", "instances.delete", "instances.start", "instances.stop"],
179 read_cost_per_1k: 0.0,
180 write_cost_per_1k: 0.0,
181 data_cost_per_gb: 0.0,
182 typical_requests: 10,
183 notes: "GCE API calls are free. Instance costs: $0.0076/hr (e2-micro) to $2.84/hr (a2-megagpu-16g)",
184 }),
185 ("bigquery", ServiceCostProfile {
186 read_actions: &["tables.getData", "jobs.get", "datasets.get"],
187 write_actions: &["jobs.create", "tables.create", "tables.insert"],
188 read_cost_per_1k: 0.0,
189 write_cost_per_1k: 0.0,
190 data_cost_per_gb: 0.00625, typical_requests: 10,
192 notes: "BigQuery on-demand: $6.25/TB queried, $0.02/GB/mo storage",
193 }),
194 ("cloudfunctions", ServiceCostProfile {
195 read_actions: &["functions.get", "functions.list"],
196 write_actions: &["functions.create", "functions.call"],
197 read_cost_per_1k: 0.0,
198 write_cost_per_1k: 0.0004, data_cost_per_gb: 0.0000025, typical_requests: 10,
201 notes: "Cloud Functions: $0.40/1M invocations + $0.0000025/GB-s compute",
202 }),
203 ("pubsub", ServiceCostProfile {
204 read_actions: &["subscriptions.pull", "topics.get"],
205 write_actions: &["topics.publish", "topics.create", "subscriptions.create"],
206 read_cost_per_1k: 0.0,
207 write_cost_per_1k: 0.0,
208 data_cost_per_gb: 0.04, typical_requests: 100,
210 notes: "Pub/Sub: $40/TiB message delivery (first 10 GiB free)",
211 }),
212 ("run", ServiceCostProfile {
213 read_actions: &["services.get", "services.list"],
214 write_actions: &["services.create", "services.replaceService"],
215 read_cost_per_1k: 0.0,
216 write_cost_per_1k: 0.0,
217 data_cost_per_gb: 0.0,
218 typical_requests: 5,
219 notes: "Cloud Run: $0.00002400/vCPU-s, $0.00000250/GiB-s, $0.40/1M requests",
220 }),
221 ("Microsoft.Storage", ServiceCostProfile {
223 read_actions: &["storageAccounts/read", "storageAccounts/blobServices/containers/blobs/read"],
224 write_actions: &["storageAccounts/write", "storageAccounts/blobServices/containers/blobs/write"],
225 read_cost_per_1k: 0.0004,
226 write_cost_per_1k: 0.005,
227 data_cost_per_gb: 0.018, typical_requests: 100,
229 notes: "Azure Blob: $0.018/GB/mo Hot, PUT $0.065/10K, GET $0.005/10K",
230 }),
231 ("Microsoft.Compute", ServiceCostProfile {
232 read_actions: &["virtualMachines/read", "disks/read"],
233 write_actions: &["virtualMachines/write", "virtualMachines/start/action", "virtualMachines/delete"],
234 read_cost_per_1k: 0.0,
235 write_cost_per_1k: 0.0,
236 data_cost_per_gb: 0.0,
237 typical_requests: 10,
238 notes: "Azure VM API calls are free. Instance costs: $0.008/hr (B1ls) to $3.045/hr (NC24s_v3)",
239 }),
240 ("Microsoft.Sql", ServiceCostProfile {
241 read_actions: &["servers/read", "servers/databases/read"],
242 write_actions: &["servers/write", "servers/databases/write"],
243 read_cost_per_1k: 0.0,
244 write_cost_per_1k: 0.0,
245 data_cost_per_gb: 0.0,
246 typical_requests: 5,
247 notes: "Azure SQL: $0.0211/hr (Basic) to $174.98/hr (Business Critical). Billed per DTU or vCore",
248 }),
249 ("Microsoft.ContainerService", ServiceCostProfile {
250 read_actions: &["managedClusters/read"],
251 write_actions: &["managedClusters/write", "managedClusters/delete"],
252 read_cost_per_1k: 0.0,
253 write_cost_per_1k: 0.0,
254 data_cost_per_gb: 0.0,
255 typical_requests: 5,
256 notes: "AKS: free control plane, node costs apply. Standard tier $0.10/hr per cluster",
257 }),
258 ("Microsoft.KeyVault", ServiceCostProfile {
259 read_actions: &["vaults/read", "vaults/secrets/read"],
260 write_actions: &["vaults/write", "vaults/secrets/write"],
261 read_cost_per_1k: 0.003, write_cost_per_1k: 0.003,
263 data_cost_per_gb: 0.0,
264 typical_requests: 10,
265 notes: "Key Vault: $0.03/10K operations (secrets), $1/key/mo (HSM-protected keys)",
266 }),
267 ("Microsoft.Web", ServiceCostProfile {
268 read_actions: &["sites/read", "serverFarms/read"],
269 write_actions: &["sites/write", "serverFarms/write"],
270 read_cost_per_1k: 0.0,
271 write_cost_per_1k: 0.0,
272 data_cost_per_gb: 0.0,
273 typical_requests: 5,
274 notes: "App Service: $0.018/hr (B1) to $0.80/hr (P3v3). Free tier available",
275 }),
276 ])
277}
278
279struct ServiceCostProfile {
280 read_actions: &'static [&'static str],
281 write_actions: &'static [&'static str],
282 read_cost_per_1k: f64,
283 write_cost_per_1k: f64,
284 data_cost_per_gb: f64,
285 typical_requests: u32,
286 notes: &'static str,
287}
288
289pub fn estimate(policy: &ScopedPolicy, ttl_seconds: u64) -> CostEstimate {
294 let profiles = cost_profiles();
295 let mut services = Vec::new();
296 let mut warnings = Vec::new();
297 let disclaimer = "Cost estimates use floor prices (us-east-1 / us-central1 / East US) — \
298 actual costs may be higher depending on region, data transfer, and usage volume."
299 .to_string();
300 let mut total_min = 0.0;
301 let mut total_max = 0.0;
302
303 let mut by_service: HashMap<String, Vec<String>> = HashMap::new();
305 for action in &policy.actions {
306 by_service
307 .entry(action.service.clone())
308 .or_default()
309 .push(action.action.clone());
310 }
311
312 let ttl_hours = ttl_seconds as f64 / 3600.0;
313
314 for (service, actions) in &by_service {
315 let profile_entry = profiles.get(service.as_str()).or_else(|| {
320 profiles
321 .iter()
322 .find(|(k, _)| k.eq_ignore_ascii_case(service.as_str()))
323 .map(|(_, v)| v)
324 });
325 if let Some(profile) = profile_entry {
326 let mut read_count = 0;
327 let mut write_count = 0;
328
329 for action in actions {
330 if action == "*" || action.contains('*') {
337 read_count += profile.typical_requests;
338 write_count += profile.typical_requests;
339 warnings.push(format!(
340 "{}:{} contains a wildcard — cost depends on actual usage",
341 service, action
342 ));
343 } else if profile
344 .read_actions
345 .iter()
346 .any(|r| action.starts_with(r) || *r == action.as_str())
347 {
348 read_count += profile.typical_requests;
349 } else if profile
350 .write_actions
351 .iter()
352 .any(|w| action.starts_with(w) || *w == action.as_str())
353 {
354 write_count += profile.typical_requests;
355 } else {
356 write_count += profile.typical_requests / 2;
358 }
359 }
360
361 let scale = (ttl_hours * 0.5).max(1.0); let scaled_reads = (read_count as f64 * scale) as u32;
364 let scaled_writes = (write_count as f64 * scale) as u32;
365
366 let read_cost = (scaled_reads as f64 / 1000.0) * profile.read_cost_per_1k;
367 let write_cost = (scaled_writes as f64 / 1000.0) * profile.write_cost_per_1k;
368 let min_cost = read_cost + write_cost;
369 let max_cost = min_cost * 10.0 + profile.data_cost_per_gb * 0.1;
371
372 total_min += min_cost;
373 total_max += max_cost;
374
375 services.push(ServiceEstimate {
376 service: service.clone(),
377 actions: actions.clone(),
378 min_cost,
379 max_cost,
380 notes: profile.notes.to_string(),
381 });
382
383 if service == "ec2"
385 && actions
386 .iter()
387 .any(|a| a.contains("RunInstances") || a == "*")
388 {
389 warnings.push(
390 "ec2:RunInstances can launch instances costing up to $3.84/hr".to_string(),
391 );
392 }
393 } else {
394 services.push(ServiceEstimate {
395 service: service.clone(),
396 actions: actions.clone(),
397 min_cost: 0.0,
398 max_cost: 0.0,
399 notes: "No cost estimate available for this service".to_string(),
400 });
401 }
402 }
403
404 let risk_level = if total_max > 10.0 || !warnings.is_empty() {
406 "high"
407 } else if total_max > 1.0 {
408 "medium"
409 } else {
410 "low"
411 }
412 .to_string();
413
414 if by_service.contains_key("iam") {
416 warnings.push("IAM actions can modify account security — review carefully".to_string());
417 }
418
419 warnings.insert(0, disclaimer);
421
422 services.sort_by(|a, b| {
423 b.max_cost
424 .partial_cmp(&a.max_cost)
425 .unwrap_or(std::cmp::Ordering::Equal)
426 });
427
428 let sanitize = |v: f64| if v.is_finite() { v } else { 0.0 };
431 let total_min = sanitize(total_min);
432 let total_max = sanitize(total_max);
433 let services: Vec<ServiceEstimate> = services
434 .into_iter()
435 .map(|mut s| {
436 s.min_cost = sanitize(s.min_cost);
437 s.max_cost = sanitize(s.max_cost);
438 s
439 })
440 .collect();
441
442 CostEstimate {
443 services,
444 total_min,
445 total_max,
446 risk_level,
447 warnings,
448 }
449}
450
451pub fn format_text(est: &CostEstimate) -> String {
453 let mut out = String::new();
454
455 out.push_str(&format!(
456 "Estimated cost: ${:.4} — ${:.4}\n",
457 est.total_min, est.total_max
458 ));
459 out.push_str(&format!("Risk level: {}\n\n", est.risk_level));
460
461 for svc in &est.services {
462 out.push_str(&format!(
463 " {} (${:.4} — ${:.4})\n",
464 svc.service, svc.min_cost, svc.max_cost
465 ));
466 out.push_str(&format!(" Actions: {}\n", svc.actions.join(", ")));
467 out.push_str(&format!(" {}\n", svc.notes));
468 }
469
470 if !est.warnings.is_empty() {
471 out.push_str("\nWarnings:\n");
472 for w in &est.warnings {
473 out.push_str(&format!(" ! {}\n", w));
474 }
475 }
476
477 out
478}
479
480pub fn format_json(est: &CostEstimate) -> String {
482 serde_json::to_string_pretty(est).unwrap_or_else(|_| "{}".to_string())
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488
489 #[test]
490 fn test_estimate_s3_readonly() {
491 let policy = ScopedPolicy::from_allow_str("s3:GetObject,s3:ListBucket").unwrap();
492 let est = estimate(&policy, 900);
493 assert_eq!(est.services.len(), 1);
494 assert_eq!(est.services[0].service, "s3");
495 assert!(est.total_min >= 0.0);
496 assert_eq!(est.risk_level, "low");
497 }
498
499 #[test]
500 fn test_estimate_ec2_high_risk() {
501 let policy =
502 ScopedPolicy::from_allow_str("ec2:RunInstances,ec2:DescribeInstances").unwrap();
503 let est = estimate(&policy, 3600);
504 assert_eq!(est.risk_level, "high");
505 assert!(est.warnings.iter().any(|w| w.contains("RunInstances")));
506 }
507
508 #[test]
509 fn test_estimate_iam_warning() {
510 let policy = ScopedPolicy::from_allow_str("iam:CreateRole").unwrap();
511 let est = estimate(&policy, 900);
512 assert!(est.warnings.iter().any(|w| w.contains("IAM")));
513 }
514
515 #[test]
516 fn test_estimate_wildcard() {
517 let policy = ScopedPolicy::from_allow_str("s3:*").unwrap();
518 let est = estimate(&policy, 900);
519 assert!(est.warnings.iter().any(|w| w.contains("s3:*")));
520 }
521
522 #[test]
523 fn test_estimate_multi_service() {
524 let policy =
525 ScopedPolicy::from_allow_str("s3:GetObject,lambda:InvokeFunction,dynamodb:Query")
526 .unwrap();
527 let est = estimate(&policy, 900);
528 assert_eq!(est.services.len(), 3);
529 }
530
531 #[test]
532 fn test_estimate_unknown_service() {
533 let policy = ScopedPolicy::from_allow_str("xray:GetTraceSummaries").unwrap();
534 let est = estimate(&policy, 900);
535 assert_eq!(est.services.len(), 1);
536 assert!(est.services[0].notes.contains("No cost estimate"));
537 }
538
539 #[test]
540 fn test_format_text() {
541 let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
542 let est = estimate(&policy, 900);
543 let text = format_text(&est);
544 assert!(text.contains("Estimated cost"));
545 assert!(text.contains("s3"));
546 }
547
548 #[test]
549 fn test_format_json() {
550 let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
551 let est = estimate(&policy, 900);
552 let json = format_json(&est);
553 assert!(json.contains("total_min"));
554 assert!(json.contains("risk_level"));
555 }
556
557 #[test]
558 fn test_longer_ttl_scales_cost() {
559 let policy = ScopedPolicy::from_allow_str("s3:GetObject,s3:PutObject").unwrap();
560 let short = estimate(&policy, 900); let long = estimate(&policy, 14400); assert!(long.total_max >= short.total_max);
564 }
565
566 #[test]
567 fn test_free_services() {
568 let policy = ScopedPolicy::from_allow_str("sts:GetCallerIdentity").unwrap();
569 let est = estimate(&policy, 900);
570 assert_eq!(est.total_min, 0.0);
571 }
572}