iam_rs/policy/
policy.rs

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/// JSON policy documents are made up of elements.
13/// The elements are listed here in the general order you use them in a policy.
14/// The order of the elements doesn't matter—for example, the Resource element can come before the Action element.
15/// You're not required to specify any Condition elements in the policy.
16/// To learn more about the general structure and purpose of a JSON policy document, see Overview of JSON policies.
17///
18/// Some JSON policy elements are mutually exclusive.
19/// This means that you cannot create a policy that uses both.
20/// For example, you cannot use both Action and NotAction in the same policy statement.
21/// Other pairs that are mutually exclusive include Principal/NotPrincipal and Resource/NotResource.
22///
23/// The details of what goes into a policy vary for each service, depending on what actions the service makes available, what types of resources it contains, and so on.
24/// When you're writing policies for a specific service, it's helpful to see examples of policies for that service.
25///
26/// When you create or edit a JSON policy, `iam-rw` can perform policy validation to help you create an effective policy.
27///
28/// https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements.html
29#[serde_as]
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct IAMPolicy {
32    /// The `Version` policy element specifies the language syntax rules that are to be used to process a policy.
33    ///
34    /// To use all of the available policy features, include the following Version element outside the Statement element in all policies.
35    /// `Version` is a required element in all IAM policies and must always be set to at least `2012-10-17`.
36    ///
37    /// https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_version.html
38    #[serde(rename = "Version")]
39    pub version: IAMVersion,
40
41    /// The `Id` element specifies an optional identifier for the policy.
42    ///
43    /// The ID is used differently in different services.
44    /// ID is allowed in resource-based policies, but not in identity-based policies.
45    ///
46    /// Recommendation is to use a UUID (GUID) for the value, or incorporate a UUID as part of the ID to ensure uniqueness.
47    ///
48    /// https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_id.html
49    #[serde(rename = "Id", skip_serializing_if = "Option::is_none")]
50    pub id: Option<String>,
51
52    /// The Statement element is the main element for a policy.
53    ///
54    /// The Statement element can contain a single statement or an array of individual statements.
55    /// Each individual statement block must be enclosed in curly braces { }.
56    /// For multiple statements, the array must be enclosed in square brackets [ ].
57    ///
58    /// https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_statement.html
59    #[serde(rename = "Statement")]
60    #[serde_as(as = "OneOrMany<_, PreferOne>")]
61    pub statement: Vec<IAMStatement>,
62}
63
64impl IAMPolicy {
65    /// Creates a new IAM policy with the default version
66    pub fn new() -> Self {
67        Self {
68            version: IAMVersion::default(),
69            id: None,
70            statement: Vec::new(),
71        }
72    }
73
74    /// Creates a new IAM policy with a specific version
75    pub fn with_version(version: IAMVersion) -> Self {
76        Self {
77            version,
78            id: None,
79            statement: Vec::new(),
80        }
81    }
82
83    /// Adds a statement to the policy
84    pub fn add_statement(mut self, statement: IAMStatement) -> Self {
85        self.statement.push(statement);
86        self
87    }
88
89    /// Sets the policy ID
90    pub fn with_id<S: Into<String>>(mut self, id: S) -> Self {
91        self.id = Some(id.into());
92        self
93    }
94
95    /// Parses an IAM policy from a JSON string
96    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
97        serde_json::from_str(json)
98    }
99
100    /// Serializes the IAM policy to a JSON string
101    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            // Check that policy has at least one statement
118            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            // Validate each statement
127            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            // Check for duplicate statement IDs
134            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            // Validate that policy version is supported
151            match self.version {
152                IAMVersion::V20121017 => {
153                    // Supported version
154                }
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            // Validate policy ID format if present
165            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        // Valid policy with UUID-format ID
188        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        // Empty policy (no statements)
199        let empty_policy = IAMPolicy::new();
200        assert!(!empty_policy.is_valid());
201
202        // Policy with duplicate statement IDs and valid UUID
203        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        // Empty ID
223        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        // Valid short ID
233        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        // List filenames in the tests/policies directory
273        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        // Verify we actually found policy files to test
294        assert!(
295            !policy_files.is_empty(),
296            "No policy JSON files found in {}/",
297            policies_dir
298        );
299
300        // Sort files by name for consistent test order
301        // All files are called 1.json, 2.json, ..., 10.json, etc.
302        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            // Read the JSON file
324            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            // Parse the policy from JSON
329            let original_policy = IAMPolicy::from_json(&json_content)
330                .unwrap_or_else(|e| panic!("Failed to parse JSON policy: {:?}", e));
331
332            // Validate the parsed policy
333            assert!(
334                original_policy.is_valid(),
335                "Policy {} is invalid: {:?}",
336                filename,
337                original_policy.validate(&mut ValidationContext::new())
338            );
339
340            // Serialize the policy back to JSON
341            let serialized_json = original_policy
342                .to_json()
343                .unwrap_or_else(|e| panic!("Failed to serialize policy to JSON: {:?}", e));
344
345            // Compare the serialized JSON with the prettified original
346            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}