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
This commit is contained in:
Anand Krishnamoorthi
2026-04-06 11:36:24 -05:00
committed by GitHub
parent 687be2850b
commit 8f740e2f6f
11 changed files with 1311 additions and 40 deletions

View File

@@ -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 ']'

View File

@@ -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<JsonValue>,
/// 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<ParameterDefinition>,
/// 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<ObjectEntry>,
}

View File

@@ -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<T>(slot: &mut Option<T>, 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<Constraint, ParseError> {
@@ -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 {

View File

@@ -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<T>(
slot: &mut Option<T>,
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
// ========================================================================

View File

@@ -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<PolicyRule, ParseError> {
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<PolicyDefinition, ParseError> {
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<Constraint, ParseError> {
let mut parser = Parser::new(source)?;
let constraint = parser.parse_constraint()?;

View File

@@ -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<PolicyDefinition, ParseError> {
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<String> = None;
let mut description: Option<String> = None;
let mut mode: Option<String> = None;
let mut metadata: Option<JsonValue> = None;
let mut parameters: Vec<ParameterDefinition> = Vec::new();
let mut policy_rule: Option<PolicyRule> = 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<String> = 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<String>,
description: &mut Option<String>,
mode: &mut Option<String>,
metadata: &mut Option<JsonValue>,
parameters: &mut Vec<ParameterDefinition>,
policy_rule: &mut Option<PolicyRule>,
extra: &mut Vec<ObjectEntry>,
seen_keys: &mut Vec<String>,
) -> 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<String>,
description: &mut Option<String>,
mode: &mut Option<String>,
metadata: &mut Option<JsonValue>,
parameters: &mut Vec<ParameterDefinition>,
policy_rule: &mut Option<PolicyRule>,
extra: &mut Vec<ObjectEntry>,
seen_keys: &mut Vec<String>,
) -> 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<Vec<ParameterDefinition>, 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<ParameterDefinition, ParseError> {
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<String> = None;
let mut default_value: Option<JsonValue> = None;
let mut allowed_values: Option<Vec<JsonValue>> = None;
let mut metadata: Option<JsonValue> = 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<String>,
key_span: Span,
key: &str,
extra: &mut Vec<ObjectEntry>,
) {
if let JsonValue::Str(_, ref s) = value {
*target = Some(s.clone());
} else {
extra.push(ObjectEntry {
key_span,
key: key.into(),
value,
});
}
}

View File

@@ -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<PolicyRule, ParseError> {
let open = self.expect_symbol("{")?;
let mut condition: Option<Constraint> = None;
let mut then_block: Option<ThenBlock> = 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<ThenBlock, ParseError> {
let open = self.expect_symbol("{")?;
let mut effect: Option<EffectNode> = None;
let mut details: Option<JsonValue> = None;
let mut existence_condition: Option<Constraint> = 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<Constraint>), ParseError> {
let open = self.expect_symbol("{")?;
let mut entries = alloc::vec::Vec::new();
let mut existence_condition: Option<Constraint> = 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))
}
}

View File

@@ -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": {

View File

@@ -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

View File

@@ -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"
}

View File

@@ -32,6 +32,13 @@ struct TestCase {
/// If true, skip this test case.
#[serde(default)]
pub skip: Option<bool>,
/// 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<String>,
}
/// 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 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)?;
let parse_result = parser::parse_constraint(&source).map(|_| ());
parser::parse_constraint(&source).map(|_| ())
}
other => {
panic!("case '{}': unknown parse_level '{}'", case.note, other);
}
};
match parse_result {
Ok(()) => {