1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::{AvError, Result};
6
7#[derive(Debug, Clone, Serialize, Deserialize, Default)]
9pub struct NetworkPolicy {
10 #[serde(default)]
12 pub allowed_ips: Vec<String>,
13 #[serde(default)]
15 pub allowed_vpcs: Vec<String>,
16 #[serde(default)]
18 pub allowed_vpc_endpoints: Vec<String>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct ActionPattern {
24 pub service: String,
25 pub action: String,
26}
27
28impl ActionPattern {
29 pub fn parse(s: &str) -> Result<Self> {
31 let parts: Vec<&str> = s.splitn(2, ':').collect();
32 if parts.len() != 2 {
33 return Err(AvError::InvalidPolicy(format!(
34 "Invalid action format '{}'. Expected 'service:Action' (e.g. 's3:GetObject')",
35 s
36 )));
37 }
38 Ok(Self {
39 service: parts[0].to_string(),
40 action: parts[1].to_string(),
41 })
42 }
43
44 pub fn to_iam_action(&self) -> String {
46 format!("{}:{}", self.service, self.action)
47 }
48
49 pub fn parse_gcp(s: &str) -> Result<Self> {
51 let pos = s.find('.').ok_or_else(|| {
52 AvError::InvalidPolicy(format!(
53 "Invalid GCP permission '{}'. Expected 'service.resource.verb' (e.g. 'storage.objects.get')",
54 s
55 ))
56 })?;
57 Ok(Self {
58 service: s[..pos].to_string(),
59 action: s[pos + 1..].to_string(),
60 })
61 }
62
63 pub fn to_gcp_permission(&self) -> String {
65 format!("{}.{}", self.service, self.action)
66 }
67
68 pub fn parse_azure(s: &str) -> Result<Self> {
70 let pos = s.find('/').ok_or_else(|| {
71 AvError::InvalidPolicy(format!(
72 "Invalid Azure permission '{}'. Expected 'Microsoft.Service/resource/action' (e.g. 'Microsoft.Storage/storageAccounts/read')",
73 s
74 ))
75 })?;
76 Ok(Self {
77 service: s[..pos].to_string(),
78 action: s[pos + 1..].to_string(),
79 })
80 }
81
82 pub fn to_azure_permission(&self) -> String {
84 format!("{}/{}", self.service, self.action)
85 }
86
87 pub fn matches(&self, other: &ActionPattern) -> bool {
90 let service_match = self.service == "*" || self.service == other.service;
91 let action_match = self.action == "*"
92 || self.action == other.action
93 || (self.action.ends_with('*')
94 && other
95 .action
96 .starts_with(&self.action[..self.action.len() - 1]));
97 service_match && action_match
98 }
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct ScopedPolicy {
104 pub actions: Vec<ActionPattern>,
105 pub resources: Vec<String>,
106}
107
108impl ScopedPolicy {
109 pub fn from_allow_str(allow: &str) -> Result<Self> {
112 Self::from_allow_str_with_resources(allow, None)
113 }
114
115 pub fn from_allow_str_with_resources(allow: &str, resources: Option<&str>) -> Result<Self> {
117 let actions = allow
118 .split(',')
119 .map(|s| s.trim())
120 .filter(|s| !s.is_empty())
121 .map(ActionPattern::parse)
122 .collect::<Result<Vec<_>>>()?;
123
124 if actions.is_empty() {
125 return Err(AvError::InvalidPolicy(
126 "No valid actions provided".to_string(),
127 ));
128 }
129
130 let resources = match resources {
131 Some(r) => {
132 let parsed: Vec<String> = r
133 .split(',')
134 .map(|s| s.trim().to_string())
135 .filter(|s| !s.is_empty())
136 .collect();
137 if parsed.is_empty() {
138 vec!["*".to_string()]
139 } else {
140 parsed
141 }
142 }
143 None => vec!["*".to_string()],
144 };
145
146 Ok(Self { actions, resources })
147 }
148
149 pub fn from_gcp_allow_str(allow: &str) -> Result<Self> {
151 let actions = allow
152 .split(',')
153 .map(|s| s.trim())
154 .filter(|s| !s.is_empty())
155 .map(ActionPattern::parse_gcp)
156 .collect::<Result<Vec<_>>>()?;
157
158 if actions.is_empty() {
159 return Err(AvError::InvalidPolicy(
160 "No valid GCP permissions provided".to_string(),
161 ));
162 }
163
164 Ok(Self {
165 actions,
166 resources: vec!["*".to_string()],
167 })
168 }
169
170 pub fn from_azure_allow_str(allow: &str) -> Result<Self> {
172 let actions = allow
173 .split(',')
174 .map(|s| s.trim())
175 .filter(|s| !s.is_empty())
176 .map(ActionPattern::parse_azure)
177 .collect::<Result<Vec<_>>>()?;
178
179 if actions.is_empty() {
180 return Err(AvError::InvalidPolicy(
181 "No valid Azure permissions provided".to_string(),
182 ));
183 }
184
185 Ok(Self {
186 actions,
187 resources: vec!["*".to_string()],
188 })
189 }
190
191 pub fn to_iam_policy_json(&self) -> Result<String> {
193 self.to_iam_policy_json_with_network(None)
194 }
195
196 pub fn to_iam_policy_json_with_tag_lock(&self, tag_key: &str) -> Result<String> {
201 self.to_iam_policy_json_with_network_and_tag_lock(None, Some(tag_key))
202 }
203
204 pub fn to_iam_policy_json_with_network_and_tag_lock(
206 &self,
207 network: Option<&NetworkPolicy>,
208 tag_lock_key: Option<&str>,
209 ) -> Result<String> {
210 let base_json = self.to_iam_policy_json_with_network(network)?;
212 let tag_lock_key = match tag_lock_key {
213 Some(k) => k,
214 None => return Ok(base_json),
215 };
216 let mut doc: serde_json::Value =
217 serde_json::from_str(&base_json).map_err(|e| AvError::InvalidPolicy(e.to_string()))?;
218 let deny = serde_json::json!({
219 "Sid": "DenyTryaudexTagRemoval",
220 "Effect": "Deny",
221 "Action": tag_mutation_actions(),
222 "Resource": "*",
223 "Condition": {
224 "ForAnyValue:StringEquals": {
225 "aws:TagKeys": [tag_lock_key]
226 }
227 }
228 });
229 if let Some(stmts) = doc.get_mut("Statement").and_then(|s| s.as_array_mut()) {
230 stmts.push(deny);
231 }
232 serde_json::to_string_pretty(&doc).map_err(|e| AvError::InvalidPolicy(e.to_string()))
233 }
234
235 pub fn to_iam_policy_json_with_network(
237 &self,
238 network: Option<&NetworkPolicy>,
239 ) -> Result<String> {
240 let mut statement = serde_json::json!({
241 "Effect": "Allow",
242 "Action": self.actions.iter().map(|a| a.to_iam_action()).collect::<Vec<_>>(),
243 "Resource": self.resources,
244 });
245
246 if let Some(net) = network {
248 let mut conditions: HashMap<String, HashMap<String, serde_json::Value>> =
249 HashMap::new();
250
251 if !net.allowed_ips.is_empty() {
252 let mut ip_cond = HashMap::new();
253 ip_cond.insert(
254 "aws:SourceIp".to_string(),
255 serde_json::json!(net.allowed_ips),
256 );
257 conditions.insert("IpAddress".to_string(), ip_cond);
258 }
259
260 if !net.allowed_vpcs.is_empty() {
261 let mut vpc_cond = HashMap::new();
262 vpc_cond.insert(
263 "aws:SourceVpc".to_string(),
264 serde_json::json!(net.allowed_vpcs),
265 );
266 conditions
267 .entry("StringEquals".to_string())
268 .or_default()
269 .extend(vpc_cond);
270 }
271
272 if !net.allowed_vpc_endpoints.is_empty() {
273 let mut vpce_cond = HashMap::new();
274 vpce_cond.insert(
275 "aws:SourceVpce".to_string(),
276 serde_json::json!(net.allowed_vpc_endpoints),
277 );
278 conditions
279 .entry("StringEquals".to_string())
280 .or_default()
281 .extend(vpce_cond);
282 }
283
284 if !conditions.is_empty() {
285 statement["Condition"] = serde_json::json!(conditions);
286 }
287 }
288
289 let policy = serde_json::json!({
290 "Version": "2012-10-17",
291 "Statement": [statement]
292 });
293 serde_json::to_string_pretty(&policy).map_err(|e| AvError::InvalidPolicy(e.to_string()))
294 }
295
296 pub fn enforce_deny_list(&self, deny: &[String]) -> Result<()> {
298 let deny_patterns: Vec<ActionPattern> = deny
299 .iter()
300 .filter_map(|d| ActionPattern::parse(d).ok())
301 .collect();
302
303 for action in &self.actions {
304 for deny_pattern in &deny_patterns {
305 if deny_pattern.matches(action) {
306 return Err(AvError::InvalidPolicy(format!(
307 "Action '{}' is blocked by deny list rule '{}'",
308 action.to_iam_action(),
309 deny_pattern.to_iam_action()
310 )));
311 }
312 }
313 }
314 Ok(())
315 }
316
317 pub fn services(&self) -> Vec<String> {
319 let mut services: Vec<String> = self.actions.iter().map(|a| a.service.clone()).collect();
320 services.sort();
321 services.dedup();
322 services
323 }
324}
325
326fn tag_mutation_actions() -> Vec<&'static str> {
331 vec![
332 "ec2:DeleteTags",
334 "ec2:CreateTags",
335 "tag:UntagResources",
337 "tag:TagResources",
338 "s3:DeleteBucketTagging",
340 "s3:PutBucketTagging",
341 "dynamodb:UntagResource",
343 "dynamodb:TagResource",
344 "sns:UntagResource",
345 "sns:TagResource",
346 "sqs:UntagQueue",
347 "sqs:TagQueue",
348 "lambda:UntagResource",
349 "lambda:TagResource",
350 "rds:RemoveTagsFromResource",
351 "rds:AddTagsToResource",
352 "iam:UntagRole",
353 "iam:UntagUser",
354 "iam:UntagPolicy",
355 "iam:TagRole",
356 "iam:TagUser",
357 "iam:TagPolicy",
358 "secretsmanager:UntagResource",
359 "secretsmanager:TagResource",
360 "ssm:RemoveTagsFromResource",
361 "ssm:AddTagsToResource",
362 "cloudformation:UntagResource",
363 "cloudformation:TagResource",
364 "ecr:UntagResource",
365 "ecr:TagResource",
366 "kms:UntagResource",
367 "kms:TagResource",
368 "logs:UntagLogGroup",
369 "logs:TagLogGroup",
370 ]
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376
377 #[test]
378 fn test_parse_action() {
379 let action = ActionPattern::parse("s3:GetObject").unwrap();
380 assert_eq!(action.service, "s3");
381 assert_eq!(action.action, "GetObject");
382 }
383
384 #[test]
385 fn test_parse_wildcard_action() {
386 let action = ActionPattern::parse("lambda:Update*").unwrap();
387 assert_eq!(action.service, "lambda");
388 assert_eq!(action.action, "Update*");
389 }
390
391 #[test]
392 fn test_invalid_action() {
393 assert!(ActionPattern::parse("invalid").is_err());
394 }
395
396 #[test]
397 fn test_from_allow_str() {
398 let policy =
399 ScopedPolicy::from_allow_str("s3:GetObject, lambda:UpdateFunctionCode").unwrap();
400 assert_eq!(policy.actions.len(), 2);
401 }
402
403 #[test]
404 fn test_from_allow_str_with_resources() {
405 let policy = ScopedPolicy::from_allow_str_with_resources(
406 "s3:GetObject",
407 Some("arn:aws:s3:::my-bucket/*,arn:aws:s3:::my-bucket"),
408 )
409 .unwrap();
410 assert_eq!(policy.resources.len(), 2);
411 assert_eq!(policy.resources[0], "arn:aws:s3:::my-bucket/*");
412 assert_eq!(policy.resources[1], "arn:aws:s3:::my-bucket");
413 }
414
415 #[test]
416 fn test_from_allow_str_default_resources() {
417 let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
418 assert_eq!(policy.resources, vec!["*"]);
419 }
420
421 #[test]
422 fn test_to_iam_policy_json() {
423 let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
424 let json = policy.to_iam_policy_json().unwrap();
425 assert!(json.contains("s3:GetObject"));
426 assert!(json.contains("2012-10-17"));
427 }
428
429 #[test]
430 fn tag_lock_appends_deny_statement_bound_to_key() {
431 let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
432 let json = policy
433 .to_iam_policy_json_with_tag_lock("tryaudex-session")
434 .unwrap();
435 let doc: serde_json::Value = serde_json::from_str(&json).unwrap();
436 let stmts = doc["Statement"].as_array().unwrap();
437 assert_eq!(stmts.len(), 2, "expected Allow + Deny statements");
438 let deny = &stmts[1];
439 assert_eq!(deny["Effect"], "Deny");
440 assert_eq!(deny["Sid"], "DenyTryaudexTagRemoval");
441 assert_eq!(
442 deny["Condition"]["ForAnyValue:StringEquals"]["aws:TagKeys"][0],
443 "tryaudex-session"
444 );
445 let actions = deny["Action"].as_array().unwrap();
447 let action_strs: Vec<&str> = actions.iter().filter_map(|v| v.as_str()).collect();
448 assert!(action_strs.contains(&"ec2:DeleteTags"));
449 assert!(action_strs.contains(&"tag:UntagResources"));
450 assert!(action_strs.contains(&"s3:DeleteBucketTagging"));
451 }
452
453 #[test]
454 fn tag_lock_with_none_key_is_noop() {
455 let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
456 let base = policy.to_iam_policy_json_with_network(None).unwrap();
457 let with_none = policy
458 .to_iam_policy_json_with_network_and_tag_lock(None, None)
459 .unwrap();
460 assert_eq!(base, with_none);
461 }
462
463 #[test]
464 fn test_wildcard_matches() {
465 let deny = ActionPattern::parse("iam:*").unwrap();
466 let action = ActionPattern::parse("iam:CreateRole").unwrap();
467 assert!(deny.matches(&action));
468 }
469
470 #[test]
471 fn test_prefix_wildcard_matches() {
472 let deny = ActionPattern::parse("lambda:Update*").unwrap();
473 let update = ActionPattern::parse("lambda:UpdateFunctionCode").unwrap();
474 let get = ActionPattern::parse("lambda:GetFunction").unwrap();
475 assert!(deny.matches(&update));
476 assert!(!deny.matches(&get));
477 }
478
479 #[test]
480 fn test_wildcard_no_cross_service() {
481 let deny = ActionPattern::parse("iam:*").unwrap();
482 let action = ActionPattern::parse("s3:GetObject").unwrap();
483 assert!(!deny.matches(&action));
484 }
485
486 #[test]
487 fn test_deny_list_blocks_action() {
488 let policy = ScopedPolicy::from_allow_str("iam:CreateRole,s3:GetObject").unwrap();
489 let deny = vec!["iam:*".to_string()];
490 let result = policy.enforce_deny_list(&deny);
491 assert!(result.is_err());
492 assert!(result.unwrap_err().to_string().contains("iam:CreateRole"));
493 }
494
495 #[test]
496 fn test_deny_list_allows_safe_actions() {
497 let policy = ScopedPolicy::from_allow_str("s3:GetObject,s3:ListBucket").unwrap();
498 let deny = vec!["iam:*".to_string(), "organizations:*".to_string()];
499 assert!(policy.enforce_deny_list(&deny).is_ok());
500 }
501
502 #[test]
503 fn test_parse_gcp_permission() {
504 let action = ActionPattern::parse_gcp("storage.objects.get").unwrap();
505 assert_eq!(action.service, "storage");
506 assert_eq!(action.action, "objects.get");
507 assert_eq!(action.to_gcp_permission(), "storage.objects.get");
508 }
509
510 #[test]
511 fn test_from_gcp_allow_str() {
512 let policy =
513 ScopedPolicy::from_gcp_allow_str("storage.objects.get, compute.instances.list")
514 .unwrap();
515 assert_eq!(policy.actions.len(), 2);
516 assert_eq!(policy.actions[0].to_gcp_permission(), "storage.objects.get");
517 assert_eq!(
518 policy.actions[1].to_gcp_permission(),
519 "compute.instances.list"
520 );
521 }
522
523 #[test]
524 fn test_gcp_permission_invalid() {
525 assert!(ActionPattern::parse_gcp("invalidpermission").is_err());
526 }
527
528 #[test]
529 fn test_parse_azure_permission() {
530 let action = ActionPattern::parse_azure("Microsoft.Storage/storageAccounts/read").unwrap();
531 assert_eq!(action.service, "Microsoft.Storage");
532 assert_eq!(action.action, "storageAccounts/read");
533 assert_eq!(
534 action.to_azure_permission(),
535 "Microsoft.Storage/storageAccounts/read"
536 );
537 }
538
539 #[test]
540 fn test_from_azure_allow_str() {
541 let policy = ScopedPolicy::from_azure_allow_str(
542 "Microsoft.Storage/storageAccounts/read, Microsoft.Compute/virtualMachines/read",
543 )
544 .unwrap();
545 assert_eq!(policy.actions.len(), 2);
546 assert_eq!(
547 policy.actions[0].to_azure_permission(),
548 "Microsoft.Storage/storageAccounts/read"
549 );
550 assert_eq!(
551 policy.actions[1].to_azure_permission(),
552 "Microsoft.Compute/virtualMachines/read"
553 );
554 }
555
556 #[test]
557 fn test_azure_permission_invalid() {
558 assert!(ActionPattern::parse_azure("invalidpermission").is_err());
559 }
560
561 #[test]
562 fn test_network_policy_ip_condition() {
563 let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
564 let network = NetworkPolicy {
565 allowed_ips: vec!["10.0.0.0/8".to_string(), "203.0.113.5".to_string()],
566 allowed_vpcs: vec![],
567 allowed_vpc_endpoints: vec![],
568 };
569 let json = policy
570 .to_iam_policy_json_with_network(Some(&network))
571 .unwrap();
572 assert!(json.contains("aws:SourceIp"));
573 assert!(json.contains("10.0.0.0/8"));
574 assert!(json.contains("IpAddress"));
575 }
576
577 #[test]
578 fn test_network_policy_vpc_condition() {
579 let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
580 let network = NetworkPolicy {
581 allowed_ips: vec![],
582 allowed_vpcs: vec!["vpc-abc123".to_string()],
583 allowed_vpc_endpoints: vec!["vpce-xyz789".to_string()],
584 };
585 let json = policy
586 .to_iam_policy_json_with_network(Some(&network))
587 .unwrap();
588 assert!(json.contains("aws:SourceVpc"));
589 assert!(json.contains("vpc-abc123"));
590 assert!(json.contains("aws:SourceVpce"));
591 assert!(json.contains("vpce-xyz789"));
592 assert!(json.contains("StringEquals"));
593 }
594
595 #[test]
596 fn test_no_network_policy() {
597 let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
598 let json = policy.to_iam_policy_json_with_network(None).unwrap();
599 assert!(!json.contains("Condition"));
600 }
601}