Skip to main content

iam_rs/policy/
condition.rs

1use crate::{
2    OperatorType,
3    core::IAMOperator,
4    evaluation::parse_date,
5    validation::{Validate, ValidationContext, ValidationError, ValidationResult, helpers},
6};
7use serde::{Deserialize, Serialize, Serializer};
8use std::collections::{BTreeMap, HashMap};
9
10/// Represents a condition value that can be a boolean, number, string, or list of strings
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(untagged)]
13#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
14pub enum ConditionValue {
15    /// A boolean value (e.g., `true`, `false`)
16    Boolean(bool),
17    /// A numeric value (e.g., `42`, `3.14`)
18    #[cfg_attr(feature = "utoipa", schema(value_type = f64))]
19    Number(serde_json::Number),
20    /// A single string value (e.g., `"us-east-1"`)
21    String(String),
22    /// Multiple string values (e.g., `["us-east-1", "us-west-2"]`)
23    StringList(Vec<String>),
24}
25
26impl ConditionValue {
27    /// Returns true if this is a string value
28    #[must_use]
29    pub fn is_string(&self) -> bool {
30        matches!(self, ConditionValue::String(_))
31    }
32
33    /// Returns true if this is a boolean value
34    #[must_use]
35    pub fn is_boolean(&self) -> bool {
36        matches!(self, ConditionValue::Boolean(_))
37    }
38
39    /// Returns true if this is a number value
40    #[must_use]
41    pub fn is_number(&self) -> bool {
42        matches!(self, ConditionValue::Number(_))
43    }
44
45    /// Returns true if this is a string list value
46    #[must_use]
47    pub fn is_string_list(&self) -> bool {
48        matches!(self, ConditionValue::StringList(_))
49    }
50
51    /// Returns true if this value represents multiple items (i.e., is a list)
52    #[must_use]
53    pub fn is_array(&self) -> bool {
54        matches!(self, ConditionValue::StringList(_))
55    }
56
57    /// Returns the length of the value (1 for single values, list length for arrays)
58    #[must_use]
59    pub fn len(&self) -> usize {
60        match self {
61            ConditionValue::StringList(list) => list.len(),
62            _ => 1,
63        }
64    }
65
66    /// Returns true if this is an empty list
67    #[must_use]
68    pub fn is_empty(&self) -> bool {
69        match self {
70            ConditionValue::StringList(list) => list.is_empty(),
71            _ => false,
72        }
73    }
74
75    /// Converts to a `serde_json::Value` for backward compatibility
76    #[must_use]
77    pub fn to_json_value(&self) -> serde_json::Value {
78        match self {
79            ConditionValue::Boolean(b) => serde_json::Value::Bool(*b),
80            ConditionValue::Number(n) => serde_json::Value::Number(n.clone()),
81            ConditionValue::String(s) => serde_json::Value::String(s.clone()),
82            ConditionValue::StringList(list) => serde_json::Value::Array(
83                list.iter()
84                    .map(|s| serde_json::Value::String(s.clone()))
85                    .collect(),
86            ),
87        }
88    }
89
90    /// Creates a `ConditionValue` from a `serde_json::Value`
91    pub fn from_json_value(value: serde_json::Value) -> Result<Self, String> {
92        match value {
93            serde_json::Value::Bool(b) => Ok(ConditionValue::Boolean(b)),
94            serde_json::Value::Number(n) => Ok(ConditionValue::Number(n)),
95            serde_json::Value::String(s) => Ok(ConditionValue::String(s)),
96            serde_json::Value::Array(arr) => {
97                let mut strings = Vec::new();
98                for item in arr {
99                    if let serde_json::Value::String(s) = item {
100                        strings.push(s);
101                    } else {
102                        return Err(format!("Array must contain only strings, found: {item:?}"));
103                    }
104                }
105                Ok(ConditionValue::StringList(strings))
106            }
107            _ => Err(format!("Unsupported JSON value type: {value:?}")),
108        }
109    }
110}
111
112/// Represents a single condition in an IAM policy
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
115pub struct Condition {
116    /// The condition operator (e.g., `StringEquals`, `DateGreaterThan`)
117    pub operator: IAMOperator,
118    /// The condition key (e.g., "aws:username", "s3:prefix")
119    pub key: String,
120    /// The condition value(s)
121    pub value: ConditionValue,
122}
123
124/// Represents a condition block in an IAM policy
125/// This is a collection of conditions grouped by operator
126#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
127#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
128pub struct ConditionBlock {
129    /// Map of operators to their key-value pairs
130    #[serde(flatten)]
131    pub conditions: HashMap<IAMOperator, HashMap<String, ConditionValue>>,
132}
133
134impl Serialize for ConditionBlock {
135    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
136    where
137        S: Serializer,
138    {
139        // Convert the HashMap<Operator, ...> to BTreeMap<String, ...> for ordered serialization
140        // Also, sort the keys within each condition (e.g., inside StringEquals)
141        let ordered_map: BTreeMap<String, BTreeMap<String, &ConditionValue>> = self
142            .conditions
143            .iter()
144            .map(|(op, conditions)| {
145                let inner_ordered: BTreeMap<String, &ConditionValue> =
146                    conditions.iter().map(|(k, v)| (k.clone(), v)).collect();
147                (op.as_str().to_string(), inner_ordered)
148            })
149            .collect();
150
151        ordered_map.serialize(serializer)
152    }
153}
154
155impl Condition {
156    /// Creates a new condition
157    pub fn new<K: Into<String>>(operator: IAMOperator, key: K, value: ConditionValue) -> Self {
158        Self {
159            operator,
160            key: key.into(),
161            value,
162        }
163    }
164
165    /// Creates a condition with a string value
166    pub fn string<K: Into<String>, V: Into<String>>(
167        operator: IAMOperator,
168        key: K,
169        value: V,
170    ) -> Self {
171        Self::new(operator, key, ConditionValue::String(value.into()))
172    }
173
174    /// Creates a condition with a boolean value
175    pub fn boolean<K: Into<String>>(operator: IAMOperator, key: K, value: bool) -> Self {
176        Self::new(operator, key, ConditionValue::Boolean(value))
177    }
178
179    /// Creates a condition with a numeric value
180    pub fn number<K: Into<String>>(operator: IAMOperator, key: K, value: i64) -> Self {
181        Self::new(operator, key, ConditionValue::Number(value.into()))
182    }
183
184    /// Creates a condition with a floating-point numeric value
185    ///
186    /// # Panics
187    ///
188    /// Panics if `value` is not finite (NaN or infinity).
189    pub fn float<K: Into<String>>(operator: IAMOperator, key: K, value: f64) -> Self {
190        let number = serde_json::Number::from_f64(value)
191            .expect("float value must be finite (not NaN or infinity)");
192        Self::new(operator, key, ConditionValue::Number(number))
193    }
194
195    /// Creates a condition with an array of string values
196    pub fn string_array<K: Into<String>>(
197        operator: IAMOperator,
198        key: K,
199        values: Vec<String>,
200    ) -> Self {
201        Self::new(operator, key, ConditionValue::StringList(values))
202    }
203}
204
205impl ConditionBlock {
206    /// Creates a new empty condition block
207    #[must_use]
208    pub fn new() -> Self {
209        Self {
210            conditions: HashMap::new(),
211        }
212    }
213
214    /// Adds a condition to the block
215    pub fn add_condition(&mut self, condition: Condition) {
216        let operator_map = self.conditions.entry(condition.operator).or_default();
217        operator_map.insert(condition.key, condition.value);
218    }
219
220    /// Adds a condition using the builder pattern
221    #[must_use]
222    pub fn with_condition(mut self, condition: Condition) -> Self {
223        self.add_condition(condition);
224        self
225    }
226
227    /// Adds a condition directly with operator, key, and value
228    #[must_use]
229    pub fn with_condition_direct<K: Into<String>>(
230        mut self,
231        operator: IAMOperator,
232        key: K,
233        value: ConditionValue,
234    ) -> Self {
235        let condition = Condition::new(operator, key, value);
236        self.add_condition(condition);
237        self
238    }
239
240    /// Gets all conditions for a specific operator
241    #[must_use]
242    pub fn get_conditions_for_operator(
243        &self,
244        operator: &IAMOperator,
245    ) -> Option<&HashMap<String, ConditionValue>> {
246        self.conditions.get(operator)
247    }
248
249    /// Gets a specific condition value
250    #[must_use]
251    pub fn get_condition_value(
252        &self,
253        operator: &IAMOperator,
254        key: &str,
255    ) -> Option<&ConditionValue> {
256        self.conditions.get(operator)?.get(key)
257    }
258
259    /// Checks if a condition exists
260    #[must_use]
261    pub fn has_condition(&self, operator: &IAMOperator, key: &str) -> bool {
262        self.conditions
263            .get(operator)
264            .is_some_and(|map| map.contains_key(key))
265    }
266
267    /// Gets all operators used in this condition block
268    #[must_use]
269    pub fn operators(&self) -> Vec<&IAMOperator> {
270        self.conditions.keys().collect()
271    }
272
273    /// Checks if the condition block is empty
274    #[must_use]
275    pub fn is_empty(&self) -> bool {
276        self.conditions.is_empty()
277    }
278
279    /// Converts to the legacy `HashMap` format for backward compatibility
280    #[must_use]
281    pub fn to_legacy_format(&self) -> HashMap<String, HashMap<String, serde_json::Value>> {
282        self.conditions
283            .iter()
284            .map(|(op, conditions)| {
285                let json_conditions = conditions
286                    .iter()
287                    .map(|(k, v)| (k.clone(), v.to_json_value()))
288                    .collect();
289                (op.as_str().to_string(), json_conditions)
290            })
291            .collect()
292    }
293
294    /// Creates a condition block from the legacy `HashMap` format
295    ///
296    /// # Errors
297    ///
298    /// Returns an error if any operator string cannot be parsed into a valid `Operator`.
299    pub fn from_legacy_format(
300        legacy: HashMap<String, HashMap<String, serde_json::Value>>,
301    ) -> Result<Self, String> {
302        let mut conditions = HashMap::new();
303
304        for (op_str, condition_map) in legacy {
305            let operator = op_str
306                .parse::<IAMOperator>()
307                .map_err(|e| format!("Invalid operator '{op_str}': {e}"))?;
308
309            let mut converted_conditions = HashMap::new();
310            for (key, value) in condition_map {
311                let condition_value = ConditionValue::from_json_value(value)
312                    .map_err(|e| format!("Invalid condition value for key '{key}': {e}"))?;
313                converted_conditions.insert(key, condition_value);
314            }
315
316            conditions.insert(operator, converted_conditions);
317        }
318
319        Ok(Self { conditions })
320    }
321}
322
323impl Default for ConditionBlock {
324    fn default() -> Self {
325        Self::new()
326    }
327}
328
329impl Validate for Condition {
330    #[allow(clippy::too_many_lines)]
331    fn validate(&self, context: &mut ValidationContext) -> ValidationResult {
332        context.with_segment("Condition", |ctx| {
333            let mut results = Vec::new();
334
335            // Validate that key is not empty
336            results.push(helpers::validate_non_empty(&self.key, "key", ctx));
337
338            // Validate that the operator and value are compatible
339            #[allow(clippy::single_match)]
340            match &self.value {
341                ConditionValue::StringList(arr) => {
342                    if arr.is_empty() {
343                        results.push(Err(ValidationError::InvalidCondition {
344                            operator: self.operator.as_str().to_string(),
345                            key: self.key.clone(),
346                            reason: "Condition value array cannot be empty".to_string(),
347                        }));
348                    }
349
350                    // Check if operator supports multiple values
351                    if !self.operator.supports_multiple_values() && arr.len() > 1 {
352                        results.push(Err(ValidationError::InvalidCondition {
353                            operator: self.operator.as_str().to_string(),
354                            key: self.key.clone(),
355                            reason: format!("Operator {} does not support multiple values", self.operator.as_str()),
356                        }));
357                    }
358                }
359                _ => {} // Single values are generally OK
360            }
361
362            // Validate operator-specific rules
363            match self.operator.category() {
364                    OperatorType::String => {
365                        // String operators should have string values
366                        match &self.value {
367                            ConditionValue::String(_) => {},
368                            ConditionValue::StringList(arr) => {
369                                if arr.is_empty() {
370                                    results.push(Err(ValidationError::InvalidCondition {
371                                        operator: self.operator.as_str().to_string(),
372                                        key: self.key.clone(),
373                                        reason: "String operator requires non-empty string array".to_string(),
374                                    }));
375                                }
376                            },
377                            _ => {
378                                results.push(Err(ValidationError::InvalidCondition {
379                                    operator: self.operator.as_str().to_string(),
380                                    key: self.key.clone(),
381                                    reason: "String operator requires string value(s)".to_string(),
382                                }));
383                            }
384                        }
385                    },
386                    OperatorType::Numeric => {
387                        // Numeric operators should have numeric values
388                        #[allow(clippy::match_wildcard_for_single_variants)]
389                        match &self.value {
390                            ConditionValue::Number(_) => {},
391                            ConditionValue::String(s) => {
392                                // Allow string representation of numbers
393                                if s.parse::<f64>().is_err() {
394                                    results.push(Err(ValidationError::InvalidCondition {
395                                        operator: self.operator.as_str().to_string(),
396                                        key: self.key.clone(),
397                                        reason: format!("Numeric operator requires numeric value, found non-numeric string: {s}"),
398                                    }));
399                                }
400                            },
401                            ConditionValue::StringList(arr) => {
402                                for (i, s) in arr.iter().enumerate() {
403                                    if s.parse::<f64>().is_err() {
404                                        results.push(Err(ValidationError::InvalidCondition {
405                                            operator: self.operator.as_str().to_string(),
406                                            key: self.key.clone(),
407                                            reason: format!("Numeric operator requires numeric values, found non-numeric string at index {i}: {s}"),
408                                        }));
409                                    }
410                                }
411                            },
412                            _ => {
413                                results.push(Err(ValidationError::InvalidCondition {
414                                    operator: self.operator.as_str().to_string(),
415                                    key: self.key.clone(),
416                                    reason: "Numeric operator requires numeric value(s)".to_string(),
417                                }));
418                            }
419                        }
420                    },
421                    OperatorType::Date => {
422                        // Date operators should have valid date values
423                        match &self.value {
424                            ConditionValue::String(s) => {
425                                // AWS accepts ISO 8601 date/time values and Unix
426                                // epoch time. Reuse the evaluator's parser so
427                                // validation and evaluation never disagree.
428                                if parse_date(s).is_err() {
429                                    results.push(Err(ValidationError::InvalidCondition {
430                                        operator: self.operator.as_str().to_string(),
431                                        key: self.key.clone(),
432                                        reason: format!("Date operator requires an ISO 8601 date/time or Unix epoch value, found: {s}"),
433                                    }));
434                                }
435                            },
436                            // AWS also accepts Unix epoch as a JSON number, e.g.
437                            // "DateLessThan": {"aws:EpochTime": 1704067200}.
438                            ConditionValue::Number(n) => {
439                                if parse_date(&n.to_string()).is_err() {
440                                    results.push(Err(ValidationError::InvalidCondition {
441                                        operator: self.operator.as_str().to_string(),
442                                        key: self.key.clone(),
443                                        reason: format!("Date operator requires an ISO 8601 date/time or Unix epoch value, found: {n}"),
444                                    }));
445                                }
446                            },
447                            _ => {
448                                results.push(Err(ValidationError::InvalidCondition {
449                                    operator: self.operator.as_str().to_string(),
450                                    key: self.key.clone(),
451                                    reason: "Date operator requires a string or number date value".to_string(),
452                                }));
453                            }
454                        }
455                    },
456                    OperatorType::Boolean => {
457                        // Boolean operators should have boolean values
458                        match &self.value {
459                            ConditionValue::Boolean(_) => {},
460                            ConditionValue::String(s) => {
461                                if !matches!(s.as_str(), "true" | "false") {
462                                    results.push(Err(ValidationError::InvalidCondition {
463                                        operator: self.operator.as_str().to_string(),
464                                        key: self.key.clone(),
465                                        reason: format!("Boolean operator requires boolean value, found: {s}"),
466                                    }));
467                                }
468                            },
469                            _ => {
470                                results.push(Err(ValidationError::InvalidCondition {
471                                    operator: self.operator.as_str().to_string(),
472                                    key: self.key.clone(),
473                                    reason: "Boolean operator requires boolean value".to_string(),
474                                }));
475                            }
476                        }
477                    },
478                    _ => {} // Other categories are more flexible
479                }
480
481            helpers::collect_errors(results)
482        })
483    }
484}
485
486impl Validate for ConditionBlock {
487    fn validate(&self, context: &mut ValidationContext) -> ValidationResult {
488        context.with_segment("ConditionBlock", |ctx| {
489            if self.conditions.is_empty() {
490                return Err(ValidationError::InvalidValue {
491                    field: "Condition".to_string(),
492                    value: "{}".to_string(),
493                    reason: "Condition block cannot be empty".to_string(),
494                });
495            }
496
497            let mut results = Vec::new();
498
499            for (operator, condition_map) in &self.conditions {
500                ctx.with_segment(operator.as_str(), |op_ctx| {
501                    if condition_map.is_empty() {
502                        results.push(Err(ValidationError::InvalidValue {
503                            field: "Condition operator".to_string(),
504                            value: operator.as_str().to_string(),
505                            reason: "Condition operator cannot have empty condition map"
506                                .to_string(),
507                        }));
508                        return;
509                    }
510
511                    for (key, value) in condition_map {
512                        op_ctx.with_segment(key, |key_ctx| {
513                            let condition = Condition {
514                                operator: operator.clone(),
515                                key: key.clone(),
516                                value: value.clone(),
517                            };
518                            results.push(condition.validate(key_ctx));
519                        });
520                    }
521                });
522            }
523
524            helpers::collect_errors(results)
525        })
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    #[test]
534    fn test_condition_creation() {
535        let condition = Condition::string(IAMOperator::StringEquals, "aws:username", "john");
536
537        assert_eq!(condition.operator, IAMOperator::StringEquals);
538        assert_eq!(condition.key, "aws:username");
539        assert_eq!(condition.value, ConditionValue::String("john".to_string()));
540    }
541
542    #[test]
543    fn test_condition_block() {
544        let block = ConditionBlock::new()
545            .with_condition(Condition::string(
546                IAMOperator::StringEquals,
547                "aws:username",
548                "john",
549            ))
550            .with_condition(Condition::boolean(
551                IAMOperator::Bool,
552                "aws:SecureTransport",
553                true,
554            ));
555
556        assert!(block.has_condition(&IAMOperator::StringEquals, "aws:username"));
557        assert!(block.has_condition(&IAMOperator::Bool, "aws:SecureTransport"));
558        assert!(!block.has_condition(&IAMOperator::StringEquals, "nonexistent"));
559
560        let username = block.get_condition_value(&IAMOperator::StringEquals, "aws:username");
561        assert_eq!(username, Some(&ConditionValue::String("john".to_string())));
562    }
563
564    #[test]
565    fn test_legacy_format_conversion() {
566        let mut legacy = HashMap::new();
567        let mut string_conditions = HashMap::new();
568        string_conditions.insert("aws:username".to_string(), serde_json::json!("john"));
569        legacy.insert("StringEquals".to_string(), string_conditions);
570
571        let block = ConditionBlock::from_legacy_format(legacy.clone()).unwrap();
572        assert!(block.has_condition(&IAMOperator::StringEquals, "aws:username"));
573
574        let converted_back = block.to_legacy_format();
575        assert_eq!(converted_back, legacy);
576    }
577
578    #[test]
579    fn test_condition_serialization() {
580        let condition = Condition::string(IAMOperator::StringEquals, "aws:username", "john");
581
582        let json = serde_json::to_string(&condition).unwrap();
583        let deserialized: Condition = serde_json::from_str(&json).unwrap();
584
585        assert_eq!(condition, deserialized);
586    }
587
588    #[test]
589    fn test_condition_block_serialization() {
590        let block = ConditionBlock::new()
591            .with_condition(Condition::string_array(
592                IAMOperator::StringEquals,
593                "aws:PrincipalTag/department",
594                vec!["finance".to_string(), "hr".to_string(), "legal".to_string()],
595            ))
596            .with_condition(Condition::string_array(
597                IAMOperator::ArnLike,
598                "aws:PrincipalArn",
599                vec![
600                    "arn:aws:iam::222222222222:user/Ana".to_string(),
601                    "arn:aws:iam::222222222222:user/Mary".to_string(),
602                ],
603            ));
604
605        let json = serde_json::to_string_pretty(&block).unwrap();
606        println!("Current serialization:\n{json}");
607
608        // Test that it can be deserialized back
609        let deserialized: ConditionBlock = serde_json::from_str(&json).unwrap();
610        assert_eq!(block, deserialized);
611    }
612
613    #[test]
614    fn test_condition_value_float_number() {
615        // JSON integers deserialize as integers and round-trip without ".0".
616        let int_value: ConditionValue = serde_json::from_str("10").unwrap();
617        assert_eq!(int_value, ConditionValue::Number(10.into()));
618        assert_eq!(serde_json::to_string(&int_value).unwrap(), "10");
619
620        // JSON floats deserialize and round-trip exactly.
621        let float_value: ConditionValue = serde_json::from_str("10.5").unwrap();
622        assert_eq!(
623            float_value,
624            ConditionValue::Number(serde_json::Number::from_f64(10.5).unwrap())
625        );
626        assert_eq!(serde_json::to_string(&float_value).unwrap(), "10.5");
627
628        // from_json_value / to_json_value round-trip a float.
629        let cv = ConditionValue::from_json_value(serde_json::json!(1234.5)).unwrap();
630        assert_eq!(cv.to_json_value(), serde_json::json!(1234.5));
631
632        // The float builder constructs a float condition.
633        let condition = Condition::float(
634            IAMOperator::NumericLessThan,
635            "aws:multifactorAuthAge",
636            1234.5,
637        );
638        assert!(condition.value.is_number());
639    }
640
641    #[test]
642    fn test_date_condition_validation() {
643        // AWS accepts ISO 8601 date/time values and Unix epoch time. The
644        // validator must accept both, matching the evaluator's parse_date.
645        // (Previously it rejected epoch values like "1704067200".)
646        let iso = Condition::string(
647            IAMOperator::DateGreaterThan,
648            "aws:CurrentTime",
649            "2024-01-01T00:00:00Z",
650        );
651        assert!(iso.is_valid(), "ISO 8601 date should validate");
652
653        let epoch = Condition::string(
654            IAMOperator::DateGreaterThan,
655            "aws:CurrentTime",
656            "1704067200",
657        );
658        assert!(epoch.is_valid(), "Unix epoch should validate");
659
660        let fractional_epoch = Condition::string(
661            IAMOperator::DateGreaterThan,
662            "aws:CurrentTime",
663            "1704067200.5",
664        );
665        assert!(
666            fractional_epoch.is_valid(),
667            "fractional epoch should validate"
668        );
669
670        // AWS accepts Unix epoch as a JSON number for date operators.
671        let numeric_epoch = Condition::new(
672            IAMOperator::DateGreaterThan,
673            "aws:CurrentTime",
674            ConditionValue::Number(serde_json::Number::from(1_704_067_200u64)),
675        );
676        assert!(
677            numeric_epoch.is_valid(),
678            "numeric Unix epoch should validate"
679        );
680
681        // A string that looks date-ish but is not a valid date must fail.
682        let invalid = Condition::string(
683            IAMOperator::DateGreaterThan,
684            "aws:CurrentTime",
685            "not-a-date",
686        );
687        assert!(!invalid.is_valid(), "non-date string should not validate");
688    }
689}