mirror of
https://github.com/microsoft/regorus.git
synced 2026-08-05 02:16:11 +00:00
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:
committed by
GitHub
parent
687be2850b
commit
8f740e2f6f
77
docs/azure-policy/azurepolicy.ebnf
Normal file
77
docs/azure-policy/azurepolicy.ebnf
Normal 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 ']'
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
// ========================================================================
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
459
src/languages/azure_policy/parser/policy_definition.rs
Normal file
459
src/languages/azure_policy/parser/policy_definition.rs
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
213
src/languages/azure_policy/parser/policy_rule.rs
Normal file
213
src/languages/azure_policy/parser/policy_rule.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
197
tests/azure_policy/parser_tests/cases/policy_definition.yaml
Normal file
197
tests/azure_policy/parser_tests/cases/policy_definition.yaml
Normal 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
|
||||
225
tests/azure_policy/parser_tests/cases/policy_rule.yaml
Normal file
225
tests/azure_policy/parser_tests/cases/policy_rule.yaml
Normal 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"
|
||||
}
|
||||
@@ -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 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(()) => {
|
||||
|
||||
Reference in New Issue
Block a user