Skip to main content

template_model/
iam_policy.rs

1//! Structural validation of embedded IAM identity policy documents.
2//!
3//! IAM identity policies are embedded JSON inside CloudFormation properties
4//! (`PolicyDocument`, `InlinePolicy`). IAM rejects a malformed document at
5//! deploy time, so the shape is validated here: allowed top-level keys, the
6//! `Version` enum, the `Statement` list, and each statement's keys, `Effect`
7//! enum, exactly-one-of `Action`/`NotAction` and `Resource`/`NotResource`
8//! pairs, value types, `Sid` uniqueness, the `Condition` operator/value schema,
9//! and the resource-ARN format.
10//!
11//! This engine-agnostic implementation lets every consumer apply the same
12//! policy-document semantics. Marker-bearing fields are skipped individually:
13//! independent literal fields remain statically verifiable.
14//! Intrinsic-generated descendants are recognized by path ancestry in the
15//! `substituted` set so they suppress only checks depending on their
16//! generated value.
17
18use crate::conditions::Satisfiability;
19use crate::consts::{
20    MARKER_CONDITIONAL, MARKER_DYNAMIC, MARKER_ENUM, MARKER_IF_FALSE, MARKER_IF_TRUE, MARKER_INTRINSIC, MARKER_REF,
21};
22use crate::message::render_str_list;
23use crate::model::SemanticModel;
24use crate::resolved_value::effective_path_from_scenario_source_path;
25use crate::resolver::{MapEntry, ResolvedValue};
26use crate::serialization::resolved_value_to_json;
27use serde_json::Value;
28use std::collections::{BTreeSet, HashMap, HashSet};
29
30/// One structural defect in a policy document. `path` is relative to the
31/// document root (empty for a document-level finding), dot-separated with
32/// numeric list indices, e.g. `Statement.0.Effect`.
33#[derive(Debug, Clone)]
34pub struct PolicyFinding {
35    pub path: String,
36    pub message: String,
37}
38
39/// A scenario-aware policy defect with the authored path used to locate the
40/// selected conditional branch independently from its effective public path.
41#[derive(Debug, Clone)]
42pub struct ScenarioPolicyFinding {
43    pub path: String,
44    pub message: String,
45    pub source_path: String,
46}
47
48const IDENTITY_DOCUMENT_KEYS: &[&str] = &["Statement", "Version"];
49const VERSION_VALUES: &[&str] = &["2008-10-17", "2012-10-17"];
50const EFFECT_VALUES: &[&str] = &["Allow", "Deny"];
51const IDENTITY_STATEMENT_KEYS: &[&str] =
52    &["Action", "Condition", "Effect", "NotAction", "NotResource", "Resource", "Sid"];
53
54/// The ARN shape IAM accepts in a `Resource`/`NotResource` entry: a full ARN
55/// or the lone `*`. Wildcards may appear in the partition but not the service.
56const RESOURCE_ARN_PATTERN: &str =
57    "^(arn:(aws[A-Za-z\\-]*?|[A-Za-z?*\\-]*[?*][A-Za-z?*\\-]*):[^:*?]+:[^:]*(:(?:\\d{12}|\\*|aws)?:.+|)|\\*)$";
58
59const CONDITION_OPERATORS: &[&str] = &[
60    "ArnEquals",
61    "ArnLike",
62    "ArnNotEquals",
63    "ArnNotLike",
64    "BinaryEquals",
65    "Bool",
66    "DateEquals",
67    "DateGreaterThan",
68    "DateGreaterThanEquals",
69    "DateLessThan",
70    "DateLessThanEquals",
71    "DateNotEquals",
72    "IpAddress",
73    "NotIpAddress",
74    "Null",
75    "NumericEquals",
76    "NumericGreaterThan",
77    "NumericGreaterThanEquals",
78    "NumericLessThan",
79    "NumericLessThanEquals",
80    "NumericNotEquals",
81    "StringEquals",
82    "StringEqualsIgnoreCase",
83    "StringLike",
84    "StringNotEquals",
85    "StringNotEqualsIgnoreCase",
86    "StringNotLike",
87];
88
89fn resource_arn_matches(value: &str) -> bool {
90    if value == "*" {
91        return true;
92    }
93    let Some(fields) = split_arn_fields(value) else {
94        return false;
95    };
96    if fields.len() < 3 || fields[0] != "arn" {
97        return false;
98    }
99    if !arn_partition_is_valid(&fields[1]) || !arn_service_is_valid(&fields[2]) {
100        return false;
101    }
102
103    // IAM policy variables are expanded by IAM and are permitted only in the
104    // resource-identifying portion. CloudFormation placeholders such as
105    // `${AWS::Partition}` and `${AWS::AccountId}` remain valid in ARN fields.
106    if fields.iter().take(5).any(|field| field_has_iam_policy_variable(field)) {
107        return false;
108    }
109
110    if let Some(account) = fields.get(4)
111        && !account.is_empty()
112        && account != "*"
113        && account != "aws"
114        && !field_has_placeholder(account)
115        && !(account.len() == 12 && account.bytes().all(|byte| byte.is_ascii_digit()))
116    {
117        return false;
118    }
119    fields.get(5).is_none_or(|resource| !resource.is_empty())
120}
121
122/// Splits an ARN on colons outside `${...}` placeholders. IAM accepts
123/// incomplete ARNs (for example `arn:aws:sqs`) and wildcard-completes omitted
124/// trailing fields, so only the partition and service fields are mandatory.
125fn split_arn_fields(value: &str) -> Option<Vec<String>> {
126    let mut fields = Vec::new();
127    let mut current = String::new();
128    let mut chars = value.chars().peekable();
129    let mut in_placeholder = false;
130    while let Some(character) = chars.next() {
131        if character == '$' && chars.peek() == Some(&'{') {
132            in_placeholder = true;
133            current.push(character);
134        } else if character == '}' && in_placeholder {
135            in_placeholder = false;
136            current.push(character);
137        } else if character == ':' && !in_placeholder {
138            fields.push(std::mem::take(&mut current));
139        } else {
140            current.push(character);
141        }
142    }
143    if in_placeholder {
144        return None;
145    }
146    fields.push(current);
147    Some(fields)
148}
149
150fn field_has_placeholder(field: &str) -> bool {
151    field.contains("${")
152}
153
154fn field_has_iam_policy_variable(field: &str) -> bool {
155    let mut remaining = field;
156    while let Some(start) = remaining.find("${") {
157        let content = &remaining[start + 2..];
158        let Some(end) = content.find('}') else {
159            return true;
160        };
161        let name = &content[..end];
162        if name.contains(':') && !name.starts_with("AWS::") {
163            return true;
164        }
165        remaining = &content[end + 1..];
166    }
167    false
168}
169
170fn field_without_placeholders(field: &str) -> Option<String> {
171    let mut output = String::new();
172    let mut remaining = field;
173    while let Some(start) = remaining.find("${") {
174        output.push_str(&remaining[..start]);
175        let content = &remaining[start + 2..];
176        let end = content.find('}')?;
177        remaining = &content[end + 1..];
178    }
179    output.push_str(remaining);
180    Some(output)
181}
182
183fn arn_partition_is_valid(partition: &str) -> bool {
184    if field_has_iam_policy_variable(partition) {
185        return false;
186    }
187    let Some(literal) = field_without_placeholders(partition) else {
188        return false;
189    };
190    if literal.is_empty() {
191        return field_has_placeholder(partition);
192    }
193
194    let uses_only_partition_characters =
195        literal.chars().all(|character| character.is_ascii_alphabetic() || matches!(character, '-' | '*' | '?'));
196    let contains_wildcard = literal.contains(['*', '?']);
197    uses_only_partition_characters && (contains_wildcard || literal.starts_with("aws"))
198}
199
200fn arn_service_is_valid(service: &str) -> bool {
201    if service.contains(['*', '?']) || field_has_iam_policy_variable(service) {
202        return false;
203    }
204    let Some(literal) = field_without_placeholders(service) else {
205        return false;
206    };
207    !literal.is_empty() || field_has_placeholder(service)
208}
209
210fn condition_operator_is_valid(operator: &str) -> bool {
211    let without_set_operator =
212        operator.strip_prefix("ForAnyValue:").or_else(|| operator.strip_prefix("ForAllValues:")).unwrap_or(operator);
213    let base = without_set_operator.strip_suffix("IfExists").unwrap_or(without_set_operator);
214    CONDITION_OPERATORS.contains(&base)
215}
216
217/// Indexes paths whose values came from intrinsic resolution. Exact paths and
218/// ancestors use hash lookups; descendants use a sorted-prefix lookup.
219struct SubstitutedPathIndex<'a> {
220    paths: &'a HashSet<String>,
221    sorted_paths: Vec<&'a str>,
222}
223
224impl<'a> SubstitutedPathIndex<'a> {
225    fn new(paths: &'a HashSet<String>) -> Self {
226        let mut sorted_paths: Vec<&str> = paths.iter().map(String::as_str).collect();
227        sorted_paths.sort_unstable();
228        Self { paths, sorted_paths }
229    }
230
231    /// Whether `path` is equal to, an ancestor of, or a descendant of an
232    /// indexed path, with dots treated as component boundaries.
233    ///
234    /// A descendant only suppresses the ancestor when the immediate next path
235    /// component is non-numeric (an intrinsic like `Fn::Join`). A numeric
236    /// descendant (a list item index) means only that one array element was
237    /// generated — it must not suppress the entire list or its other items.
238    fn covers(&self, path: &str) -> bool {
239        if self.paths.contains("") || self.paths.contains(path) {
240            return true;
241        }
242
243        if path.match_indices('.').any(|(separator, _)| self.paths.contains(&path[..separator])) {
244            return true;
245        }
246
247        // Find the first candidate at or beyond the conceptual `path + "."`
248        // lower bound without allocating that temporary string. Candidates
249        // such as `path-name` sort before `path.name` and must be skipped.
250        let descendant_index = self.sorted_paths.partition_point(|candidate| {
251            candidate
252                .strip_prefix(path)
253                .map_or(*candidate < path, |suffix| suffix.as_bytes().first().is_none_or(|first| *first < b'.'))
254        });
255        self.sorted_paths.get(descendant_index).is_some_and(|candidate| {
256            candidate.strip_prefix(path).is_some_and(|suffix| {
257                // The suffix starts with '.'; the component after the dot
258                // determines whether the descendant generated the ancestor's
259                // scalar value or is merely one item of the ancestor's array.
260                // Only non-numeric components (intrinsic names like `Fn::Join`)
261                // indicate the ancestor was generated.
262                if !suffix.starts_with('.') {
263                    return false;
264                }
265                let next_component = &suffix[1..];
266                let component_end = next_component.find('.').unwrap_or(next_component.len());
267                let first_component = &next_component[..component_end];
268                !first_component.bytes().all(|byte| byte.is_ascii_digit())
269            })
270        })
271    }
272}
273
274fn path_is_intrinsic_generated(path: &str, substituted: &SubstitutedPathIndex<'_>) -> bool {
275    substituted.covers(path)
276}
277
278fn is_resolution_marker(value: &Value) -> bool {
279    value.as_object().is_some_and(|object| {
280        object.contains_key(MARKER_DYNAMIC)
281            || object.contains_key(MARKER_REF)
282            || object.contains_key(MARKER_INTRINSIC)
283            || object.contains_key(MARKER_CONDITIONAL)
284            || object.contains_key(MARKER_ENUM)
285    })
286}
287
288fn non_null_property<'a>(object: &'a serde_json::Map<String, Value>, key: &str) -> Option<&'a Value> {
289    object.get(key).filter(|value| !value.is_null())
290}
291
292/// Validates an IAM *identity* policy document (no `Principal` allowed).
293/// The document must already be concrete JSON; values containing
294/// resolved-intrinsic markers are skipped rather than judged.
295///
296/// `substituted` holds document-relative paths whose values were produced by
297/// resolving an intrinsic (a `Ref` to a defaulted parameter, say) rather than
298/// written literally. String-content checks are withheld at those paths: the
299/// author wrote a reference, not the substituted text.
300pub fn validate_identity_policy(doc: &Value, substituted: &HashSet<String>) -> Vec<PolicyFinding> {
301    let substituted_index = SubstitutedPathIndex::new(substituted);
302    let mut out = Vec::new();
303    if is_resolution_marker(doc) {
304        return out;
305    }
306    let Some(obj) = doc.as_object() else {
307        out.push(PolicyFinding {
308            path: String::new(),
309            message: format!("{} is not of type 'object'", describe_value(doc)),
310        });
311        return out;
312    };
313
314    for key in obj.keys() {
315        if !IDENTITY_DOCUMENT_KEYS.contains(&key.as_str()) {
316            out.push(PolicyFinding {
317                path: key.clone(),
318                message: format!("Additional properties are not allowed ('{}' was unexpected)", key),
319            });
320        }
321    }
322
323    if let Some(version) = obj.get("Version")
324        && !is_resolution_marker(version)
325    {
326        if let Some(v) = version.as_str() {
327            if !VERSION_VALUES.contains(&v) {
328                out.push(PolicyFinding {
329                    path: "Version".to_string(),
330                    message: format!("'{}' is not one of {}", v, render_str_list(VERSION_VALUES)),
331                });
332            }
333        } else {
334            out.push(PolicyFinding {
335                path: "Version".to_string(),
336                message: format!("{} is not of type 'string'", describe_value(version)),
337            });
338        }
339    }
340
341    match non_null_property(obj, "Statement") {
342        None => {
343            out.push(PolicyFinding { path: String::new(), message: "'Statement' is a required property".to_string() })
344        }
345        Some(Value::Array(stmts)) => {
346            if stmts.is_empty() {
347                out.push(PolicyFinding {
348                    path: "Statement".to_string(),
349                    message: "[] is too short (minimum 1 item)".to_string(),
350                });
351            }
352            let mut seen_sids: Vec<(String, usize)> = Vec::new();
353            for (idx, stmt) in stmts.iter().enumerate() {
354                validate_identity_statement(stmt, &format!("Statement.{}", idx), &substituted_index, &mut out);
355                if let Some(statement) = stmt.as_object()
356                    && let Some(Value::String(sid)) = statement.get("Sid")
357                    && !sid.is_empty()
358                    && !path_is_intrinsic_generated(&format!("Statement.{}.Sid", idx), &substituted_index)
359                {
360                    if let Some((_, first_index)) = seen_sids.iter().find(|(seen_sid, _)| seen_sid == sid) {
361                        out.push(PolicyFinding {
362                            path: format!("Statement.{}.Sid", idx),
363                            message: format!("'{}' is a duplicate of Statement.{}.Sid", sid, first_index),
364                        });
365                    } else {
366                        seen_sids.push((sid.clone(), idx));
367                    }
368                }
369            }
370        }
371        Some(stmt @ Value::Object(_)) => validate_identity_statement(stmt, "Statement", &substituted_index, &mut out),
372        Some(other) => {
373            if !is_resolution_marker(other) {
374                out.push(PolicyFinding {
375                    path: "Statement".to_string(),
376                    message: format!("{} is not of type 'object', 'array'", describe_value(other)),
377                });
378            }
379        }
380    }
381
382    out
383}
384
385fn contains_scenario_branching(value: &ResolvedValue) -> bool {
386    match value {
387        ResolvedValue::Conditional { .. } | ResolvedValue::Enum { .. } => true,
388        ResolvedValue::List { items } => items.iter().any(contains_scenario_branching),
389        ResolvedValue::Map { entries } => entries.iter().any(|entry| contains_scenario_branching(&entry.value)),
390        ResolvedValue::Concrete { .. }
391        | ResolvedValue::Reference { .. }
392        | ResolvedValue::Dynamic { .. }
393        | ResolvedValue::TypedDynamic { .. } => false,
394    }
395}
396
397fn mask_scenario_branching(value: &ResolvedValue) -> ResolvedValue {
398    match value {
399        ResolvedValue::Conditional { .. } | ResolvedValue::Enum { .. } => {
400            ResolvedValue::Dynamic { reason: "scenario expansion curtailed".to_string() }
401        }
402        ResolvedValue::List { items } => {
403            ResolvedValue::List { items: items.iter().map(mask_scenario_branching).collect() }
404        }
405        ResolvedValue::Map { entries } => ResolvedValue::Map {
406            entries: entries
407                .iter()
408                .map(|entry| MapEntry { key: entry.key.clone(), value: mask_scenario_branching(&entry.value) })
409                .collect(),
410        },
411        other => other.clone(),
412    }
413}
414
415fn branch_alternatives_match(value: &ResolvedValue, concrete_matches: &impl Fn(&serde_json::Value) -> bool) -> bool {
416    match value {
417        ResolvedValue::Conditional { if_true, if_false, .. } => {
418            branch_alternatives_match(if_true, concrete_matches)
419                && branch_alternatives_match(if_false, concrete_matches)
420        }
421        ResolvedValue::Enum { variants } => {
422            variants.iter().all(|variant| branch_alternatives_match(variant, concrete_matches))
423        }
424        ResolvedValue::Concrete { value } => concrete_matches(value),
425        ResolvedValue::Reference { .. } | ResolvedValue::Dynamic { .. } | ResolvedValue::TypedDynamic { .. } => true,
426        ResolvedValue::List { .. } | ResolvedValue::Map { .. } => false,
427    }
428}
429
430fn list_item_branches_are_validation_invariant(
431    value: &ResolvedValue,
432    concrete_matches: &impl Fn(&serde_json::Value) -> bool,
433) -> bool {
434    let ResolvedValue::List { items } = value else {
435        return !contains_scenario_branching(value);
436    };
437    items.iter().all(|item| !contains_scenario_branching(item) || branch_alternatives_match(item, concrete_matches))
438}
439
440fn identity_statement_branches_are_validation_invariant(statement: &ResolvedValue) -> bool {
441    let ResolvedValue::Map { entries } = statement else {
442        return !contains_scenario_branching(statement);
443    };
444    entries.iter().all(|entry| match entry.key.as_str() {
445        "Action" | "NotAction" => {
446            list_item_branches_are_validation_invariant(&entry.value, &serde_json::Value::is_string)
447        }
448        "Resource" | "NotResource" => list_item_branches_are_validation_invariant(&entry.value, &|value| {
449            value.as_str().is_some_and(resource_arn_matches)
450        }),
451        _ => !contains_scenario_branching(&entry.value),
452    })
453}
454
455fn identity_policy_branches_are_validation_invariant(document: &ResolvedValue) -> bool {
456    let ResolvedValue::Map { entries } = document else {
457        return !contains_scenario_branching(document);
458    };
459    entries.iter().all(|entry| {
460        if entry.key != "Statement" {
461            return !contains_scenario_branching(&entry.value);
462        }
463        match &entry.value {
464            ResolvedValue::List { items } => items.iter().all(identity_statement_branches_are_validation_invariant),
465            statement @ ResolvedValue::Map { .. } => identity_statement_branches_are_validation_invariant(statement),
466            value => !contains_scenario_branching(value),
467        }
468    })
469}
470
471/// Returns whether a finding should be suppressed because its authored value is
472/// intrinsic-generated and cannot be proven invalid before deployment.
473fn should_suppress_intrinsic_finding(model: &SemanticModel, resource_id: &str, source_path: &str) -> bool {
474    if !model.is_from_intrinsic(resource_id, source_path) {
475        return false;
476    }
477    let Some(template) = model.authored_sub_template_at(resource_id, source_path) else {
478        return true;
479    };
480    !sub_resource_skeleton_is_definitely_invalid(template)
481}
482
483fn sub_resource_skeleton_is_definitely_invalid(template: &str) -> bool {
484    if template == "*" {
485        return false;
486    }
487    // A leading substitution can provide the whole ARN (or its prefix), so no
488    // conclusion is possible without knowing the deployment-time value.
489    if template.starts_with("${") {
490        return false;
491    }
492    // Elsewhere, placeholders cannot remove authored characters. The ordinary
493    // ARN matcher understands placeholders in valid ARN fields, so failure is a
494    // proof that the fixed skeleton itself is malformed.
495    !resource_arn_matches(template)
496}
497
498/// Returns every possible inline policy document path under a policy list.
499pub fn inline_identity_policy_document_paths(
500    model: &SemanticModel,
501    resource_id: &str,
502    policies_path: &str,
503) -> Vec<String> {
504    let Some(policies) =
505        model.resolve_deep(resource_id, policies_path).or_else(|| model.resolve(resource_id, policies_path).cloned())
506    else {
507        return Vec::new();
508    };
509    let mut indices = BTreeSet::new();
510    collect_inline_policy_indices(&policies, &mut indices);
511    indices.into_iter().map(|index| format!("{policies_path}.{index}.PolicyDocument")).collect()
512}
513
514fn collect_inline_policy_indices(value: &ResolvedValue, indices: &mut BTreeSet<usize>) {
515    match value {
516        ResolvedValue::List { items } => {
517            for (index, item) in items.iter().enumerate() {
518                if !matches!(item, ResolvedValue::Concrete { value } if value.is_null()) {
519                    indices.insert(index);
520                }
521            }
522        }
523        ResolvedValue::Concrete { value } => {
524            if let Some(items) = value.as_array() {
525                for (index, item) in items.iter().enumerate() {
526                    if !item.is_null() {
527                        indices.insert(index);
528                    }
529                }
530            }
531        }
532        ResolvedValue::Conditional { if_true, if_false, .. } => {
533            collect_inline_policy_indices(if_true, indices);
534            collect_inline_policy_indices(if_false, indices);
535        }
536        ResolvedValue::Enum { variants } => {
537            for variant in variants {
538                collect_inline_policy_indices(variant, indices);
539            }
540        }
541        ResolvedValue::Map { .. }
542        | ResolvedValue::Reference { .. }
543        | ResolvedValue::Dynamic { .. }
544        | ResolvedValue::TypedDynamic { .. } => {}
545    }
546}
547
548fn identity_policy_scenario_is_reachable(
549    model: &SemanticModel,
550    resource_id: &str,
551    conditions: &HashMap<String, bool>,
552) -> bool {
553    let mut assumptions: Vec<(String, bool)> = conditions.iter().map(|(name, value)| (name.clone(), *value)).collect();
554    if let Some(resource_condition) = model.resources.get(resource_id).and_then(|resource| resource.condition.as_ref())
555    {
556        match conditions.get(resource_condition) {
557            Some(false) => return false,
558            Some(true) => {}
559            None => assumptions.push((resource_condition.clone(), true)),
560        }
561    }
562    assumptions.sort_unstable();
563    assumptions.is_empty() || model.conditions.satisfiability(&assumptions) == Satisfiability::Satisfiable
564}
565
566fn reachable_identity_policy_scenarios(
567    model: &SemanticModel,
568    resource_id: &str,
569    document_path: &str,
570    use_masked_fallback: bool,
571) -> Vec<(ResolvedValue, HashMap<String, bool>)> {
572    let Some(document) =
573        model.resolve_deep(resource_id, document_path).or_else(|| model.resolve(resource_id, document_path).cloned())
574    else {
575        return Vec::new();
576    };
577    let scenarios = if identity_policy_branches_are_validation_invariant(&document) {
578        vec![(document, HashMap::new())]
579    } else {
580        let expanded = model.resolve_scenarios(resource_id, document_path);
581        if !expanded.is_empty() {
582            expanded
583        } else if use_masked_fallback {
584            vec![(mask_scenario_branching(&document), HashMap::new())]
585        } else {
586            Vec::new()
587        }
588    };
589    scenarios
590        .into_iter()
591        .filter(|(_, conditions)| identity_policy_scenario_is_reachable(model, resource_id, conditions))
592        .collect()
593}
594
595/// Returns whether any reachable Allow statement uses `NotAction`.
596pub fn policy_has_allow_not_action_scenarios(model: &SemanticModel, resource_id: &str, document_path: &str) -> bool {
597    reachable_identity_policy_scenarios(model, resource_id, document_path, false).into_iter().any(
598        |(document, conditions)| {
599            let materialized = materialize_policy_document(
600                &resolved_value_to_json(&document),
601                model,
602                resource_id,
603                document_path,
604                &conditions,
605            );
606            doc_has_allow_not_action(&materialized.value)
607        },
608    )
609}
610
611fn doc_has_allow_not_action(document: &Value) -> bool {
612    let Some(statement) = document.as_object().and_then(|object| object.get("Statement")) else {
613        return false;
614    };
615    match statement {
616        Value::Array(statements) => statements.iter().any(statement_is_allow_not_action),
617        Value::Object(_) => statement_is_allow_not_action(statement),
618        _ => false,
619    }
620}
621
622fn statement_is_allow_not_action(statement: &Value) -> bool {
623    let Some(object) = statement.as_object() else { return false };
624    object.get("Effect").and_then(Value::as_str) == Some("Allow")
625        && matches!(object.get("NotAction"), Some(value) if !value.is_null())
626}
627
628/// Raw scenarios retain dynamic marker subtrees, allowing literal siblings to
629/// be checked without judging deployment-time values. Findings produced from an
630/// intrinsic-generated authored path are discarded per scenario, so an
631/// intrinsic in one conditional branch cannot suppress a literal sibling branch.
632pub fn validate_identity_policy_scenarios(
633    model: &SemanticModel,
634    resource_id: &str,
635    document_path: &str,
636) -> Vec<ScenarioPolicyFinding> {
637    let scenarios = reachable_identity_policy_scenarios(model, resource_id, document_path, true);
638    let mut findings = Vec::new();
639    let mut seen = HashSet::new();
640    let no_substitutions = HashSet::new();
641
642    for (document, conditions) in scenarios {
643        let materialized = materialize_policy_document(
644            &resolved_value_to_json(&document),
645            model,
646            resource_id,
647            document_path,
648            &conditions,
649        );
650        let document = materialized.value;
651        for finding in validate_identity_policy(&document, &no_substitutions) {
652            let effective_path = if finding.path.is_empty() {
653                document_path.to_string()
654            } else {
655                format!("{document_path}.{}", finding.path)
656            };
657            let source_path = source_path_for_effective_path(
658                &materialized.source_paths,
659                model,
660                resource_id,
661                &effective_path,
662                &conditions,
663            );
664            if should_suppress_intrinsic_finding(model, resource_id, &source_path) {
665                continue;
666            }
667            let deduplication_message = scenario_finding_deduplication_message(&finding);
668            if seen.insert((source_path.clone(), deduplication_message)) {
669                let public_path = public_policy_path(document_path, &source_path, &finding.path);
670                findings.push(ScenarioPolicyFinding { path: public_path, message: finding.message, source_path });
671            }
672        }
673        collect_raw_placeholder_findings(
674            &document,
675            &materialized.source_paths,
676            model,
677            resource_id,
678            document_path,
679            &conditions,
680            &mut seen,
681            &mut findings,
682        );
683    }
684    findings
685}
686
687fn scenario_finding_deduplication_message(finding: &PolicyFinding) -> String {
688    if finding.path.is_empty() && finding.message.ends_with(" is not of type 'object'") {
689        "policy document is not an object".to_string()
690    } else {
691        finding.message.clone()
692    }
693}
694/// Scans Resource/NotResource string values in the resolved document for raw
695/// CloudFormation-style `${...}` placeholders that are not IAM policy
696/// variables. When found and the value is NOT from Fn::Sub (checked via
697/// is_from_intrinsic), produces an E3510 finding. This fires independently
698/// of E1029 (which reports the substitution syntax error).
699fn collect_raw_placeholder_findings(
700    document: &Value,
701    source_paths: &HashMap<String, String>,
702    model: &SemanticModel,
703    resource_id: &str,
704    document_path: &str,
705    conditions: &HashMap<String, bool>,
706    seen: &mut HashSet<(String, String)>,
707    findings: &mut Vec<ScenarioPolicyFinding>,
708) {
709    let Some(obj) = document.as_object() else { return };
710    let statements = match obj.get("Statement") {
711        Some(Value::Array(stmts)) => stmts.as_slice(),
712        Some(stmt @ Value::Object(_)) => std::slice::from_ref(stmt),
713        _ => return,
714    };
715    for (idx, stmt) in statements.iter().enumerate() {
716        let stmt_path = if statements.len() == 1 && !obj.get("Statement").is_some_and(Value::is_array) {
717            "Statement".to_string()
718        } else {
719            format!("Statement.{idx}")
720        };
721        let Some(stmt_obj) = stmt.as_object() else { continue };
722        for key in ["Resource", "NotResource"] {
723            let Some(value) = stmt_obj.get(key) else { continue };
724            let field_path = format!("{stmt_path}.{key}");
725            match value {
726                Value::String(s) => {
727                    check_raw_placeholder_in_resource(
728                        s,
729                        &field_path,
730                        document_path,
731                        source_paths,
732                        model,
733                        resource_id,
734                        conditions,
735                        seen,
736                        findings,
737                    );
738                }
739                Value::Array(items) => {
740                    for (item_idx, item) in items.iter().enumerate() {
741                        if let Value::String(s) = item {
742                            let item_path = format!("{field_path}.{item_idx}");
743                            check_raw_placeholder_in_resource(
744                                s,
745                                &item_path,
746                                document_path,
747                                source_paths,
748                                model,
749                                resource_id,
750                                conditions,
751                                seen,
752                                findings,
753                            );
754                        }
755                    }
756                }
757                _ => {}
758            }
759        }
760    }
761}
762
763fn check_raw_placeholder_in_resource(
764    resource: &str,
765    relative_path: &str,
766    document_path: &str,
767    source_paths: &HashMap<String, String>,
768    model: &SemanticModel,
769    resource_id: &str,
770    conditions: &HashMap<String, bool>,
771    seen: &mut HashSet<(String, String)>,
772    findings: &mut Vec<ScenarioPolicyFinding>,
773) {
774    if !has_raw_cfn_placeholder(resource) {
775        return;
776    }
777    // Only fire if the value already passes resource_arn_matches (otherwise
778    // the standard ARN check already flagged it).
779    if !resource_arn_matches(resource) {
780        return;
781    }
782    let effective_path = format!("{document_path}.{relative_path}");
783    let source_path = source_path_for_effective_path(source_paths, model, resource_id, &effective_path, conditions);
784    // Skip if the value came from Fn::Sub (the placeholder is valid there)
785    if model.is_from_intrinsic(resource_id, &source_path) {
786        return;
787    }
788    let message = format!("'{}' does not match '{}'", resource, RESOURCE_ARN_PATTERN);
789    if seen.insert((source_path.clone(), message.clone())) {
790        let public_path = public_policy_path(document_path, &source_path, relative_path);
791        findings.push(ScenarioPolicyFinding { path: public_path, message, source_path });
792    }
793}
794
795/// Returns true if the string contains `${...}` placeholders that are NOT
796/// valid IAM policy variables. These would only be expanded inside Fn::Sub
797/// and are raw/unresolved otherwise.
798fn has_raw_cfn_placeholder(value: &str) -> bool {
799    let mut remaining = value;
800    while let Some(start) = remaining.find("${") {
801        let content = &remaining[start + 2..];
802        let Some(end) = content.find('}') else {
803            return true;
804        };
805        let name = &content[..end];
806        // IAM policy variables contain ':' and don't start with "AWS::"
807        let is_iam_variable = name.contains(':') && !name.starts_with("AWS::");
808        if !is_iam_variable {
809            return true;
810        }
811        remaining = &content[end + 1..];
812    }
813    false
814}
815
816struct MaterializedPolicyDocument {
817    value: Value,
818    source_paths: HashMap<String, String>,
819}
820
821fn materialize_policy_document(
822    document: &Value,
823    model: &SemanticModel,
824    resource_id: &str,
825    document_path: &str,
826    conditions: &HashMap<String, bool>,
827) -> MaterializedPolicyDocument {
828    let mut source_paths = HashMap::new();
829    let value = materialize_policy_value(
830        document,
831        model,
832        resource_id,
833        document_path,
834        document_path,
835        conditions,
836        &mut source_paths,
837    );
838    MaterializedPolicyDocument { value, source_paths }
839}
840
841fn materialize_policy_value(
842    value: &Value,
843    model: &SemanticModel,
844    resource_id: &str,
845    effective_path: &str,
846    authored_path: &str,
847    conditions: &HashMap<String, bool>,
848    source_paths: &mut HashMap<String, String>,
849) -> Value {
850    let source_path =
851        model.scenario_source_path(resource_id, authored_path, conditions).unwrap_or_else(|| authored_path.to_string());
852    source_paths.insert(effective_path.to_string(), source_path);
853
854    match value {
855        Value::Object(map) => Value::Object(
856            map.iter()
857                .map(|(key, child)| {
858                    let effective_child = format!("{effective_path}.{key}");
859                    let authored_child = format!("{authored_path}.{key}");
860                    (
861                        key.clone(),
862                        materialize_policy_value(
863                            child,
864                            model,
865                            resource_id,
866                            &effective_child,
867                            &authored_child,
868                            conditions,
869                            source_paths,
870                        ),
871                    )
872                })
873                .collect(),
874        ),
875        Value::Array(items) => {
876            let mut materialized = Vec::with_capacity(items.len());
877            for (authored_index, item) in items.iter().enumerate() {
878                let authored_item_path = format!("{authored_path}.{authored_index}");
879                let authored_source = model
880                    .scenario_source_path(resource_id, &authored_item_path, conditions)
881                    .unwrap_or_else(|| authored_item_path.clone());
882                if item.is_null() && model.is_from_intrinsic(resource_id, &authored_source) {
883                    continue;
884                }
885                let effective_item_path = format!("{effective_path}.{}", materialized.len());
886                materialized.push(materialize_policy_value(
887                    item,
888                    model,
889                    resource_id,
890                    &effective_item_path,
891                    &authored_item_path,
892                    conditions,
893                    source_paths,
894                ));
895            }
896            Value::Array(materialized)
897        }
898        other => other.clone(),
899    }
900}
901
902fn source_path_for_effective_path(
903    source_paths: &HashMap<String, String>,
904    model: &SemanticModel,
905    resource_id: &str,
906    effective_path: &str,
907    conditions: &HashMap<String, bool>,
908) -> String {
909    source_paths.get(effective_path).cloned().unwrap_or_else(|| {
910        model
911            .scenario_source_path(resource_id, effective_path, conditions)
912            .unwrap_or_else(|| effective_path.to_string())
913    })
914}
915
916fn public_policy_path(document_path: &str, source_path: &str, fallback: &str) -> String {
917    let relative = source_path.strip_prefix(document_path).and_then(|path| path.strip_prefix('.')).unwrap_or(fallback);
918    effective_path_from_scenario_source_path(relative).unwrap_or_else(|| fallback.to_string())
919}
920
921fn validate_identity_statement(
922    stmt: &Value,
923    path: &str,
924    substituted: &SubstitutedPathIndex<'_>,
925    out: &mut Vec<PolicyFinding>,
926) {
927    if is_resolution_marker(stmt) {
928        return;
929    }
930    let Some(obj) = stmt.as_object() else {
931        out.push(PolicyFinding {
932            path: path.to_string(),
933            message: format!("{} is not of type 'object'", describe_value(stmt)),
934        });
935        return;
936    };
937
938    for key in obj.keys() {
939        if !IDENTITY_STATEMENT_KEYS.contains(&key.as_str()) {
940            out.push(PolicyFinding {
941                path: format!("{}.{}", path, key),
942                message: format!("Additional properties are not allowed ('{}' was unexpected)", key),
943            });
944        }
945    }
946
947    match non_null_property(obj, "Effect") {
948        None => {
949            out.push(PolicyFinding { path: path.to_string(), message: "'Effect' is a required property".to_string() })
950        }
951        Some(effect_val) => {
952            if !is_resolution_marker(effect_val) {
953                match effect_val.as_str() {
954                    Some(effect) => {
955                        if !EFFECT_VALUES.contains(&effect)
956                            && !path_is_intrinsic_generated(&format!("{}.Effect", path), substituted)
957                        {
958                            out.push(PolicyFinding {
959                                path: format!("{}.Effect", path),
960                                message: format!("'{}' is not one of {}", effect, render_str_list(EFFECT_VALUES)),
961                            });
962                        }
963                    }
964                    None => out.push(PolicyFinding {
965                        path: format!("{}.Effect", path),
966                        message: format!("{} is not of type 'string'", describe_value(effect_val)),
967                    }),
968                }
969            }
970        }
971    }
972
973    check_required_xor(obj, path, &["Action", "NotAction"], out);
974    check_required_xor(obj, path, &["Resource", "NotResource"], out);
975
976    for key in ["Action", "NotAction"] {
977        if let Some(value) = non_null_property(obj, key)
978            && !is_resolution_marker(value)
979        {
980            check_string_or_string_list_with_min_items(value, &format!("{}.{}", path, key), substituted, out);
981        }
982    }
983    for key in ["Resource", "NotResource"] {
984        if let Some(value) = non_null_property(obj, key) {
985            // Allow conditional markers through — the recursive validation of
986            // each branch happens inside check_resource_string_or_list.
987            let is_non_conditional_marker =
988                is_resolution_marker(value) && !value.as_object().is_some_and(|o| o.contains_key(MARKER_CONDITIONAL));
989            if is_non_conditional_marker {
990                continue;
991            }
992            check_resource_string_or_list(value, &format!("{}.{}", path, key), substituted, out);
993        }
994    }
995
996    if let Some(sid) = obj.get("Sid")
997        && !is_resolution_marker(sid)
998    {
999        match sid.as_str() {
1000            Some(s) if s.is_empty() || !s.chars().all(|c| c.is_ascii_alphanumeric()) => {
1001                out.push(PolicyFinding {
1002                    path: format!("{}.Sid", path),
1003                    message: format!("'{}' does not match '^[A-Za-z0-9]+$'", s),
1004                });
1005            }
1006            Some(_) => {}
1007            None => out.push(PolicyFinding {
1008                path: format!("{}.Sid", path),
1009                message: format!("{} is not of type 'string'", describe_value(sid)),
1010            }),
1011        }
1012    }
1013
1014    if let Some(condition) = obj.get("Condition") {
1015        validate_condition_block(condition, &format!("{}.Condition", path), out);
1016    }
1017}
1018
1019/// Validates the IAM Condition block structure and each context value that can
1020/// be decided statically.
1021fn validate_condition_block(condition: &Value, path: &str, out: &mut Vec<PolicyFinding>) {
1022    if is_resolution_marker(condition) {
1023        return;
1024    }
1025    let Some(operators) = condition.as_object() else {
1026        out.push(PolicyFinding {
1027            path: path.to_string(),
1028            message: format!("{} is not of type 'object'", describe_value(condition)),
1029        });
1030        return;
1031    };
1032
1033    for (operator, context_values) in operators {
1034        let operator_path = format!("{}.{}", path, operator);
1035        if !condition_operator_is_valid(operator) {
1036            out.push(PolicyFinding {
1037                path: operator_path.clone(),
1038                message: format!("'{}' is not a valid IAM condition operator", operator),
1039            });
1040        }
1041        if is_resolution_marker(context_values) {
1042            continue;
1043        }
1044        let Some(context_map) = context_values.as_object() else {
1045            out.push(PolicyFinding {
1046                path: operator_path,
1047                message: format!("{} is not of type 'object'", describe_value(context_values)),
1048            });
1049            continue;
1050        };
1051        let is_null_operator = operator == "Null";
1052        for (context_key, context_value) in context_map {
1053            if is_resolution_marker(context_value) {
1054                continue;
1055            }
1056            let context_path = format!("{}.{}", operator_path, context_key);
1057            if is_null_operator {
1058                validate_null_condition_value(context_value, &context_path, out);
1059            } else {
1060                validate_condition_value(context_value, &context_path, out);
1061            }
1062        }
1063    }
1064}
1065
1066fn validate_condition_value(value: &Value, path: &str, out: &mut Vec<PolicyFinding>) {
1067    match value {
1068        Value::String(_) | Value::Number(_) | Value::Bool(_) => {}
1069        Value::Array(items) => {
1070            for (index, item) in items.iter().enumerate() {
1071                if is_resolution_marker(item) {
1072                    continue;
1073                }
1074                if !item.is_string() {
1075                    out.push(PolicyFinding {
1076                        path: format!("{}.{}", path, index),
1077                        message: format!("{} is not of type 'string'", describe_value(item)),
1078                    });
1079                }
1080            }
1081        }
1082        _ => out.push(PolicyFinding {
1083            path: path.to_string(),
1084            message: format!("{} is not of type 'boolean', 'number', 'string', 'array'", describe_value(value)),
1085        }),
1086    }
1087}
1088
1089fn validate_null_condition_value(value: &Value, path: &str, out: &mut Vec<PolicyFinding>) {
1090    let is_boolean = |candidate: &Value| candidate.is_boolean() || matches!(candidate.as_str(), Some("true" | "false"));
1091    match value {
1092        Value::Array(items) => {
1093            for (index, item) in items.iter().enumerate() {
1094                if is_resolution_marker(item) {
1095                    continue;
1096                }
1097                if !is_boolean(item) {
1098                    out.push(PolicyFinding {
1099                        path: format!("{}.{}", path, index),
1100                        message: format!("{} is not one of ['true', 'false', true, false]", describe_value(item)),
1101                    });
1102                }
1103            }
1104        }
1105        candidate if is_boolean(candidate) => {}
1106        _ => out.push(PolicyFinding {
1107            path: path.to_string(),
1108            message: format!("{} is not one of ['true', 'false', true, false]", describe_value(value)),
1109        }),
1110    }
1111}
1112
1113fn check_string_or_string_list_with_min_items(
1114    value: &Value,
1115    path: &str,
1116    substituted: &SubstitutedPathIndex<'_>,
1117    out: &mut Vec<PolicyFinding>,
1118) {
1119    if path_is_intrinsic_generated(path, substituted) {
1120        return;
1121    }
1122    match value {
1123        Value::String(_) => {}
1124        Value::Array(items) => {
1125            if items.is_empty() {
1126                out.push(PolicyFinding {
1127                    path: path.to_string(),
1128                    message: "[] is too short (minimum 1 item)".to_string(),
1129                });
1130                return;
1131            }
1132            for (index, item) in items.iter().enumerate() {
1133                let item_path = format!("{}.{}", path, index);
1134                if is_resolution_marker(item) || path_is_intrinsic_generated(&item_path, substituted) {
1135                    continue;
1136                }
1137                if !item.is_string() {
1138                    out.push(PolicyFinding {
1139                        path: item_path,
1140                        message: format!("{} is not of type 'string'", describe_value(item)),
1141                    });
1142                }
1143            }
1144        }
1145        other => out.push(PolicyFinding {
1146            path: path.to_string(),
1147            message: format!("{} is not of type 'string', 'array'", describe_value(other)),
1148        }),
1149    }
1150}
1151
1152/// Exactly one of `pair` must be present: zero present is reported at the
1153/// statement, more than one at each present member.
1154fn check_required_xor(
1155    obj: &serde_json::Map<String, Value>,
1156    path: &str,
1157    pair: &[&str; 2],
1158    out: &mut Vec<PolicyFinding>,
1159) {
1160    let present: Vec<&str> = pair.iter().copied().filter(|key| non_null_property(obj, key).is_some()).collect();
1161    let message = format!("Only one of {} is a required property", render_str_list(pair));
1162    if present.is_empty() {
1163        out.push(PolicyFinding { path: path.to_string(), message });
1164    } else if present.len() > 1 {
1165        for key in present {
1166            out.push(PolicyFinding { path: format!("{}.{}", path, key), message: message.clone() });
1167        }
1168    }
1169}
1170
1171fn check_resource_string_or_list(
1172    value: &Value,
1173    path: &str,
1174    substituted: &SubstitutedPathIndex<'_>,
1175    out: &mut Vec<PolicyFinding>,
1176) {
1177    if path_is_intrinsic_generated(path, substituted) {
1178        return;
1179    }
1180    // Recurse into conditional branches so every reachable static value
1181    // is validated — a conditional marker wrapping a Resource/NotResource
1182    // field must not suppress validation of both branches entirely.
1183    if let Some(obj) = value.as_object()
1184        && obj.contains_key(MARKER_CONDITIONAL)
1185    {
1186        if let Some(if_true) = obj.get(MARKER_IF_TRUE) {
1187            check_resource_string_or_list(if_true, path, substituted, out);
1188        }
1189        if let Some(if_false) = obj.get(MARKER_IF_FALSE) {
1190            check_resource_string_or_list(if_false, path, substituted, out);
1191        }
1192        return;
1193    }
1194    if is_resolution_marker(value) {
1195        return;
1196    }
1197    match value {
1198        Value::String(resource) => check_resource_arn(resource, path, out),
1199        Value::Array(items) => {
1200            for (index, item) in items.iter().enumerate() {
1201                let item_path = format!("{}.{}", path, index);
1202                if path_is_intrinsic_generated(&item_path, substituted) {
1203                    continue;
1204                }
1205                if item.as_object().is_some_and(|object| object.contains_key(MARKER_CONDITIONAL)) {
1206                    check_resource_string_or_list(item, &item_path, substituted, out);
1207                    continue;
1208                }
1209                if is_resolution_marker(item) {
1210                    continue;
1211                }
1212                match item {
1213                    Value::String(resource) => check_resource_arn(resource, &item_path, out),
1214                    other => out.push(PolicyFinding {
1215                        path: item_path,
1216                        message: format!("{} is not of type 'string'", describe_value(other)),
1217                    }),
1218                }
1219            }
1220        }
1221        other => out.push(PolicyFinding {
1222            path: path.to_string(),
1223            message: format!("{} is not of type 'string', 'array'", describe_value(other)),
1224        }),
1225    }
1226}
1227
1228/// Validates an ARN value from a Resource/NotResource field.
1229fn check_resource_arn(resource: &str, path: &str, out: &mut Vec<PolicyFinding>) {
1230    if resource_arn_matches(resource) {
1231        return;
1232    }
1233    out.push(PolicyFinding {
1234        path: path.to_string(),
1235        message: format!("'{}' does not match '{}'", resource, RESOURCE_ARN_PATTERN),
1236    });
1237}
1238
1239/// The compact rendering of a JSON value for a diagnostic message: strings are
1240/// quoted, composites are shown as JSON.
1241fn describe_value(value: &Value) -> String {
1242    match value {
1243        Value::String(s) => format!("'{}'", s),
1244        other => other.to_string(),
1245    }
1246}
1247
1248#[cfg(test)]
1249mod tests {
1250    use super::*;
1251    use serde_json::json;
1252
1253    fn findings(doc: serde_json::Value) -> Vec<(String, String)> {
1254        let none = std::collections::HashSet::new();
1255        validate_identity_policy(&doc, &none).into_iter().map(|f| (f.path, f.message)).collect()
1256    }
1257
1258    /// A value substituted from an intrinsic is not judged on its content: the
1259    /// author wrote a reference, not the substituted text.
1260    #[test]
1261    fn substituted_paths_are_not_judged_on_content() {
1262        let doc = json!({"Statement": [{"Effect": "Allow", "Action": "s3:*", "Resource": [""]}]});
1263        let substituted: std::collections::HashSet<String> =
1264            [String::from("Statement.0.Resource.0")].into_iter().collect();
1265        assert!(
1266            validate_identity_policy(&doc, &substituted).is_empty(),
1267            "a parameter-substituted resource entry must not be held to the ARN pattern"
1268        );
1269    }
1270
1271    #[test]
1272    fn well_formed_document_is_clean() {
1273        let doc = json!({
1274            "Version": "2012-10-17",
1275            "Statement": [{
1276                "Sid": "AllowRead",
1277                "Effect": "Allow",
1278                "Action": ["s3:GetObject"],
1279                "Resource": "arn:aws:s3:::my-bucket/*"
1280            }]
1281        });
1282        assert!(findings(doc).is_empty());
1283    }
1284
1285    #[test]
1286    fn independent_valid_action_item_conditions_do_not_materialize_policy_worlds() {
1287        let parameters: serde_json::Map<String, Value> =
1288            (0..8).map(|index| (format!("P{index}"), json!({"Type": "String"}))).collect();
1289        let conditions: serde_json::Map<String, Value> = (0..8)
1290            .map(|index| (format!("C{index}"), json!({"Fn::Equals": [{"Ref": format!("P{index}")}, "true"]})))
1291            .collect();
1292        let actions: Vec<Value> =
1293            (0..8).map(|index| json!({"Fn::If": [format!("C{index}"), "s3:GetObject", "s3:PutObject"]})).collect();
1294        let template = json!({
1295            "Parameters": parameters,
1296            "Conditions": conditions,
1297            "Resources": {
1298                "Policy": {
1299                    "Type": "AWS::IAM::ManagedPolicy",
1300                    "Properties": {
1301                        "PolicyDocument": {
1302                            "Statement": [{"Effect": "Allow", "Action": actions, "Resource": "*"}]
1303                        }
1304                    }
1305                }
1306            }
1307        });
1308        let model = SemanticModel::from_bytes(&serde_json::to_vec(&template).unwrap()).unwrap();
1309
1310        let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
1311
1312        assert!(found.is_empty());
1313        assert_eq!(
1314            model.scenario_combinations_used(),
1315            0,
1316            "validation-invariant item branches must not produce a Cartesian set of policy documents"
1317        );
1318    }
1319
1320    #[test]
1321    fn resource_arn_validation_accepts_representative_forms() {
1322        for resource in [
1323            "arn:aws:s3:::my-bucket/*",
1324            "arn:aws:iam::aws:policy/ReadOnlyAccess",
1325            "arn:aws:lambda:us-east-1:123456789012:function:worker",
1326            "arn:aws-us-gov:service:region:*:resource",
1327        ] {
1328            assert!(resource_arn_matches(resource), "expected valid resource ARN: {resource}");
1329        }
1330        for resource in [
1331            "not-an-arn",
1332            "arn:example:s3:::bucket",
1333            "arn:aws::region:123456789012:resource",
1334            "arn:aws:s3::named-account:bucket",
1335            "arn:aws:s3:::",
1336        ] {
1337            assert!(!resource_arn_matches(resource), "expected invalid resource ARN: {resource}");
1338        }
1339    }
1340
1341    #[test]
1342    fn missing_statement_is_required() {
1343        assert_eq!(
1344            findings(json!({"Version": "2012-10-17"})),
1345            [(String::new(), "'Statement' is a required property".into())]
1346        );
1347    }
1348
1349    #[test]
1350    fn invalid_version_and_unknown_document_key() {
1351        let doc = json!({"Version": "blah", "BadProperty": 1, "Statement": []});
1352        let found = findings(doc);
1353        assert!(found.contains(&("Version".into(), "'blah' is not one of ['2008-10-17', '2012-10-17']".into())));
1354        assert!(found.contains(&(
1355            "BadProperty".into(),
1356            "Additional properties are not allowed ('BadProperty' was unexpected)".into()
1357        )));
1358    }
1359
1360    #[test]
1361    fn statement_wrong_type_is_reported() {
1362        assert_eq!(
1363            findings(json!({"Statement": "Test"})),
1364            [("Statement".into(), "'Test' is not of type 'object', 'array'".into())]
1365        );
1366    }
1367
1368    #[test]
1369    fn statement_structure_defects_are_reported() {
1370        let doc = json!({
1371            "Version": "2012-10-17",
1372            "Statement": [{
1373                "Effect": "NotAllow",
1374                "Action": "s3:*",
1375                "Principal": {"AWS": "*"},
1376                "Resource": [{"Effect": "Allow"}, "not-an-arn"]
1377            }]
1378        });
1379        let found = findings(doc);
1380        assert!(found.contains(&("Statement.0.Effect".into(), "'NotAllow' is not one of ['Allow', 'Deny']".into())));
1381        assert!(found.contains(&(
1382            "Statement.0.Principal".into(),
1383            "Additional properties are not allowed ('Principal' was unexpected)".into()
1384        )));
1385        assert!(
1386            found.contains(&("Statement.0.Resource.0".into(), "{\"Effect\":\"Allow\"} is not of type 'string'".into()))
1387        );
1388        assert!(
1389            found.iter().any(|(p, m)| p == "Statement.0.Resource.1" && m.starts_with("'not-an-arn' does not match"))
1390        );
1391    }
1392
1393    #[test]
1394    fn missing_effect_and_action_xor() {
1395        let doc = json!({"Statement": [{"Resource": "*"}]});
1396        let found = findings(doc);
1397        assert!(found.contains(&("Statement.0".into(), "'Effect' is a required property".into())));
1398        assert!(
1399            found
1400                .contains(&("Statement.0".into(), "Only one of ['Action', 'NotAction'] is a required property".into()))
1401        );
1402    }
1403
1404    #[test]
1405    fn null_action_without_not_action_reports_required_pair() {
1406        let doc = json!({"Statement": [{"Effect": "Allow", "Action": null, "Resource": "*"}]});
1407        assert_eq!(
1408            findings(doc),
1409            [("Statement.0".into(), "Only one of ['Action', 'NotAction'] is a required property".into())]
1410        );
1411    }
1412
1413    #[test]
1414    fn null_resource_without_not_resource_reports_required_pair() {
1415        let doc = json!({"Statement": [{"Effect": "Allow", "Action": "s3:GetObject", "Resource": null}]});
1416        assert_eq!(
1417            findings(doc),
1418            [("Statement.0".into(), "Only one of ['Resource', 'NotResource'] is a required property".into())]
1419        );
1420    }
1421
1422    #[test]
1423    fn concrete_xor_member_is_valid_when_other_member_is_null() {
1424        let doc = json!({
1425            "Statement": [{
1426                "Effect": "Allow",
1427                "Action": null,
1428                "NotAction": "s3:DeleteObject",
1429                "Resource": null,
1430                "NotResource": "*"
1431            }]
1432        });
1433        assert!(findings(doc).is_empty());
1434    }
1435
1436    #[test]
1437    fn both_null_xor_members_report_each_required_pair_once() {
1438        let doc = json!({
1439            "Statement": [{
1440                "Effect": "Allow",
1441                "Action": null,
1442                "NotAction": null,
1443                "Resource": null,
1444                "NotResource": null
1445            }]
1446        });
1447        assert_eq!(
1448            findings(doc),
1449            [
1450                ("Statement.0".into(), "Only one of ['Action', 'NotAction'] is a required property".into()),
1451                ("Statement.0".into(), "Only one of ['Resource', 'NotResource'] is a required property".into())
1452            ]
1453        );
1454    }
1455
1456    #[test]
1457    fn null_effect_and_statement_are_reported_as_missing() {
1458        assert_eq!(
1459            findings(json!({"Statement": [{"Effect": null, "Action": "s3:GetObject", "Resource": "*"}]})),
1460            [("Statement.0".into(), "'Effect' is a required property".into())]
1461        );
1462        assert_eq!(
1463            findings(json!({"Statement": null})),
1464            [(String::new(), "'Statement' is a required property".into())]
1465        );
1466    }
1467
1468    #[test]
1469    fn both_action_and_notaction_flagged_at_each_member() {
1470        let doc =
1471            json!({"Statement": [{"Effect": "Allow", "Action": "s3:*", "NotAction": "s3:Get*", "Resource": "*"}]});
1472        let found = findings(doc);
1473        assert!(found.contains(&(
1474            "Statement.0.Action".into(),
1475            "Only one of ['Action', 'NotAction'] is a required property".into()
1476        )));
1477        assert!(found.contains(&(
1478            "Statement.0.NotAction".into(),
1479            "Only one of ['Action', 'NotAction'] is a required property".into()
1480        )));
1481    }
1482
1483    #[test]
1484    fn substitution_placeholders_are_not_judged_against_the_arn_pattern() {
1485        let doc = json!({"Statement": [{"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:iam::${AWS::AccountId}:user/x"}]});
1486        assert!(findings(doc).is_empty());
1487    }
1488
1489    #[test]
1490    fn marker_bearing_field_is_skipped_when_literal_siblings_are_valid() {
1491        let doc = json!({"Statement": [{"Effect": "Allow", "Action": {"__dynamic": "unknown"}, "Resource": "*"}]});
1492        assert!(findings(doc).is_empty(), "a document carrying resolution markers must not be judged");
1493    }
1494
1495    /// Dynamic subtrees are skipped per field, but sibling literals
1496    /// are still validated.
1497    #[test]
1498    fn dynamic_subtrees_are_skipped_but_sibling_literals_are_validated() {
1499        let doc = json!({
1500            "Statement": [{
1501                "Effect": "Invalid",
1502                "Action": {"__dynamic": "unknown"},
1503                "Resource": "*"
1504            }]
1505        });
1506        let found = findings(doc);
1507        assert!(
1508            found.iter().any(|(p, m)| p == "Statement.0.Effect" && m.contains("'Invalid'")),
1509            "an invalid literal Effect alongside dynamic Action must still be reported: {:?}",
1510            found
1511        );
1512    }
1513
1514    #[test]
1515    fn wholly_dynamic_document_is_skipped() {
1516        assert!(findings(json!({"__dynamic": "unknown policy"})).is_empty());
1517    }
1518
1519    /// A dynamic array item does not hide invalid literal siblings.
1520    #[test]
1521    fn dynamic_array_item_does_not_hide_invalid_literal_sibling() {
1522        let doc = json!({
1523            "Statement": [{
1524                "Effect": "Allow",
1525                "Action": [{"__dynamic": "unknown action"}, 7],
1526                "Resource": "*"
1527            }]
1528        });
1529        let found = findings(doc);
1530        assert!(
1531            found
1532                .iter()
1533                .any(|(path, message)| { path == "Statement.0.Action.1" && message.contains("not of type 'string'") }),
1534            "a dynamic array item must not hide an invalid literal sibling: {found:?}"
1535        );
1536    }
1537
1538    #[test]
1539    fn whole_document_with_markers_only_skips_marked_subtrees() {
1540        let doc = json!({
1541            "Version": "invalid",
1542            "Statement": [{
1543                "Effect": "Allow",
1544                "Action": {"__dynamic": "ref"},
1545                "Resource": "*"
1546            }]
1547        });
1548        let found = findings(doc);
1549        assert!(
1550            found.iter().any(|(p, _)| p == "Version"),
1551            "literal Version must still be validated even when Statement has markers"
1552        );
1553    }
1554
1555    /// Empty Action/NotAction arrays report a minimum-size violation.
1556    #[test]
1557    fn empty_action_array_reports_min_items() {
1558        let doc = json!({"Statement": [{"Effect": "Allow", "Action": [], "Resource": "*"}]});
1559        let found = findings(doc);
1560        assert!(
1561            found.iter().any(|(p, m)| p == "Statement.0.Action" && m.contains("too short")),
1562            "empty Action array must report minItems violation: {:?}",
1563            found
1564        );
1565    }
1566
1567    #[test]
1568    fn empty_not_action_array_reports_min_items() {
1569        let doc = json!({"Statement": [{"Effect": "Allow", "NotAction": [], "Resource": "*"}]});
1570        let found = findings(doc);
1571        assert!(
1572            found.iter().any(|(p, m)| p == "Statement.0.NotAction" && m.contains("too short")),
1573            "empty NotAction array must report minItems violation: {:?}",
1574            found
1575        );
1576    }
1577
1578    /// Sid values must be unique across statements.
1579    #[test]
1580    fn duplicate_sid_across_statements_is_reported() {
1581        let doc = json!({
1582            "Statement": [
1583                {"Sid": "ReadOnly", "Effect": "Allow", "Action": "s3:Get*", "Resource": "*"},
1584                {"Sid": "ReadOnly", "Effect": "Deny", "Action": "s3:Put*", "Resource": "*"}
1585            ]
1586        });
1587        let found = findings(doc);
1588        assert!(
1589            found.iter().any(|(p, m)| p == "Statement.1.Sid" && m.contains("duplicate")),
1590            "duplicate Sid must be reported: {:?}",
1591            found
1592        );
1593    }
1594
1595    #[test]
1596    fn identity_policy_id_is_rejected_regardless_of_scalar_type() {
1597        for id in [json!("my-policy"), json!(123), json!(true)] {
1598            let doc = json!({"Id": id, "Statement": [{"Effect": "Allow", "Action": "s3:*", "Resource": "*"}]});
1599            assert_eq!(
1600                findings(doc),
1601                [("Id".to_string(), "Additional properties are not allowed ('Id' was unexpected)".to_string())],
1602                "identity-policy Id must be rejected regardless of value type"
1603            );
1604        }
1605    }
1606
1607    /// Condition operators are validated.
1608    #[test]
1609    fn valid_condition_operators_are_accepted() {
1610        let doc = json!({
1611            "Statement": [{
1612                "Effect": "Allow",
1613                "Action": "s3:*",
1614                "Resource": "*",
1615                "Condition": {
1616                    "StringEquals": {"aws:RequestedRegion": ["us-east-1"]},
1617                    "ForAnyValue:ArnLike": {"aws:PrincipalOrgPaths": ["o-*/r-*/ou-*"]},
1618                    "Null": {"aws:TokenIssueTime": ["true"]}
1619                }
1620            }]
1621        });
1622        assert!(findings(doc).is_empty());
1623    }
1624
1625    #[test]
1626    fn invalid_condition_operator_is_reported() {
1627        let doc = json!({
1628            "Statement": [{
1629                "Effect": "Allow",
1630                "Action": "s3:*",
1631                "Resource": "*",
1632                "Condition": {
1633                    "InvalidOperator": {"key": ["value"]}
1634                }
1635            }]
1636        });
1637        let found = findings(doc);
1638        assert!(
1639            found.iter().any(|(_, m)| m.contains("not a valid IAM condition operator")),
1640            "invalid operator must be reported: {:?}",
1641            found
1642        );
1643    }
1644
1645    #[test]
1646    fn condition_operator_value_must_be_object() {
1647        let doc = json!({
1648            "Statement": [{
1649                "Effect": "Allow",
1650                "Action": "s3:*",
1651                "Resource": "*",
1652                "Condition": {
1653                    "StringEquals": "not-an-object"
1654                }
1655            }]
1656        });
1657        let found = findings(doc);
1658        assert!(
1659            found.iter().any(|(_, m)| m.contains("is not of type 'object'")),
1660            "non-object operator value must be reported: {:?}",
1661            found
1662        );
1663    }
1664
1665    #[test]
1666    fn condition_context_values_follow_operator_schema() {
1667        let doc = json!({
1668            "Statement": [{
1669                "Effect": "Allow",
1670                "Action": "s3:*",
1671                "Resource": "*",
1672                "Condition": {
1673                    "StringEquals": {
1674                        "aws:RequestedRegion": ["us-east-1", 7],
1675                        "aws:PrincipalTag/dynamic": {"__dynamic": "unknown"}
1676                    },
1677                    "Null": {"aws:TokenIssueTime": "not-a-boolean"}
1678                }
1679            }]
1680        });
1681        let found = findings(doc);
1682        assert!(
1683            found
1684                .iter()
1685                .any(|(path, message)| path.ends_with("aws:RequestedRegion.1")
1686                    && message.contains("not of type 'string'")),
1687            "non-string array member must be reported: {:?}",
1688            found
1689        );
1690        assert!(
1691            found.iter().any(|(path, message)| path.ends_with("aws:TokenIssueTime") && message.contains("not one of")),
1692            "Null values must be boolean spellings: {:?}",
1693            found
1694        );
1695        assert!(
1696            found.iter().all(|(path, _)| !path.contains("aws:PrincipalTag/dynamic")),
1697            "a dynamic context value must not suppress or create sibling findings: {:?}",
1698            found
1699        );
1700    }
1701
1702    #[test]
1703    fn sid_must_be_non_empty() {
1704        let doc = json!({
1705            "Statement": [{"Sid": "", "Effect": "Allow", "Action": "s3:*", "Resource": "*"}]
1706        });
1707        let found = findings(doc);
1708        assert!(
1709            found.iter().any(|(path, message)| path == "Statement.0.Sid" && message.contains("does not match")),
1710            "empty Sid must be rejected: {:?}",
1711            found
1712        );
1713    }
1714
1715    /// An ancestor in the substituted set suppresses descendant checks.
1716    #[test]
1717    fn substituted_ancestor_path_suppresses_descendant_check() {
1718        let doc = json!({"Statement": [{"Effect": "Allow", "Action": "s3:*", "Resource": ["not-an-arn"]}]});
1719        let substituted: HashSet<String> = [String::from("Statement.0.Resource")].into_iter().collect();
1720        let found = validate_identity_policy(&doc, &substituted);
1721        assert!(
1722            !found.iter().any(|f| f.path.starts_with("Statement.0.Resource")),
1723            "descendant of substituted path must be suppressed: {:?}",
1724            found
1725        );
1726    }
1727
1728    /// A descendant in the substituted set, such as one from Fn::Join,
1729    /// recognizes that the ancestor value was generated by the intrinsic.
1730    #[test]
1731    fn substituted_descendant_path_suppresses_generated_ancestor_check() {
1732        let doc = json!({"Statement": [{"Effect": "Allow", "Action": "s3:*", "Resource": "not-an-arn"}]});
1733        let substituted: HashSet<String> = [String::from("Statement.0.Resource.Fn::Join.1.0")].into_iter().collect();
1734        let found = validate_identity_policy(&doc, &substituted);
1735        assert!(
1736            !found.iter().any(|finding| finding.path == "Statement.0.Resource"),
1737            "an intrinsic nested beneath the checked path generated the ancestor value: {:?}",
1738            found
1739        );
1740    }
1741
1742    #[test]
1743    fn substituted_path_index_respects_component_boundaries() {
1744        let substituted: HashSet<String> = [
1745            "Statement.0.Action.0",
1746            "Statement.0.Resource-qualifier",
1747            "Statement.0.Resource.Fn::Join.1.0",
1748            "Statement.10.Condition",
1749        ]
1750        .into_iter()
1751        .map(String::from)
1752        .collect();
1753        let index = SubstitutedPathIndex::new(&substituted);
1754
1755        // A numeric list descendant does NOT cover the parent array/scalar path.
1756        assert!(!index.covers("Statement.0.Action"));
1757        // Exact match and descendants of exact matches are still covered.
1758        assert!(index.covers("Statement.0.Action.0"));
1759        assert!(index.covers("Statement.0.Action.0.Value"));
1760        // A non-numeric (intrinsic) descendant DOES cover the ancestor scalar.
1761        assert!(index.covers("Statement.0.Resource"));
1762        assert!(!index.covers("Statement.0.Condition"));
1763        assert!(!index.covers("Statement.0.Actionable"));
1764        assert!(!index.covers("Statement.1"));
1765    }
1766
1767    /// A large substituted-path set must suppress only the corresponding
1768    /// generated values, without hiding invalid literal siblings.
1769    #[test]
1770    fn many_substituted_policy_paths_preserve_literal_findings() {
1771        const STATEMENT_COUNT: usize = 2_000;
1772        let mut statements = Vec::with_capacity(STATEMENT_COUNT);
1773        let mut substituted = HashSet::with_capacity(STATEMENT_COUNT / 2);
1774        let mut expected_paths = HashSet::with_capacity(STATEMENT_COUNT / 2);
1775
1776        for index in 0..STATEMENT_COUNT {
1777            statements.push(json!({
1778                "Effect": "Allow",
1779                "Action": "s3:GetObject",
1780                "Resource": "not-an-arn"
1781            }));
1782            let resource_path = format!("Statement.{index}.Resource");
1783            if index.is_multiple_of(2) {
1784                substituted.insert(resource_path);
1785            } else {
1786                expected_paths.insert(resource_path);
1787            }
1788        }
1789
1790        let doc = json!({"Statement": statements});
1791        let found = validate_identity_policy(&doc, &substituted);
1792        let actual_paths: HashSet<String> = found.iter().map(|finding| finding.path.clone()).collect();
1793
1794        assert_eq!(found.len(), expected_paths.len());
1795        assert_eq!(actual_paths, expected_paths);
1796        assert!(found.iter().all(|finding| finding.message.contains("does not match")));
1797    }
1798
1799    /// A dynamic Action must not suppress Effect validation.
1800    #[test]
1801    fn dynamic_action_does_not_suppress_effect_validation() {
1802        let doc = json!({
1803            "Statement": [{
1804                "Effect": "BadValue",
1805                "Action": {"__dynamic": "generated by Fn::Join"},
1806                "Resource": "*"
1807            }]
1808        });
1809        let found = findings(doc);
1810        assert!(
1811            found.iter().any(|(p, m)| p == "Statement.0.Effect" && m.contains("'BadValue'")),
1812            "invalid Effect must still be reported when Action is dynamic: {:?}",
1813            found
1814        );
1815    }
1816
1817    // --- Substituted numeric list descendant must not suppress literal siblings ---
1818
1819    /// A substituted item at a numeric list index must not suppress validation
1820    /// of other literal items at sibling indices.
1821    #[test]
1822    fn substituted_numeric_list_descendant_does_not_suppress_literal_siblings() {
1823        let doc = json!({"Statement": [{"Effect": "Allow", "Action": "s3:*", "Resource": ["not-an-arn", "also-bad"]}]});
1824        let substituted: HashSet<String> = [String::from("Statement.0.Resource.1")].into_iter().collect();
1825        let found = validate_identity_policy(&doc, &substituted);
1826        assert!(
1827            found.iter().any(|f| f.path == "Statement.0.Resource.0" && f.message.contains("does not match")),
1828            "a substituted numeric sibling must not suppress validation of literal siblings: {:?}",
1829            found
1830        );
1831        assert!(
1832            !found.iter().any(|f| f.path == "Statement.0.Resource.1"),
1833            "the substituted item itself must be suppressed: {:?}",
1834            found
1835        );
1836    }
1837
1838    /// A non-numeric descendant (intrinsic path) still suppresses the ancestor.
1839    #[test]
1840    fn non_numeric_descendant_still_suppresses_ancestor() {
1841        let doc = json!({"Statement": [{"Effect": "Allow", "Action": "s3:*", "Resource": "not-an-arn"}]});
1842        let substituted: HashSet<String> = [String::from("Statement.0.Resource.Fn::Sub.0")].into_iter().collect();
1843        let found = validate_identity_policy(&doc, &substituted);
1844        assert!(
1845            !found.iter().any(|f| f.path == "Statement.0.Resource"),
1846            "an intrinsic descendant path must suppress the ancestor scalar: {:?}",
1847            found
1848        );
1849    }
1850
1851    // --- Conditional Resource/NotResource branches are validated ---
1852
1853    /// A conditional marker wrapping a Resource field must validate both
1854    /// branches for static values.
1855    #[test]
1856    fn conditional_resource_branches_are_validated() {
1857        let doc = json!({
1858            "Statement": [{
1859                "Effect": "Allow",
1860                "Action": "s3:*",
1861                "Resource": {
1862                    "__conditional": "IsProd",
1863                    "__if_true": "not-an-arn",
1864                    "__if_false": "arn:aws:s3:::bucket/*"
1865                }
1866            }]
1867        });
1868        let found = findings(doc);
1869        assert!(
1870            found.iter().any(|(p, m)| p == "Statement.0.Resource" && m.contains("does not match")),
1871            "invalid ARN in a conditional branch must be reported: {:?}",
1872            found
1873        );
1874    }
1875
1876    /// Both branches of a conditional Resource are validated independently.
1877    #[test]
1878    fn conditional_resource_both_branches_valid_is_clean() {
1879        let doc = json!({
1880            "Statement": [{
1881                "Effect": "Allow",
1882                "Action": "s3:*",
1883                "Resource": {
1884                    "__conditional": "IsProd",
1885                    "__if_true": "arn:aws:s3:::prod-bucket/*",
1886                    "__if_false": "arn:aws:s3:::dev-bucket/*"
1887                }
1888            }]
1889        });
1890        assert!(findings(doc).is_empty());
1891    }
1892
1893    /// A conditional with a valid array branch and an invalid scalar branch.
1894    #[test]
1895    fn conditional_resource_invalid_branch_among_valid_array_reported() {
1896        let doc = json!({
1897            "Statement": [{
1898                "Effect": "Allow",
1899                "Action": "s3:*",
1900                "Resource": {
1901                    "__conditional": "UseWildcard",
1902                    "__if_true": "*",
1903                    "__if_false": "bad-arn"
1904                }
1905            }]
1906        });
1907        let found = findings(doc);
1908        assert!(
1909            found.iter().any(|(p, m)| p == "Statement.0.Resource" && m.contains("does not match")),
1910            "invalid false-branch must be reported: {:?}",
1911            found
1912        );
1913    }
1914
1915    // --- ARN skeleton validation with ${...} variables ---
1916
1917    /// Valid CloudFormation placeholders in partition and account are accepted.
1918    #[test]
1919    fn arn_with_cfn_partition_placeholder_is_valid() {
1920        let doc = json!({
1921            "Statement": [{
1922                "Effect": "Allow",
1923                "Action": "iam:*",
1924                "Resource": "arn:${AWS::Partition}:iam::${AWS::AccountId}:user/x"
1925            }]
1926        });
1927        assert!(findings(doc).is_empty());
1928    }
1929
1930    /// Trailing IAM policy variables are accepted.
1931    #[test]
1932    fn arn_with_trailing_iam_variable_is_valid() {
1933        let doc = json!({
1934            "Statement": [{
1935                "Effect": "Allow",
1936                "Action": "s3:*",
1937                "Resource": "arn:aws:s3:::bucket/${aws:username}/*"
1938            }]
1939        });
1940        assert!(findings(doc).is_empty());
1941    }
1942
1943    /// A wildcard partition remains valid when the resource portion uses an IAM variable.
1944    #[test]
1945    fn arn_with_wildcard_partition_and_trailing_iam_variable_is_valid() {
1946        let doc = json!({
1947            "Statement": [{
1948                "Effect": "Allow",
1949                "Action": "s3:*",
1950                "Resource": "arn:*:s3:::bucket/${aws:username}"
1951            }]
1952        });
1953        assert!(findings(doc).is_empty());
1954    }
1955
1956    /// Wildcard service `arn:aws:*:...` is rejected even with placeholders.
1957    #[test]
1958    fn arn_with_wildcard_service_is_rejected() {
1959        let doc = json!({
1960            "Statement": [{
1961                "Effect": "Allow",
1962                "Action": "s3:*",
1963                "Resource": "arn:aws:*:us-east-1:${AWS::AccountId}:thing"
1964            }]
1965        });
1966        let found = findings(doc);
1967        assert!(
1968            found.iter().any(|(p, m)| p == "Statement.0.Resource" && m.contains("does not match")),
1969            "wildcard service must be rejected: {:?}",
1970            found
1971        );
1972    }
1973
1974    /// Literal wildcard `*` (all resources) remains valid.
1975    #[test]
1976    fn literal_wildcard_star_remains_valid() {
1977        let doc = json!({
1978            "Statement": [{"Effect": "Allow", "Action": "s3:*", "Resource": "*"}]
1979        });
1980        assert!(findings(doc).is_empty());
1981    }
1982
1983    /// ARN with valid iso partition family is accepted.
1984    #[test]
1985    fn arn_with_iso_partition_is_valid() {
1986        assert!(resource_arn_matches("arn:aws-iso:s3:::bucket/key"));
1987        assert!(resource_arn_matches("arn:aws-iso-b:kms:us-isob-east-1:123456789012:key/id"));
1988    }
1989
1990    /// ARN partition wildcards are accepted in both complete and partial patterns.
1991    #[test]
1992    fn resource_arn_accepts_wildcard_partition() {
1993        for resource in [
1994            "arn:*:s3:::bucket",
1995            "arn:*:iam::123456789012:role/test",
1996            "arn:aw?:s3:::bucket",
1997            "arn:aws-*:kms:us-east-1:123456789012:key/test",
1998        ] {
1999            assert!(resource_arn_matches(resource), "partition wildcard must be accepted in {resource}");
2000        }
2001    }
2002
2003    /// ARN with wildcard service is rejected by resource_arn_matches.
2004    #[test]
2005    fn resource_arn_rejects_wildcard_service() {
2006        assert!(!resource_arn_matches("arn:aws:*:us-east-1:123456789012:thing"));
2007        assert!(!resource_arn_matches("arn:aws:*:::bucket"));
2008        assert!(!resource_arn_matches("arn:aws:s?:::bucket"));
2009    }
2010
2011    /// Malformed prefix before placeholder is rejected.
2012    #[test]
2013    fn arn_skeleton_rejects_malformed_prefix() {
2014        let doc = json!({
2015            "Statement": [{
2016                "Effect": "Allow",
2017                "Action": "s3:*",
2018                "Resource": "not-arn:${AWS::Partition}:s3:::bucket"
2019            }]
2020        });
2021        let found = findings(doc);
2022        assert!(
2023            found.iter().any(|(p, m)| p == "Statement.0.Resource" && m.contains("does not match")),
2024            "malformed prefix must be rejected: {:?}",
2025            found
2026        );
2027    }
2028
2029    /// An incomplete identity-policy ARN (just `arn:partition:service`) with a
2030    /// trailing variable is valid.
2031    #[test]
2032    fn incomplete_arn_with_trailing_variable_is_valid() {
2033        let doc = json!({
2034            "Statement": [{
2035                "Effect": "Allow",
2036                "Action": "s3:*",
2037                "Resource": "arn:aws:s3:${AWS::Region}:${AWS::AccountId}:bucket"
2038            }]
2039        });
2040        assert!(findings(doc).is_empty());
2041    }
2042
2043    /// The exact incomplete ARN form is valid because IAM wildcard-completes
2044    /// omitted trailing fields.
2045    #[test]
2046    fn exact_incomplete_arn_is_valid() {
2047        assert!(resource_arn_matches("arn:aws:sqs"));
2048    }
2049
2050    /// An IAM policy variable cannot make a malformed non-ARN prefix valid.
2051    #[test]
2052    fn malformed_prefix_with_iam_variable_is_rejected() {
2053        assert!(!resource_arn_matches("not-an-arn-${aws:username}"));
2054    }
2055
2056    /// IAM policy variables are valid only in the trailing resource portion.
2057    #[test]
2058    fn iam_variables_in_arn_prefix_fields_are_rejected() {
2059        for resource in [
2060            "arn:${aws:username}:s3:::bucket/key",
2061            "arn:aws:${aws:username}:::bucket/key",
2062            "arn:aws:s3:${aws:username}:123456789012:bucket/key",
2063            "arn:aws:s3:us-east-1:${aws:username}:bucket/key",
2064        ] {
2065            assert!(!resource_arn_matches(resource), "expected invalid resource ARN: {resource}");
2066        }
2067    }
2068
2069    #[test]
2070    fn unbalanced_placeholder_is_rejected() {
2071        assert!(!resource_arn_matches("arn:aws:s3:::bucket/${aws:username"));
2072    }
2073
2074    /// Scenario-aware validation must ignore an intrinsic-generated branch
2075    /// without allowing it to suppress an invalid literal sibling branch.
2076    #[test]
2077    fn scenario_validation_checks_literal_branch_beside_intrinsic_branch() {
2078        let template = r#"
2079Conditions:
2080  UseGenerated: !Equals [!Ref AWS::Region, us-east-1]
2081Resources:
2082  Policy:
2083    Type: AWS::IAM::ManagedPolicy
2084    Properties:
2085      ManagedPolicyName: test
2086      PolicyDocument:
2087        Version: '2012-10-17'
2088        Statement:
2089          - Effect: Allow
2090            Action: s3:GetObject
2091            Resource: !If
2092              - UseGenerated
2093              - !Sub 'bad-${AWS::AccountId}'
2094              - not-an-arn
2095"#;
2096        let model = SemanticModel::from_bytes(template.as_bytes()).expect("template must parse");
2097        let generated_conditions = std::collections::HashMap::from([("UseGenerated".to_string(), true)]);
2098        let generated_source_path = model
2099            .scenario_source_path("Policy", "Properties.PolicyDocument.Statement.0.Resource", &generated_conditions)
2100            .expect("generated branch must have an authored source path");
2101        assert_eq!(generated_source_path, "Properties.PolicyDocument.Statement.0.Resource.Fn::If.1");
2102        assert!(
2103            model.is_from_intrinsic("Policy", &generated_source_path),
2104            "generated branch must retain intrinsic provenance at {generated_source_path}"
2105        );
2106
2107        let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2108
2109        // With narrowed intrinsic suppression, the Fn::Sub branch also fires
2110        // because its concrete resolved value ("bad-123456789012") does not
2111        // start with "arn:" — the skeleton is unambiguously malformed.
2112        assert_eq!(found.len(), 2, "both branches should fire: {found:?}");
2113        assert!(
2114            found
2115                .iter()
2116                .any(|f| f.source_path.ends_with("Fn::If.1")
2117                    && f.message.starts_with("'bad-123456789012' does not match")),
2118            "malformed Fn::Sub branch must fire: {found:?}"
2119        );
2120        assert!(
2121            found
2122                .iter()
2123                .any(|f| f.source_path.ends_with("Fn::If.2") && f.message.starts_with("'not-an-arn' does not match")),
2124            "literal branch must fire: {found:?}"
2125        );
2126    }
2127
2128    /// Service placeholder is accepted.
2129    #[test]
2130    fn arn_with_service_placeholder_is_valid() {
2131        let doc = json!({
2132            "Statement": [{
2133                "Effect": "Allow",
2134                "Action": "s3:*",
2135                "Resource": "arn:aws:${ServiceName}:us-east-1:123456789012:resource"
2136            }]
2137        });
2138        assert!(findings(doc).is_empty());
2139    }
2140
2141    #[test]
2142    fn same_authored_root_type_violation_is_reported_once_across_scenarios() {
2143        let template = r#"
2144Conditions:
2145  UseObjectStatement: !Equals [!Ref AWS::Region, us-east-1]
2146Resources:
2147  Policy:
2148    Type: AWS::IAM::Policy
2149    Properties:
2150      PolicyName: test
2151      Roles: [test-role]
2152      PolicyDocument:
2153        - !If
2154          - UseObjectStatement
2155          - Statement: {}
2156          - Statement: []
2157"#;
2158        let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2159        let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2160
2161        assert_eq!(found.len(), 1, "one authored outer-list defect must produce one finding: {found:?}");
2162        assert_eq!(found[0].path, "");
2163        assert_eq!(found[0].source_path, "Properties.PolicyDocument");
2164        assert!(found[0].message.ends_with(" is not of type 'object'"));
2165    }
2166
2167    // ===== E3510 Scenario Gap Tests =====
2168
2169    /// Identical conditional branches each produce their own finding (no dedup).
2170    #[test]
2171    fn identical_conditional_branches_produce_two_findings() {
2172        let template = r#"
2173Parameters:
2174  Env:
2175    Type: String
2176    Default: prod
2177Conditions:
2178  IsProd: !Equals [!Ref Env, prod]
2179Resources:
2180  Policy:
2181    Type: AWS::IAM::ManagedPolicy
2182    Properties:
2183      PolicyDocument:
2184        Version: '2012-10-17'
2185        Statement:
2186          - Effect: Allow
2187            Action: s3:GetObject
2188            Resource: !If [IsProd, not-an-arn, not-an-arn]
2189"#;
2190        let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2191        let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2192        assert_eq!(found.len(), 2, "identical branches must not be deduplicated: {found:?}");
2193        assert!(found.iter().any(|f| f.source_path.ends_with("Fn::If.1")));
2194        assert!(found.iter().any(|f| f.source_path.ends_with("Fn::If.2")));
2195    }
2196
2197    /// Valid Fn::Sub (arn:${AWS::Partition}:...) must NOT fire.
2198    #[test]
2199    fn valid_fn_sub_arn_is_not_flagged() {
2200        let template = r#"
2201Resources:
2202  Policy:
2203    Type: AWS::IAM::ManagedPolicy
2204    Properties:
2205      PolicyDocument:
2206        Version: '2012-10-17'
2207        Statement:
2208          - Effect: Allow
2209            Action: s3:GetObject
2210            Resource: !Sub "arn:${AWS::Partition}:s3:::my-bucket/*"
2211"#;
2212        let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2213        let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2214        assert!(found.is_empty(), "valid Fn::Sub must not fire: {found:?}");
2215    }
2216
2217    /// Indeterminate Fn::Sub (entire value is ${Param}) must NOT fire.
2218    #[test]
2219    fn indeterminate_fn_sub_is_not_flagged() {
2220        let template = r#"
2221Parameters:
2222  BucketArn:
2223    Type: String
2224    Default: "arn:aws:s3:::my-bucket"
2225Resources:
2226  Policy:
2227    Type: AWS::IAM::ManagedPolicy
2228    Properties:
2229      PolicyDocument:
2230        Version: '2012-10-17'
2231        Statement:
2232          - Effect: Allow
2233            Action: s3:GetObject
2234            Resource: !Sub "${BucketArn}"
2235"#;
2236        let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2237        let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2238        assert!(found.is_empty(), "indeterminate Fn::Sub must not fire: {found:?}");
2239    }
2240
2241    #[test]
2242    fn simple_fn_sub_with_invalid_parameter_default_is_indeterminate() {
2243        let template = r#"
2244Parameters:
2245  BucketArn:
2246    Type: String
2247    Default: not-an-arn
2248Resources:
2249  Policy:
2250    Type: AWS::IAM::ManagedPolicy
2251    Properties:
2252      PolicyDocument:
2253        Statement:
2254          - Effect: Allow
2255            Action: s3:GetObject
2256            Resource: !Sub "${BucketArn}"
2257"#;
2258        let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2259        let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2260        assert!(
2261            found.is_empty(),
2262            "an overrideable parameter default must not be judged as deploy-time truth: {found:?}"
2263        );
2264    }
2265
2266    #[test]
2267    fn malformed_arn_prefixed_fn_sub_skeleton_is_flagged() {
2268        let template = r#"
2269Resources:
2270  Policy:
2271    Type: AWS::IAM::ManagedPolicy
2272    Properties:
2273      PolicyDocument:
2274        Statement:
2275          - Effect: Allow
2276            Action: s3:GetObject
2277            Resource: !Sub "arn:${AWS::Partition}:*:::bucket"
2278"#;
2279        let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2280        let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2281        assert_eq!(found.len(), 1, "the fixed service wildcard makes the authored skeleton invalid: {found:?}");
2282        assert!(found[0].message.contains("does not match"));
2283    }
2284
2285    /// Raw CloudFormation placeholder outside Fn::Sub fires E3510.
2286    #[test]
2287    fn raw_cfn_placeholder_outside_fn_sub_fires() {
2288        let template = r#"
2289Resources:
2290  Policy:
2291    Type: AWS::IAM::ManagedPolicy
2292    Properties:
2293      PolicyDocument:
2294        Version: '2012-10-17'
2295        Statement:
2296          - Effect: Allow
2297            Action: s3:GetObject
2298            Resource: "arn:aws:s3:::${BucketName}/*"
2299          - Effect: Deny
2300            Action: s3:DeleteObject
2301            NotResource: "arn:aws:s3:::${ProtectedBucket}/*"
2302"#;
2303        let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2304        let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2305        assert_eq!(found.len(), 2, "raw placeholders in Resource and NotResource must fire: {found:?}");
2306        assert!(found.iter().all(|finding| finding.message.contains("does not match")));
2307        assert!(found.iter().any(|finding| finding.path.ends_with("Resource")));
2308        assert!(found.iter().any(|finding| finding.path.ends_with("NotResource")));
2309    }
2310
2311    /// NoValue Action/NotAction pruning: list becomes empty after removing NoValue items.
2312    #[test]
2313    fn novalue_action_pruning_produces_min_items_finding() {
2314        let template = r#"
2315Parameters:
2316  Env:
2317    Type: String
2318    Default: prod
2319Conditions:
2320  IsProd: !Equals [!Ref Env, prod]
2321Resources:
2322  Policy:
2323    Type: AWS::IAM::ManagedPolicy
2324    Properties:
2325      PolicyDocument:
2326        Version: '2012-10-17'
2327        Statement:
2328          - Effect: Allow
2329            Action:
2330              - !If [IsProd, !Ref "AWS::NoValue", s3:GetObject]
2331              - !If [IsProd, !Ref "AWS::NoValue", s3:PutObject]
2332            Resource: "*"
2333"#;
2334        let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2335        let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2336        assert_eq!(found.len(), 1, "expected exactly 1 finding (minItems from IsProd=true): {found:?}");
2337        assert!(
2338            found[0].message.contains("too short"),
2339            "empty Action after NoValue pruning must produce minItems error: {found:?}"
2340        );
2341    }
2342
2343    #[test]
2344    fn novalue_not_action_pruning_produces_min_items_finding() {
2345        let template = r#"
2346Conditions:
2347  IsProd: !Equals [!Ref AWS::Region, us-east-1]
2348Resources:
2349  Policy:
2350    Type: AWS::IAM::ManagedPolicy
2351    Properties:
2352      PolicyDocument:
2353        Statement:
2354          - Effect: Allow
2355            NotAction:
2356              - !If [IsProd, !Ref "AWS::NoValue", s3:GetObject]
2357              - !If [IsProd, !Ref "AWS::NoValue", s3:PutObject]
2358            Resource: "*"
2359"#;
2360        let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2361        let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2362        assert_eq!(found.len(), 1, "only the empty NotAction scenario must fail: {found:?}");
2363        assert_eq!(found[0].path, "Statement.0.NotAction");
2364        assert!(found[0].message.contains("too short"));
2365    }
2366
2367    #[test]
2368    fn scalar_novalue_is_treated_as_a_missing_action() {
2369        let template = r#"
2370Conditions:
2371  IsProd: !Equals [!Ref AWS::Region, us-east-1]
2372Resources:
2373  Policy:
2374    Type: AWS::IAM::ManagedPolicy
2375    Properties:
2376      PolicyDocument:
2377        Statement:
2378          - Effect: Allow
2379            Action: !If [IsProd, !Ref "AWS::NoValue", s3:GetObject]
2380            Resource: "*"
2381"#;
2382        let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2383        let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2384        assert_eq!(found.len(), 1, "only the scenario with the removed scalar Action must fail: {found:?}");
2385        assert_eq!(found[0].path, "Statement.0");
2386        assert!(found[0].message.contains("Only one of ['Action', 'NotAction']"));
2387    }
2388
2389    #[test]
2390    fn literal_null_keeps_authored_index_after_novalue_sibling_is_removed() {
2391        let template = r#"
2392Conditions:
2393  IsProd: !Equals [!Ref AWS::Region, us-east-1]
2394Resources:
2395  Policy:
2396    Type: AWS::IAM::ManagedPolicy
2397    Properties:
2398      PolicyDocument:
2399        Statement:
2400          - Effect: Allow
2401            Action:
2402              - !If [IsProd, !Ref "AWS::NoValue", s3:GetObject]
2403              - null
2404            Resource: "*"
2405"#;
2406        let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2407        let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2408        assert_eq!(found.len(), 1, "the authored null is one defect shared by both scenarios: {found:?}");
2409        assert_eq!(found[0].path, "Statement.0.Action.1");
2410        assert!(found[0].source_path.ends_with("Statement.0.Action.1"));
2411        assert!(found[0].message.contains("not of type 'string'"));
2412    }
2413
2414    /// Authored literal null is preserved (not treated as NoValue removal).
2415    #[test]
2416    fn literal_null_in_action_array_produces_type_error() {
2417        let template = r#"
2418Resources:
2419  Policy:
2420    Type: AWS::IAM::ManagedPolicy
2421    Properties:
2422      PolicyDocument:
2423        Version: '2012-10-17'
2424        Statement:
2425          - Effect: Allow
2426            Action:
2427              - null
2428              - s3:GetObject
2429            Resource: "*"
2430"#;
2431        let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2432        let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2433        assert_eq!(found.len(), 1, "expected exactly 1 finding (type error for null): {found:?}");
2434        assert!(found[0].message.contains("not of type 'string'"), "literal null must produce type error: {found:?}");
2435    }
2436
2437    /// Invariant policy validation runs despite exhausted scenario budget.
2438    #[test]
2439    fn invariant_policy_validated_when_budget_exhausted() {
2440        let template = r#"
2441Resources:
2442  Policy:
2443    Type: AWS::IAM::ManagedPolicy
2444    Properties:
2445      PolicyDocument:
2446        Version: '2012-10-17'
2447        Statement:
2448          - Effect: InvalidValue
2449            Action: s3:GetObject
2450            Resource: "*"
2451"#;
2452        let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2453        // Exhaust the scenario budget
2454        model.add_scenario_combinations_for_test(crate::consts::MAX_TOTAL_SCENARIO_COMBINATIONS);
2455        assert!(model.scenario_budget_exhausted());
2456
2457        let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2458        assert_eq!(found.len(), 1, "expected exactly 1 finding (Effect enum error): {found:?}");
2459        assert!(
2460            found[0].message.contains("is not one of"),
2461            "invariant checks must run even with exhausted budget: {found:?}"
2462        );
2463    }
2464
2465    #[test]
2466    fn non_invariant_policy_uses_central_budget_and_keeps_static_checks() {
2467        let template = r#"
2468Conditions:
2469  IsProd: !Equals [!Ref AWS::Region, us-east-1]
2470Resources:
2471  Policy:
2472    Type: AWS::IAM::ManagedPolicy
2473    Properties:
2474      PolicyDocument:
2475        Statement:
2476          - Effect: InvalidValue
2477            Action: s3:GetObject
2478            Resource: !If [IsProd, "*", not-an-arn]
2479"#;
2480        let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2481        model.add_scenario_combinations_for_test(crate::consts::MAX_TOTAL_SCENARIO_COMBINATIONS);
2482
2483        let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2484        assert!(model.scenario_expansion_curtailed(), "central expansion must record the exhausted global budget");
2485        assert_eq!(found.len(), 1, "the static Effect defect must survive conservative fallback: {found:?}");
2486        assert_eq!(found[0].path, "Statement.0.Effect");
2487    }
2488
2489    #[test]
2490    fn inline_policy_path_discovery_is_structural_and_budget_free() {
2491        let template = r#"
2492Conditions:
2493  IsProd: !Equals [!Ref AWS::Region, us-east-1]
2494Resources:
2495  Role:
2496    Type: AWS::IAM::Role
2497    Properties:
2498      Policies: !If
2499        - IsProd
2500        - - PolicyName: One
2501            PolicyDocument: {Statement: []}
2502          - PolicyName: Two
2503            PolicyDocument: {Statement: []}
2504        - - PolicyName: Three
2505            PolicyDocument: {Statement: []}
2506"#;
2507        let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2508        let paths = inline_identity_policy_document_paths(&model, "Role", "Properties.Policies");
2509        assert_eq!(paths, ["Properties.Policies.0.PolicyDocument", "Properties.Policies.1.PolicyDocument"]);
2510        assert_eq!(model.scenario_combinations_used(), 0);
2511    }
2512
2513    #[test]
2514    fn not_action_check_does_not_guess_when_expansion_is_curtailed() {
2515        let template = r#"
2516Conditions:
2517  IsProd: !Equals [!Ref AWS::Region, us-east-1]
2518Resources:
2519  Policy:
2520    Type: AWS::IAM::ManagedPolicy
2521    Condition: IsProd
2522    Properties:
2523      PolicyDocument:
2524        Statement: !If
2525          - IsProd
2526          - Effect: Allow
2527            Action: s3:GetObject
2528            Resource: '*'
2529          - Effect: Allow
2530            NotAction: iam:*
2531            Resource: '*'
2532"#;
2533        let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2534        model.add_scenario_combinations_for_test(crate::consts::MAX_TOTAL_SCENARIO_COMBINATIONS);
2535        assert!(!policy_has_allow_not_action_scenarios(&model, "Policy", "Properties.PolicyDocument"));
2536        assert!(model.scenario_expansion_curtailed());
2537    }
2538}