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.action.starts_with(&self.action[..self.action.len() - 1]));
95 service_match && action_match
96 }
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct ScopedPolicy {
102 pub actions: Vec<ActionPattern>,
103 pub resources: Vec<String>,
104}
105
106impl ScopedPolicy {
107 pub fn from_allow_str(allow: &str) -> Result<Self> {
110 Self::from_allow_str_with_resources(allow, None)
111 }
112
113 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 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 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 pub fn to_iam_policy_json(&self) -> Result<String> {
191 self.to_iam_policy_json_with_network(None)
192 }
193
194 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 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 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 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}