Skip to main content

tryaudex_core/
policy.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::{AvError, Result};
6
7/// AWS STS inline session policy size limit (bytes, after URL encoding).
8/// STS rejects policies whose URL-encoded form exceeds 2048 bytes.
9const MAX_POLICY_URL_ENCODED_SIZE: usize = 2048;
10
11/// Network policy configuration for restricting credential usage by source IP/VPC.
12#[derive(Debug, Clone, Serialize, Deserialize, Default)]
13pub struct NetworkPolicy {
14    /// Allowed source IP addresses or CIDR ranges (e.g. ["10.0.0.0/8", "203.0.113.5"])
15    #[serde(default)]
16    pub allowed_ips: Vec<String>,
17    /// Allowed VPC IDs (e.g. ["vpc-abc123"])
18    #[serde(default)]
19    pub allowed_vpcs: Vec<String>,
20    /// Allowed VPC endpoint IDs (e.g. ["vpce-abc123"])
21    #[serde(default)]
22    pub allowed_vpc_endpoints: Vec<String>,
23}
24
25impl NetworkPolicy {
26    /// Validate that all entries in `allowed_ips` are well-formed IP addresses or
27    /// CIDR ranges, and that wildcard ranges (`0.0.0.0/0`, `::/0`) are rejected
28    /// because they make the restriction a no-op.
29    pub fn validate(&self) -> Result<()> {
30        // AWS IAM evaluates multiple Condition keys as AND. Combining
31        // aws:SourceIp with aws:SourceVpc/aws:SourceVpce makes credentials
32        // unusable: VPC traffic originates from private RFC-1918 addresses
33        // that will never match an explicit public IP list, so the AND
34        // condition can never be satisfied. Require mutual exclusivity.
35        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        // Wildcards that render the IP restriction meaningless
48        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                // CIDR notation — validate prefix and prefix length
62                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                // Plain IP address
86                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
98/// Return true if `s` is a valid dotted-quad IPv4 or colon-separated IPv6 address.
99fn is_valid_ip(s: &str) -> bool {
100    s.parse::<std::net::IpAddr>().is_ok()
101}
102
103/// A parsed IAM action pattern like "s3:GetObject" or "lambda:Update*".
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct ActionPattern {
106    pub service: String,
107    pub action: String,
108}
109
110impl ActionPattern {
111    /// Parse a string like "s3:GetObject" into an ActionPattern.
112    ///
113    /// Both the service and action components must match `[a-zA-Z0-9*?-]+`
114    /// to prevent opaque STS errors from malformed policy documents.
115    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    /// Convert to IAM action string (AWS format: service:Action).
148    pub fn to_iam_action(&self) -> String {
149        format!("{}:{}", self.service, self.action)
150    }
151
152    /// Parse a GCP permission like "storage.objects.get" or "compute.instances.list".
153    ///
154    /// Components are validated to contain only `[a-zA-Z0-9.*?-]+` to prevent
155    /// null bytes or special characters from corrupting deny-list matching.
156    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    /// Convert to GCP permission string (service.resource.verb).
189    pub fn to_gcp_permission(&self) -> String {
190        format!("{}.{}", self.service, self.action)
191    }
192
193    /// Parse an Azure permission like "Microsoft.Storage/storageAccounts/read".
194    ///
195    /// Components are validated to contain only `[a-zA-Z0-9./*?-]+` to prevent
196    /// null bytes or special characters from corrupting deny-list matching.
197    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    /// Convert to Azure permission string (Microsoft.Service/resource/action).
235    pub fn to_azure_permission(&self) -> String {
236        format!("{}/{}", self.service, self.action)
237    }
238
239    /// Check if this pattern matches another action (for deny list checking).
240    /// Supports wildcards: "iam:*" matches "iam:CreateRole", "*:*" matches everything,
241    /// "s3:*Object" matches "s3:GetObject" and "s3:PutObject".
242    ///
243    /// AWS IAM and GCP actions are case-insensitive, so comparison is
244    /// normalised to lowercase. R6-M26: Azure permissions preserve casing
245    /// in role definitions and resource providers; blanket lowercasing
246    /// could cause spurious deny-list matches against differently-cased
247    /// operator tokens (e.g. a deny rule on
248    /// `Microsoft.Storage/storageAccounts/write` comparing against a
249    /// user-supplied `Microsoft.storage/storageaccounts/WRITE`). When
250    /// either side looks like an Azure provider (service starts with
251    /// `Microsoft.`), compare with case preserved on both sides so the
252    /// match semantics mirror Azure's own service-provider identifiers.
253    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/// A scoped IAM policy built from user-specified allowed actions.
275#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct ScopedPolicy {
277    pub actions: Vec<ActionPattern>,
278    pub resources: Vec<String>,
279}
280
281impl ScopedPolicy {
282    /// Parse a comma-separated allow string like "s3:GetObject,lambda:Update*".
283    /// Optionally accepts resource ARNs to restrict beyond just actions.
284    pub fn from_allow_str(allow: &str) -> Result<Self> {
285        Self::from_allow_str_with_resources(allow, None)
286    }
287
288    /// Parse allow string with optional resource ARN restrictions.
289    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        // Reject wildcard-all patterns that grant unrestricted access.
304        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        // Warn when a service wildcard is used for sensitive services.
313        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    /// Parse a comma-separated GCP permissions string like "storage.objects.get,compute.instances.list".
346    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        // Reject wildcard-all patterns that grant unrestricted access.
361        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        // Warn on GCP sensitive service wildcards.
370        // R6-M24: the previous list covered IAM/KMS/org basics but missed
371        // billing/identity/secret/context services that can elevate
372        // session impact well beyond the intended scope.
373        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    /// Parse a comma-separated Azure permissions string like "Microsoft.Storage/storageAccounts/read".
402    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        // Reject wildcard-all patterns that grant unrestricted access.
417        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        // Warn on Azure sensitive service wildcards.
426        // R6-M25: the previous list covered Authorization/ManagedIdentity/
427        // KeyVault but missed several tenant/management services whose
428        // wildcards grant subscription-wide control.
429        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    /// Generate the AWS IAM policy JSON document for STS inline policy.
454    pub fn to_iam_policy_json(&self) -> Result<String> {
455        self.to_iam_policy_json_with_network(None)
456    }
457
458    /// Generate the AWS IAM policy JSON with a Deny statement that blocks
459    /// the caller from removing or overwriting the given tag key. Used by
460    /// `--tag-session` to keep agents from stripping the `tryaudex-session`
461    /// marker mid-session and escaping cleanup.
462    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    /// Full policy JSON with both network conditions and tag-lock deny.
467    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        // Reuse the existing network logic for the Allow statement.
473        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        // R6-H6: Block sts:AssumeRole* unless the new session carries the
495        // session tag. Without this, an agent could chain into a second
496        // assume-role call and issue a fresh session that the cleanup flow
497        // never tags — escaping tag-based revocation and ephemeral cleanup.
498        // `Null: true` fires when the request does NOT contain the tag.
499        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    /// Generate the AWS IAM policy JSON with optional network conditions.
530    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        // Add network conditions if configured
541        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    /// Check all actions against a deny list. Returns error if any action is denied.
595    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    /// Provider-aware deny list enforcement. Parses deny patterns using
622    /// the correct format for each provider (colon-separated for AWS,
623    /// dot-separated for GCP, slash-separated for Azure).
624    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    /// List the services involved in this policy.
671    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
679/// Glob matching: `*` matches any sequence of characters, `?` matches a single
680/// character. IAM uses both wildcards in action and resource patterns.
681/// Uses an iterative two-pointer approach (O(n*m) worst case, no stack overflow).
682fn 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; // try matching * with empty sequence first
696        } else if star_pi != usize::MAX {
697            // backtrack: let the last * consume one more character
698            pi = star_pi + 1;
699            star_ti += 1;
700            ti = star_ti;
701        } else {
702            return false;
703        }
704    }
705    // Consume trailing *'s in pattern
706    while pi < pat.len() && pat[pi] == b'*' {
707        pi += 1;
708    }
709    pi == pat.len()
710}
711
712/// List every AWS IAM action that could remove or overwrite a tag. The
713/// Check that the final policy JSON fits within the STS URL-encoded size limit.
714fn 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
729/// `aws:TagKeys` condition constrains these denies to actions where the
730/// tryaudex-session key is the one being touched, so the agent can still
731/// freely manage unrelated tags on their own resources.
732fn tag_mutation_actions() -> Vec<&'static str> {
733    vec![
734        // ec2 uses a non-standard tag API
735        "ec2:DeleteTags",
736        "ec2:CreateTags",
737        // Generic tagging APIs — cover the cross-service paths
738        "tag:UntagResources",
739        "tag:TagResources",
740        // s3 — bucket-level and object-level tagging
741        "s3:DeleteBucketTagging",
742        "s3:PutBucketTagging",
743        "s3:PutObjectTagging",
744        "s3:DeleteObjectTagging",
745        "s3:PutObjectVersionTagging",
746        // per-service Untag* / Remove*Tags — common agent-accessible services
747        "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 supports wildcards in action names, and Tag*/Untag* cover
758        // Role, User, Policy, InstanceProfile, OpenIDConnectProvider, etc.
759        // Using wildcards instead of listing each variant keeps the inlined
760        // session policy under the STS 2048-byte URL-encoded limit once the
761        // R6-H6 sts:AssumeRole chaining deny is also included.
762        "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 session tagging
777        "sts:TagSession",
778        // ECS / EKS
779        "ecs:UntagResource",
780        "ecs:TagResource",
781        "eks:UntagResource",
782        "eks:TagResource",
783        // ELB / ALB
784        "elasticloadbalancing:RemoveTags",
785        "elasticloadbalancing:AddTags",
786        // NOTE: Additional services (ElastiCache, Route53, Redshift, CloudWatch,
787        // EventBridge, SageMaker, Glue, etc.) are covered by the generic
788        // `tag:UntagResources` / `tag:TagResources` entries above. Listing
789        // every service would exceed the STS inline policy size limit (2048
790        // bytes URL-encoded).
791    ]
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        // R6-H6 added a third statement blocking sts:AssumeRole chaining
859        // without the session tag; the full policy is now Allow + tag
860        // removal Deny + role chain Deny.
861        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        // Spot-check that the Deny action list covers the critical paths.
874        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        // After compression, IAM tag actions are covered by wildcards
880        // rather than per-resource variants (kept the policy under the
881        // STS 2048-byte URL-encoded limit with R6-H6 present).
882        assert!(action_strs.contains(&"iam:Tag*"));
883        assert!(action_strs.contains(&"iam:Untag*"));
884
885        // R6-H6 role-chain deny statement.
886        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}