1use super::IAMStatement;
2use crate::{
3 core::IAMVersion,
4 validation::{Validate, ValidationContext, ValidationError, ValidationResult, helpers},
5};
6use serde::{Deserialize, Serialize};
7use serde_with::OneOrMany;
8use serde_with::formats::PreferOne;
9use serde_with::serde_as;
10use std::collections::HashSet;
11
12#[serde_as]
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct IAMPolicy {
32 #[serde(rename = "Version")]
39 pub version: IAMVersion,
40
41 #[serde(rename = "Id", skip_serializing_if = "Option::is_none")]
50 pub id: Option<String>,
51
52 #[serde(rename = "Statement")]
60 #[serde_as(as = "OneOrMany<_, PreferOne>")]
61 pub statement: Vec<IAMStatement>,
62}
63
64impl IAMPolicy {
65 pub fn new() -> Self {
67 Self {
68 version: IAMVersion::default(),
69 id: None,
70 statement: Vec::new(),
71 }
72 }
73
74 pub fn with_version(version: IAMVersion) -> Self {
76 Self {
77 version,
78 id: None,
79 statement: Vec::new(),
80 }
81 }
82
83 pub fn add_statement(mut self, statement: IAMStatement) -> Self {
85 self.statement.push(statement);
86 self
87 }
88
89 pub fn with_id<S: Into<String>>(mut self, id: S) -> Self {
91 self.id = Some(id.into());
92 self
93 }
94
95 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
97 serde_json::from_str(json)
98 }
99
100 pub fn to_json(&self) -> Result<String, serde_json::Error> {
102 serde_json::to_string_pretty(self)
103 }
104}
105
106impl Default for IAMPolicy {
107 fn default() -> Self {
108 Self::new()
109 }
110}
111
112impl Validate for IAMPolicy {
113 fn validate(&self, context: &mut ValidationContext) -> ValidationResult {
114 context.with_segment("Policy", |ctx| {
115 let mut results = Vec::new();
116
117 if self.statement.is_empty() {
119 results.push(Err(ValidationError::MissingField {
120 field: "Statement".to_string(),
121 context: ctx.current_path(),
122 }));
123 return helpers::collect_errors(results);
124 }
125
126 for (i, statement) in self.statement.iter().enumerate() {
128 ctx.with_segment(&format!("Statement[{}]", i), |stmt_ctx| {
129 results.push(statement.validate(stmt_ctx));
130 });
131 }
132
133 let mut seen_sids = HashSet::new();
135 for (i, statement) in self.statement.iter().enumerate() {
136 if let Some(ref sid) = statement.sid {
137 if seen_sids.contains(sid) {
138 results.push(Err(ValidationError::LogicalError {
139 message: format!(
140 "Duplicate statement ID '{}' found at position {}",
141 sid, i
142 ),
143 }));
144 } else {
145 seen_sids.insert(sid.clone());
146 }
147 }
148 }
149
150 match self.version {
152 IAMVersion::V20121017 => {
153 }
155 _ => {
156 results.push(Err(ValidationError::InvalidValue {
157 field: "Version".to_string(),
158 value: format!("{:?}", self.version),
159 reason: "Only IAM version 2012-10-17 is supported".to_string(),
160 }));
161 }
162 }
163
164 if let Some(ref id) = self.id {
166 if id.is_empty() {
167 results.push(Err(ValidationError::InvalidValue {
168 field: "Id".to_string(),
169 value: id.clone(),
170 reason: "Policy ID cannot be empty".to_string(),
171 }));
172 }
173 }
174
175 helpers::collect_errors(results)
176 })
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use crate::{Action, Effect, IAMVersion, Resource};
184
185 #[test]
186 fn test_policy_validation() {
187 let valid_policy = IAMPolicy::new()
189 .with_id("550e8400-e29b-41d4-a716-446655440000")
190 .add_statement(
191 IAMStatement::new(Effect::Allow)
192 .with_sid("AllowS3Read")
193 .with_action(Action::Single("s3:GetObject".to_string()))
194 .with_resource(Resource::Single("arn:aws:s3:::bucket/*".to_string())),
195 );
196 assert!(valid_policy.is_valid());
197
198 let empty_policy = IAMPolicy::new();
200 assert!(!empty_policy.is_valid());
201
202 let duplicate_sid_policy = IAMPolicy::new()
204 .with_id("550e8400-e29b-41d4-a716-446655440001")
205 .add_statement(
206 IAMStatement::new(Effect::Allow)
207 .with_sid("DuplicateId")
208 .with_action(Action::Single("s3:GetObject".to_string()))
209 .with_resource(Resource::Single("*".to_string())),
210 )
211 .add_statement(
212 IAMStatement::new(Effect::Deny)
213 .with_sid("DuplicateId")
214 .with_action(Action::Single("s3:DeleteObject".to_string()))
215 .with_resource(Resource::Single("*".to_string())),
216 );
217 assert!(!duplicate_sid_policy.is_valid());
218 }
219
220 #[test]
221 fn test_policy_id_validation() {
222 let mut empty_id_policy = IAMPolicy::new();
224 empty_id_policy.id = Some("".to_string());
225 empty_id_policy.statement.push(
226 IAMStatement::new(Effect::Allow)
227 .with_action(Action::Single("s3:GetObject".to_string()))
228 .with_resource(Resource::Single("*".to_string())),
229 );
230 assert!(!empty_id_policy.is_valid());
231
232 let short_id_policy = IAMPolicy::new().with_id("short").add_statement(
234 IAMStatement::new(Effect::Allow)
235 .with_action(Action::Single("s3:GetObject".to_string()))
236 .with_resource(Resource::Single("*".to_string())),
237 );
238 assert!(short_id_policy.is_valid());
239 }
240
241 #[test]
242 fn test_iam_policy_creation() {
243 let policy = IAMPolicy::new().with_id("test-policy").add_statement(
244 IAMStatement::new(Effect::Allow)
245 .with_sid("AllowS3Access")
246 .with_action(Action::Single("s3:GetObject".to_string()))
247 .with_resource(Resource::Single("arn:aws:s3:::mybucket/*".to_string())),
248 );
249
250 assert_eq!(policy.version, IAMVersion::V20121017);
251 assert_eq!(policy.id, Some("test-policy".to_string()));
252 assert_eq!(policy.statement.len(), 1);
253 assert_eq!(policy.statement[0].effect, Effect::Allow);
254 }
255
256 #[test]
257 fn test_policy_serialization() {
258 let policy = IAMPolicy::new().add_statement(
259 IAMStatement::new(Effect::Allow)
260 .with_action(Action::Single("s3:GetObject".to_string()))
261 .with_resource(Resource::Single("*".to_string())),
262 );
263
264 let json = policy.to_json().unwrap();
265 let parsed_policy = IAMPolicy::from_json(&json).unwrap();
266
267 assert_eq!(policy, parsed_policy);
268 }
269
270 #[test]
271 fn test_policy_roundtrip_from_files() {
272 let policies_dir = "tests/policies";
274
275 let mut policy_files = std::fs::read_dir(policies_dir)
276 .unwrap_or_else(|e| {
277 panic!(
278 "Failed to read policies directory '{}': {}",
279 policies_dir, e
280 )
281 })
282 .filter_map(|entry| {
283 let entry = entry.ok()?;
284 let path = entry.path();
285 if path.extension()? == "json" {
286 Some(path)
287 } else {
288 None
289 }
290 })
291 .collect::<Vec<_>>();
292
293 assert!(
295 !policy_files.is_empty(),
296 "No policy JSON files found in {}/",
297 policies_dir
298 );
299
300 policy_files.sort_by_key(|p| {
303 p.file_name()
304 .and_then(|n| n.to_str())
305 .map(|s| s.split(".").next().unwrap().parse::<u32>().unwrap())
306 .map(|n| format!("{:010}", n))
307 });
308
309 println!(
310 "Testing {} policy files from {}/",
311 policy_files.len(),
312 policies_dir
313 );
314
315 for (index, policy_file) in policy_files.iter().enumerate() {
316 let filename = policy_file
317 .file_name()
318 .and_then(|n| n.to_str())
319 .unwrap_or("unknown");
320
321 println!("Testing policy #{}: {} ... ", index + 1, filename);
322
323 let json_content = std::fs::read_to_string(&policy_file).unwrap_or_else(|e| {
325 panic!("Failed to read file '{}': {}", policy_file.display(), e)
326 });
327
328 let original_policy = IAMPolicy::from_json(&json_content)
330 .unwrap_or_else(|e| panic!("Failed to parse JSON policy: {:?}", e));
331
332 assert!(
334 original_policy.is_valid(),
335 "Policy {} is invalid: {:?}",
336 filename,
337 original_policy.validate(&mut ValidationContext::new())
338 );
339
340 let serialized_json = original_policy
342 .to_json()
343 .unwrap_or_else(|e| panic!("Failed to serialize policy to JSON: {:?}", e));
344
345 assert_eq!(
347 serialized_json,
348 json_content.trim_end_matches("\n"),
349 "Serialized JSON does not match original prettified JSON for file '{}'",
350 filename
351 );
352 }
353 }
354}