From 8f740e2f6f76dcff269909033d4185265e616c07 Mon Sep 17 00:00:00 2001 From: Anand Krishnamoorthi <35780660+anakrish@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:36:24 -0500 Subject: [PATCH] feat(azure-policy): add policy rule and policy definition parsers (#660) Extend the Azure Policy parser to handle complete policyRule and policyDefinition JSON structures, not just standalone constraints. Policy rule parser (policy_rule.rs): - Parse top-level { "if": ..., "then": ... } objects - Extract effect kind (deny, audit, append, modify, etc.) into typed AST - Parse "details" structurally when it is an object to pull out existenceCondition as a first-class Constraint; fall back to opaque JSON for non-object details (e.g. append array form) - Detect duplicate/missing keys for "if", "then", "effect", "details" Policy definition parser (policy_definition.rs): - Handle both wrapped ARM envelope ({ "properties": { ... } }) and unwrapped (properties-level keys at top level) forms - Type-extract displayName, description, mode, metadata, parameters, and policyRule; everything else goes into extra - Parse parameter definitions with type, defaultValue, allowedValues, and metadata; detect duplicate parameter names - Duplicate key detection throughout Grammar documentation (docs/azure-policy/azurepolicy.ebnf): - Add formal EBNF grammar covering policy-rule, then-block, constraints, conditions, all 19 operators, count expressions, JSON values, and ARM template expressions Test harness changes: - Add parse_level field to YAML test cases: "constraint" (default), "policy_rule", or "policy_definition" - Un-skip three parse_errors cases that needed policy_rule-level parsing - Add policy_rule.yaml with 12 cases covering all 9 effect kinds, existenceCondition, parameterized effects, complex conditions, and extra key handling - Add policy_definition.yaml with wrapped, unwrapped, parameterized, missing-policyRule, and duplicate-key error cases --- docs/azure-policy/azurepolicy.ebnf | 77 +++ src/languages/azure_policy/ast/mod.rs | 11 +- .../azure_policy/parser/constraint.rs | 26 +- src/languages/azure_policy/parser/core.rs | 21 + src/languages/azure_policy/parser/mod.rs | 71 ++- .../azure_policy/parser/policy_definition.rs | 459 ++++++++++++++++++ .../azure_policy/parser/policy_rule.rs | 213 ++++++++ .../parser_tests/cases/parse_errors.yaml | 6 +- .../parser_tests/cases/policy_definition.yaml | 197 ++++++++ .../parser_tests/cases/policy_rule.yaml | 225 +++++++++ tests/azure_policy/parser_tests/mod.rs | 45 +- 11 files changed, 1311 insertions(+), 40 deletions(-) create mode 100644 docs/azure-policy/azurepolicy.ebnf create mode 100644 src/languages/azure_policy/parser/policy_definition.rs create mode 100644 src/languages/azure_policy/parser/policy_rule.rs create mode 100644 tests/azure_policy/parser_tests/cases/policy_definition.yaml create mode 100644 tests/azure_policy/parser_tests/cases/policy_rule.yaml diff --git a/docs/azure-policy/azurepolicy.ebnf b/docs/azure-policy/azurepolicy.ebnf new file mode 100644 index 0000000..c2c1027 --- /dev/null +++ b/docs/azure-policy/azurepolicy.ebnf @@ -0,0 +1,77 @@ +(* Azure Policy grammar. + * + * All key matching is case-insensitive. JSON object keys are unordered, + * so the ordering shown below is for readability only. + *) + +(* ================================================================ + * Policy rule & then block + * NOTE: Keys may appear in any order; extra keys may appear between + * the recognized ones. The ordering below is illustrative. + * ================================================================ *) + +policy-rule ::= '{' '"if"' ':' constraint ',' '"then"' ':' then-block + (',' STRING ':' json-value)* '}' + +then-block ::= '{' '"effect"' ':' STRING + (',' '"details"' ':' json-value)? '}' + +(* ================================================================ + * Constraints + * ================================================================ *) + +constraint ::= allOf | anyOf | not | condition + +allOf ::= '{' '"allOf"' ':' '[' (constraint (',' constraint)*)? ']' '}' +anyOf ::= '{' '"anyOf"' ':' '[' (constraint (',' constraint)*)? ']' '}' +not ::= '{' '"not"' ':' constraint '}' + +(* Keys within a condition are unordered; exactly one lhs-entry and one + * op-entry are required. *) +condition ::= '{' lhs-entry ',' op-entry '}' + +lhs-entry ::= field | value-lhs | count +field ::= '"field"' ':' string-value +value-lhs ::= '"value"' ':' json-value +op-entry ::= operator ':' json-value + +operator ::= '"contains"' | '"containsKey"' | '"equals"' | '"notEquals"' + | '"greater"' | '"greaterOrEquals"' | '"less"' | '"lessOrEquals"' + | '"exists"' | '"in"' | '"notIn"' + | '"like"' | '"notLike"' + | '"match"' | '"matchInsensitively"' + | '"notMatch"' | '"notMatchInsensitively"' + | '"notContains"' | '"notContainsKey"' + +(* ================================================================ + * Count expressions + * ================================================================ *) + +count ::= '"count"' ':' count-inner +count-inner ::= count-field | count-value +count-field ::= '{' field (',' where)? '}' +count-value ::= '{' value-lhs (',' '"name"' ':' STRING)? (',' where)? '}' +where ::= '"where"' ':' constraint + +(* ================================================================ + * JSON values & template expressions + * ================================================================ *) + +string-value ::= STRING | '"[' string-expr ']"' + +json-value ::= STRING | NUMBER | BOOL | NULL + | array | object + | '"[' string-expr ']"' +array ::= '[' (json-value (',' json-value)*)? ']' +object ::= '{' (STRING ':' json-value (',' STRING ':' json-value)*)? '}' + +(* ================================================================ + * ARM template expression sub-grammar + * ================================================================ *) + +string-expr ::= NUMBER | STRING | '-' string-expr | complex-expr + +complex-expr ::= IDENT + | complex-expr '.' IDENT + | complex-expr '(' (string-expr (',' string-expr)*)? ')' + | complex-expr '[' string-expr ']' \ No newline at end of file diff --git a/src/languages/azure_policy/ast/mod.rs b/src/languages/azure_policy/ast/mod.rs index 418adbb..eb001c3 100644 --- a/src/languages/azure_policy/ast/mod.rs +++ b/src/languages/azure_policy/ast/mod.rs @@ -87,7 +87,7 @@ pub enum EffectKind { DenyAction, Manual, /// An effect value that wasn't recognized (may be a parameterized expression). - /// Use [`EffectNode::raw`] to get the original text. + /// The original text can be retrieved from [`EffectNode::raw`]. Other, } @@ -283,13 +283,18 @@ pub struct PolicyDefinition { /// Optional `metadata` (kept as raw JSON). pub metadata: Option, - /// Parameter definitions as an ordered list; lookups should match `ParameterDefinition::name`. + /// Parameter definitions as an ordered list; each entry includes its parameter name. pub parameters: Vec, /// The parsed `policyRule`. pub policy_rule: PolicyRule, - /// Any other top-level fields not handled above (e.g., `id`, `name`, `type`, `policyType`). + /// Any unrecognized fields collected during parsing. + /// + /// In the **wrapped** form this includes both envelope-level keys + /// (e.g., `id`, `name`, `type`) and unrecognized keys inside the + /// inner `properties` object. In the **unwrapped** form it contains + /// only unrecognized top-level keys. pub extra: Vec, } diff --git a/src/languages/azure_policy/parser/constraint.rs b/src/languages/azure_policy/parser/constraint.rs index ef4b284..349f542 100644 --- a/src/languages/azure_policy/parser/constraint.rs +++ b/src/languages/azure_policy/parser/constraint.rs @@ -18,18 +18,6 @@ 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 { @@ -191,7 +179,7 @@ impl<'source> Parser<'source> { expected: "JSON value for 'field'", }); }; - set_once(&mut field, (key_span.clone(), jv), &key, &key_span)?; + Self::set_once(&mut field, (key_span.clone(), jv), &key, &key_span)?; } "value" => { let EntryValue::Json(jv) = entry_value else { @@ -200,7 +188,7 @@ impl<'source> Parser<'source> { expected: "JSON value for 'value'", }); }; - set_once(&mut value, (key_span.clone(), jv), &key, &key_span)?; + Self::set_once(&mut value, (key_span.clone(), jv), &key, &key_span)?; } "count" => { let EntryValue::CountInner(ci) = entry_value else { @@ -209,7 +197,7 @@ impl<'source> Parser<'source> { expected: "object for 'count'", }); }; - set_once(&mut count, (key_span.clone(), ci), &key, &key_span)?; + Self::set_once(&mut count, (key_span.clone(), ci), &key, &key_span)?; } _ => { if let Some(op_kind) = parse_operator_kind(&key.to_lowercase()) { @@ -288,19 +276,19 @@ impl<'source> Parser<'source> { match key_lower.as_str() { "field" => { let jv = self.parse_json_value()?; - set_once(&mut field, (key_span.clone(), jv), &key_lower, &key_span)?; + Self::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)?; + Self::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)?; + Self::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)?; + Self::set_once(&mut where_, c, &key_lower, &key_span)?; } _ => { return Err(ParseError::UnrecognizedKey { diff --git a/src/languages/azure_policy/parser/core.rs b/src/languages/azure_policy/parser/core.rs index 433fedb..321aff9 100644 --- a/src/languages/azure_policy/parser/core.rs +++ b/src/languages/azure_policy/parser/core.rs @@ -346,6 +346,27 @@ impl<'source> Parser<'source> { }) } + // ======================================================================== + // Duplicate-key guard + // ======================================================================== + + /// Set `slot` to `val`, returning [`ParseError::DuplicateKey`] if already set. + pub(super) 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(()) + } + // ======================================================================== // Conversion helpers // ======================================================================== diff --git a/src/languages/azure_policy/parser/mod.rs b/src/languages/azure_policy/parser/mod.rs index 54be67b..b18f127 100644 --- a/src/languages/azure_policy/parser/mod.rs +++ b/src/languages/azure_policy/parser/mod.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Recursive-descent JSON parser for Azure Policy rule constraints. +//! Custom recursive-descent JSON parser for Azure Policy rules. //! //! Parses Azure Policy JSON directly from [`Lexer`] tokens, building span-annotated //! AST nodes in a single pass. No intermediate `serde_json::Value` is created. @@ -9,19 +9,34 @@ //! 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. +//! +//! ## Usage +//! +//! ```ignore +//! use regorus::Source; +//! use regorus::languages::azure_policy::parser; +//! +//! let json = r#"{ "if": { "field": "type", "equals": "Microsoft.Compute/virtualMachines" }, +//! "then": { "effect": "deny" } }"#; +//! let source = Source::from_contents("policy.json".into(), json.into())?; +//! let rule = parser::parse_policy_rule(&source)?; +//! ``` mod constraint; mod core; mod error; +mod policy_definition; +mod policy_rule; pub(super) use self::core::json_unescape; + pub use error::ParseError; use alloc::string::ToString as _; use crate::lexer::{Source, TokenKind}; -use super::ast::{Constraint, FieldKind, OperatorKind}; +use super::ast::{Constraint, FieldKind, OperatorKind, PolicyDefinition, PolicyRule}; use super::expr::ExprParser; use self::core::Parser; @@ -30,12 +45,56 @@ use self::core::Parser; // Public API // ============================================================================ +/// Parse an Azure Policy rule from a JSON source. +/// +/// The source should contain a complete `policyRule` JSON object: +/// ```json +/// { +/// "if": { "field": "type", "equals": "Microsoft.Compute/virtualMachines" }, +/// "then": { "effect": "deny" } +/// } +/// ``` +/// +/// Returns a span-annotated [`PolicyRule`] AST. +pub fn parse_policy_rule(source: &Source) -> Result { + let mut parser = Parser::new(source)?; + let rule = parser.parse_policy_rule()?; + + if parser.tok.0 != TokenKind::Eof { + return Err(ParseError::UnexpectedToken { + span: parser.tok.1.clone(), + expected: "end of input", + }); + } + + Ok(rule) +} + +/// Parse a full Azure Policy definition from a JSON source. +/// +/// Accepts two forms: +/// 1. **Wrapped**: `{ "properties": { "policyRule": ..., ... }, "id": ..., ... }` +/// 2. **Unwrapped**: `{ "displayName": ..., "policyRule": ..., ... }` +/// +/// Returns a [`PolicyDefinition`] with typed fields for known properties +/// and a catch-all list of `extra` entries for everything else. +pub fn parse_policy_definition(source: &Source) -> Result { + let mut parser = Parser::new(source)?; + let defn = parser.parse_policy_definition()?; + + if parser.tok.0 != TokenKind::Eof { + return Err(ParseError::UnexpectedToken { + span: parser.tok.1.clone(), + expected: "end of input", + }); + } + + Ok(defn) +} + /// 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 }` +/// Useful for parsing just the `"if"` part of a policy rule. pub fn parse_constraint(source: &Source) -> Result { let mut parser = Parser::new(source)?; let constraint = parser.parse_constraint()?; diff --git a/src/languages/azure_policy/parser/policy_definition.rs b/src/languages/azure_policy/parser/policy_definition.rs new file mode 100644 index 0000000..b6175ed --- /dev/null +++ b/src/languages/azure_policy/parser/policy_definition.rs @@ -0,0 +1,459 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Parsing for the full Azure Policy definition envelope. +//! +//! Handles the outer `{ "properties": { ... } }` wrapper, extracting typed +//! fields (`displayName`, `description`, `mode`, `parameters`, `policyRule`) +//! and collecting everything else into `extra`. + +use alloc::collections::BTreeSet; +use alloc::string::String; +use alloc::vec::Vec; + +use crate::lexer::Span; + +use crate::languages::azure_policy::ast::{ + JsonValue, ObjectEntry, ParameterDefinition, PolicyDefinition, PolicyRule, +}; + +use super::core::Parser; +use super::error::ParseError; + +impl<'source> Parser<'source> { + /// Parse a full Azure Policy definition JSON. + /// + /// Accepts two forms: + /// 1. **Wrapped**: `{ "properties": { ... }, ... }` — the standard ARM resource format. + /// 2. **Unwrapped**: `{ "displayName": ..., "policyRule": ..., ... }` — just the + /// `properties` contents directly. + /// + /// In the wrapped form, fields outside `properties` (like `id`, `name`, `type`) + /// are collected into `extra`. + /// + /// Detection: if the top-level object has a `"properties"` key whose value + /// is a JSON object, the wrapped path is taken. (In practice, the ARM + /// resource envelope always uses `"properties"` to wrap the definition body.) + pub fn parse_policy_definition(&mut self) -> Result { + let open = self.expect_symbol("{")?; + + // Determine whether this looks like the wrapped ARM resource form by + // scanning top-level keys as they are parsed. + // + // Because JSON keys can appear in any order, we handle the general case: + // iterate all top-level keys; if we encounter "properties" whose value is + // an object, take the wrapped path. Keys outside "properties" go into + // `extra`. + // + // For the unwrapped form, all top-level keys are treated as + // properties-level fields. + let mut display_name: Option = None; + let mut description: Option = None; + let mut mode: Option = None; + let mut metadata: Option = None; + let mut parameters: Vec = Vec::new(); + let mut policy_rule: Option = None; + let mut extra = Vec::new(); + + // Track whether we found a "properties" object (wrapped form). + let mut found_properties = false; + // Track whether we have seen any "properties" key (for duplicate detection). + let mut seen_properties = false; + // Track seen recognized keys for duplicate detection (unwrapped path). + let mut seen_keys: Vec = Vec::new(); + + if self.token_text() != "}" { + loop { + let (key_span, key) = self.expect_string()?; + self.expect_symbol(":")?; + + match key.to_lowercase().as_str() { + "properties" if seen_properties => { + return Err(ParseError::DuplicateKey { + span: key_span, + key, + }); + } + "properties" => { + seen_properties = true; + + if self.token_text() == "{" { + // Wrapped form: parse inner properties object structurally. + found_properties = true; + self.parse_properties_fields( + &mut display_name, + &mut description, + &mut mode, + &mut metadata, + &mut parameters, + &mut policy_rule, + &mut extra, + &mut seen_keys, + )?; + } else { + // Unwrapped form: "properties" is just a regular key. + Self::handle_properties_key( + self, + key_span, + &key, + &mut display_name, + &mut description, + &mut mode, + &mut metadata, + &mut parameters, + &mut policy_rule, + &mut extra, + &mut seen_keys, + )?; + } + } + _ if !found_properties => { + // Unwrapped form: dispatch on properties-level keys. + Self::handle_properties_key( + self, + key_span, + &key, + &mut display_name, + &mut description, + &mut mode, + &mut metadata, + &mut parameters, + &mut policy_rule, + &mut extra, + &mut seen_keys, + )?; + } + _ => { + // Wrapped form: keys outside "properties" go to extra. + let value = self.parse_json_value()?; + extra.push(ObjectEntry { + 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, + }; + + let policy_rule = policy_rule.ok_or_else(|| ParseError::MissingKey { + span: span.clone(), + key: "policyRule", + })?; + + Ok(PolicyDefinition { + span, + display_name, + description, + mode, + metadata, + parameters, + policy_rule, + extra, + }) + } + + /// Parse the interior of a `"properties": { ... }` object, dispatching + /// recognized keys to their typed parsers. + #[allow(clippy::too_many_arguments)] + fn parse_properties_fields( + &mut self, + display_name: &mut Option, + description: &mut Option, + mode: &mut Option, + metadata: &mut Option, + parameters: &mut Vec, + policy_rule: &mut Option, + extra: &mut Vec, + seen_keys: &mut Vec, + ) -> Result<(), ParseError> { + self.expect_symbol("{")?; + + if self.token_text() != "}" { + loop { + let (key_span, key) = self.expect_string()?; + self.expect_symbol(":")?; + + Self::handle_properties_key( + self, + key_span, + &key, + display_name, + description, + mode, + metadata, + parameters, + policy_rule, + extra, + seen_keys, + )?; + + if self.token_text() == "," { + self.advance()?; + } else { + break; + } + } + } + + self.expect_symbol("}")?; + Ok(()) + } + + /// Handle a single properties-level key, dispatching to the appropriate + /// typed parser or collecting into `extra`. + #[allow(clippy::too_many_arguments)] + fn handle_properties_key( + &mut self, + key_span: Span, + key: &str, + display_name: &mut Option, + description: &mut Option, + mode: &mut Option, + metadata: &mut Option, + parameters: &mut Vec, + policy_rule: &mut Option, + extra: &mut Vec, + seen_keys: &mut Vec, + ) -> Result<(), ParseError> { + let lk = key.to_lowercase(); + + // Reject duplicate recognized property keys regardless of value type. + let recognized = matches!( + lk.as_str(), + "displayname" | "description" | "mode" | "metadata" | "parameters" | "policyrule" + ); + if recognized { + if seen_keys.contains(&lk) { + return Err(ParseError::DuplicateKey { + span: key_span, + key: key.into(), + }); + } + seen_keys.push(lk.clone()); + } + + match lk.as_str() { + "displayname" => { + let value = self.parse_json_value()?; + assign_string_or_extra(value, display_name, key_span, key, extra); + } + "description" => { + let value = self.parse_json_value()?; + assign_string_or_extra(value, description, key_span, key, extra); + } + "mode" => { + let value = self.parse_json_value()?; + assign_string_or_extra(value, mode, key_span, key, extra); + } + "metadata" => { + *metadata = Some(self.parse_json_value()?); + } + "parameters" => { + // Parameters must be a JSON object; if not, push to extra. + if self.token_text() == "{" { + *parameters = self.parse_parameter_definitions()?; + } else { + let value = self.parse_json_value()?; + extra.push(ObjectEntry { + key_span, + key: key.into(), + value, + }); + } + } + "policyrule" => { + // Parse the policyRule directly from the token stream! + *policy_rule = Some(self.parse_policy_rule()?); + } + _ => { + let value = self.parse_json_value()?; + extra.push(ObjectEntry { + key_span, + key: key.into(), + value, + }); + } + } + Ok(()) + } + + /// Parse a `"parameters": { ... }` object into a list of + /// [`ParameterDefinition`], consuming tokens directly from the stream. + fn parse_parameter_definitions(&mut self) -> Result, ParseError> { + self.expect_symbol("{")?; + + let mut defs = Vec::new(); + let mut seen_names = BTreeSet::new(); + + if self.token_text() != "}" { + loop { + let param = self.parse_single_parameter()?; + if !seen_names.insert(param.name.to_lowercase()) { + return Err(ParseError::DuplicateKey { + span: param.name_span.clone(), + key: param.name.clone(), + }); + } + defs.push(param); + + if self.token_text() == "," { + self.advance()?; + } else { + break; + } + } + } + + self.expect_symbol("}")?; + Ok(defs) + } + + /// Parse a single `"paramName": { ... }` entry. + fn parse_single_parameter(&mut self) -> Result { + let (name_span, name) = self.expect_string()?; + self.expect_symbol(":")?; + + // Parameter definitions must be JSON objects. + if self.token_text() != "{" { + return Err(ParseError::Custom { + span: name_span, + message: alloc::format!("expected object for parameter '{}'", name), + }); + } + + let open = self.expect_symbol("{")?; + + let mut param_type: Option = None; + let mut default_value: Option = None; + let mut allowed_values: Option> = None; + let mut metadata: Option = None; + let mut extra = Vec::new(); + // Track seen recognized keys for duplicate detection. + let mut seen_type = false; + let mut seen_default_value = false; + let mut seen_allowed_values = false; + let mut seen_metadata = false; + + if self.token_text() != "}" { + loop { + let (ks, k) = self.expect_string()?; + self.expect_symbol(":")?; + + match k.to_lowercase().as_str() { + "type" => { + if seen_type { + return Err(ParseError::DuplicateKey { span: ks, key: k }); + } + seen_type = true; + let value = self.parse_json_value()?; + assign_string_or_extra(value, &mut param_type, ks, &k, &mut extra); + } + "defaultvalue" => { + if seen_default_value { + return Err(ParseError::DuplicateKey { span: ks, key: k }); + } + seen_default_value = true; + default_value = Some(self.parse_json_value()?); + } + "allowedvalues" => { + if seen_allowed_values { + return Err(ParseError::DuplicateKey { span: ks, key: k }); + } + seen_allowed_values = true; + let value = self.parse_json_value()?; + if let JsonValue::Array(_, items) = value { + allowed_values = Some(items); + } else { + extra.push(ObjectEntry { + key_span: ks, + key: k, + value, + }); + } + } + "metadata" => { + if seen_metadata { + return Err(ParseError::DuplicateKey { span: ks, key: k }); + } + seen_metadata = true; + metadata = Some(self.parse_json_value()?); + } + _ => { + let value = self.parse_json_value()?; + extra.push(ObjectEntry { + key_span: ks, + key: k, + 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, + }; + + Ok(ParameterDefinition { + span, + name, + name_span, + param_type, + default_value, + allowed_values, + metadata, + extra, + }) + } +} + +// ============================================================================ +// Helpers +// ============================================================================ + +/// If `value` is a plain string, assign it to `target`; otherwise push +/// the entry to `extra` so callers don't have to repeat this pattern. +fn assign_string_or_extra( + value: JsonValue, + target: &mut Option, + key_span: Span, + key: &str, + extra: &mut Vec, +) { + if let JsonValue::Str(_, ref s) = value { + *target = Some(s.clone()); + } else { + extra.push(ObjectEntry { + key_span, + key: key.into(), + value, + }); + } +} diff --git a/src/languages/azure_policy/parser/policy_rule.rs b/src/languages/azure_policy/parser/policy_rule.rs new file mode 100644 index 0000000..9f6389b --- /dev/null +++ b/src/languages/azure_policy/parser/policy_rule.rs @@ -0,0 +1,213 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Top-level policy rule parsing: `parse_policy_rule` and `parse_then_block`. + +use crate::lexer::Span; + +use crate::languages::azure_policy::ast::{ + Constraint, EffectKind, EffectNode, JsonValue, ObjectEntry, PolicyRule, ThenBlock, +}; + +use super::core::Parser; +use super::error::ParseError; + +impl<'source> Parser<'source> { + /// Parse the top-level `policyRule` object. + pub fn parse_policy_rule(&mut self) -> Result { + let open = self.expect_symbol("{")?; + + let mut condition: Option = None; + let mut then_block: Option = None; + + if self.token_text() != "}" { + loop { + let (key_span, key) = self.expect_string()?; + self.expect_symbol(":")?; + + match key.to_lowercase().as_str() { + "if" => { + let c = self.parse_constraint()?; + Self::set_once(&mut condition, c, "if", &key_span)?; + } + "then" => { + let tb = self.parse_then_block()?; + Self::set_once(&mut then_block, tb, "then", &key_span)?; + } + _ => { + let _ = self.parse_json_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, + }; + + let condition = condition.ok_or_else(|| ParseError::MissingKey { + span: span.clone(), + key: "if", + })?; + let then_block = then_block.ok_or_else(|| ParseError::MissingKey { + span: span.clone(), + key: "then", + })?; + + Ok(PolicyRule { + span, + condition, + then_block, + }) + } + + /// Parse the `"then"` block. + fn parse_then_block(&mut self) -> Result { + let open = self.expect_symbol("{")?; + + let mut effect: Option = None; + let mut details: Option = None; + let mut existence_condition: Option = None; + + if self.token_text() != "}" { + loop { + let (key_span, key) = self.expect_string()?; + self.expect_symbol(":")?; + + match key.to_lowercase().as_str() { + "effect" => { + let (val_span, val_text) = self.expect_string()?; + let kind = match val_text.to_lowercase().as_str() { + "deny" => EffectKind::Deny, + "audit" => EffectKind::Audit, + "append" => EffectKind::Append, + "auditifnotexists" => EffectKind::AuditIfNotExists, + "deployifnotexists" => EffectKind::DeployIfNotExists, + "disabled" => EffectKind::Disabled, + "modify" => EffectKind::Modify, + "denyaction" => EffectKind::DenyAction, + "manual" => EffectKind::Manual, + _ => EffectKind::Other, + }; + let node = EffectNode { + span: val_span, + kind, + raw: val_text, + }; + Self::set_once(&mut effect, node, "effect", &key_span)?; + } + "details" => { + // If details is an object, parse structurally to + // recognize existenceCondition inline. Otherwise + // (e.g., append's array form), parse as opaque JSON. + if self.token_text() == "{" { + let (det, ec) = self.parse_then_details()?; + Self::set_once(&mut details, det, "details", &key_span)?; + existence_condition = ec; + } else { + let det = self.parse_json_value()?; + Self::set_once(&mut details, det, "details", &key_span)?; + } + } + _ => { + let _ = self.parse_json_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, + }; + + let effect = effect.ok_or_else(|| ParseError::MissingKey { + span: span.clone(), + key: "effect", + })?; + + Ok(ThenBlock { + span, + effect, + details, + existence_condition, + }) + } + + /// Parse the `"details"` object, extracting `existenceCondition` as a + /// structured [`Constraint`] directly from the token stream. + /// + /// All other entries are collected into a generic `JsonValue::Object`. + fn parse_then_details(&mut self) -> Result<(JsonValue, Option), ParseError> { + let open = self.expect_symbol("{")?; + + let mut entries = alloc::vec::Vec::new(); + let mut existence_condition: Option = None; + + if self.token_text() != "}" { + loop { + let (key_span, key) = self.expect_string()?; + self.expect_symbol(":")?; + + if key.eq_ignore_ascii_case("existenceCondition") { + // Parse the constraint directly from the token stream and + // expose it separately via `ThenBlock::existence_condition`. + // Intentionally omit the `existenceCondition` entry from + // the `details` JsonValue since that field is returned + // separately on the AST node. + Self::set_once( + &mut existence_condition, + self.parse_constraint()?, + "existenceCondition", + &key_span, + )?; + } else { + let value = self.parse_json_value()?; + entries.push(ObjectEntry { + 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, + }; + + Ok((JsonValue::Object(span, entries), existence_condition)) + } +} diff --git a/tests/azure_policy/parser_tests/cases/parse_errors.yaml b/tests/azure_policy/parser_tests/cases/parse_errors.yaml index d4f0809..95b461c 100644 --- a/tests/azure_policy/parser_tests/cases/parse_errors.yaml +++ b/tests/azure_policy/parser_tests/cases/parse_errors.yaml @@ -11,7 +11,7 @@ cases: # ========================================================================= - note: missing_if_key - skip: true # Tests policy_rule-level error; needs parse_policy_rule + parse_level: policy_rule policy_rule: | { "then": { "effect": "deny" } @@ -19,7 +19,7 @@ cases: want_parse_error: true - note: missing_then_key - skip: true # Tests policy_rule-level error; needs parse_policy_rule + parse_level: policy_rule policy_rule: | { "if": { @@ -30,7 +30,7 @@ cases: want_parse_error: true - note: missing_effect_in_then - skip: true # Tests policy_rule-level error; needs parse_policy_rule + parse_level: policy_rule policy_rule: | { "if": { diff --git a/tests/azure_policy/parser_tests/cases/policy_definition.yaml b/tests/azure_policy/parser_tests/cases/policy_definition.yaml new file mode 100644 index 0000000..8be8efe --- /dev/null +++ b/tests/azure_policy/parser_tests/cases/policy_definition.yaml @@ -0,0 +1,197 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Policy Definition Test Suite +# Tests that full policy definition envelopes parse correctly via parse_policy_definition. + +cases: + # ========================================================================= + # Unwrapped form (properties-level keys directly) + # ========================================================================= + + - note: unwrapped_simple + parse_level: policy_definition + policy_rule: | + { + "displayName": "Deny VMs", + "description": "Deny creation of VMs", + "mode": "All", + "policyRule": { + "if": { + "field": "type", + "equals": "Microsoft.Compute/virtualMachines" + }, + "then": { + "effect": "deny" + } + } + } + + - note: unwrapped_with_parameters + parse_level: policy_definition + policy_rule: | + { + "displayName": "Allowed locations", + "mode": "Indexed", + "parameters": { + "allowedLocations": { + "type": "Array", + "metadata": { + "displayName": "Allowed locations", + "description": "The list of allowed locations." + } + } + }, + "policyRule": { + "if": { + "not": { + "field": "location", + "in": "[parameters('allowedLocations')]" + } + }, + "then": { + "effect": "deny" + } + } + } + + # ========================================================================= + # Wrapped form (ARM resource envelope) + # ========================================================================= + + - note: wrapped_arm_envelope + parse_level: policy_definition + policy_rule: | + { + "id": "/providers/Microsoft.Authorization/policyDefinitions/abc", + "name": "abc", + "type": "Microsoft.Authorization/policyDefinitions", + "properties": { + "displayName": "Test policy", + "policyRule": { + "if": { + "field": "type", + "equals": "Microsoft.Storage/storageAccounts" + }, + "then": { + "effect": "audit" + } + } + } + } + + # ========================================================================= + # Missing policyRule (should error) + # ========================================================================= + + - note: missing_policy_rule + parse_level: policy_definition + policy_rule: | + { + "displayName": "No rule here", + "mode": "All" + } + want_parse_error: true + + # ========================================================================= + # Duplicate keys (should error) + # ========================================================================= + + - note: wrapped_duplicate_properties_key + parse_level: policy_definition + policy_rule: | + { + "id": "/providers/Microsoft.Authorization/policyDefinitions/dup-properties", + "name": "dup-properties", + "type": "Microsoft.Authorization/policyDefinitions", + "properties": { + "displayName": "First properties block" + }, + "properties": { + "policyRule": { + "if": { + "field": "type", + "equals": "Microsoft.Storage/storageAccounts" + }, + "then": { + "effect": "audit" + } + } + } + } + want_parse_error: true + + - note: unwrapped_duplicate_policy_rule + parse_level: policy_definition + policy_rule: | + { + "displayName": "Duplicate policyRule", + "mode": "All", + "policyRule": { + "if": { + "field": "type", + "equals": "Microsoft.Compute/virtualMachines" + }, + "then": { + "effect": "deny" + } + }, + "policyRule": { + "if": { + "field": "type", + "equals": "Microsoft.Storage/storageAccounts" + }, + "then": { + "effect": "audit" + } + } + } + want_parse_error: true + + - note: wrapped_cross_scope_duplicate_key + parse_level: policy_definition + policy_rule: | + { + "displayName": "Outer displayName", + "properties": { + "displayName": "Inner displayName", + "policyRule": { + "if": { + "field": "type", + "equals": "Microsoft.Compute/virtualMachines" + }, + "then": { + "effect": "deny" + } + } + } + } + want_parse_error: true + + - note: unwrapped_duplicate_parameter_name_case_insensitive + parse_level: policy_definition + policy_rule: | + { + "displayName": "Duplicate parameter names by casing", + "mode": "Indexed", + "parameters": { + "allowedLocations": { + "type": "Array" + }, + "AllowedLocations": { + "type": "Array" + } + }, + "policyRule": { + "if": { + "not": { + "field": "location", + "in": "[parameters('allowedLocations')]" + } + }, + "then": { + "effect": "deny" + } + } + } + want_parse_error: true diff --git a/tests/azure_policy/parser_tests/cases/policy_rule.yaml b/tests/azure_policy/parser_tests/cases/policy_rule.yaml new file mode 100644 index 0000000..2d1b580 --- /dev/null +++ b/tests/azure_policy/parser_tests/cases/policy_rule.yaml @@ -0,0 +1,225 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Policy Rule Test Suite +# Tests that complete policyRule objects (with "if" and "then") parse correctly. + +cases: + # ========================================================================= + # Basic policy rules + # ========================================================================= + + - note: simple_deny + parse_level: policy_rule + policy_rule: | + { + "if": { + "field": "type", + "equals": "Microsoft.Compute/virtualMachines" + }, + "then": { + "effect": "deny" + } + } + + - note: audit_effect + parse_level: policy_rule + policy_rule: | + { + "if": { + "field": "location", + "notIn": ["eastus", "westus"] + }, + "then": { + "effect": "audit" + } + } + + - note: disabled_effect + parse_level: policy_rule + policy_rule: | + { + "if": { + "field": "type", + "equals": "Microsoft.Storage/storageAccounts" + }, + "then": { + "effect": "disabled" + } + } + + # ========================================================================= + # Effects with details + # ========================================================================= + + - note: append_effect + parse_level: policy_rule + policy_rule: | + { + "if": { + "field": "type", + "equals": "Microsoft.Storage/storageAccounts" + }, + "then": { + "effect": "Append", + "details": [ + { + "field": "Microsoft.Storage/storageAccounts/networkAcls.defaultAction", + "value": "Deny" + } + ] + } + } + + - note: modify_effect + parse_level: policy_rule + policy_rule: | + { + "if": { + "field": "type", + "equals": "Microsoft.Compute/virtualMachines" + }, + "then": { + "effect": "Modify", + "details": { + "roleDefinitionIds": [ + "/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c" + ], + "operations": [ + { + "operation": "addOrReplace", + "field": "tags.environment", + "value": "production" + } + ] + } + } + } + + - note: deny_action_effect + parse_level: policy_rule + policy_rule: | + { + "if": { + "field": "type", + "equals": "Microsoft.Sql/servers/databases" + }, + "then": { + "effect": "DenyAction", + "details": { + "actionNames": ["delete"] + } + } + } + + - note: manual_effect + parse_level: policy_rule + policy_rule: | + { + "if": { + "field": "type", + "equals": "Microsoft.Resources/subscriptions" + }, + "then": { + "effect": "Manual", + "details": { + "defaultState": "Unknown" + } + } + } + + - note: deploy_if_not_exists + parse_level: policy_rule + policy_rule: | + { + "if": { + "field": "type", + "equals": "Microsoft.Compute/virtualMachines" + }, + "then": { + "effect": "DeployIfNotExists", + "details": { + "type": "Microsoft.Compute/virtualMachines/extensions", + "existenceCondition": { + "field": "Microsoft.Compute/virtualMachines/extensions/type", + "equals": "MicrosoftMonitoringAgent" + } + } + } + } + + - note: audit_if_not_exists + parse_level: policy_rule + policy_rule: | + { + "if": { + "field": "type", + "equals": "Microsoft.Sql/servers" + }, + "then": { + "effect": "AuditIfNotExists", + "details": { + "type": "Microsoft.Sql/servers/auditingSettings", + "existenceCondition": { + "field": "Microsoft.Sql/servers/auditingSettings/state", + "equals": "Enabled" + } + } + } + } + + # ========================================================================= + # Parameterized effect + # ========================================================================= + + - note: parameterized_effect + parse_level: policy_rule + policy_rule: | + { + "if": { + "field": "type", + "equals": "Microsoft.Storage/storageAccounts" + }, + "then": { + "effect": "[parameters('effect')]" + } + } + + # ========================================================================= + # Complex conditions with then + # ========================================================================= + + - note: allof_condition_with_then + parse_level: policy_rule + policy_rule: | + { + "if": { + "allOf": [ + { + "field": "type", + "equals": "Microsoft.Compute/virtualMachines" + }, + { + "field": "location", + "equals": "eastus" + } + ] + }, + "then": { + "effect": "deny" + } + } + + - note: unknown_extra_keys_ignored + parse_level: policy_rule + policy_rule: | + { + "if": { + "field": "type", + "equals": "Microsoft.Compute/virtualMachines" + }, + "then": { + "effect": "deny" + }, + "extraKey": "ignored" + } diff --git a/tests/azure_policy/parser_tests/mod.rs b/tests/azure_policy/parser_tests/mod.rs index c581a01..125e4dd 100644 --- a/tests/azure_policy/parser_tests/mod.rs +++ b/tests/azure_policy/parser_tests/mod.rs @@ -32,6 +32,13 @@ struct TestCase { /// If true, skip this test case. #[serde(default)] pub skip: Option, + + /// Parsing level: `"constraint"` (default) extracts the `"if"` block and + /// calls `parse_constraint`; `"policy_rule"` calls `parse_policy_rule` on + /// the full JSON; `"policy_definition"` calls `parse_policy_definition` + /// on the full JSON. + #[serde(default)] + pub parse_level: Option, } /// Top-level YAML test file structure. @@ -95,7 +102,7 @@ fn yaml_test_impl(file: &str) -> Result<()> { let expects_parse_error = case.want_parse_error == Some(true); - let policy_rule_json = if let Some(ref rule) = case.policy_rule { + let input_json = if let Some(ref rule) = case.policy_rule { rule.clone() } else if let Some(ref rule) = test.policy_rule { rule.clone() @@ -103,15 +110,35 @@ fn yaml_test_impl(file: &str) -> Result<()> { 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 parse_level = case.parse_level.as_deref().unwrap_or("constraint"); - let source = Source::from_contents(format!("test:{}", case.note), constraint_json)?; - - let parse_result = parser::parse_constraint(&source).map(|_| ()); + let parse_result = match parse_level { + "policy_rule" => { + // Parse the full policy_rule JSON with parse_policy_rule. + let source = Source::from_contents(format!("test:{}", case.note), input_json)?; + parser::parse_policy_rule(&source).map(|_| ()) + } + "policy_definition" => { + // Parse the full policy definition JSON with parse_policy_definition. + let source = Source::from_contents(format!("test:{}", case.note), input_json)?; + parser::parse_policy_definition(&source).map(|_| ()) + } + "constraint" => { + // Extract the "if" constraint JSON. If extraction fails + // (malformed JSON or missing "if" key), feed the raw + // input to parse_constraint — it should fail, + // matching want_parse_error. + let constraint_json = match extract_if_json(&input_json) { + Some(json) => json, + None => input_json, + }; + let source = Source::from_contents(format!("test:{}", case.note), constraint_json)?; + parser::parse_constraint(&source).map(|_| ()) + } + other => { + panic!("case '{}': unknown parse_level '{}'", case.note, other); + } + }; match parse_result { Ok(()) => {