diff --git a/src/languages/azure_policy/parser/constraint.rs b/src/languages/azure_policy/parser/constraint.rs new file mode 100644 index 00000000..ef4b2841 --- /dev/null +++ b/src/languages/azure_policy/parser/constraint.rs @@ -0,0 +1,411 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Constraint parsing: logical combinators, leaf conditions, and count blocks. + +use alloc::boxed::Box; +use alloc::string::String; +use alloc::vec::Vec; + +use crate::lexer::Span; + +use crate::languages::azure_policy::ast::{ + Condition, Constraint, CountNode, FieldNode, JsonValue, Lhs, NameNode, OperatorNode, +}; + +use super::classify_field; +use super::core::{CountInner, EntryValue, Parser}; +use super::error::ParseError; +use super::parse_operator_kind; + +/// Set `slot` to `val`, returning a [`ParseError::DuplicateKey`] if it was already set. +fn set_once(slot: &mut Option, val: T, key: &str, span: &Span) -> Result<(), ParseError> { + if slot.is_some() { + return Err(ParseError::DuplicateKey { + span: span.clone(), + key: String::from(key), + }); + } + *slot = Some(val); + Ok(()) +} + +impl<'source> Parser<'source> { + /// Parse a constraint (a JSON object: logical combinator or leaf condition). + pub fn parse_constraint(&mut self) -> Result { + let open = self.expect_symbol("{")?; + let mut entries: Vec<(Span, String, EntryValue)> = Vec::new(); + + if self.token_text() != "}" { + loop { + let (key_span, key) = self.expect_string()?; + self.expect_symbol(":")?; + let key_lower = key.to_lowercase(); + + let value = match key_lower.as_str() { + "allof" | "anyof" => { + if self.token_text() != "[" { + return Err(ParseError::LogicalOperatorNotArray { + span: key_span, + operator: key, + }); + } + EntryValue::ConstraintArray(self.parse_constraint_array()?) + } + "not" => EntryValue::SingleConstraint(self.parse_constraint()?), + "count" => EntryValue::CountInner(Box::new(self.parse_count_inner()?)), + _ => EntryValue::Json(self.parse_json_value()?), + }; + + // Detect duplicate keys during collection (case-insensitive). + if entries + .iter() + .any(|entry| entry.1.eq_ignore_ascii_case(&key)) + { + return Err(ParseError::DuplicateKey { + span: key_span, + key, + }); + } + + entries.push((key_span, key, value)); + + if self.token_text() == "," { + self.advance()?; + } else { + break; + } + } + } + + let close = self.expect_symbol("}")?; + let span = Span { + source: open.source.clone(), + line: open.line, + col: open.col, + start: open.start, + end: close.end, + }; + + Self::build_constraint(span, entries) + } + + /// Parse a `[constraint, constraint, ...]` array for `allOf`/`anyOf`. + fn parse_constraint_array(&mut self) -> Result, ParseError> { + self.expect_symbol("[")?; + let mut constraints = Vec::new(); + if self.token_text() != "]" { + constraints.push(self.parse_constraint()?); + while self.token_text() == "," { + self.advance()?; + constraints.push(self.parse_constraint()?); + } + } + self.expect_symbol("]")?; + Ok(constraints) + } + + /// Dispatch on collected entries to build the appropriate constraint. + fn build_constraint( + span: Span, + entries: Vec<(Span, String, EntryValue)>, + ) -> Result { + // Check for logical operators with extra keys. + for entry in entries.iter() { + let lower = entry.1.to_lowercase(); + if matches!(lower.as_str(), "allof" | "anyof" | "not") && entries.len() > 1 { + return Err(ParseError::ExtraKeysInLogical { + span: entry.0.clone(), + operator: entry.1.clone(), + }); + } + } + + // Single-entry logical operators. + if entries.len() == 1 { + // Safe: we just checked len() == 1. + let mut entries = entries; + let (key_span, key, value) = entries + .pop() + .ok_or_else(|| ParseError::MissingLhsOperand { span: span.clone() })?; + match key.to_lowercase().as_str() { + "allof" => { + // Non-array values are caught during parsing (LogicalOperatorNotArray). + let EntryValue::ConstraintArray(constraints) = value else { + return Err(ParseError::LogicalOperatorNotArray { + span: key_span, + operator: key, + }); + }; + return Ok(Constraint::AllOf { span, constraints }); + } + "anyof" => { + let EntryValue::ConstraintArray(constraints) = value else { + return Err(ParseError::LogicalOperatorNotArray { + span: key_span, + operator: key, + }); + }; + return Ok(Constraint::AnyOf { span, constraints }); + } + "not" => { + let EntryValue::SingleConstraint(constraint) = value else { + // `not` is always parsed as SingleConstraint in parse_constraint, + // so this branch is structurally unreachable. + return Err(ParseError::UnexpectedToken { + span: key_span, + expected: "constraint for 'not'", + }); + }; + return Ok(Constraint::Not { + span, + constraint: Box::new(constraint), + }); + } + _ => { + return Self::build_condition(span, alloc::vec![(key_span, key, value)]); + } + } + } + + Self::build_condition(span, entries) + } + + /// Build a leaf condition from collected object entries. + fn build_condition( + span: Span, + entries: Vec<(Span, String, EntryValue)>, + ) -> Result { + let mut field: Option<(Span, JsonValue)> = None; + let mut value: Option<(Span, JsonValue)> = None; + let mut count: Option<(Span, Box)> = None; + let mut operator: Option = None; + let mut rhs: Option = None; + + for (key_span, key, entry_value) in entries { + match key.to_lowercase().as_str() { + "field" => { + let EntryValue::Json(jv) = entry_value else { + return Err(ParseError::UnexpectedToken { + span: key_span, + expected: "JSON value for 'field'", + }); + }; + set_once(&mut field, (key_span.clone(), jv), &key, &key_span)?; + } + "value" => { + let EntryValue::Json(jv) = entry_value else { + return Err(ParseError::UnexpectedToken { + span: key_span, + expected: "JSON value for 'value'", + }); + }; + set_once(&mut value, (key_span.clone(), jv), &key, &key_span)?; + } + "count" => { + let EntryValue::CountInner(ci) = entry_value else { + return Err(ParseError::UnexpectedToken { + span: key_span, + expected: "object for 'count'", + }); + }; + set_once(&mut count, (key_span.clone(), ci), &key, &key_span)?; + } + _ => { + if let Some(op_kind) = parse_operator_kind(&key.to_lowercase()) { + let EntryValue::Json(jv) = entry_value else { + return Err(ParseError::UnexpectedToken { + span: key_span, + expected: "JSON value for operator", + }); + }; + if operator.is_some() { + return Err(ParseError::MultipleOperators { span: key_span }); + } + operator = Some(OperatorNode { + span: key_span, + kind: op_kind, + }); + rhs = Some(jv); + } else { + return Err(ParseError::UnrecognizedKey { + span: key_span, + key, + }); + } + } + } + } + + let operator = + operator.ok_or_else(|| ParseError::MissingOperator { span: span.clone() })?; + let rhs_json = rhs.ok_or_else(|| ParseError::MissingOperator { span: span.clone() })?; + let rhs_value = Self::json_to_value_or_expr(rhs_json)?; + + let lhs = match (field, value, count) { + (Some((_, fv)), None, None) => Lhs::Field(Self::json_to_field(fv)?), + (None, Some((key_span, vv)), None) => Lhs::Value { + key_span, + value: Self::json_to_value_or_expr(vv)?, + }, + (None, None, Some((_, ci))) => Lhs::Count(Self::finalize_count(*ci)?), + + (None, None, None) => { + return Err(ParseError::MissingLhsOperand { span: span.clone() }); + } + _ => { + return Err(ParseError::MultipleLhsOperands { span: span.clone() }); + } + }; + + Ok(Constraint::Condition(Box::new(Condition { + span, + lhs, + operator, + rhs: rhs_value, + }))) + } + + // ======================================================================== + // Count parsing + // ======================================================================== + + /// Parse the inner object of a `"count": { ... }` block. + pub fn parse_count_inner(&mut self) -> Result { + let open = self.expect_symbol("{")?; + + let mut field: Option<(Span, JsonValue)> = None; + let mut value: Option<(Span, JsonValue)> = None; + let mut name: Option<(Span, JsonValue)> = None; + let mut where_: Option = None; + + if self.token_text() != "}" { + loop { + let (key_span, key) = self.expect_string()?; + self.expect_symbol(":")?; + let key_lower = key.to_lowercase(); + + match key_lower.as_str() { + "field" => { + let jv = self.parse_json_value()?; + set_once(&mut field, (key_span.clone(), jv), &key_lower, &key_span)?; + } + "value" => { + let jv = self.parse_json_value()?; + set_once(&mut value, (key_span.clone(), jv), &key_lower, &key_span)?; + } + "name" => { + let jv = self.parse_json_value()?; + set_once(&mut name, (key_span.clone(), jv), &key_lower, &key_span)?; + } + "where" => { + let c = self.parse_constraint()?; + set_once(&mut where_, c, &key_lower, &key_span)?; + } + _ => { + return Err(ParseError::UnrecognizedKey { + span: key_span, + key: key_lower, + }); + } + } + + if self.token_text() == "," { + self.advance()?; + } else { + break; + } + } + } + + let close = self.expect_symbol("}")?; + let span = Span { + source: open.source.clone(), + line: open.line, + col: open.col, + start: open.start, + end: close.end, + }; + + Ok(CountInner { + span, + field, + value, + name, + where_, + }) + } + + // ======================================================================== + // Helpers + // ======================================================================== + + /// Convert a JSON value (expected to be a string) into a [`FieldNode`]. + pub fn json_to_field(jv: JsonValue) -> Result { + let (span, text) = match jv { + JsonValue::Str(span, text) => (span, text), + other => { + return Err(ParseError::UnexpectedToken { + span: other.span().clone(), + expected: "string for 'field' value", + }); + } + }; + let kind = classify_field(&text, &span)?; + Ok(FieldNode { span, kind }) + } + + /// Finalize a [`CountInner`] into a [`CountNode`]. + pub fn finalize_count(ci: CountInner) -> Result { + let CountInner { + span, + field, + value, + name, + where_, + } = ci; + let where_box = where_.map(Box::new); + + let name_node = match name { + Some((key_span, jv)) => { + if value.is_none() { + return Err(ParseError::MisplacedCountName { span: key_span }); + } + match jv { + JsonValue::Str(name_span, text) => Some(NameNode { + span: name_span, + name: text, + }), + _ => { + return Err(ParseError::InvalidCountName { + span: jv.span().clone(), + }); + } + } + } + None => None, + }; + + match (field, value) { + (None, None) => Err(ParseError::MissingCountCollection { span }), + (Some((_, fv)), None) => { + let field_node = Self::json_to_field(fv)?; + Ok(CountNode::Field { + span, + field: field_node, + where_: where_box, + }) + } + (None, Some((_, vv))) => { + let val = Self::json_to_value_or_expr(vv)?; + Ok(CountNode::Value { + span, + value: val, + name: name_node, + where_: where_box, + }) + } + (Some(_), Some(_)) => Err(ParseError::MultipleCountCollections { span }), + } + } +} diff --git a/src/languages/azure_policy/parser/error.rs b/src/languages/azure_policy/parser/error.rs index bba454cb..c79d132e 100644 --- a/src/languages/azure_policy/parser/error.rs +++ b/src/languages/azure_policy/parser/error.rs @@ -39,6 +39,10 @@ pub enum ParseError { InvalidCountName { span: Span }, /// `name` used without `value` in count. MisplacedCountName { span: Span }, + /// Multiple operator keys in a single condition. + MultipleOperators { span: Span }, + /// A duplicate key was found in a JSON object. + DuplicateKey { span: Span, key: String }, /// A custom error message (e.g., from sub-parsing). Custom { span: Span, message: String }, } @@ -137,6 +141,16 @@ impl core::fmt::Display for ParseError { span.error("'name' can only be used with count-value") ) } + ParseError::MultipleOperators { ref span } => { + write!( + f, + "{}", + span.error("only one operator key allowed in a condition") + ) + } + ParseError::DuplicateKey { ref span, ref key } => { + write!(f, "{}", span.error(&format!("duplicate key \"{}\"", key))) + } ParseError::Custom { ref span, ref message, diff --git a/src/languages/azure_policy/parser/mod.rs b/src/languages/azure_policy/parser/mod.rs index 011e3216..54be67b0 100644 --- a/src/languages/azure_policy/parser/mod.rs +++ b/src/languages/azure_policy/parser/mod.rs @@ -1,19 +1,17 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Core recursive-descent JSON parser for Azure Policy. +//! Recursive-descent JSON parser for Azure Policy rule constraints. //! -//! Provides the low-level token-driven parser (`core::Parser`) that reads JSON from -//! [`Lexer`] tokens, building span-annotated AST values in a single pass. -//! No intermediate `serde_json::Value` is created. +//! Parses Azure Policy JSON directly from [`Lexer`] tokens, building span-annotated +//! AST nodes in a single pass. No intermediate `serde_json::Value` is created. //! -//! Higher-level policy-aware parsing (constraints, policy rules, policy -//! definitions) is layered on top by sibling modules. +//! The parser is policy-aware: when parsing JSON objects, it dispatches on key names +//! (`allOf`, `anyOf`, `not`, `field`, `value`, `count`, operator names) to build +//! the appropriate AST nodes. -// Parser internals are consumed by constraint/policy_rule/policy_definition -// modules added in a subsequent PR. -#[allow(dead_code)] -pub(crate) mod core; +mod constraint; +mod core; mod error; pub(super) use self::core::json_unescape; @@ -21,15 +19,42 @@ pub use error::ParseError; use alloc::string::ToString as _; -use super::ast::{FieldKind, OperatorKind}; +use crate::lexer::{Source, TokenKind}; + +use super::ast::{Constraint, FieldKind, OperatorKind}; use super::expr::ExprParser; +use self::core::Parser; + +// ============================================================================ +// Public API +// ============================================================================ + +/// Parse a standalone constraint from a JSON source. +/// +/// A constraint is one of: +/// - Logical combinator: `{ "allOf": [...] }`, `{ "anyOf": [...] }`, `{ "not": {...} }` +/// - Leaf condition: `{ "field": "...", "equals": "..." }` +/// - Count condition: `{ "count": { "field": "..." }, "greater": 0 }` +pub fn parse_constraint(source: &Source) -> Result { + let mut parser = Parser::new(source)?; + let constraint = parser.parse_constraint()?; + + if parser.tok.0 != TokenKind::Eof { + return Err(ParseError::UnexpectedToken { + span: parser.tok.1.clone(), + expected: "end of input", + }); + } + + Ok(constraint) +} + // ============================================================================ -// Helper functions (used by constraint/policy_rule/policy_definition modules) +// Helper functions (used across submodules) // ============================================================================ /// Check if a string is an ARM template expression (`[...]` but not `[[...`). -#[allow(dead_code)] pub(super) fn is_template_expr(s: &str) -> bool { s.starts_with('[') && s.ends_with(']') && !s.starts_with("[[") } @@ -51,7 +76,6 @@ fn unwrap(s: &str, prefix_len: usize, suffix_len: usize) -> &str { } /// Classify a field string into a [`FieldKind`]. -#[allow(dead_code)] pub(super) fn classify_field( text: &str, span: &crate::lexer::Span, @@ -96,7 +120,6 @@ pub(super) fn classify_field( } /// Try to parse a lowercase key as an operator kind. -#[allow(dead_code)] pub(super) fn parse_operator_kind(key: &str) -> Option { match key { "contains" => Some(OperatorKind::Contains), diff --git a/tests/azure_policy/mod.rs b/tests/azure_policy/mod.rs index 00bb5d07..90cc66fa 100644 --- a/tests/azure_policy/mod.rs +++ b/tests/azure_policy/mod.rs @@ -2,3 +2,4 @@ // Licensed under the MIT License. mod normalization; +mod parser_tests; diff --git a/tests/azure_policy/parser_tests/cases/count.yaml b/tests/azure_policy/parser_tests/cases/count.yaml new file mode 100644 index 00000000..76b52e26 --- /dev/null +++ b/tests/azure_policy/parser_tests/cases/count.yaml @@ -0,0 +1,617 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Count Expressions Test Suite +# Tests field count and value count with optional where clauses and name bindings. + +cases: + # ========================================================================= + # Field count — direct path (core subset, no alias resolution) + # ========================================================================= + + - note: field_count_direct_path_core + policy_rule: | + { + "if": { + "count": { + "field": "securityRules[*]" + }, + "greater": 2 + }, + "then": { "effect": "deny" } + } + resource: + securityRules: + - { "name": "r1" } + - { "name": "r2" } + - { "name": "r3" } + want_effect: "deny" + + - note: field_count_direct_path_where_core + policy_rule: | + { + "if": { + "count": { + "field": "securityRules[*]", + "where": { + "field": "securityRules[*].access", + "equals": "Allow" + } + }, + "equals": 2 + }, + "then": { "effect": "audit" } + } + resource: + securityRules: + - { "access": "Allow" } + - { "access": "Deny" } + - { "access": "Allow" } + want_effect: "audit" + + # ========================================================================= + # Field count — basic + # ========================================================================= + + - note: field_count_basic + policy_rule: | + { + "if": { + "count": { + "field": "securityRules[*]" + }, + "greater": 10 + }, + "then": { "effect": "deny" } + } + resource: + type: "Microsoft.Network/networkSecurityGroups" + securityRules: + - { "name": "r1" } + - { "name": "r2" } + - { "name": "r3" } + - { "name": "r4" } + - { "name": "r5" } + - { "name": "r6" } + - { "name": "r7" } + - { "name": "r8" } + - { "name": "r9" } + - { "name": "r10" } + - { "name": "r11" } + want_effect: "deny" + + - note: field_count_equals_zero + policy_rule: | + { + "if": { + "count": { + "field": "storageProfile.dataDisks[*]" + }, + "equals": 0 + }, + "then": { "effect": "audit" } + } + resource: + type: "Microsoft.Compute/virtualMachines" + want_effect: "audit" + + # ========================================================================= + # Field count — with where clause + # ========================================================================= + + - note: field_count_with_where + policy_rule: | + { + "if": { + "count": { + "field": "securityRules[*]", + "where": { + "field": "securityRules[*].access", + "equals": "Allow" + } + }, + "greater": 5 + }, + "then": { "effect": "deny" } + } + resource: + type: "Microsoft.Network/networkSecurityGroups" + securityRules: + - { "access": "Allow" } + - { "access": "Allow" } + - { "access": "Allow" } + - { "access": "Allow" } + - { "access": "Allow" } + - { "access": "Allow" } + want_effect: "deny" + + - note: field_count_where_allOf + policy_rule: | + { + "if": { + "count": { + "field": "securityRules[*]", + "where": { + "allOf": [ + { + "field": "securityRules[*].access", + "equals": "Allow" + }, + { + "field": "securityRules[*].direction", + "equals": "Inbound" + } + ] + } + }, + "greaterOrEquals": 1 + }, + "then": { "effect": "deny" } + } + resource: + type: "Microsoft.Network/networkSecurityGroups" + securityRules: + - { "access": "Allow", "direction": "Inbound" } + - { "access": "Deny", "direction": "Outbound" } + want_effect: "deny" + + - note: field_count_where_anyOf + policy_rule: | + { + "if": { + "count": { + "field": "securityRules[*]", + "where": { + "anyOf": [ + { + "field": "securityRules[*].destinationPortRange", + "equals": "22" + }, + { + "field": "securityRules[*].destinationPortRange", + "equals": "3389" + } + ] + } + }, + "notEquals": 0 + }, + "then": { "effect": "deny" } + } + resource: + type: "Microsoft.Network/networkSecurityGroups" + securityRules: + - { "destinationPortRange": "22" } + - { "destinationPortRange": "443" } + want_effect: "deny" + + - note: field_count_where_not + policy_rule: | + { + "if": { + "count": { + "field": "securityRules[*]", + "where": { + "not": { + "field": "securityRules[*].access", + "equals": "Deny" + } + } + }, + "greater": 0 + }, + "then": { "effect": "audit" } + } + resource: + type: "Microsoft.Network/networkSecurityGroups" + securityRules: + - { "access": "Allow" } + - { "access": "Deny" } + want_effect: "audit" + + # ========================================================================= + # Value count + # ========================================================================= + + - note: value_count_basic + policy_rule: | + { + "if": { + "count": { + "value": ["eastus", "westus", "centralus"] + }, + "equals": 3 + }, + "then": { "effect": "audit" } + } + resource: + type: "any" + want_effect: "audit" + + - note: value_count_with_name + policy_rule: | + { + "if": { + "count": { + "value": ["eastus", "westus", "centralus"], + "name": "location" + }, + "greater": 0 + }, + "then": { "effect": "audit" } + } + resource: + type: "any" + want_effect: "audit" + + - note: value_count_with_name_and_where + policy_rule: | + { + "if": { + "count": { + "value": ["eastus", "westus", "centralus", "northeurope"], + "name": "loc", + "where": { + "value": "[current('loc')]", + "like": "*us" + } + }, + "equals": 3 + }, + "then": { "effect": "audit" } + } + resource: + type: "any" + want_effect: "audit" + + - note: value_count_expression + policy_rule: | + { + "if": { + "count": { + "value": "[parameters('allowedLocations')]", + "name": "loc" + }, + "greater": 0 + }, + "then": { "effect": "audit" } + } + parameters: + allowedLocations: + - "eastus" + - "westus" + resource: + type: "any" + want_effect: "audit" + + # ========================================================================= + # Count in allOf/anyOf + # ========================================================================= + + - note: count_in_allOf + policy_rule: | + { + "if": { + "allOf": [ + { "field": "type", "equals": "Microsoft.Network/networkSecurityGroups" }, + { + "count": { + "field": "securityRules[*]", + "where": { + "field": "securityRules[*].access", + "equals": "Allow" + } + }, + "greater": 10 + } + ] + }, + "then": { "effect": "deny" } + } + resource: + type: "Microsoft.Network/networkSecurityGroups" + securityRules: + - { "access": "Allow" } + - { "access": "Allow" } + - { "access": "Allow" } + - { "access": "Allow" } + - { "access": "Allow" } + - { "access": "Allow" } + - { "access": "Allow" } + - { "access": "Allow" } + - { "access": "Allow" } + - { "access": "Allow" } + - { "access": "Allow" } + want_effect: "deny" + + - note: count_in_not + policy_rule: | + { + "if": { + "not": { + "count": { + "field": "storageProfile.dataDisks[*]" + }, + "lessOrEquals": 4 + } + }, + "then": { "effect": "deny" } + } + resource: + type: "Microsoft.Compute/virtualMachines" + storageProfile: + dataDisks: + - { "name": "d1" } + - { "name": "d2" } + - { "name": "d3" } + - { "name": "d4" } + - { "name": "d5" } + want_effect: "deny" + + # ========================================================================= + # Count with nested where containing count + # ========================================================================= + + - note: value_count_nested_where + policy_rule: | + { + "if": { + "count": { + "value": "[parameters('requiredTags')]", + "name": "tag", + "where": { + "field": "[concat('tags[', current('tag'), ']')]", + "exists": true + } + }, + "notEquals": "[length(parameters('requiredTags'))]" + }, + "then": { "effect": "deny" } + } + parameters: + requiredTags: + - "environment" + - "costCenter" + resource: + type: "Microsoft.Compute/virtualMachines" + tags: + environment: "prod" + want_effect: "deny" + + # ========================================================================= + # Alias field refs inside count resolve to current loop element + # ========================================================================= + + - note: field_count_multiple_alias_refs_same_element + policy_rule: | + { + "if": { + "count": { + "field": "securityRules[*]", + "where": { + "allOf": [ + { "field": "securityRules[*].access", "equals": "Allow" }, + { "field": "securityRules[*].direction", "equals": "Inbound" }, + { "field": "securityRules[*].protocol", "equals": "Tcp" } + ] + } + }, + "equals": 1 + }, + "then": { "effect": "audit" } + } + resource: + securityRules: + - { "access": "Allow", "direction": "Inbound", "protocol": "Tcp" } + - { "access": "Allow", "direction": "Outbound", "protocol": "Tcp" } + - { "access": "Deny", "direction": "Inbound", "protocol": "Tcp" } + want_effect: "audit" + + - note: field_count_nested_field_access + policy_rule: | + { + "if": { + "count": { + "field": "storageProfile.dataDisks[*]", + "where": { + "field": "storageProfile.dataDisks[*].managedDisk.storageAccountType", + "notEquals": "Premium_LRS" + } + }, + "greater": 0 + }, + "then": { "effect": "deny" } + } + resource: + storageProfile: + dataDisks: + - { "name": "d1", "managedDisk": { "storageAccountType": "Premium_LRS" } } + - { "name": "d2", "managedDisk": { "storageAccountType": "Standard_LRS" } } + want_effect: "deny" + + - note: field_count_where_zero_matches + policy_rule: | + { + "if": { + "count": { + "field": "items[*]", + "where": { + "field": "items[*].status", + "equals": "failed" + } + }, + "equals": 0 + }, + "then": { "effect": "audit" } + } + resource: + items: + - { "status": "ok" } + - { "status": "ok" } + - { "status": "ok" } + want_effect: "audit" + + # ========================================================================= + # Nested count: count inside another count's where clause + # ========================================================================= + + - note: nested_field_and_value_count + policy_rule: | + { + "if": { + "count": { + "value": "[parameters('requiredPorts')]", + "name": "port", + "where": { + "count": { + "field": "securityRules[*]", + "where": { + "allOf": [ + { "field": "securityRules[*].destinationPortRange", "equals": "[current('port')]" }, + { "field": "securityRules[*].access", "equals": "Allow" } + ] + } + }, + "greater": 0 + } + }, + "equals": "[length(parameters('requiredPorts'))]" + }, + "then": { "effect": "audit" } + } + parameters: + requiredPorts: + - "443" + - "80" + resource: + securityRules: + - { "destinationPortRange": "443", "access": "Allow" } + - { "destinationPortRange": "80", "access": "Allow" } + - { "destinationPortRange": "22", "access": "Deny" } + want_effect: "audit" + + - note: nested_field_and_value_count_fail + policy_rule: | + { + "if": { + "count": { + "value": "[parameters('requiredPorts')]", + "name": "port", + "where": { + "count": { + "field": "securityRules[*]", + "where": { + "allOf": [ + { "field": "securityRules[*].destinationPortRange", "equals": "[current('port')]" }, + { "field": "securityRules[*].access", "equals": "Allow" } + ] + } + }, + "greater": 0 + } + }, + "equals": "[length(parameters('requiredPorts'))]" + }, + "then": { "effect": "deny" } + } + parameters: + requiredPorts: + - "443" + - "80" + - "8080" + resource: + securityRules: + - { "destinationPortRange": "443", "access": "Allow" } + - { "destinationPortRange": "80", "access": "Allow" } + - { "destinationPortRange": "22", "access": "Deny" } + want_effect: ~ + + # ========================================================================= + # current() — zero-arg form (innermost count element) + # ========================================================================= + + - note: current_zero_arg_value_count + policy_rule: | + { + "if": { + "count": { + "value": ["Allow", "Allow", "Deny"], + "name": "access", + "where": { + "value": "[current()]", + "equals": "Allow" + } + }, + "equals": 2 + }, + "then": { "effect": "deny" } + } + resource: + type: "any" + want_effect: "deny" + + - note: current_zero_arg_field_count + policy_rule: | + { + "if": { + "count": { + "field": "items[*]", + "where": { + "value": "[current()]", + "equals": "yes" + } + }, + "equals": 2 + }, + "then": { "effect": "deny" } + } + resource: + items: ["yes", "no", "yes"] + want_effect: "deny" + + - note: current_zero_arg_with_function + policy_rule: | + { + "if": { + "count": { + "value": ["HELLO", "WORLD"], + "name": "word", + "where": { + "value": "[startsWith(current(), 'HE')]", + "equals": true + } + }, + "equals": 1 + }, + "then": { "effect": "deny" } + } + resource: + type: "any" + want_effect: "deny" + + - note: current_zero_arg_nested_innermost + policy_rule: | + { + "if": { + "count": { + "value": ["a", "b"], + "name": "outer", + "where": { + "count": { + "value": ["x", "y"], + "name": "inner", + "where": { + "value": "[current()]", + "equals": "x" + } + }, + "greater": 0 + } + }, + "greater": 0 + }, + "then": { "effect": "deny" } + } + resource: + type: "any" + want_effect: "deny" diff --git a/tests/azure_policy/parser_tests/cases/expressions.yaml b/tests/azure_policy/parser_tests/cases/expressions.yaml new file mode 100644 index 00000000..732742a7 --- /dev/null +++ b/tests/azure_policy/parser_tests/cases/expressions.yaml @@ -0,0 +1,554 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# ARM Template Expressions Test Suite +# Tests [parameters(...)], [concat(...)], [field(...)], [if(...)], and other +# ARM template expression patterns in field, value, and effect positions. + +cases: + # ========================================================================= + # parameters() references + # ========================================================================= + + - note: expr_parameters_in_value + policy_rule: | + { + "if": { + "value": "[parameters('environment')]", + "equals": "production" + }, + "then": { "effect": "deny" } + } + parameters: + environment: "production" + resource: + type: "any" + want_effect: "deny" + + - note: expr_parameters_in_rhs + policy_rule: | + { + "if": { + "field": "location", + "in": "[parameters('allowedLocations')]" + }, + "then": { "effect": "deny" } + } + parameters: + allowedLocations: + - "eastus" + - "westus" + resource: + location: "eastus" + want_effect: "deny" + + - note: expr_parameters_in_effect + policy_rule: | + { + "if": { + "field": "type", + "equals": "Microsoft.Compute/virtualMachines" + }, + "then": { + "effect": "[parameters('effect')]" + } + } + parameters: + effect: "deny" + resource: + type: "Microsoft.Compute/virtualMachines" + want_effect: "deny" + + # ========================================================================= + # concat() + # ========================================================================= + + - note: expr_concat_strings + policy_rule: | + { + "if": { + "value": "[concat('Microsoft.Compute/', 'virtualMachines')]", + "equals": "Microsoft.Compute/virtualMachines" + }, + "then": { "effect": "audit" } + } + resource: + type: "any" + want_effect: "audit" + + - note: expr_concat_with_parameters + policy_rule: | + { + "if": { + "value": "[concat(parameters('prefix'), '-vm')]", + "equals": "prod-vm" + }, + "then": { "effect": "audit" } + } + parameters: + prefix: "prod" + resource: + type: "any" + want_effect: "audit" + + - note: expr_concat_nested + policy_rule: | + { + "if": { + "value": "[concat(concat('a', 'b'), 'c')]", + "equals": "abc" + }, + "then": { "effect": "audit" } + } + resource: + type: "any" + want_effect: "audit" + + # ========================================================================= + # field() function + # ========================================================================= + + - note: expr_field_function + policy_rule: | + { + "if": { + "value": "[field('name')]", + "contains": "prod" + }, + "then": { "effect": "audit" } + } + resource: + name: "my-prod-vm" + want_effect: "audit" + + - note: expr_field_in_concat + policy_rule: | + { + "if": { + "value": "[concat(field('type'), '/', field('name'))]", + "contains": "Microsoft.Compute" + }, + "then": { "effect": "audit" } + } + resource: + type: "Microsoft.Compute/virtualMachines" + name: "vm1" + want_effect: "audit" + + # ========================================================================= + # if() conditional + # ========================================================================= + + - note: expr_if_conditional + policy_rule: | + { + "if": { + "value": "[if(equals(parameters('env'), 'prod'), 'deny', 'audit')]", + "equals": "deny" + }, + "then": { "effect": "audit" } + } + parameters: + env: "prod" + resource: + type: "any" + want_effect: "audit" + + # ========================================================================= + # String functions + # ========================================================================= + + - note: expr_toLower + policy_rule: | + { + "if": { + "value": "[toLower(field('name'))]", + "equals": "my-vm" + }, + "then": { "effect": "audit" } + } + resource: + name: "My-VM" + want_effect: "audit" + + - note: expr_toUpper + policy_rule: | + { + "if": { + "value": "[toUpper(parameters('prefix'))]", + "equals": "PROD" + }, + "then": { "effect": "audit" } + } + parameters: + prefix: "prod" + resource: + type: "any" + want_effect: "audit" + + - note: expr_replace + policy_rule: | + { + "if": { + "value": "[replace(field('name'), '-', '_')]", + "equals": "my_prod_vm" + }, + "then": { "effect": "audit" } + } + resource: + name: "my-prod-vm" + want_effect: "audit" + + - note: expr_substring + policy_rule: | + { + "if": { + "value": "[substring(field('name'), 0, 4)]", + "equals": "prod" + }, + "then": { "effect": "audit" } + } + resource: + name: "prod-vm-01" + want_effect: "audit" + + # ========================================================================= + # Numeric functions + # ========================================================================= + + - note: expr_length + policy_rule: | + { + "if": { + "value": "[length(parameters('allowedLocations'))]", + "greater": 0 + }, + "then": { "effect": "audit" } + } + parameters: + allowedLocations: + - "eastus" + - "westus" + resource: + type: "any" + want_effect: "audit" + + - note: expr_add + policy_rule: | + { + "if": { + "value": "[add(parameters('base'), 1)]", + "greater": 5 + }, + "then": { "effect": "audit" } + } + parameters: + base: 10 + resource: + type: "any" + want_effect: "audit" + + # ========================================================================= + # Context functions + # ========================================================================= + + - note: expr_resourceGroup + policy_rule: | + { + "if": { + "value": "[resourceGroup().location]", + "notIn": "[parameters('allowedLocations')]" + }, + "then": { "effect": "deny" } + } + parameters: + allowedLocations: + - "westus2" + - "centralus" + resource: + type: "any" + want_effect: "deny" + + - note: expr_subscription + policy_rule: | + { + "if": { + "value": "[subscription().subscriptionId]", + "equals": "00000000-0000-0000-0000-000000000000" + }, + "then": { "effect": "audit" } + } + resource: + type: "any" + want_effect: "audit" + + - note: expr_requestContext_apiVersion + policy_rule: | + { + "if": { + "value": "[requestContext().apiVersion]", + "greaterOrEquals": "2021-04-01" + }, + "then": { "effect": "deny" } + } + context: + resourceGroup: + name: "myResourceGroup" + location: "eastus" + subscription: + subscriptionId: "00000000-0000-0000-0000-000000000000" + requestContext: + apiVersion: "2023-01-01" + resource: + type: "any" + want_effect: "deny" + + - note: expr_requestContext_apiVersion_older + policy_rule: | + { + "if": { + "value": "[requestContext().apiVersion]", + "greaterOrEquals": "2024-06-01" + }, + "then": { "effect": "deny" } + } + context: + resourceGroup: + name: "myResourceGroup" + location: "eastus" + subscription: + subscriptionId: "00000000-0000-0000-0000-000000000000" + requestContext: + apiVersion: "2023-01-01" + resource: + type: "any" + want_undefined: true + + - note: expr_policy_assignmentId + policy_rule: | + { + "if": { + "value": "[policy().assignmentId]", + "equals": "/subscriptions/sub1/providers/Microsoft.Authorization/policyAssignments/myAssignment" + }, + "then": { "effect": "audit" } + } + context: + resourceGroup: + name: "myResourceGroup" + location: "eastus" + subscription: + subscriptionId: "00000000-0000-0000-0000-000000000000" + policy: + assignmentId: "/subscriptions/sub1/providers/Microsoft.Authorization/policyAssignments/myAssignment" + definitionId: "/providers/Microsoft.Authorization/policyDefinitions/myDefinition" + setDefinitionId: "" + definitionReferenceId: "" + resource: + type: "any" + want_effect: "audit" + + - note: expr_policy_definitionId + policy_rule: | + { + "if": { + "value": "[policy().definitionId]", + "contains": "myDefinition" + }, + "then": { "effect": "deny" } + } + context: + resourceGroup: + name: "myResourceGroup" + location: "eastus" + subscription: + subscriptionId: "00000000-0000-0000-0000-000000000000" + policy: + assignmentId: "/subscriptions/sub1/providers/Microsoft.Authorization/policyAssignments/myAssignment" + definitionId: "/providers/Microsoft.Authorization/policyDefinitions/myDefinition" + resource: + type: "any" + want_effect: "deny" + + # ========================================================================= + # current() in count contexts + # ========================================================================= + + - note: expr_current_in_value_count + policy_rule: | + { + "if": { + "count": { + "value": "[parameters('requiredTags')]", + "name": "tagName", + "where": { + "value": "[current('tagName')]", + "notEquals": "" + } + }, + "greater": 0 + }, + "then": { "effect": "audit" } + } + parameters: + requiredTags: + - "environment" + - "costCenter" + resource: + type: "any" + want_effect: "audit" + + # ========================================================================= + # Dot and index access in expressions + # ========================================================================= + + - note: expr_dot_access + policy_rule: | + { + "if": { + "value": "[resourceGroup().name]", + "equals": "myResourceGroup" + }, + "then": { "effect": "audit" } + } + resource: + type: "any" + want_effect: "audit" + + - note: expr_index_access + policy_rule: | + { + "if": { + "value": "[parameters('allowedLocations')[0]]", + "equals": "eastus" + }, + "then": { "effect": "audit" } + } + parameters: + allowedLocations: + - "eastus" + - "westus" + resource: + type: "any" + want_effect: "audit" + + # ========================================================================= + # Escaped bracket literals (not expressions) + # ========================================================================= + + - note: escaped_bracket_literal + policy_rule: | + { + "if": { + "field": "name", + "equals": "[[not-an-expression]" + }, + "then": { "effect": "audit" } + } + resource: + name: "[not-an-expression]" + want_effect: "audit" + + # ========================================================================= + # Complex nested expressions + # ========================================================================= + + - note: expr_complex_nested + policy_rule: | + { + "if": { + "value": "[if(contains(toLower(field('location')), 'us'), 'allowed', 'blocked')]", + "equals": "blocked" + }, + "then": { "effect": "deny" } + } + resource: + location: "northeurope" + want_effect: "deny" + + - note: expr_multiple_expression_fields + policy_rule: | + { + "if": { + "allOf": [ + { + "value": "[parameters('environment')]", + "equals": "production" + }, + { + "value": "[concat(parameters('prefix'), '-', parameters('suffix'))]", + "notEquals": "" + }, + { + "value": "[length(parameters('allowedLocations'))]", + "greater": 0 + } + ] + }, + "then": { "effect": "deny" } + } + parameters: + environment: "production" + prefix: "prod" + suffix: "01" + allowedLocations: + - "eastus" + resource: + type: "any" + want_effect: "deny" + + # ========================================================================= + # Unary minus for negative number literals + # ========================================================================= + + - note: expr_unary_minus_literal + policy_rule: | + { + "if": { + "value": "[add(-1, 5)]", + "equals": 4 + }, + "then": { "effect": "deny" } + } + resource: + type: "any" + want_effect: "deny" + + - note: expr_unary_minus_float + policy_rule: | + { + "if": { + "value": "[add(-2.5, 3.5)]", + "equals": 1 + }, + "then": { "effect": "deny" } + } + resource: + type: "any" + want_effect: "deny" + + - note: expr_unary_minus_sub_expression + policy_rule: | + { + "if": { + "value": "[sub(10, -3)]", + "equals": 13 + }, + "then": { "effect": "deny" } + } + resource: + type: "any" + want_effect: "deny" + + - note: expr_zero_arg_function_call + policy_rule: | + { + "if": { + "value": "[concat()]", + "equals": "" + }, + "then": { "effect": "deny" } + } + resource: + type: "any" + want_effect: "deny" diff --git a/tests/azure_policy/parser_tests/cases/fields.yaml b/tests/azure_policy/parser_tests/cases/fields.yaml new file mode 100644 index 00000000..6b78cf5a --- /dev/null +++ b/tests/azure_policy/parser_tests/cases/fields.yaml @@ -0,0 +1,323 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Built-in Fields Test Suite +# Tests all built-in field types: type, id, kind, name, location, fullName, +# tags, identity.type, and tag indexing patterns. + +cases: + # ========================================================================= + # Core built-in fields + # ========================================================================= + + - note: field_type + policy_rule: | + { + "if": { + "field": "type", + "equals": "Microsoft.Compute/virtualMachines" + }, + "then": { "effect": "deny" } + } + resource: + type: "Microsoft.Compute/virtualMachines" + want_effect: "deny" + + - note: field_id + policy_rule: | + { + "if": { + "field": "id", + "contains": "/resourceGroups/myRg/" + }, + "then": { "effect": "audit" } + } + resource: + id: "/subscriptions/sub1/resourceGroups/myRg/providers/Microsoft.Compute/virtualMachines/vm1" + want_effect: "audit" + + - note: field_kind + policy_rule: | + { + "if": { + "field": "kind", + "equals": "StorageV2" + }, + "then": { "effect": "audit" } + } + resource: + kind: "StorageV2" + want_effect: "audit" + + - note: field_name + policy_rule: | + { + "if": { + "field": "name", + "contains": "prod" + }, + "then": { "effect": "deny" } + } + resource: + name: "my-prod-vm" + want_effect: "deny" + + - note: field_location + policy_rule: | + { + "if": { + "field": "location", + "equals": "eastus" + }, + "then": { "effect": "deny" } + } + resource: + location: "eastus" + want_effect: "deny" + + - note: field_fullName + policy_rule: | + { + "if": { + "field": "fullName", + "contains": "Microsoft.Compute" + }, + "then": { "effect": "audit" } + } + resource: + fullName: "Microsoft.Compute/virtualMachines/vm1" + want_effect: "audit" + + - note: field_identity_type + policy_rule: | + { + "if": { + "field": "identity.type", + "equals": "SystemAssigned" + }, + "then": { "effect": "audit" } + } + resource: + identity: + type: "SystemAssigned" + want_effect: "audit" + + # ========================================================================= + # Tags + # ========================================================================= + + - note: field_tags_object + policy_rule: | + { + "if": { + "field": "tags", + "containsKey": "environment" + }, + "then": { "effect": "audit" } + } + resource: + tags: + environment: "production" + want_effect: "audit" + + - note: field_tags_dot_notation + policy_rule: | + { + "if": { + "field": "tags.environment", + "equals": "production" + }, + "then": { "effect": "audit" } + } + resource: + tags: + environment: "production" + want_effect: "audit" + + - note: field_tags_bracket_notation + policy_rule: | + { + "if": { + "field": "tags['environment']", + "equals": "production" + }, + "then": { "effect": "deny" } + } + resource: + tags: + environment: "production" + want_effect: "deny" + + - note: field_tags_dot_hyphen + policy_rule: | + { + "if": { + "field": "tags.cost-center", + "equals": "engineering" + }, + "then": { "effect": "audit" } + } + resource: + tags: + cost-center: "engineering" + want_effect: "audit" + + - note: field_tags_bracket_space + policy_rule: | + { + "if": { + "field": "tags['Created By']", + "exists": true + }, + "then": { "effect": "audit" } + } + resource: + tags: + Created By: "admin" + want_effect: "audit" + + - note: field_tags_missing + policy_rule: | + { + "if": { + "field": "tags.environment", + "exists": false + }, + "then": { "effect": "deny" } + } + resource: + tags: {} + want_effect: "deny" + + # ========================================================================= + # Nested property fields (aliases) + # ========================================================================= + # Note: These test parsing of alias-like dotted paths in field position. + # Actual alias resolution is out of scope; these confirm the parser + # correctly handles them. + + - note: field_deep_property + policy_rule: | + { + "if": { + "field": "properties.securityProfile.uefiSettings.secureBootEnabled", + "equals": true + }, + "then": { "effect": "audit" } + } + resource: + properties: + securityProfile: + uefiSettings: + secureBootEnabled: true + want_effect: "audit" + + - note: field_multiple_field_conditions + policy_rule: | + { + "if": { + "allOf": [ + { "field": "type", "equals": "Microsoft.Network/networkSecurityGroups/securityRules" }, + { "field": "name", "contains": "allow" }, + { "field": "location", "in": ["eastus", "westus", "centralus"] }, + { "field": "tags.team", "equals": "security" } + ] + }, + "then": { "effect": "audit" } + } + resource: + type: "Microsoft.Network/networkSecurityGroups/securityRules" + name: "allow-https" + location: "eastus" + tags: + team: "security" + want_effect: "audit" + + # ========================================================================= + # Bracket notation in field paths + # ========================================================================= + + - note: field_bracket_notation_string_key + policy_rule: | + { + "if": { + "field": "properties.networkAcls['default-action']", + "equals": "Allow" + }, + "then": { "effect": "deny" } + } + resource: + properties: + networkAcls: + default-action: "Allow" + want_effect: "deny" + + - note: field_bracket_notation_double_quote + policy_rule: | + { + "if": { + "field": "properties.settings['log-level']", + "equals": "debug" + }, + "then": { "effect": "deny" } + } + resource: + properties: + settings: + log-level: "debug" + want_effect: "deny" + + # ========================================================================= + # Array index access in field paths + # ========================================================================= + + - note: field_array_index_zero + policy_rule: | + { + "if": { + "field": "properties.ipConfigurations[0].name", + "equals": "primary" + }, + "then": { "effect": "deny" } + } + resource: + properties: + ipConfigurations: + - name: "primary" + properties: + subnet: "default" + - name: "secondary" + properties: + subnet: "dmz" + want_effect: "deny" + + - note: field_array_index_one + policy_rule: | + { + "if": { + "field": "properties.ipConfigurations[1].name", + "equals": "secondary" + }, + "then": { "effect": "deny" } + } + resource: + properties: + ipConfigurations: + - name: "primary" + - name: "secondary" + want_effect: "deny" + + - note: field_array_index_out_of_bounds + policy_rule: | + { + "if": { + "field": "properties.ipConfigurations[5].name", + "exists": true + }, + "then": { "effect": "deny" } + } + resource: + properties: + ipConfigurations: + - name: "primary" + want_undefined: true diff --git a/tests/azure_policy/parser_tests/cases/logical_combinators.yaml b/tests/azure_policy/parser_tests/cases/logical_combinators.yaml new file mode 100644 index 00000000..fec2d3fe --- /dev/null +++ b/tests/azure_policy/parser_tests/cases/logical_combinators.yaml @@ -0,0 +1,344 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Logical Combinators Test Suite +# Tests allOf, anyOf, not, and nested combinations. + +cases: + # ========================================================================= + # allOf + # ========================================================================= + + - note: allOf_two_conditions + policy_rule: | + { + "if": { + "allOf": [ + { "field": "type", "equals": "Microsoft.Compute/virtualMachines" }, + { "field": "location", "equals": "eastus" } + ] + }, + "then": { "effect": "deny" } + } + resource: + type: "Microsoft.Compute/virtualMachines" + location: "eastus" + want_effect: "deny" + + - note: allOf_partial_match + policy_rule: | + { + "if": { + "allOf": [ + { "field": "type", "equals": "Microsoft.Compute/virtualMachines" }, + { "field": "location", "equals": "westus" } + ] + }, + "then": { "effect": "deny" } + } + resource: + type: "Microsoft.Compute/virtualMachines" + location: "eastus" + want_undefined: true + + - note: allOf_three_conditions + policy_rule: | + { + "if": { + "allOf": [ + { "field": "type", "equals": "Microsoft.Compute/virtualMachines" }, + { "field": "location", "equals": "eastus" }, + { "field": "name", "contains": "prod" } + ] + }, + "then": { "effect": "deny" } + } + resource: + type: "Microsoft.Compute/virtualMachines" + location: "eastus" + name: "my-prod-vm" + want_effect: "deny" + + - note: allOf_single_condition + policy_rule: | + { + "if": { + "allOf": [ + { "field": "type", "equals": "Microsoft.Compute/virtualMachines" } + ] + }, + "then": { "effect": "deny" } + } + resource: + type: "Microsoft.Compute/virtualMachines" + want_effect: "deny" + + - note: allOf_empty_array + policy_rule: | + { + "if": { + "allOf": [] + }, + "then": { "effect": "deny" } + } + resource: + type: "anything" + want_effect: "deny" + + # ========================================================================= + # anyOf + # ========================================================================= + + - note: anyOf_first_matches + policy_rule: | + { + "if": { + "anyOf": [ + { "field": "location", "equals": "eastus" }, + { "field": "location", "equals": "westus" } + ] + }, + "then": { "effect": "deny" } + } + resource: + location: "eastus" + want_effect: "deny" + + - note: anyOf_second_matches + policy_rule: | + { + "if": { + "anyOf": [ + { "field": "location", "equals": "eastus" }, + { "field": "location", "equals": "westus" } + ] + }, + "then": { "effect": "deny" } + } + resource: + location: "westus" + want_effect: "deny" + + - note: anyOf_no_match + policy_rule: | + { + "if": { + "anyOf": [ + { "field": "location", "equals": "eastus" }, + { "field": "location", "equals": "westus" } + ] + }, + "then": { "effect": "deny" } + } + resource: + location: "northeurope" + want_undefined: true + + - note: anyOf_three_options + policy_rule: | + { + "if": { + "anyOf": [ + { "field": "type", "equals": "Microsoft.Compute/virtualMachines" }, + { "field": "type", "equals": "Microsoft.Compute/virtualMachineScaleSets" }, + { "field": "type", "equals": "Microsoft.Compute/disks" } + ] + }, + "then": { "effect": "audit" } + } + resource: + type: "Microsoft.Compute/disks" + want_effect: "audit" + + # ========================================================================= + # not + # ========================================================================= + + - note: not_condition + policy_rule: | + { + "if": { + "not": { + "field": "type", + "equals": "Microsoft.Compute/virtualMachines" + } + }, + "then": { "effect": "deny" } + } + resource: + type: "Microsoft.Storage/storageAccounts" + want_effect: "deny" + + - note: not_condition_no_match + policy_rule: | + { + "if": { + "not": { + "field": "type", + "equals": "Microsoft.Compute/virtualMachines" + } + }, + "then": { "effect": "deny" } + } + resource: + type: "Microsoft.Compute/virtualMachines" + want_undefined: true + + - note: not_allOf + policy_rule: | + { + "if": { + "not": { + "allOf": [ + { "field": "type", "equals": "Microsoft.Compute/virtualMachines" }, + { "field": "location", "equals": "eastus" } + ] + } + }, + "then": { "effect": "deny" } + } + resource: + type: "Microsoft.Compute/virtualMachines" + location: "westus" + want_effect: "deny" + + - note: not_anyOf + policy_rule: | + { + "if": { + "not": { + "anyOf": [ + { "field": "location", "equals": "eastus" }, + { "field": "location", "equals": "westus" } + ] + } + }, + "then": { "effect": "deny" } + } + resource: + location: "northeurope" + want_effect: "deny" + + # ========================================================================= + # Nested combinations + # ========================================================================= + + - note: allOf_with_nested_anyOf + policy_rule: | + { + "if": { + "allOf": [ + { "field": "type", "equals": "Microsoft.Compute/virtualMachines" }, + { + "anyOf": [ + { "field": "location", "equals": "eastus" }, + { "field": "location", "equals": "westus" } + ] + } + ] + }, + "then": { "effect": "deny" } + } + resource: + type: "Microsoft.Compute/virtualMachines" + location: "westus" + want_effect: "deny" + + - note: anyOf_with_nested_allOf + policy_rule: | + { + "if": { + "anyOf": [ + { + "allOf": [ + { "field": "type", "equals": "Microsoft.Compute/virtualMachines" }, + { "field": "location", "equals": "eastus" } + ] + }, + { + "allOf": [ + { "field": "type", "equals": "Microsoft.Storage/storageAccounts" }, + { "field": "location", "equals": "westus" } + ] + } + ] + }, + "then": { "effect": "deny" } + } + resource: + type: "Microsoft.Storage/storageAccounts" + location: "westus" + want_effect: "deny" + + - note: allOf_with_not + policy_rule: | + { + "if": { + "allOf": [ + { "field": "type", "equals": "Microsoft.Compute/virtualMachines" }, + { + "not": { + "field": "location", + "equals": "eastus" + } + } + ] + }, + "then": { "effect": "deny" } + } + resource: + type: "Microsoft.Compute/virtualMachines" + location: "westus" + want_effect: "deny" + + - note: deeply_nested_combinators + policy_rule: | + { + "if": { + "allOf": [ + { "field": "type", "equals": "Microsoft.Network/networkSecurityGroups/securityRules" }, + { + "not": { + "anyOf": [ + { + "allOf": [ + { "field": "properties.protocol", "equals": "TCP" }, + { "field": "properties.destinationPortRange", "in": ["443", "8443"] } + ] + }, + { + "allOf": [ + { "field": "properties.protocol", "equals": "UDP" }, + { "field": "properties.destinationPortRange", "equals": "53" } + ] + } + ] + } + } + ] + }, + "then": { "effect": "deny" } + } + resource: + type: "Microsoft.Network/networkSecurityGroups/securityRules" + properties: + protocol: "TCP" + destinationPortRange: "80" + want_effect: "deny" + + - note: double_negation + policy_rule: | + { + "if": { + "not": { + "not": { + "field": "type", + "equals": "Microsoft.Compute/virtualMachines" + } + } + }, + "then": { "effect": "deny" } + } + resource: + type: "Microsoft.Compute/virtualMachines" + want_effect: "deny" diff --git a/tests/azure_policy/parser_tests/cases/operators.yaml b/tests/azure_policy/parser_tests/cases/operators.yaml new file mode 100644 index 00000000..e4b5ba61 --- /dev/null +++ b/tests/azure_policy/parser_tests/cases/operators.yaml @@ -0,0 +1,453 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Operators Test Suite +# Tests all 19 Azure Policy condition operators with field-based conditions. + +cases: + # ========================================================================= + # equals / notEquals + # ========================================================================= + + - note: equals_string + policy_rule: | + { + "if": { + "field": "type", + "equals": "Microsoft.Compute/virtualMachines" + }, + "then": { "effect": "deny" } + } + resource: + type: "Microsoft.Compute/virtualMachines" + want_effect: "deny" + + - note: equals_string_no_match + policy_rule: | + { + "if": { + "field": "type", + "equals": "Microsoft.Compute/virtualMachines" + }, + "then": { "effect": "deny" } + } + resource: + type: "Microsoft.Storage/storageAccounts" + want_undefined: true + + - note: equals_number + policy_rule: | + { + "if": { + "field": "properties.count", + "equals": 5 + }, + "then": { "effect": "audit" } + } + resource: + properties: + count: 5 + want_effect: "audit" + + - note: equals_boolean + policy_rule: | + { + "if": { + "field": "properties.enabled", + "equals": true + }, + "then": { "effect": "audit" } + } + resource: + properties: + enabled: true + want_effect: "audit" + + - note: equals_null + policy_rule: | + { + "if": { + "field": "properties.optionalField", + "equals": null + }, + "then": { "effect": "audit" } + } + resource: + properties: {} + want_effect: "audit" + + - note: notEquals_string + policy_rule: | + { + "if": { + "field": "type", + "notEquals": "Microsoft.Compute/virtualMachines" + }, + "then": { "effect": "deny" } + } + resource: + type: "Microsoft.Storage/storageAccounts" + want_effect: "deny" + + - note: notEquals_no_match + policy_rule: | + { + "if": { + "field": "type", + "notEquals": "Microsoft.Compute/virtualMachines" + }, + "then": { "effect": "deny" } + } + resource: + type: "Microsoft.Compute/virtualMachines" + want_undefined: true + + # ========================================================================= + # contains / notContains + # ========================================================================= + + - note: contains_string + policy_rule: | + { + "if": { + "field": "name", + "contains": "prod" + }, + "then": { "effect": "audit" } + } + resource: + name: "my-prod-vm" + want_effect: "audit" + + - note: contains_no_match + policy_rule: | + { + "if": { + "field": "name", + "contains": "staging" + }, + "then": { "effect": "audit" } + } + resource: + name: "my-prod-vm" + want_undefined: true + + - note: notContains_string + policy_rule: | + { + "if": { + "field": "name", + "notContains": "staging" + }, + "then": { "effect": "audit" } + } + resource: + name: "my-prod-vm" + want_effect: "audit" + + # ========================================================================= + # containsKey / notContainsKey + # ========================================================================= + + - note: containsKey_field + policy_rule: | + { + "if": { + "field": "tags", + "containsKey": "environment" + }, + "then": { "effect": "audit" } + } + resource: + tags: + environment: "production" + want_effect: "audit" + + - note: notContainsKey_field + policy_rule: | + { + "if": { + "field": "tags", + "notContainsKey": "costCenter" + }, + "then": { "effect": "deny" } + } + resource: + tags: + environment: "production" + want_effect: "deny" + + # ========================================================================= + # greater / greaterOrEquals / less / lessOrEquals + # ========================================================================= + + - note: greater_number + policy_rule: | + { + "if": { + "field": "properties.maxRetries", + "greater": 5 + }, + "then": { "effect": "deny" } + } + resource: + properties: + maxRetries: 10 + want_effect: "deny" + + - note: greater_no_match + policy_rule: | + { + "if": { + "field": "properties.maxRetries", + "greater": 5 + }, + "then": { "effect": "deny" } + } + resource: + properties: + maxRetries: 3 + want_undefined: true + + - note: greaterOrEquals_equal + policy_rule: | + { + "if": { + "field": "properties.minInstances", + "greaterOrEquals": 3 + }, + "then": { "effect": "audit" } + } + resource: + properties: + minInstances: 3 + want_effect: "audit" + + - note: less_number + policy_rule: | + { + "if": { + "field": "properties.retentionDays", + "less": 30 + }, + "then": { "effect": "deny" } + } + resource: + properties: + retentionDays: 7 + want_effect: "deny" + + - note: lessOrEquals_number + policy_rule: | + { + "if": { + "field": "properties.maxConnections", + "lessOrEquals": 100 + }, + "then": { "effect": "audit" } + } + resource: + properties: + maxConnections: 50 + want_effect: "audit" + + # ========================================================================= + # in / notIn + # ========================================================================= + + - note: in_string_array + policy_rule: | + { + "if": { + "field": "location", + "in": ["eastus", "westus", "centralus"] + }, + "then": { "effect": "deny" } + } + resource: + location: "eastus" + want_effect: "deny" + + - note: in_no_match + policy_rule: | + { + "if": { + "field": "location", + "in": ["eastus", "westus"] + }, + "then": { "effect": "deny" } + } + resource: + location: "northeurope" + want_undefined: true + + - note: notIn_string_array + policy_rule: | + { + "if": { + "field": "location", + "notIn": ["eastus", "westus"] + }, + "then": { "effect": "deny" } + } + resource: + location: "northeurope" + want_effect: "deny" + + - note: in_number_array + policy_rule: | + { + "if": { + "field": "properties.port", + "in": [80, 443, 8080] + }, + "then": { "effect": "deny" } + } + resource: + properties: + port: 443 + want_effect: "deny" + + # ========================================================================= + # like / notLike + # ========================================================================= + + - note: like_wildcard + policy_rule: | + { + "if": { + "field": "name", + "like": "prod-*" + }, + "then": { "effect": "audit" } + } + resource: + name: "prod-server-01" + want_effect: "audit" + + - note: like_question_mark + policy_rule: | + { + "if": { + "field": "name", + "like": "vm-?" + }, + "then": { "effect": "audit" } + } + resource: + name: "vm-1" + want_effect: "audit" + + - note: notLike_wildcard + policy_rule: | + { + "if": { + "field": "name", + "notLike": "test-*" + }, + "then": { "effect": "audit" } + } + resource: + name: "prod-server-01" + want_effect: "audit" + + # ========================================================================= + # match / matchInsensitively + # ========================================================================= + + - note: match_pattern + policy_rule: | + { + "if": { + "field": "name", + "match": "vm-##" + }, + "then": { "effect": "audit" } + } + resource: + name: "vm-01" + want_effect: "audit" + + - note: matchInsensitively_pattern + policy_rule: | + { + "if": { + "field": "name", + "matchInsensitively": "VM-##" + }, + "then": { "effect": "audit" } + } + resource: + name: "vm-01" + want_effect: "audit" + + - note: notMatch_pattern + policy_rule: | + { + "if": { + "field": "name", + "notMatch": "test-*" + }, + "then": { "effect": "audit" } + } + resource: + name: "prod-server-01" + want_effect: "audit" + + - note: notMatchInsensitively_pattern + policy_rule: | + { + "if": { + "field": "name", + "notMatchInsensitively": "TEST-##" + }, + "then": { "effect": "audit" } + } + resource: + name: "prod-01" + want_effect: "audit" + + # ========================================================================= + # exists + # ========================================================================= + + - note: exists_true + policy_rule: | + { + "if": { + "field": "properties.optionalSetting", + "exists": true + }, + "then": { "effect": "audit" } + } + resource: + properties: + optionalSetting: "value" + want_effect: "audit" + + - note: exists_false + policy_rule: | + { + "if": { + "field": "properties.optionalSetting", + "exists": false + }, + "then": { "effect": "audit" } + } + resource: + properties: {} + want_effect: "audit" + + - note: exists_string_true + policy_rule: | + { + "if": { + "field": "properties.optionalSetting", + "exists": "true" + }, + "then": { "effect": "audit" } + } + resource: + properties: + optionalSetting: "value" + want_effect: "audit" diff --git a/tests/azure_policy/parser_tests/cases/parse_errors.yaml b/tests/azure_policy/parser_tests/cases/parse_errors.yaml new file mode 100644 index 00000000..d4f08093 --- /dev/null +++ b/tests/azure_policy/parser_tests/cases/parse_errors.yaml @@ -0,0 +1,273 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Parse Error Test Suite +# Tests that malformed policy JSON and invalid constructs are properly rejected. +# These test cases are expected to fail parsing. + +cases: + # ========================================================================= + # Missing required keys + # ========================================================================= + + - note: missing_if_key + skip: true # Tests policy_rule-level error; needs parse_policy_rule + policy_rule: | + { + "then": { "effect": "deny" } + } + want_parse_error: true + + - note: missing_then_key + skip: true # Tests policy_rule-level error; needs parse_policy_rule + policy_rule: | + { + "if": { + "field": "type", + "equals": "Microsoft.Compute/virtualMachines" + } + } + want_parse_error: true + + - note: missing_effect_in_then + skip: true # Tests policy_rule-level error; needs parse_policy_rule + policy_rule: | + { + "if": { + "field": "type", + "equals": "Microsoft.Compute/virtualMachines" + }, + "then": {} + } + want_parse_error: true + + # ========================================================================= + # Missing operator in condition + # ========================================================================= + + - note: field_without_operator + policy_rule: | + { + "if": { + "field": "type" + }, + "then": { "effect": "deny" } + } + want_parse_error: true + + - note: value_without_operator + policy_rule: | + { + "if": { + "value": "[parameters('x')]" + }, + "then": { "effect": "deny" } + } + want_parse_error: true + + # ========================================================================= + # Invalid JSON structure + # ========================================================================= + + - note: allOf_not_array + policy_rule: | + { + "if": { + "allOf": "not-an-array" + }, + "then": { "effect": "deny" } + } + want_parse_error: true + + - note: anyOf_not_array + policy_rule: | + { + "if": { + "anyOf": 42 + }, + "then": { "effect": "deny" } + } + want_parse_error: true + + - note: not_not_object + policy_rule: | + { + "if": { + "not": [1, 2, 3] + }, + "then": { "effect": "deny" } + } + want_parse_error: true + + # ========================================================================= + # Unknown keys in condition objects + # ========================================================================= + + - note: unknown_key_in_condition + policy_rule: | + { + "if": { + "field": "type", + "equals": "Microsoft.Compute/virtualMachines", + "unknownKey": "value" + }, + "then": { "effect": "deny" } + } + want_parse_error: true + + # ========================================================================= + # Count structure issues + # ========================================================================= + + - note: count_missing_field_and_value + policy_rule: | + { + "if": { + "count": {}, + "equals": 0 + }, + "then": { "effect": "deny" } + } + want_parse_error: true + + - note: count_with_both_field_and_value + policy_rule: | + { + "if": { + "count": { + "field": "some.alias[*]", + "value": ["a", "b"] + }, + "equals": 0 + }, + "then": { "effect": "deny" } + } + want_parse_error: true + + # ========================================================================= + # Invalid ARM template expressions + # ========================================================================= + + - note: malformed_expression_unclosed_paren + policy_rule: | + { + "if": { + "value": "[parameters('x']", + "equals": "something" + }, + "then": { "effect": "deny" } + } + want_parse_error: true + + # ========================================================================= + # Both field and value LHS + # ========================================================================= + + - note: both_field_and_value_lhs + policy_rule: | + { + "if": { + "field": "type", + "value": "something", + "equals": "Microsoft.Compute/virtualMachines" + }, + "then": { "effect": "deny" } + } + want_parse_error: true + + # ========================================================================= + # Empty input + # ========================================================================= + + - note: empty_object + policy_rule: | + {} + want_parse_error: true + + - note: not_an_object + policy_rule: | + "just a string" + want_parse_error: true + + # ========================================================================= + # Extra keys in logical operators + # ========================================================================= + + - note: extra_key_in_allOf + policy_rule: | + { + "if": { + "allOf": [ + { "field": "type", "equals": "X" } + ], + "field": "name", + "equals": "Y" + }, + "then": { "effect": "deny" } + } + want_parse_error: true + + - note: extra_key_in_not + policy_rule: | + { + "if": { + "not": { + "field": "type", + "equals": "Microsoft.Compute/virtualMachines" + }, + "field": "name", + "equals": "something" + }, + "then": { "effect": "deny" } + } + want_parse_error: true + + # ========================================================================= + # count.name errors + # ========================================================================= + + - note: count_name_with_field_not_value + policy_rule: | + { + "if": { + "count": { + "field": "items[*]", + "name": "item" + }, + "equals": 0 + }, + "then": { "effect": "deny" } + } + want_parse_error: true + + - note: count_name_not_string + policy_rule: | + { + "if": { + "count": { + "value": ["a", "b"], + "name": 42 + }, + "equals": 2 + }, + "then": { "effect": "deny" } + } + want_parse_error: true + + # ========================================================================= + # Multiple operators in a single condition + # ========================================================================= + + - note: multiple_operators + policy_rule: | + { + "if": { + "field": "type", + "equals": "Microsoft.Compute/virtualMachines", + "in": ["Microsoft.Compute/virtualMachines"] + }, + "then": { "effect": "deny" } + } + want_parse_error: true + + diff --git a/tests/azure_policy/parser_tests/mod.rs b/tests/azure_policy/parser_tests/mod.rs new file mode 100644 index 00000000..c581a015 --- /dev/null +++ b/tests/azure_policy/parser_tests/mod.rs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! YAML-driven test suite for Azure Policy constraint parser. +//! +//! Each YAML file in `tests/azure_policy/parser_tests/cases/` contains a list +//! of test cases. Each case specifies a `policy_rule` JSON string with +//! `"if"` / `"then"` structure. The test runner extracts the `"if"` constraint +//! JSON and parses it with `parse_constraint`. + +use anyhow::Result; +use regorus::languages::azure_policy::parser; +use regorus::Source; +use serde::{Deserialize, Serialize}; +use std::fs; +use test_generator::test_resources; + +/// A single test case in the YAML file. +#[derive(Serialize, Deserialize, Debug)] +struct TestCase { + /// Short identifier for the test case. + pub note: String, + + /// The Azure Policy `policyRule` JSON string. + #[serde(default)] + pub policy_rule: Option, + + /// If true, the constraint is expected to fail parsing. + #[serde(default)] + pub want_parse_error: Option, + + /// If true, skip this test case. + #[serde(default)] + pub skip: Option, +} + +/// Top-level YAML test file structure. +#[derive(Serialize, Deserialize, Debug)] +struct YamlTest { + /// Optional global policy rule JSON string. + #[serde(default)] + pub policy_rule: Option, + + pub cases: Vec, +} + +/// Filter test cases by the `TEST_CASE_FILTER` environment variable. +fn should_run_test_case(case_note: &str) -> bool { + if let Ok(filter) = std::env::var("TEST_CASE_FILTER") { + case_note.contains(&filter) + } else { + true + } +} + +/// Extract the `"if"` sub-object from a policy rule JSON string. +/// +/// Returns `None` if parsing fails or there is no `"if"` key (the caller +/// should feed the raw string to `parse_constraint` for error tests). +fn extract_if_json(policy_rule_json: &str) -> Option { + let v: serde_json::Value = serde_json::from_str(policy_rule_json).ok()?; + let if_value = v.get("if")?; + Some(if_value.to_string()) +} + +/// Run all test cases from a YAML file. +fn yaml_test_impl(file: &str) -> Result<()> { + let yaml_str = fs::read_to_string(file)?; + let test: YamlTest = serde_yaml::from_str(&yaml_str)?; + + println!("running {file}"); + if let Ok(filter) = std::env::var("TEST_CASE_FILTER") { + println!(" Test case filter active: '{filter}'"); + } + + let mut executed_count = 0usize; + let mut skipped_count = 0usize; + + for case in &test.cases { + if !should_run_test_case(&case.note) { + println!(" case {} filtered out", case.note); + skipped_count += 1; + continue; + } + + print!(" case {} ", case.note); + + if case.skip == Some(true) { + println!("skipped"); + skipped_count += 1; + continue; + } + + executed_count += 1; + + let expects_parse_error = case.want_parse_error == Some(true); + + let policy_rule_json = if let Some(ref rule) = case.policy_rule { + rule.clone() + } else if let Some(ref rule) = test.policy_rule { + rule.clone() + } else { + panic!("case '{}': must specify 'policy_rule'", case.note); + }; + + // Extract the "if" constraint JSON. If extraction fails (malformed + // JSON or missing "if" key), feed the raw policy_rule to + // parse_constraint — it should fail, matching want_parse_error. + let constraint_json = + extract_if_json(&policy_rule_json).unwrap_or_else(|| policy_rule_json.clone()); + + let source = Source::from_contents(format!("test:{}", case.note), constraint_json)?; + + let parse_result = parser::parse_constraint(&source).map(|_| ()); + + match parse_result { + Ok(()) => { + if expects_parse_error { + panic!( + "case '{}': expected parse error but parsing succeeded", + case.note + ); + } + println!("passed (parsed ok)"); + } + Err(e) => { + if expects_parse_error { + println!("passed (expected parse error: {})", e); + } else { + panic!("case '{}': unexpected parse error: {}", case.note, e); + } + } + } + } + + println!( + " Summary: {executed_count} executed, {skipped_count} skipped, {} total", + test.cases.len() + ); + Ok(()) +} + +#[test_resources("tests/azure_policy/parser_tests/cases/**/*.yaml")] +fn yaml_test(file: &str) { + yaml_test_impl(file).unwrap(); +} + +/// Test duplicate-key detection directly (bypassing serde_json which +/// silently deduplicates keys). +#[test] +fn duplicate_key_in_condition() { + // Two "field" keys in a single condition object. + let json = r#"{"field": "type", "field": "name", "equals": "X"}"#; + let source = Source::from_contents("test:dup_field".to_string(), json.to_string()).unwrap(); + let err = parser::parse_constraint(&source).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("duplicate key"), + "expected duplicate key error, got: {msg}" + ); +} + +#[test] +fn duplicate_key_in_count() { + // Two "field" keys inside a count block. + let json = r#"{"count": {"field": "a[*]", "field": "b[*]"}, "equals": 0}"#; + let source = + Source::from_contents("test:dup_count_field".to_string(), json.to_string()).unwrap(); + let err = parser::parse_constraint(&source).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("duplicate key"), + "expected duplicate key error, got: {msg}" + ); +}