1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::{AvError, Result};
6
7const MAX_POLICY_URL_ENCODED_SIZE: usize = 2048;
10
11#[derive(Debug, Clone, Serialize, Deserialize, Default)]
13pub struct NetworkPolicy {
14 #[serde(default)]
16 pub allowed_ips: Vec<String>,
17 #[serde(default)]
19 pub allowed_vpcs: Vec<String>,
20 #[serde(default)]
22 pub allowed_vpc_endpoints: Vec<String>,
23}
24
25impl NetworkPolicy {
26 pub fn validate(&self) -> Result<()> {
30 let has_ips = !self.allowed_ips.is_empty();
36 let has_vpcs = !self.allowed_vpcs.is_empty() || !self.allowed_vpc_endpoints.is_empty();
37 if has_ips && has_vpcs {
38 return Err(AvError::InvalidPolicy(
39 "NetworkPolicy: allowed_ips and allowed_vpcs/allowed_vpc_endpoints are mutually \
40 exclusive. AWS IAM evaluates Condition keys as AND, so combining source IPs with \
41 VPC conditions creates an unsatisfiable policy that will deny all requests. \
42 Use either IP-based or VPC-based restrictions, not both."
43 .to_string(),
44 ));
45 }
46
47 const WILDCARDS: &[&str] = &["0.0.0.0/0", "::/0"];
49
50 for entry in &self.allowed_ips {
51 let s = entry.trim();
52
53 if WILDCARDS.contains(&s) {
54 return Err(AvError::InvalidPolicy(format!(
55 "NetworkPolicy: '{}' is a wildcard CIDR that makes the IP restriction a no-op",
56 s
57 )));
58 }
59
60 if s.contains('/') {
61 let mut parts = s.splitn(2, '/');
63 let addr = parts.next().unwrap_or("");
64 let prefix_len = parts.next().unwrap_or("");
65 if !is_valid_ip(addr) {
66 return Err(AvError::InvalidPolicy(format!(
67 "NetworkPolicy: '{}' contains an invalid IP address in CIDR notation",
68 s
69 )));
70 }
71 let prefix: u8 = prefix_len.parse().map_err(|_| {
72 AvError::InvalidPolicy(format!(
73 "NetworkPolicy: '{}' has an invalid CIDR prefix length",
74 s
75 ))
76 })?;
77 let max_prefix = if addr.contains(':') { 128 } else { 32 };
78 if prefix > max_prefix {
79 return Err(AvError::InvalidPolicy(format!(
80 "NetworkPolicy: '{}' has a prefix length exceeding {} for this address family",
81 s, max_prefix
82 )));
83 }
84 } else {
85 if !is_valid_ip(s) {
87 return Err(AvError::InvalidPolicy(format!(
88 "NetworkPolicy: '{}' is not a valid IP address or CIDR range",
89 s
90 )));
91 }
92 }
93 }
94 Ok(())
95 }
96}
97
98fn is_valid_ip(s: &str) -> bool {
100 s.parse::<std::net::IpAddr>().is_ok()
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct ActionPattern {
106 pub service: String,
107 pub action: String,
108}
109
110impl ActionPattern {
111 pub fn parse(s: &str) -> Result<Self> {
116 let parts: Vec<&str> = s.splitn(2, ':').collect();
117 if parts.len() != 2 {
118 return Err(AvError::InvalidPolicy(format!(
119 "Invalid action format '{}'. Expected 'service:Action' (e.g. 's3:GetObject')",
120 s
121 )));
122 }
123 let service = parts[0];
124 let action = parts[1];
125 if service.is_empty() || action.is_empty() {
126 return Err(AvError::InvalidPolicy(format!(
127 "Invalid action '{}': service and action must not be empty",
128 s
129 )));
130 }
131 fn is_valid_component(c: &str) -> bool {
132 c.chars()
133 .all(|ch| ch.is_ascii_alphanumeric() || ch == '*' || ch == '?' || ch == '-')
134 }
135 if !is_valid_component(service) || !is_valid_component(action) {
136 return Err(AvError::InvalidPolicy(format!(
137 "Invalid action '{}': components must match [a-zA-Z0-9*?-]+",
138 s
139 )));
140 }
141 Ok(Self {
142 service: service.to_string(),
143 action: action.to_string(),
144 })
145 }
146
147 pub fn to_iam_action(&self) -> String {
149 format!("{}:{}", self.service, self.action)
150 }
151
152 pub fn parse_gcp(s: &str) -> Result<Self> {
157 let pos = s.find('.').ok_or_else(|| {
158 AvError::InvalidPolicy(format!(
159 "Invalid GCP permission '{}'. Expected 'service.resource.verb' (e.g. 'storage.objects.get')",
160 s
161 ))
162 })?;
163 let service = &s[..pos];
164 let action = &s[pos + 1..];
165 if service.is_empty() || action.is_empty() {
166 return Err(AvError::InvalidPolicy(format!(
167 "Invalid GCP permission '{}': service and action must not be empty",
168 s
169 )));
170 }
171 fn is_valid_gcp_component(c: &str) -> bool {
172 c.chars().all(|ch| {
173 ch.is_ascii_alphanumeric() || ch == '.' || ch == '*' || ch == '?' || ch == '-'
174 })
175 }
176 if !is_valid_gcp_component(service) || !is_valid_gcp_component(action) {
177 return Err(AvError::InvalidPolicy(format!(
178 "Invalid GCP permission '{}': components must match [a-zA-Z0-9.*?-]+",
179 s
180 )));
181 }
182 Ok(Self {
183 service: service.to_string(),
184 action: action.to_string(),
185 })
186 }
187
188 pub fn to_gcp_permission(&self) -> String {
190 format!("{}.{}", self.service, self.action)
191 }
192
193 pub fn parse_azure(s: &str) -> Result<Self> {
198 let pos = s.find('/').ok_or_else(|| {
199 AvError::InvalidPolicy(format!(
200 "Invalid Azure permission '{}'. Expected 'Microsoft.Service/resource/action' (e.g. 'Microsoft.Storage/storageAccounts/read')",
201 s
202 ))
203 })?;
204 let service = &s[..pos];
205 let action = &s[pos + 1..];
206 if service.is_empty() || action.is_empty() {
207 return Err(AvError::InvalidPolicy(format!(
208 "Invalid Azure permission '{}': service and action must not be empty",
209 s
210 )));
211 }
212 fn is_valid_azure_component(c: &str) -> bool {
213 c.chars().all(|ch| {
214 ch.is_ascii_alphanumeric()
215 || ch == '.'
216 || ch == '/'
217 || ch == '*'
218 || ch == '?'
219 || ch == '-'
220 })
221 }
222 if !is_valid_azure_component(service) || !is_valid_azure_component(action) {
223 return Err(AvError::InvalidPolicy(format!(
224 "Invalid Azure permission '{}': components must match [a-zA-Z0-9./*?-]+",
225 s
226 )));
227 }
228 Ok(Self {
229 service: service.to_string(),
230 action: action.to_string(),
231 })
232 }
233
234 pub fn to_azure_permission(&self) -> String {
236 format!("{}/{}", self.service, self.action)
237 }
238
239 pub fn matches(&self, other: &ActionPattern) -> bool {
254 let is_azure =
255 self.service.starts_with("Microsoft.") || other.service.starts_with("Microsoft.");
256 if is_azure {
257 let service_match = self.service == "*" || self.service == other.service;
258 let action_match = self.action == "*"
259 || self.action == other.action
260 || glob_match(&self.action, &other.action);
261 return service_match && action_match;
262 }
263 let self_svc = self.service.to_ascii_lowercase();
264 let other_svc = other.service.to_ascii_lowercase();
265 let self_act = self.action.to_ascii_lowercase();
266 let other_act = other.action.to_ascii_lowercase();
267 let service_match = self_svc == "*" || self_svc == other_svc;
268 let action_match =
269 self_act == "*" || self_act == other_act || glob_match(&self_act, &other_act);
270 service_match && action_match
271 }
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct ScopedPolicy {
277 pub actions: Vec<ActionPattern>,
278 pub resources: Vec<String>,
279}
280
281impl ScopedPolicy {
282 pub fn from_allow_str(allow: &str) -> Result<Self> {
285 Self::from_allow_str_with_resources(allow, None)
286 }
287
288 pub fn from_allow_str_with_resources(allow: &str, resources: Option<&str>) -> Result<Self> {
290 let actions = allow
291 .split(',')
292 .map(|s| s.trim())
293 .filter(|s| !s.is_empty())
294 .map(ActionPattern::parse)
295 .collect::<Result<Vec<_>>>()?;
296
297 if actions.is_empty() {
298 return Err(AvError::InvalidPolicy(
299 "No valid actions provided".to_string(),
300 ));
301 }
302
303 if actions.iter().any(|a| a.service == "*" && a.action == "*") {
305 return Err(AvError::InvalidPolicy(
306 "Wildcard '*:*' grants all actions and is not permitted. \
307 Use specific service:action patterns."
308 .to_string(),
309 ));
310 }
311
312 const SENSITIVE_SERVICES: &[&str] = &["iam", "sts", "organizations", "account"];
314 for a in &actions {
315 let svc_lower = a.service.to_lowercase();
316 if a.action == "*" && SENSITIVE_SERVICES.contains(&svc_lower.as_str()) {
317 tracing::warn!(
318 service = %a.service,
319 "Service wildcard '{}:*' grants all {} actions; consider restricting to specific actions",
320 a.service,
321 a.service,
322 );
323 }
324 }
325
326 let resources = match resources {
327 Some(r) => {
328 let parsed: Vec<String> = r
329 .split(',')
330 .map(|s| s.trim().to_string())
331 .filter(|s| !s.is_empty())
332 .collect();
333 if parsed.is_empty() {
334 vec!["*".to_string()]
335 } else {
336 parsed
337 }
338 }
339 None => vec!["*".to_string()],
340 };
341
342 Ok(Self { actions, resources })
343 }
344
345 pub fn from_gcp_allow_str(allow: &str) -> Result<Self> {
347 let actions = allow
348 .split(',')
349 .map(|s| s.trim())
350 .filter(|s| !s.is_empty())
351 .map(ActionPattern::parse_gcp)
352 .collect::<Result<Vec<_>>>()?;
353
354 if actions.is_empty() {
355 return Err(AvError::InvalidPolicy(
356 "No valid GCP permissions provided".to_string(),
357 ));
358 }
359
360 if actions.iter().any(|a| a.service == "*" && a.action == "*") {
362 return Err(AvError::InvalidPolicy(
363 "Wildcard '*:*' grants all actions and is not permitted. \
364 Use specific service:action patterns."
365 .to_string(),
366 ));
367 }
368
369 const GCP_SENSITIVE_SERVICES: &[&str] = &[
374 "iam",
375 "resourcemanager",
376 "orgpolicy",
377 "cloudkms",
378 "sts",
379 "cloudbilling",
380 "servicemanagement",
381 "cloudidentity",
382 "secretmanager",
383 "accesscontextmanager",
384 ];
385 for a in &actions {
386 if a.action.ends_with('*') && GCP_SENSITIVE_SERVICES.contains(&a.service.as_str()) {
387 tracing::warn!(
388 service = %a.service,
389 "GCP service wildcard '{}.{}' grants broad IAM/admin permissions; consider restricting to specific actions",
390 a.service, a.action
391 );
392 }
393 }
394
395 Ok(Self {
396 actions,
397 resources: vec!["*".to_string()],
398 })
399 }
400
401 pub fn from_azure_allow_str(allow: &str) -> Result<Self> {
403 let actions = allow
404 .split(',')
405 .map(|s| s.trim())
406 .filter(|s| !s.is_empty())
407 .map(ActionPattern::parse_azure)
408 .collect::<Result<Vec<_>>>()?;
409
410 if actions.is_empty() {
411 return Err(AvError::InvalidPolicy(
412 "No valid Azure permissions provided".to_string(),
413 ));
414 }
415
416 if actions.iter().any(|a| a.service == "*" && a.action == "*") {
418 return Err(AvError::InvalidPolicy(
419 "Wildcard '*:*' grants all actions and is not permitted. \
420 Use specific service:action patterns."
421 .to_string(),
422 ));
423 }
424
425 const AZURE_SENSITIVE_SERVICES: &[&str] = &[
430 "Microsoft.Authorization",
431 "Microsoft.ManagedIdentity",
432 "Microsoft.KeyVault",
433 "Microsoft.Subscription",
434 "Microsoft.Management",
435 "Microsoft.PolicyInsights",
436 ];
437 for a in &actions {
438 if a.action.ends_with('*') && AZURE_SENSITIVE_SERVICES.contains(&a.service.as_str()) {
439 tracing::warn!(
440 service = %a.service,
441 "Azure service wildcard '{}/{}' grants broad IAM/RBAC permissions; consider restricting to specific actions",
442 a.service, a.action
443 );
444 }
445 }
446
447 Ok(Self {
448 actions,
449 resources: vec!["*".to_string()],
450 })
451 }
452
453 pub fn to_iam_policy_json(&self) -> Result<String> {
455 self.to_iam_policy_json_with_network(None)
456 }
457
458 pub fn to_iam_policy_json_with_tag_lock(&self, tag_key: &str) -> Result<String> {
463 self.to_iam_policy_json_with_network_and_tag_lock(None, Some(tag_key))
464 }
465
466 pub fn to_iam_policy_json_with_network_and_tag_lock(
468 &self,
469 network: Option<&NetworkPolicy>,
470 tag_lock_key: Option<&str>,
471 ) -> Result<String> {
472 let base_json = self.to_iam_policy_json_with_network(network)?;
474 let tag_lock_key = match tag_lock_key {
475 Some(k) => k,
476 None => {
477 check_policy_size(&base_json)?;
478 return Ok(base_json);
479 }
480 };
481 let mut doc: serde_json::Value =
482 serde_json::from_str(&base_json).map_err(|e| AvError::InvalidPolicy(e.to_string()))?;
483 let deny = serde_json::json!({
484 "Sid": "DenyTryaudexTagRemoval",
485 "Effect": "Deny",
486 "Action": tag_mutation_actions(),
487 "Resource": "*",
488 "Condition": {
489 "ForAnyValue:StringEquals": {
490 "aws:TagKeys": [tag_lock_key]
491 }
492 }
493 });
494 let request_tag_key = format!("aws:RequestTag/{}", tag_lock_key);
500 let mut null_cond = serde_json::Map::new();
501 null_cond.insert(
502 request_tag_key,
503 serde_json::Value::String("true".to_string()),
504 );
505 let null_value = serde_json::Value::Object(null_cond);
506 let deny_chain = serde_json::json!({
507 "Sid": "DenyTryaudexRoleChaining",
508 "Effect": "Deny",
509 "Action": [
510 "sts:AssumeRole",
511 "sts:AssumeRoleWithWebIdentity",
512 "sts:AssumeRoleWithSAML"
513 ],
514 "Resource": "*",
515 "Condition": {
516 "Null": null_value
517 }
518 });
519 if let Some(stmts) = doc.get_mut("Statement").and_then(|s| s.as_array_mut()) {
520 stmts.push(deny);
521 stmts.push(deny_chain);
522 }
523 let json = serde_json::to_string_pretty(&doc)
524 .map_err(|e| AvError::InvalidPolicy(e.to_string()))?;
525 check_policy_size(&json)?;
526 Ok(json)
527 }
528
529 pub fn to_iam_policy_json_with_network(
531 &self,
532 network: Option<&NetworkPolicy>,
533 ) -> Result<String> {
534 let mut statement = serde_json::json!({
535 "Effect": "Allow",
536 "Action": self.actions.iter().map(|a| a.to_iam_action()).collect::<Vec<_>>(),
537 "Resource": self.resources,
538 });
539
540 if let Some(net) = network {
542 let mut conditions: HashMap<String, HashMap<String, serde_json::Value>> =
543 HashMap::new();
544
545 if !net.allowed_ips.is_empty() {
546 let mut ip_cond = HashMap::new();
547 ip_cond.insert(
548 "aws:SourceIp".to_string(),
549 serde_json::json!(net.allowed_ips),
550 );
551 conditions.insert("IpAddress".to_string(), ip_cond);
552 }
553
554 if !net.allowed_vpcs.is_empty() {
555 let mut vpc_cond = HashMap::new();
556 vpc_cond.insert(
557 "aws:SourceVpc".to_string(),
558 serde_json::json!(net.allowed_vpcs),
559 );
560 conditions
561 .entry("StringEquals".to_string())
562 .or_default()
563 .extend(vpc_cond);
564 }
565
566 if !net.allowed_vpc_endpoints.is_empty() {
567 let mut vpce_cond = HashMap::new();
568 vpce_cond.insert(
569 "aws:SourceVpce".to_string(),
570 serde_json::json!(net.allowed_vpc_endpoints),
571 );
572 conditions
573 .entry("StringEquals".to_string())
574 .or_default()
575 .extend(vpce_cond);
576 }
577
578 if !conditions.is_empty() {
579 statement["Condition"] = serde_json::json!(conditions);
580 }
581 }
582
583 let policy = serde_json::json!({
584 "Version": "2012-10-17",
585 "Statement": [statement]
586 });
587 let json = serde_json::to_string_pretty(&policy)
588 .map_err(|e| AvError::InvalidPolicy(e.to_string()))?;
589
590 check_policy_size(&json)?;
591 Ok(json)
592 }
593
594 pub fn enforce_deny_list(&self, deny: &[String]) -> Result<()> {
596 let deny_patterns: Vec<ActionPattern> = deny
597 .iter()
598 .filter_map(|d| match ActionPattern::parse(d) {
599 Ok(p) => Some(p),
600 Err(e) => {
601 tracing::warn!(pattern = %d, error = %e, "Skipping unparseable deny pattern");
602 None
603 }
604 })
605 .collect();
606
607 for action in &self.actions {
608 for deny_pattern in &deny_patterns {
609 if deny_pattern.matches(action) {
610 return Err(AvError::InvalidPolicy(format!(
611 "Action '{}' is blocked by deny list rule '{}'",
612 action.to_iam_action(),
613 deny_pattern.to_iam_action()
614 )));
615 }
616 }
617 }
618 Ok(())
619 }
620
621 pub fn enforce_deny_list_for_provider(
625 &self,
626 deny: &[String],
627 provider: crate::session::CloudProvider,
628 ) -> Result<()> {
629 use crate::session::CloudProvider;
630 let parser: fn(&str) -> Result<ActionPattern> = match provider {
631 CloudProvider::Gcp => ActionPattern::parse_gcp,
632 CloudProvider::Azure => ActionPattern::parse_azure,
633 CloudProvider::Aws => ActionPattern::parse,
634 };
635
636 let deny_patterns: Vec<ActionPattern> = deny
637 .iter()
638 .filter_map(|d| match parser(d) {
639 Ok(p) => Some(p),
640 Err(e) => {
641 tracing::warn!(pattern = %d, error = %e, "Skipping unparseable deny pattern");
642 None
643 }
644 })
645 .collect();
646
647 for action in &self.actions {
648 for deny_pattern in &deny_patterns {
649 if deny_pattern.matches(action) {
650 let action_str = match provider {
651 CloudProvider::Gcp => action.to_gcp_permission(),
652 CloudProvider::Azure => action.to_azure_permission(),
653 CloudProvider::Aws => action.to_iam_action(),
654 };
655 let deny_str = match provider {
656 CloudProvider::Gcp => deny_pattern.to_gcp_permission(),
657 CloudProvider::Azure => deny_pattern.to_azure_permission(),
658 CloudProvider::Aws => deny_pattern.to_iam_action(),
659 };
660 return Err(AvError::InvalidPolicy(format!(
661 "Action '{}' is blocked by deny list rule '{}'",
662 action_str, deny_str
663 )));
664 }
665 }
666 }
667 Ok(())
668 }
669
670 pub fn services(&self) -> Vec<String> {
672 let mut services: Vec<String> = self.actions.iter().map(|a| a.service.clone()).collect();
673 services.sort();
674 services.dedup();
675 services
676 }
677}
678
679fn glob_match(pattern: &str, text: &str) -> bool {
683 let pat = pattern.as_bytes();
684 let txt = text.as_bytes();
685 let (mut pi, mut ti) = (0usize, 0usize);
686 let (mut star_pi, mut star_ti) = (usize::MAX, 0usize);
687
688 while ti < txt.len() {
689 if pi < pat.len() && (pat[pi] == b'?' || pat[pi] == txt[ti]) {
690 pi += 1;
691 ti += 1;
692 } else if pi < pat.len() && pat[pi] == b'*' {
693 star_pi = pi;
694 star_ti = ti;
695 pi += 1; } else if star_pi != usize::MAX {
697 pi = star_pi + 1;
699 star_ti += 1;
700 ti = star_ti;
701 } else {
702 return false;
703 }
704 }
705 while pi < pat.len() && pat[pi] == b'*' {
707 pi += 1;
708 }
709 pi == pat.len()
710}
711
712fn check_policy_size(pretty_json: &str) -> Result<()> {
715 let doc: serde_json::Value =
716 serde_json::from_str(pretty_json).map_err(|e| AvError::InvalidPolicy(e.to_string()))?;
717 let compact = serde_json::to_string(&doc).map_err(|e| AvError::InvalidPolicy(e.to_string()))?;
718 let encoded_len = urlencoding::encode(&compact).len();
719 if encoded_len > MAX_POLICY_URL_ENCODED_SIZE {
720 return Err(AvError::InvalidPolicy(format!(
721 "Session policy is {} bytes URL-encoded (max {}). \
722 Reduce the number of actions or split into multiple sessions.",
723 encoded_len, MAX_POLICY_URL_ENCODED_SIZE
724 )));
725 }
726 Ok(())
727}
728
729fn tag_mutation_actions() -> Vec<&'static str> {
733 vec![
734 "ec2:DeleteTags",
736 "ec2:CreateTags",
737 "tag:UntagResources",
739 "tag:TagResources",
740 "s3:DeleteBucketTagging",
742 "s3:PutBucketTagging",
743 "s3:PutObjectTagging",
744 "s3:DeleteObjectTagging",
745 "s3:PutObjectVersionTagging",
746 "dynamodb:UntagResource",
748 "dynamodb:TagResource",
749 "sns:UntagResource",
750 "sns:TagResource",
751 "sqs:UntagQueue",
752 "sqs:TagQueue",
753 "lambda:UntagResource",
754 "lambda:TagResource",
755 "rds:RemoveTagsFromResource",
756 "rds:AddTagsToResource",
757 "iam:Untag*",
763 "iam:Tag*",
764 "secretsmanager:UntagResource",
765 "secretsmanager:TagResource",
766 "ssm:RemoveTagsFromResource",
767 "ssm:AddTagsToResource",
768 "cloudformation:UntagResource",
769 "cloudformation:TagResource",
770 "ecr:UntagResource",
771 "ecr:TagResource",
772 "kms:UntagResource",
773 "kms:TagResource",
774 "logs:UntagLogGroup",
775 "logs:TagLogGroup",
776 "sts:TagSession",
778 "ecs:UntagResource",
780 "ecs:TagResource",
781 "eks:UntagResource",
782 "eks:TagResource",
783 "elasticloadbalancing:RemoveTags",
785 "elasticloadbalancing:AddTags",
786 ]
792}
793
794#[cfg(test)]
795mod tests {
796 use super::*;
797
798 #[test]
799 fn test_parse_action() {
800 let action = ActionPattern::parse("s3:GetObject").unwrap();
801 assert_eq!(action.service, "s3");
802 assert_eq!(action.action, "GetObject");
803 }
804
805 #[test]
806 fn test_parse_wildcard_action() {
807 let action = ActionPattern::parse("lambda:Update*").unwrap();
808 assert_eq!(action.service, "lambda");
809 assert_eq!(action.action, "Update*");
810 }
811
812 #[test]
813 fn test_invalid_action() {
814 assert!(ActionPattern::parse("invalid").is_err());
815 }
816
817 #[test]
818 fn test_from_allow_str() {
819 let policy =
820 ScopedPolicy::from_allow_str("s3:GetObject, lambda:UpdateFunctionCode").unwrap();
821 assert_eq!(policy.actions.len(), 2);
822 }
823
824 #[test]
825 fn test_from_allow_str_with_resources() {
826 let policy = ScopedPolicy::from_allow_str_with_resources(
827 "s3:GetObject",
828 Some("arn:aws:s3:::my-bucket/*,arn:aws:s3:::my-bucket"),
829 )
830 .unwrap();
831 assert_eq!(policy.resources.len(), 2);
832 assert_eq!(policy.resources[0], "arn:aws:s3:::my-bucket/*");
833 assert_eq!(policy.resources[1], "arn:aws:s3:::my-bucket");
834 }
835
836 #[test]
837 fn test_from_allow_str_default_resources() {
838 let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
839 assert_eq!(policy.resources, vec!["*"]);
840 }
841
842 #[test]
843 fn test_to_iam_policy_json() {
844 let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
845 let json = policy.to_iam_policy_json().unwrap();
846 assert!(json.contains("s3:GetObject"));
847 assert!(json.contains("2012-10-17"));
848 }
849
850 #[test]
851 fn tag_lock_appends_deny_statement_bound_to_key() {
852 let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
853 let json = policy
854 .to_iam_policy_json_with_tag_lock("tryaudex-session")
855 .unwrap();
856 let doc: serde_json::Value = serde_json::from_str(&json).unwrap();
857 let stmts = doc["Statement"].as_array().unwrap();
858 assert_eq!(
862 stmts.len(),
863 3,
864 "expected Allow + tag-removal Deny + role-chain Deny statements"
865 );
866 let deny = &stmts[1];
867 assert_eq!(deny["Effect"], "Deny");
868 assert_eq!(deny["Sid"], "DenyTryaudexTagRemoval");
869 assert_eq!(
870 deny["Condition"]["ForAnyValue:StringEquals"]["aws:TagKeys"][0],
871 "tryaudex-session"
872 );
873 let actions = deny["Action"].as_array().unwrap();
875 let action_strs: Vec<&str> = actions.iter().filter_map(|v| v.as_str()).collect();
876 assert!(action_strs.contains(&"ec2:DeleteTags"));
877 assert!(action_strs.contains(&"tag:UntagResources"));
878 assert!(action_strs.contains(&"s3:DeleteBucketTagging"));
879 assert!(action_strs.contains(&"iam:Tag*"));
883 assert!(action_strs.contains(&"iam:Untag*"));
884
885 let role_chain_deny = &stmts[2];
887 assert_eq!(role_chain_deny["Effect"], "Deny");
888 assert_eq!(role_chain_deny["Sid"], "DenyTryaudexRoleChaining");
889 let chain_actions = role_chain_deny["Action"].as_array().unwrap();
890 let chain_action_strs: Vec<&str> =
891 chain_actions.iter().filter_map(|v| v.as_str()).collect();
892 assert!(chain_action_strs.contains(&"sts:AssumeRole"));
893 }
894
895 #[test]
896 fn tag_lock_with_none_key_is_noop() {
897 let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
898 let base = policy.to_iam_policy_json_with_network(None).unwrap();
899 let with_none = policy
900 .to_iam_policy_json_with_network_and_tag_lock(None, None)
901 .unwrap();
902 assert_eq!(base, with_none);
903 }
904
905 #[test]
906 fn test_wildcard_matches() {
907 let deny = ActionPattern::parse("iam:*").unwrap();
908 let action = ActionPattern::parse("iam:CreateRole").unwrap();
909 assert!(deny.matches(&action));
910 }
911
912 #[test]
913 fn test_prefix_wildcard_matches() {
914 let deny = ActionPattern::parse("lambda:Update*").unwrap();
915 let update = ActionPattern::parse("lambda:UpdateFunctionCode").unwrap();
916 let get = ActionPattern::parse("lambda:GetFunction").unwrap();
917 assert!(deny.matches(&update));
918 assert!(!deny.matches(&get));
919 }
920
921 #[test]
922 fn test_wildcard_no_cross_service() {
923 let deny = ActionPattern::parse("iam:*").unwrap();
924 let action = ActionPattern::parse("s3:GetObject").unwrap();
925 assert!(!deny.matches(&action));
926 }
927
928 #[test]
929 fn test_deny_list_blocks_action() {
930 let policy = ScopedPolicy::from_allow_str("iam:CreateRole,s3:GetObject").unwrap();
931 let deny = vec!["iam:*".to_string()];
932 let result = policy.enforce_deny_list(&deny);
933 assert!(result.is_err());
934 assert!(result.unwrap_err().to_string().contains("iam:CreateRole"));
935 }
936
937 #[test]
938 fn test_deny_list_allows_safe_actions() {
939 let policy = ScopedPolicy::from_allow_str("s3:GetObject,s3:ListBucket").unwrap();
940 let deny = vec!["iam:*".to_string(), "organizations:*".to_string()];
941 assert!(policy.enforce_deny_list(&deny).is_ok());
942 }
943
944 #[test]
945 fn test_parse_gcp_permission() {
946 let action = ActionPattern::parse_gcp("storage.objects.get").unwrap();
947 assert_eq!(action.service, "storage");
948 assert_eq!(action.action, "objects.get");
949 assert_eq!(action.to_gcp_permission(), "storage.objects.get");
950 }
951
952 #[test]
953 fn test_from_gcp_allow_str() {
954 let policy =
955 ScopedPolicy::from_gcp_allow_str("storage.objects.get, compute.instances.list")
956 .unwrap();
957 assert_eq!(policy.actions.len(), 2);
958 assert_eq!(policy.actions[0].to_gcp_permission(), "storage.objects.get");
959 assert_eq!(
960 policy.actions[1].to_gcp_permission(),
961 "compute.instances.list"
962 );
963 }
964
965 #[test]
966 fn test_gcp_permission_invalid() {
967 assert!(ActionPattern::parse_gcp("invalidpermission").is_err());
968 }
969
970 #[test]
971 fn test_parse_azure_permission() {
972 let action = ActionPattern::parse_azure("Microsoft.Storage/storageAccounts/read").unwrap();
973 assert_eq!(action.service, "Microsoft.Storage");
974 assert_eq!(action.action, "storageAccounts/read");
975 assert_eq!(
976 action.to_azure_permission(),
977 "Microsoft.Storage/storageAccounts/read"
978 );
979 }
980
981 #[test]
982 fn test_from_azure_allow_str() {
983 let policy = ScopedPolicy::from_azure_allow_str(
984 "Microsoft.Storage/storageAccounts/read, Microsoft.Compute/virtualMachines/read",
985 )
986 .unwrap();
987 assert_eq!(policy.actions.len(), 2);
988 assert_eq!(
989 policy.actions[0].to_azure_permission(),
990 "Microsoft.Storage/storageAccounts/read"
991 );
992 assert_eq!(
993 policy.actions[1].to_azure_permission(),
994 "Microsoft.Compute/virtualMachines/read"
995 );
996 }
997
998 #[test]
999 fn test_azure_permission_invalid() {
1000 assert!(ActionPattern::parse_azure("invalidpermission").is_err());
1001 }
1002
1003 #[test]
1004 fn test_network_policy_ip_condition() {
1005 let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
1006 let network = NetworkPolicy {
1007 allowed_ips: vec!["10.0.0.0/8".to_string(), "203.0.113.5".to_string()],
1008 allowed_vpcs: vec![],
1009 allowed_vpc_endpoints: vec![],
1010 };
1011 let json = policy
1012 .to_iam_policy_json_with_network(Some(&network))
1013 .unwrap();
1014 assert!(json.contains("aws:SourceIp"));
1015 assert!(json.contains("10.0.0.0/8"));
1016 assert!(json.contains("IpAddress"));
1017 }
1018
1019 #[test]
1020 fn test_network_policy_vpc_condition() {
1021 let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
1022 let network = NetworkPolicy {
1023 allowed_ips: vec![],
1024 allowed_vpcs: vec!["vpc-abc123".to_string()],
1025 allowed_vpc_endpoints: vec!["vpce-xyz789".to_string()],
1026 };
1027 let json = policy
1028 .to_iam_policy_json_with_network(Some(&network))
1029 .unwrap();
1030 assert!(json.contains("aws:SourceVpc"));
1031 assert!(json.contains("vpc-abc123"));
1032 assert!(json.contains("aws:SourceVpce"));
1033 assert!(json.contains("vpce-xyz789"));
1034 assert!(json.contains("StringEquals"));
1035 }
1036
1037 #[test]
1038 fn test_no_network_policy() {
1039 let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
1040 let json = policy.to_iam_policy_json_with_network(None).unwrap();
1041 assert!(!json.contains("Condition"));
1042 }
1043}