1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::{AvError, Result};
6
7#[derive(Debug, Clone, Serialize, Deserialize, Default)]
9pub struct NetworkPolicy {
10 #[serde(default)]
12 pub allowed_ips: Vec<String>,
13 #[serde(default)]
15 pub allowed_vpcs: Vec<String>,
16 #[serde(default)]
18 pub allowed_vpc_endpoints: Vec<String>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct ActionPattern {
24 pub service: String,
25 pub action: String,
26}
27
28impl ActionPattern {
29 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 pub fn to_iam_action(&self) -> String {
46 format!("{}:{}", self.service, self.action)
47 }
48
49 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 pub fn to_gcp_permission(&self) -> String {
65 format!("{}.{}", self.service, self.action)
66 }
67
68 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 pub fn to_azure_permission(&self) -> String {
84 format!("{}/{}", self.service, self.action)
85 }
86
87 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#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct ScopedPolicy {
104 pub actions: Vec<ActionPattern>,
105 pub resources: Vec<String>,
106}
107
108impl ScopedPolicy {
109 pub fn from_allow_str(allow: &str) -> Result<Self> {
112 Self::from_allow_str_with_resources(allow, None)
113 }
114
115 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 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 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 pub fn to_iam_policy_json(&self) -> Result<String> {
193 self.to_iam_policy_json_with_network(None)
194 }
195
196 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 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 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 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}