iam_rs/validation/
validator.rs

1use std::fmt;
2
3/// Validation error types for IAM policies
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub enum ValidationError {
6    /// Empty or missing required field
7    MissingField { field: String, context: String },
8    /// Invalid field value
9    InvalidValue {
10        field: String,
11        value: String,
12        reason: String,
13    },
14    /// Logical inconsistency in policy
15    LogicalError { message: String },
16    /// ARN format error
17    InvalidArn { arn: String, reason: String },
18    /// Condition operator/value mismatch
19    InvalidCondition {
20        operator: String,
21        key: String,
22        reason: String,
23    },
24    /// Principal format error
25    InvalidPrincipal { principal: String, reason: String },
26    /// Action format error
27    InvalidAction { action: String, reason: String },
28    /// Resource format error
29    InvalidResource { resource: String, reason: String },
30    /// Multiple validation errors
31    Multiple(Vec<ValidationError>),
32}
33
34impl fmt::Display for ValidationError {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        match self {
37            ValidationError::MissingField { field, context } => {
38                write!(f, "Missing required field '{}' in {}", field, context)
39            }
40            ValidationError::InvalidValue {
41                field,
42                value,
43                reason,
44            } => {
45                write!(
46                    f,
47                    "Invalid value '{}' for field '{}': {}",
48                    value, field, reason
49                )
50            }
51            ValidationError::LogicalError { message } => {
52                write!(f, "Logical error: {}", message)
53            }
54            ValidationError::InvalidArn { arn, reason } => {
55                write!(f, "Invalid ARN '{}': {}", arn, reason)
56            }
57            ValidationError::InvalidCondition {
58                operator,
59                key,
60                reason,
61            } => {
62                write!(
63                    f,
64                    "Invalid condition '{}' for key '{}': {}",
65                    operator, key, reason
66                )
67            }
68            ValidationError::InvalidPrincipal { principal, reason } => {
69                write!(f, "Invalid principal '{}': {}", principal, reason)
70            }
71            ValidationError::InvalidAction { action, reason } => {
72                write!(f, "Invalid action '{}': {}", action, reason)
73            }
74            ValidationError::InvalidResource { resource, reason } => {
75                write!(f, "Invalid resource '{}': {}", resource, reason)
76            }
77            ValidationError::Multiple(errors) => {
78                write!(f, "Multiple validation errors:\n")?;
79                for (i, error) in errors.iter().enumerate() {
80                    write!(f, "  {}: {}\n", i + 1, error)?;
81                }
82                Ok(())
83            }
84        }
85    }
86}
87
88impl std::error::Error for ValidationError {}
89
90/// Result type for validation operations
91pub type ValidationResult = Result<(), ValidationError>;
92
93/// Validation context for tracking nested validation
94#[derive(Debug, Clone)]
95pub struct ValidationContext {
96    pub path: Vec<String>,
97}
98
99impl ValidationContext {
100    pub fn new() -> Self {
101        Self { path: Vec::new() }
102    }
103
104    pub fn push(&mut self, segment: &str) {
105        self.path.push(segment.to_string());
106    }
107
108    pub fn pop(&mut self) {
109        self.path.pop();
110    }
111
112    pub fn current_path(&self) -> String {
113        if self.path.is_empty() {
114            "root".to_string()
115        } else {
116            self.path.join(".")
117        }
118    }
119
120    pub fn with_segment<T>(&mut self, segment: &str, f: impl FnOnce(&mut Self) -> T) -> T {
121        self.push(segment);
122        let result = f(self);
123        self.pop();
124        result
125    }
126}
127
128/// Trait for validating IAM policy components
129/// All validation is strict and enforces high quality standards
130pub trait Validate {
131    fn validate(&self, context: &mut ValidationContext) -> ValidationResult;
132
133    /// Convenience method for basic validation
134    fn is_valid(&self) -> bool {
135        let mut context = ValidationContext::new();
136        self.validate(&mut context).is_ok()
137    }
138
139    /// Validate with detailed errors (same as regular validation)
140    fn validate_result(&self) -> ValidationResult {
141        let mut context = ValidationContext::new();
142        self.validate(&mut context)
143    }
144}
145
146/// Helper functions for common validation patterns
147pub(crate) mod helpers {
148    use super::*;
149    use crate::core::Arn;
150
151    /// Validate that a string is not empty
152    pub fn validate_non_empty(
153        value: &str,
154        field_name: &str,
155        context: &ValidationContext,
156    ) -> ValidationResult {
157        if value.is_empty() {
158            Err(ValidationError::MissingField {
159                field: field_name.to_string(),
160                context: context.current_path(),
161            })
162        } else {
163            Ok(())
164        }
165    }
166
167    /// Validate ARN format
168    pub fn validate_arn(arn: &str, _context: &ValidationContext) -> ValidationResult {
169        match Arn::parse(arn) {
170            Ok(parsed_arn) => {
171                if !parsed_arn.is_valid() {
172                    Err(ValidationError::InvalidArn {
173                        arn: arn.to_string(),
174                        reason: "ARN format is valid but does not conform to AWS standards"
175                            .to_string(),
176                    })
177                } else {
178                    Ok(())
179                }
180            }
181            Err(e) => Err(ValidationError::InvalidArn {
182                arn: arn.to_string(),
183                reason: e.to_string(),
184            }),
185        }
186    }
187
188    /// Validate action format (service:action)
189    pub fn validate_action(action: &str, _context: &ValidationContext) -> ValidationResult {
190        if action == "*" {
191            return Ok(());
192        }
193
194        if action.contains(':') {
195            let parts: Vec<&str> = action.split(':').collect();
196            if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
197                // Basic service:action format
198                Ok(())
199            } else {
200                Err(ValidationError::InvalidAction {
201                    action: action.to_string(),
202                    reason: "Action must be in format 'service:action'".to_string(),
203                })
204            }
205        } else {
206            Err(ValidationError::InvalidAction {
207                action: action.to_string(),
208                reason: "Action must contain a colon separator or be '*'".to_string(),
209            })
210        }
211    }
212
213    /// Validate principal ARN or special values
214    pub fn validate_principal(principal: &str, _context: &ValidationContext) -> ValidationResult {
215        if principal == "*" || principal == "AWS" || principal == "Federated" {
216            return Ok(());
217        }
218
219        // Check if it's an ARN
220        if principal.starts_with("arn:") {
221            return validate_arn(principal, _context);
222        }
223
224        // Check if it's an account ID
225        if principal.len() == 12 && principal.chars().all(|c| c.is_ascii_digit()) {
226            return Ok(());
227        }
228
229        // Check if it's a service principal (ends with .amazonaws.com or similar patterns)
230        if principal.contains('.')
231            && (principal.ends_with(".amazonaws.com")
232                || principal.ends_with(".amazonaws.com.cn")
233                || principal.ends_with(".api.aws")
234                || principal.ends_with(".internal"))
235        {
236            return Ok(());
237        }
238
239        // Check if it's a federated identity provider URL
240        if principal.starts_with("https://") || principal.starts_with("http://") {
241            return Ok(());
242        }
243
244        // Check if it's a SAML provider ARN format
245        if principal.starts_with("arn:aws:iam::") && principal.contains(":saml-provider/") {
246            return Ok(());
247        }
248
249        // Reject anything else as invalid
250        Err(ValidationError::InvalidPrincipal {
251            principal: principal.to_string(),
252            reason:
253                "Principal must be an ARN, account ID, service principal, URL, or special value"
254                    .to_string(),
255        })
256    }
257
258    /// Validate resource ARN or wildcard
259    pub fn validate_resource(resource: &str, _context: &ValidationContext) -> ValidationResult {
260        if resource == "*" {
261            return Ok(());
262        }
263
264        // Resources should be ARNs, but may contain wildcards
265        if resource.starts_with("arn:") {
266            // Use lenient parsing for resources with wildcards
267            match Arn::parse(resource) {
268                Ok(_) => Ok(()),
269                Err(e) => Err(ValidationError::InvalidResource {
270                    resource: resource.to_string(),
271                    reason: e.to_string(),
272                }),
273            }
274        } else {
275            Err(ValidationError::InvalidResource {
276                resource: resource.to_string(),
277                reason: "Resource must be an ARN or '*'".to_string(),
278            })
279        }
280    }
281
282    /// Collect multiple validation errors
283    pub fn collect_errors(results: Vec<ValidationResult>) -> ValidationResult {
284        let errors: Vec<ValidationError> = results.into_iter().filter_map(|r| r.err()).collect();
285
286        if errors.is_empty() {
287            Ok(())
288        } else if errors.len() == 1 {
289            Err(errors.into_iter().next().unwrap())
290        } else {
291            Err(ValidationError::Multiple(errors))
292        }
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn test_validation_context() {
302        let mut context = ValidationContext::new();
303        assert_eq!(context.current_path(), "root");
304
305        context.push("policy");
306        context.push("statement");
307        assert_eq!(context.current_path(), "policy.statement");
308
309        context.pop();
310        assert_eq!(context.current_path(), "policy");
311    }
312
313    #[test]
314    fn test_validation_error_display() {
315        let error = ValidationError::MissingField {
316            field: "Effect".to_string(),
317            context: "statement".to_string(),
318        };
319        assert!(
320            error
321                .to_string()
322                .contains("Missing required field 'Effect'")
323        );
324
325        let multiple = ValidationError::Multiple(vec![
326            ValidationError::MissingField {
327                field: "Effect".to_string(),
328                context: "statement".to_string(),
329            },
330            ValidationError::InvalidValue {
331                field: "Action".to_string(),
332                value: "invalid".to_string(),
333                reason: "bad format".to_string(),
334            },
335        ]);
336        let display = multiple.to_string();
337        assert!(display.contains("Multiple validation errors"));
338        assert!(display.contains("Missing required field"));
339        assert!(display.contains("Invalid value"));
340    }
341
342    #[test]
343    fn test_helper_validations() {
344        let context = ValidationContext::new();
345
346        // Test ARN validation
347        assert!(helpers::validate_arn("arn:aws:s3:::bucket/object", &context).is_ok());
348        assert!(helpers::validate_arn("invalid-arn", &context).is_err());
349
350        // Test action validation
351        assert!(helpers::validate_action("s3:GetObject", &context).is_ok());
352        assert!(helpers::validate_action("*", &context).is_ok());
353        assert!(helpers::validate_action("invalid-action", &context).is_err());
354
355        // Test principal validation
356        assert!(helpers::validate_principal("*", &context).is_ok());
357        assert!(helpers::validate_principal("123456789012", &context).is_ok());
358        assert!(
359            helpers::validate_principal("arn:aws:iam::123456789012:user/test", &context).is_ok()
360        );
361        assert!(
362            helpers::validate_principal("arn:aws:iam::123456789012:role/test", &context).is_ok()
363        );
364        assert!(helpers::validate_principal("asset.controlpanel.internal", &context).is_ok());
365        assert!(helpers::validate_principal("invalid", &context).is_err());
366        assert!(helpers::validate_principal("https://example.com", &context).is_ok());
367        assert!(helpers::validate_principal("http://example.com", &context).is_ok());
368        assert!(
369            helpers::validate_principal(
370                "arn:aws:iam::123456789012:saml-provider/TestProvider",
371                &context
372            )
373            .is_ok()
374        );
375
376        // Test resource validation
377        assert!(helpers::validate_resource("*", &context).is_ok());
378        assert!(helpers::validate_resource("arn:aws:s3:::bucket/*", &context).is_ok());
379        assert!(helpers::validate_resource("invalid-resource", &context).is_err());
380        assert!(helpers::validate_resource("arn:aws:s3:::bucket/object", &context).is_ok());
381    }
382
383    #[test]
384    fn test_policy_validation_integration() {
385        use crate::{Action, Effect, IAMPolicy, IAMStatement, Resource};
386
387        // Test valid policy with UUID-like ID
388        let valid_policy = IAMPolicy::new()
389            .with_id("550e8400-e29b-41d4-a716-446655440000") // UUID format
390            .add_statement(
391                IAMStatement::new(Effect::Allow)
392                    .with_sid("ValidStatement")
393                    .with_action(Action::Single("s3:GetObject".to_string()))
394                    .with_resource(Resource::Single("arn:aws:s3:::bucket/*".to_string())),
395            );
396        assert!(valid_policy.is_valid());
397
398        // Test invalid policy - missing action
399        let mut invalid_policy = IAMPolicy::new();
400        invalid_policy
401            .statement
402            .push(IAMStatement::new(Effect::Allow));
403        assert!(!invalid_policy.is_valid());
404
405        // Test policy with validation errors
406        let complex_invalid_policy = IAMPolicy::new().add_statement(
407            IAMStatement::new(Effect::Allow)
408                .with_action(Action::Single("invalid-action".to_string()))
409                .with_resource(Resource::Single("invalid-resource".to_string())),
410        );
411
412        assert!(!complex_invalid_policy.is_valid());
413
414        let validation_result = complex_invalid_policy.validate_result();
415        assert!(validation_result.is_err());
416
417        let error = validation_result.unwrap_err();
418        assert!(
419            error.to_string().contains("Multiple validation errors")
420                || error.to_string().contains("Invalid")
421        );
422    }
423
424    #[test]
425    fn test_condition_validation_integration() {
426        use crate::{Action, Effect, IAMStatement, Operator, Resource};
427        use serde_json::json;
428
429        // Valid condition
430        let valid_statement = IAMStatement::new(Effect::Allow)
431            .with_action(Action::Single("s3:GetObject".to_string()))
432            .with_resource(Resource::Single("*".to_string()))
433            .with_condition(
434                Operator::StringEquals,
435                "aws:username".to_string(),
436                json!("alice"),
437            );
438
439        assert!(valid_statement.is_valid());
440
441        // Invalid condition - numeric operator with string value
442        let invalid_condition_statement = IAMStatement::new(Effect::Allow)
443            .with_action(Action::Single("s3:GetObject".to_string()))
444            .with_resource(Resource::Single("*".to_string()))
445            .with_condition(
446                Operator::NumericEquals,
447                "aws:RequestedRegion".to_string(),
448                json!("invalid-number"),
449            );
450
451        // Should fail validation due to type mismatch
452        assert!(!invalid_condition_statement.is_valid());
453        assert!(invalid_condition_statement.validate_result().is_err());
454    }
455
456    #[test]
457    fn test_collect_errors() {
458        let results = vec![
459            Ok(()),
460            Err(ValidationError::MissingField {
461                field: "test".to_string(),
462                context: "root".to_string(),
463            }),
464            Ok(()),
465            Err(ValidationError::InvalidValue {
466                field: "other".to_string(),
467                value: "bad".to_string(),
468                reason: "test".to_string(),
469            }),
470        ];
471
472        let result = helpers::collect_errors(results);
473        assert!(result.is_err());
474        match result.unwrap_err() {
475            ValidationError::Multiple(errors) => assert_eq!(errors.len(), 2),
476            _ => panic!("Expected Multiple error"),
477        }
478    }
479}