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 optional network conditions.
197    pub fn to_iam_policy_json_with_network(
198        &self,
199        network: Option<&NetworkPolicy>,
200    ) -> Result<String> {
201        let mut statement = serde_json::json!({
202            "Effect": "Allow",
203            "Action": self.actions.iter().map(|a| a.to_iam_action()).collect::<Vec<_>>(),
204            "Resource": self.resources,
205        });
206
207        // Add network conditions if configured
208        if let Some(net) = network {
209            let mut conditions: HashMap<String, HashMap<String, serde_json::Value>> =
210                HashMap::new();
211
212            if !net.allowed_ips.is_empty() {
213                let mut ip_cond = HashMap::new();
214                ip_cond.insert(
215                    "aws:SourceIp".to_string(),
216                    serde_json::json!(net.allowed_ips),
217                );
218                conditions.insert("IpAddress".to_string(), ip_cond);
219            }
220
221            if !net.allowed_vpcs.is_empty() {
222                let mut vpc_cond = HashMap::new();
223                vpc_cond.insert(
224                    "aws:SourceVpc".to_string(),
225                    serde_json::json!(net.allowed_vpcs),
226                );
227                conditions
228                    .entry("StringEquals".to_string())
229                    .or_default()
230                    .extend(vpc_cond);
231            }
232
233            if !net.allowed_vpc_endpoints.is_empty() {
234                let mut vpce_cond = HashMap::new();
235                vpce_cond.insert(
236                    "aws:SourceVpce".to_string(),
237                    serde_json::json!(net.allowed_vpc_endpoints),
238                );
239                conditions
240                    .entry("StringEquals".to_string())
241                    .or_default()
242                    .extend(vpce_cond);
243            }
244
245            if !conditions.is_empty() {
246                statement["Condition"] = serde_json::json!(conditions);
247            }
248        }
249
250        let policy = serde_json::json!({
251            "Version": "2012-10-17",
252            "Statement": [statement]
253        });
254        serde_json::to_string_pretty(&policy).map_err(|e| AvError::InvalidPolicy(e.to_string()))
255    }
256
257    /// Check all actions against a deny list. Returns error if any action is denied.
258    pub fn enforce_deny_list(&self, deny: &[String]) -> Result<()> {
259        let deny_patterns: Vec<ActionPattern> = deny
260            .iter()
261            .filter_map(|d| ActionPattern::parse(d).ok())
262            .collect();
263
264        for action in &self.actions {
265            for deny_pattern in &deny_patterns {
266                if deny_pattern.matches(action) {
267                    return Err(AvError::InvalidPolicy(format!(
268                        "Action '{}' is blocked by deny list rule '{}'",
269                        action.to_iam_action(),
270                        deny_pattern.to_iam_action()
271                    )));
272                }
273            }
274        }
275        Ok(())
276    }
277
278    /// List the services involved in this policy.
279    pub fn services(&self) -> Vec<String> {
280        let mut services: Vec<String> = self.actions.iter().map(|a| a.service.clone()).collect();
281        services.sort();
282        services.dedup();
283        services
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn test_parse_action() {
293        let action = ActionPattern::parse("s3:GetObject").unwrap();
294        assert_eq!(action.service, "s3");
295        assert_eq!(action.action, "GetObject");
296    }
297
298    #[test]
299    fn test_parse_wildcard_action() {
300        let action = ActionPattern::parse("lambda:Update*").unwrap();
301        assert_eq!(action.service, "lambda");
302        assert_eq!(action.action, "Update*");
303    }
304
305    #[test]
306    fn test_invalid_action() {
307        assert!(ActionPattern::parse("invalid").is_err());
308    }
309
310    #[test]
311    fn test_from_allow_str() {
312        let policy =
313            ScopedPolicy::from_allow_str("s3:GetObject, lambda:UpdateFunctionCode").unwrap();
314        assert_eq!(policy.actions.len(), 2);
315    }
316
317    #[test]
318    fn test_from_allow_str_with_resources() {
319        let policy = ScopedPolicy::from_allow_str_with_resources(
320            "s3:GetObject",
321            Some("arn:aws:s3:::my-bucket/*,arn:aws:s3:::my-bucket"),
322        )
323        .unwrap();
324        assert_eq!(policy.resources.len(), 2);
325        assert_eq!(policy.resources[0], "arn:aws:s3:::my-bucket/*");
326        assert_eq!(policy.resources[1], "arn:aws:s3:::my-bucket");
327    }
328
329    #[test]
330    fn test_from_allow_str_default_resources() {
331        let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
332        assert_eq!(policy.resources, vec!["*"]);
333    }
334
335    #[test]
336    fn test_to_iam_policy_json() {
337        let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
338        let json = policy.to_iam_policy_json().unwrap();
339        assert!(json.contains("s3:GetObject"));
340        assert!(json.contains("2012-10-17"));
341    }
342
343    #[test]
344    fn test_wildcard_matches() {
345        let deny = ActionPattern::parse("iam:*").unwrap();
346        let action = ActionPattern::parse("iam:CreateRole").unwrap();
347        assert!(deny.matches(&action));
348    }
349
350    #[test]
351    fn test_prefix_wildcard_matches() {
352        let deny = ActionPattern::parse("lambda:Update*").unwrap();
353        let update = ActionPattern::parse("lambda:UpdateFunctionCode").unwrap();
354        let get = ActionPattern::parse("lambda:GetFunction").unwrap();
355        assert!(deny.matches(&update));
356        assert!(!deny.matches(&get));
357    }
358
359    #[test]
360    fn test_wildcard_no_cross_service() {
361        let deny = ActionPattern::parse("iam:*").unwrap();
362        let action = ActionPattern::parse("s3:GetObject").unwrap();
363        assert!(!deny.matches(&action));
364    }
365
366    #[test]
367    fn test_deny_list_blocks_action() {
368        let policy = ScopedPolicy::from_allow_str("iam:CreateRole,s3:GetObject").unwrap();
369        let deny = vec!["iam:*".to_string()];
370        let result = policy.enforce_deny_list(&deny);
371        assert!(result.is_err());
372        assert!(result.unwrap_err().to_string().contains("iam:CreateRole"));
373    }
374
375    #[test]
376    fn test_deny_list_allows_safe_actions() {
377        let policy = ScopedPolicy::from_allow_str("s3:GetObject,s3:ListBucket").unwrap();
378        let deny = vec!["iam:*".to_string(), "organizations:*".to_string()];
379        assert!(policy.enforce_deny_list(&deny).is_ok());
380    }
381
382    #[test]
383    fn test_parse_gcp_permission() {
384        let action = ActionPattern::parse_gcp("storage.objects.get").unwrap();
385        assert_eq!(action.service, "storage");
386        assert_eq!(action.action, "objects.get");
387        assert_eq!(action.to_gcp_permission(), "storage.objects.get");
388    }
389
390    #[test]
391    fn test_from_gcp_allow_str() {
392        let policy =
393            ScopedPolicy::from_gcp_allow_str("storage.objects.get, compute.instances.list")
394                .unwrap();
395        assert_eq!(policy.actions.len(), 2);
396        assert_eq!(policy.actions[0].to_gcp_permission(), "storage.objects.get");
397        assert_eq!(
398            policy.actions[1].to_gcp_permission(),
399            "compute.instances.list"
400        );
401    }
402
403    #[test]
404    fn test_gcp_permission_invalid() {
405        assert!(ActionPattern::parse_gcp("invalidpermission").is_err());
406    }
407
408    #[test]
409    fn test_parse_azure_permission() {
410        let action = ActionPattern::parse_azure("Microsoft.Storage/storageAccounts/read").unwrap();
411        assert_eq!(action.service, "Microsoft.Storage");
412        assert_eq!(action.action, "storageAccounts/read");
413        assert_eq!(
414            action.to_azure_permission(),
415            "Microsoft.Storage/storageAccounts/read"
416        );
417    }
418
419    #[test]
420    fn test_from_azure_allow_str() {
421        let policy = ScopedPolicy::from_azure_allow_str(
422            "Microsoft.Storage/storageAccounts/read, Microsoft.Compute/virtualMachines/read",
423        )
424        .unwrap();
425        assert_eq!(policy.actions.len(), 2);
426        assert_eq!(
427            policy.actions[0].to_azure_permission(),
428            "Microsoft.Storage/storageAccounts/read"
429        );
430        assert_eq!(
431            policy.actions[1].to_azure_permission(),
432            "Microsoft.Compute/virtualMachines/read"
433        );
434    }
435
436    #[test]
437    fn test_azure_permission_invalid() {
438        assert!(ActionPattern::parse_azure("invalidpermission").is_err());
439    }
440
441    #[test]
442    fn test_network_policy_ip_condition() {
443        let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
444        let network = NetworkPolicy {
445            allowed_ips: vec!["10.0.0.0/8".to_string(), "203.0.113.5".to_string()],
446            allowed_vpcs: vec![],
447            allowed_vpc_endpoints: vec![],
448        };
449        let json = policy
450            .to_iam_policy_json_with_network(Some(&network))
451            .unwrap();
452        assert!(json.contains("aws:SourceIp"));
453        assert!(json.contains("10.0.0.0/8"));
454        assert!(json.contains("IpAddress"));
455    }
456
457    #[test]
458    fn test_network_policy_vpc_condition() {
459        let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
460        let network = NetworkPolicy {
461            allowed_ips: vec![],
462            allowed_vpcs: vec!["vpc-abc123".to_string()],
463            allowed_vpc_endpoints: vec!["vpce-xyz789".to_string()],
464        };
465        let json = policy
466            .to_iam_policy_json_with_network(Some(&network))
467            .unwrap();
468        assert!(json.contains("aws:SourceVpc"));
469        assert!(json.contains("vpc-abc123"));
470        assert!(json.contains("aws:SourceVpce"));
471        assert!(json.contains("vpce-xyz789"));
472        assert!(json.contains("StringEquals"));
473    }
474
475    #[test]
476    fn test_no_network_policy() {
477        let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
478        let json = policy.to_iam_policy_json_with_network(None).unwrap();
479        assert!(!json.contains("Condition"));
480    }
481}