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.action.starts_with(&self.action[..self.action.len() - 1]));
95        service_match && action_match
96    }
97}
98
99/// A scoped IAM policy built from user-specified allowed actions.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct ScopedPolicy {
102    pub actions: Vec<ActionPattern>,
103    pub resources: Vec<String>,
104}
105
106impl ScopedPolicy {
107    /// Parse a comma-separated allow string like "s3:GetObject,lambda:Update*".
108    /// Optionally accepts resource ARNs to restrict beyond just actions.
109    pub fn from_allow_str(allow: &str) -> Result<Self> {
110        Self::from_allow_str_with_resources(allow, None)
111    }
112
113    /// Parse allow string with optional resource ARN restrictions.
114    pub fn from_allow_str_with_resources(allow: &str, resources: Option<&str>) -> Result<Self> {
115        let actions = allow
116            .split(',')
117            .map(|s| s.trim())
118            .filter(|s| !s.is_empty())
119            .map(ActionPattern::parse)
120            .collect::<Result<Vec<_>>>()?;
121
122        if actions.is_empty() {
123            return Err(AvError::InvalidPolicy(
124                "No valid actions provided".to_string(),
125            ));
126        }
127
128        let resources = match resources {
129            Some(r) => {
130                let parsed: Vec<String> = r
131                    .split(',')
132                    .map(|s| s.trim().to_string())
133                    .filter(|s| !s.is_empty())
134                    .collect();
135                if parsed.is_empty() {
136                    vec!["*".to_string()]
137                } else {
138                    parsed
139                }
140            }
141            None => vec!["*".to_string()],
142        };
143
144        Ok(Self { actions, resources })
145    }
146
147    /// Parse a comma-separated GCP permissions string like "storage.objects.get,compute.instances.list".
148    pub fn from_gcp_allow_str(allow: &str) -> Result<Self> {
149        let actions = allow
150            .split(',')
151            .map(|s| s.trim())
152            .filter(|s| !s.is_empty())
153            .map(ActionPattern::parse_gcp)
154            .collect::<Result<Vec<_>>>()?;
155
156        if actions.is_empty() {
157            return Err(AvError::InvalidPolicy(
158                "No valid GCP permissions provided".to_string(),
159            ));
160        }
161
162        Ok(Self {
163            actions,
164            resources: vec!["*".to_string()],
165        })
166    }
167
168    /// Parse a comma-separated Azure permissions string like "Microsoft.Storage/storageAccounts/read".
169    pub fn from_azure_allow_str(allow: &str) -> Result<Self> {
170        let actions = allow
171            .split(',')
172            .map(|s| s.trim())
173            .filter(|s| !s.is_empty())
174            .map(ActionPattern::parse_azure)
175            .collect::<Result<Vec<_>>>()?;
176
177        if actions.is_empty() {
178            return Err(AvError::InvalidPolicy(
179                "No valid Azure permissions provided".to_string(),
180            ));
181        }
182
183        Ok(Self {
184            actions,
185            resources: vec!["*".to_string()],
186        })
187    }
188
189    /// Generate the AWS IAM policy JSON document for STS inline policy.
190    pub fn to_iam_policy_json(&self) -> Result<String> {
191        self.to_iam_policy_json_with_network(None)
192    }
193
194    /// Generate the AWS IAM policy JSON with optional network conditions.
195    pub fn to_iam_policy_json_with_network(&self, network: Option<&NetworkPolicy>) -> Result<String> {
196        let mut statement = serde_json::json!({
197            "Effect": "Allow",
198            "Action": self.actions.iter().map(|a| a.to_iam_action()).collect::<Vec<_>>(),
199            "Resource": self.resources,
200        });
201
202        // Add network conditions if configured
203        if let Some(net) = network {
204            let mut conditions: HashMap<String, HashMap<String, serde_json::Value>> = HashMap::new();
205
206            if !net.allowed_ips.is_empty() {
207                let mut ip_cond = HashMap::new();
208                ip_cond.insert(
209                    "aws:SourceIp".to_string(),
210                    serde_json::json!(net.allowed_ips),
211                );
212                conditions.insert("IpAddress".to_string(), ip_cond);
213            }
214
215            if !net.allowed_vpcs.is_empty() {
216                let mut vpc_cond = HashMap::new();
217                vpc_cond.insert(
218                    "aws:SourceVpc".to_string(),
219                    serde_json::json!(net.allowed_vpcs),
220                );
221                conditions
222                    .entry("StringEquals".to_string())
223                    .or_default()
224                    .extend(vpc_cond);
225            }
226
227            if !net.allowed_vpc_endpoints.is_empty() {
228                let mut vpce_cond = HashMap::new();
229                vpce_cond.insert(
230                    "aws:SourceVpce".to_string(),
231                    serde_json::json!(net.allowed_vpc_endpoints),
232                );
233                conditions
234                    .entry("StringEquals".to_string())
235                    .or_default()
236                    .extend(vpce_cond);
237            }
238
239            if !conditions.is_empty() {
240                statement["Condition"] = serde_json::json!(conditions);
241            }
242        }
243
244        let policy = serde_json::json!({
245            "Version": "2012-10-17",
246            "Statement": [statement]
247        });
248        serde_json::to_string_pretty(&policy).map_err(|e| AvError::InvalidPolicy(e.to_string()))
249    }
250
251    /// Check all actions against a deny list. Returns error if any action is denied.
252    pub fn enforce_deny_list(&self, deny: &[String]) -> Result<()> {
253        let deny_patterns: Vec<ActionPattern> = deny
254            .iter()
255            .filter_map(|d| ActionPattern::parse(d).ok())
256            .collect();
257
258        for action in &self.actions {
259            for deny_pattern in &deny_patterns {
260                if deny_pattern.matches(action) {
261                    return Err(AvError::InvalidPolicy(format!(
262                        "Action '{}' is blocked by deny list rule '{}'",
263                        action.to_iam_action(),
264                        deny_pattern.to_iam_action()
265                    )));
266                }
267            }
268        }
269        Ok(())
270    }
271
272    /// List the services involved in this policy.
273    pub fn services(&self) -> Vec<String> {
274        let mut services: Vec<String> = self.actions.iter().map(|a| a.service.clone()).collect();
275        services.sort();
276        services.dedup();
277        services
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_parse_action() {
287        let action = ActionPattern::parse("s3:GetObject").unwrap();
288        assert_eq!(action.service, "s3");
289        assert_eq!(action.action, "GetObject");
290    }
291
292    #[test]
293    fn test_parse_wildcard_action() {
294        let action = ActionPattern::parse("lambda:Update*").unwrap();
295        assert_eq!(action.service, "lambda");
296        assert_eq!(action.action, "Update*");
297    }
298
299    #[test]
300    fn test_invalid_action() {
301        assert!(ActionPattern::parse("invalid").is_err());
302    }
303
304    #[test]
305    fn test_from_allow_str() {
306        let policy = ScopedPolicy::from_allow_str("s3:GetObject, lambda:UpdateFunctionCode").unwrap();
307        assert_eq!(policy.actions.len(), 2);
308    }
309
310    #[test]
311    fn test_from_allow_str_with_resources() {
312        let policy = ScopedPolicy::from_allow_str_with_resources(
313            "s3:GetObject",
314            Some("arn:aws:s3:::my-bucket/*,arn:aws:s3:::my-bucket"),
315        )
316        .unwrap();
317        assert_eq!(policy.resources.len(), 2);
318        assert_eq!(policy.resources[0], "arn:aws:s3:::my-bucket/*");
319        assert_eq!(policy.resources[1], "arn:aws:s3:::my-bucket");
320    }
321
322    #[test]
323    fn test_from_allow_str_default_resources() {
324        let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
325        assert_eq!(policy.resources, vec!["*"]);
326    }
327
328    #[test]
329    fn test_to_iam_policy_json() {
330        let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
331        let json = policy.to_iam_policy_json().unwrap();
332        assert!(json.contains("s3:GetObject"));
333        assert!(json.contains("2012-10-17"));
334    }
335
336    #[test]
337    fn test_wildcard_matches() {
338        let deny = ActionPattern::parse("iam:*").unwrap();
339        let action = ActionPattern::parse("iam:CreateRole").unwrap();
340        assert!(deny.matches(&action));
341    }
342
343    #[test]
344    fn test_prefix_wildcard_matches() {
345        let deny = ActionPattern::parse("lambda:Update*").unwrap();
346        let update = ActionPattern::parse("lambda:UpdateFunctionCode").unwrap();
347        let get = ActionPattern::parse("lambda:GetFunction").unwrap();
348        assert!(deny.matches(&update));
349        assert!(!deny.matches(&get));
350    }
351
352    #[test]
353    fn test_wildcard_no_cross_service() {
354        let deny = ActionPattern::parse("iam:*").unwrap();
355        let action = ActionPattern::parse("s3:GetObject").unwrap();
356        assert!(!deny.matches(&action));
357    }
358
359    #[test]
360    fn test_deny_list_blocks_action() {
361        let policy = ScopedPolicy::from_allow_str("iam:CreateRole,s3:GetObject").unwrap();
362        let deny = vec!["iam:*".to_string()];
363        let result = policy.enforce_deny_list(&deny);
364        assert!(result.is_err());
365        assert!(result.unwrap_err().to_string().contains("iam:CreateRole"));
366    }
367
368    #[test]
369    fn test_deny_list_allows_safe_actions() {
370        let policy = ScopedPolicy::from_allow_str("s3:GetObject,s3:ListBucket").unwrap();
371        let deny = vec!["iam:*".to_string(), "organizations:*".to_string()];
372        assert!(policy.enforce_deny_list(&deny).is_ok());
373    }
374
375    #[test]
376    fn test_parse_gcp_permission() {
377        let action = ActionPattern::parse_gcp("storage.objects.get").unwrap();
378        assert_eq!(action.service, "storage");
379        assert_eq!(action.action, "objects.get");
380        assert_eq!(action.to_gcp_permission(), "storage.objects.get");
381    }
382
383    #[test]
384    fn test_from_gcp_allow_str() {
385        let policy = ScopedPolicy::from_gcp_allow_str("storage.objects.get, compute.instances.list").unwrap();
386        assert_eq!(policy.actions.len(), 2);
387        assert_eq!(policy.actions[0].to_gcp_permission(), "storage.objects.get");
388        assert_eq!(policy.actions[1].to_gcp_permission(), "compute.instances.list");
389    }
390
391    #[test]
392    fn test_gcp_permission_invalid() {
393        assert!(ActionPattern::parse_gcp("invalidpermission").is_err());
394    }
395
396    #[test]
397    fn test_parse_azure_permission() {
398        let action = ActionPattern::parse_azure("Microsoft.Storage/storageAccounts/read").unwrap();
399        assert_eq!(action.service, "Microsoft.Storage");
400        assert_eq!(action.action, "storageAccounts/read");
401        assert_eq!(action.to_azure_permission(), "Microsoft.Storage/storageAccounts/read");
402    }
403
404    #[test]
405    fn test_from_azure_allow_str() {
406        let policy = ScopedPolicy::from_azure_allow_str(
407            "Microsoft.Storage/storageAccounts/read, Microsoft.Compute/virtualMachines/read"
408        ).unwrap();
409        assert_eq!(policy.actions.len(), 2);
410        assert_eq!(policy.actions[0].to_azure_permission(), "Microsoft.Storage/storageAccounts/read");
411        assert_eq!(policy.actions[1].to_azure_permission(), "Microsoft.Compute/virtualMachines/read");
412    }
413
414    #[test]
415    fn test_azure_permission_invalid() {
416        assert!(ActionPattern::parse_azure("invalidpermission").is_err());
417    }
418
419    #[test]
420    fn test_network_policy_ip_condition() {
421        let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
422        let network = NetworkPolicy {
423            allowed_ips: vec!["10.0.0.0/8".to_string(), "203.0.113.5".to_string()],
424            allowed_vpcs: vec![],
425            allowed_vpc_endpoints: vec![],
426        };
427        let json = policy.to_iam_policy_json_with_network(Some(&network)).unwrap();
428        assert!(json.contains("aws:SourceIp"));
429        assert!(json.contains("10.0.0.0/8"));
430        assert!(json.contains("IpAddress"));
431    }
432
433    #[test]
434    fn test_network_policy_vpc_condition() {
435        let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
436        let network = NetworkPolicy {
437            allowed_ips: vec![],
438            allowed_vpcs: vec!["vpc-abc123".to_string()],
439            allowed_vpc_endpoints: vec!["vpce-xyz789".to_string()],
440        };
441        let json = policy.to_iam_policy_json_with_network(Some(&network)).unwrap();
442        assert!(json.contains("aws:SourceVpc"));
443        assert!(json.contains("vpc-abc123"));
444        assert!(json.contains("aws:SourceVpce"));
445        assert!(json.contains("vpce-xyz789"));
446        assert!(json.contains("StringEquals"));
447    }
448
449    #[test]
450    fn test_no_network_policy() {
451        let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
452        let json = policy.to_iam_policy_json_with_network(None).unwrap();
453        assert!(!json.contains("Condition"));
454    }
455}