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/// Network policy configuration for restricting credential usage by source IP/VPC.
8#[derive(Debug, Clone, Serialize, Deserialize, Default)]
9pub struct NetworkPolicy {
10    /// Allowed source IP addresses or CIDR ranges (e.g. ["10.0.0.0/8", "203.0.113.5"])
11    #[serde(default)]
12    pub allowed_ips: Vec<String>,
13    /// Allowed VPC IDs (e.g. ["vpc-abc123"])
14    #[serde(default)]
15    pub allowed_vpcs: Vec<String>,
16    /// Allowed VPC endpoint IDs (e.g. ["vpce-abc123"])
17    #[serde(default)]
18    pub allowed_vpc_endpoints: Vec<String>,
19}
20
21/// A parsed IAM action pattern like "s3:GetObject" or "lambda:Update*".
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct ActionPattern {
24    pub service: String,
25    pub action: String,
26}
27
28impl ActionPattern {
29    /// Parse a string like "s3:GetObject" into an ActionPattern.
30    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    /// Convert to IAM action string (AWS format: service:Action).
45    pub fn to_iam_action(&self) -> String {
46        format!("{}:{}", self.service, self.action)
47    }
48
49    /// Parse a GCP permission like "storage.objects.get" or "compute.instances.list".
50    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    /// Convert to GCP permission string (service.resource.verb).
64    pub fn to_gcp_permission(&self) -> String {
65        format!("{}.{}", self.service, self.action)
66    }
67
68    /// Parse an Azure permission like "Microsoft.Storage/storageAccounts/read".
69    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    /// Convert to Azure permission string (Microsoft.Service/resource/action).
83    pub fn to_azure_permission(&self) -> String {
84        format!("{}/{}", self.service, self.action)
85    }
86
87    /// Check if this pattern matches another action (for deny list checking).
88    /// Supports wildcards: "iam:*" matches "iam:CreateRole", "*:*" matches everything.
89    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/// A scoped IAM policy built from user-specified allowed actions.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct ScopedPolicy {
104    pub actions: Vec<ActionPattern>,
105    pub resources: Vec<String>,
106}
107
108impl ScopedPolicy {
109    /// Parse a comma-separated allow string like "s3:GetObject,lambda:Update*".
110    /// Optionally accepts resource ARNs to restrict beyond just actions.
111    pub fn from_allow_str(allow: &str) -> Result<Self> {
112        Self::from_allow_str_with_resources(allow, None)
113    }
114
115    /// Parse allow string with optional resource ARN restrictions.
116    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    /// Parse a comma-separated GCP permissions string like "storage.objects.get,compute.instances.list".
150    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    /// Parse a comma-separated Azure permissions string like "Microsoft.Storage/storageAccounts/read".
171    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    /// Generate the AWS IAM policy JSON document for STS inline policy.
192    pub fn to_iam_policy_json(&self) -> Result<String> {
193        self.to_iam_policy_json_with_network(None)
194    }
195
196    /// Generate the AWS IAM policy JSON with a Deny statement that blocks
197    /// the caller from removing or overwriting the given tag key. Used by
198    /// `--tag-session` to keep agents from stripping the `tryaudex-session`
199    /// marker mid-session and escaping cleanup.
200    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    /// Full policy JSON with both network conditions and tag-lock deny.
205    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        // Reuse the existing network logic for the Allow statement.
211        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    /// Generate the AWS IAM policy JSON with optional network conditions.
236    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        // Add network conditions if configured
247        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    /// Check all actions against a deny list. Returns error if any action is denied.
297    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    /// List the services involved in this policy.
318    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
326/// List every AWS IAM action that could remove or overwrite a tag. The
327/// `aws:TagKeys` condition constrains these denies to actions where the
328/// tryaudex-session key is the one being touched, so the agent can still
329/// freely manage unrelated tags on their own resources.
330fn tag_mutation_actions() -> Vec<&'static str> {
331    vec![
332        // ec2 uses a non-standard tag API
333        "ec2:DeleteTags",
334        "ec2:CreateTags",
335        // Generic tagging APIs — cover the cross-service paths
336        "tag:UntagResources",
337        "tag:TagResources",
338        // s3 — bucket-level tagging is separate
339        "s3:DeleteBucketTagging",
340        "s3:PutBucketTagging",
341        // per-service Untag* / Remove*Tags — common agent-accessible services
342        "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        // Spot-check that the Deny action list covers the critical paths.
446        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}