Skip to content

Commit 8f740e2

Browse files
authored
feat(azure-policy): add policy rule and policy definition parsers (microsoft#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
1 parent 687be28 commit 8f740e2

11 files changed

Lines changed: 1311 additions & 40 deletions

File tree

docs/azure-policy/azurepolicy.ebnf

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
(* Azure Policy grammar.
2+
*
3+
* All key matching is case-insensitive. JSON object keys are unordered,
4+
* so the ordering shown below is for readability only.
5+
*)
6+
7+
(* ================================================================
8+
* Policy rule & then block
9+
* NOTE: Keys may appear in any order; extra keys may appear between
10+
* the recognized ones. The ordering below is illustrative.
11+
* ================================================================ *)
12+
13+
policy-rule ::= '{' '"if"' ':' constraint ',' '"then"' ':' then-block
14+
(',' STRING ':' json-value)* '}'
15+
16+
then-block ::= '{' '"effect"' ':' STRING
17+
(',' '"details"' ':' json-value)? '}'
18+
19+
(* ================================================================
20+
* Constraints
21+
* ================================================================ *)
22+
23+
constraint ::= allOf | anyOf | not | condition
24+
25+
allOf ::= '{' '"allOf"' ':' '[' (constraint (',' constraint)*)? ']' '}'
26+
anyOf ::= '{' '"anyOf"' ':' '[' (constraint (',' constraint)*)? ']' '}'
27+
not ::= '{' '"not"' ':' constraint '}'
28+
29+
(* Keys within a condition are unordered; exactly one lhs-entry and one
30+
* op-entry are required. *)
31+
condition ::= '{' lhs-entry ',' op-entry '}'
32+
33+
lhs-entry ::= field | value-lhs | count
34+
field ::= '"field"' ':' string-value
35+
value-lhs ::= '"value"' ':' json-value
36+
op-entry ::= operator ':' json-value
37+
38+
operator ::= '"contains"' | '"containsKey"' | '"equals"' | '"notEquals"'
39+
| '"greater"' | '"greaterOrEquals"' | '"less"' | '"lessOrEquals"'
40+
| '"exists"' | '"in"' | '"notIn"'
41+
| '"like"' | '"notLike"'
42+
| '"match"' | '"matchInsensitively"'
43+
| '"notMatch"' | '"notMatchInsensitively"'
44+
| '"notContains"' | '"notContainsKey"'
45+
46+
(* ================================================================
47+
* Count expressions
48+
* ================================================================ *)
49+
50+
count ::= '"count"' ':' count-inner
51+
count-inner ::= count-field | count-value
52+
count-field ::= '{' field (',' where)? '}'
53+
count-value ::= '{' value-lhs (',' '"name"' ':' STRING)? (',' where)? '}'
54+
where ::= '"where"' ':' constraint
55+
56+
(* ================================================================
57+
* JSON values & template expressions
58+
* ================================================================ *)
59+
60+
string-value ::= STRING | '"[' string-expr ']"'
61+
62+
json-value ::= STRING | NUMBER | BOOL | NULL
63+
| array | object
64+
| '"[' string-expr ']"'
65+
array ::= '[' (json-value (',' json-value)*)? ']'
66+
object ::= '{' (STRING ':' json-value (',' STRING ':' json-value)*)? '}'
67+
68+
(* ================================================================
69+
* ARM template expression sub-grammar
70+
* ================================================================ *)
71+
72+
string-expr ::= NUMBER | STRING | '-' string-expr | complex-expr
73+
74+
complex-expr ::= IDENT
75+
| complex-expr '.' IDENT
76+
| complex-expr '(' (string-expr (',' string-expr)*)? ')'
77+
| complex-expr '[' string-expr ']'

src/languages/azure_policy/ast/mod.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ pub enum EffectKind {
8787
DenyAction,
8888
Manual,
8989
/// An effect value that wasn't recognized (may be a parameterized expression).
90-
/// Use [`EffectNode::raw`] to get the original text.
90+
/// The original text can be retrieved from [`EffectNode::raw`].
9191
Other,
9292
}
9393

@@ -283,13 +283,18 @@ pub struct PolicyDefinition {
283283
/// Optional `metadata` (kept as raw JSON).
284284
pub metadata: Option<JsonValue>,
285285

286-
/// Parameter definitions as an ordered list; lookups should match `ParameterDefinition::name`.
286+
/// Parameter definitions as an ordered list; each entry includes its parameter name.
287287
pub parameters: Vec<ParameterDefinition>,
288288

289289
/// The parsed `policyRule`.
290290
pub policy_rule: PolicyRule,
291291

292-
/// Any other top-level fields not handled above (e.g., `id`, `name`, `type`, `policyType`).
292+
/// Any unrecognized fields collected during parsing.
293+
///
294+
/// In the **wrapped** form this includes both envelope-level keys
295+
/// (e.g., `id`, `name`, `type`) and unrecognized keys inside the
296+
/// inner `properties` object. In the **unwrapped** form it contains
297+
/// only unrecognized top-level keys.
293298
pub extra: Vec<ObjectEntry>,
294299
}
295300

src/languages/azure_policy/parser/constraint.rs

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,6 @@ use super::core::{CountInner, EntryValue, Parser};
1818
use super::error::ParseError;
1919
use super::parse_operator_kind;
2020

21-
/// Set `slot` to `val`, returning a [`ParseError::DuplicateKey`] if it was already set.
22-
fn set_once<T>(slot: &mut Option<T>, val: T, key: &str, span: &Span) -> Result<(), ParseError> {
23-
if slot.is_some() {
24-
return Err(ParseError::DuplicateKey {
25-
span: span.clone(),
26-
key: String::from(key),
27-
});
28-
}
29-
*slot = Some(val);
30-
Ok(())
31-
}
32-
3321
impl<'source> Parser<'source> {
3422
/// Parse a constraint (a JSON object: logical combinator or leaf condition).
3523
pub fn parse_constraint(&mut self) -> Result<Constraint, ParseError> {
@@ -191,7 +179,7 @@ impl<'source> Parser<'source> {
191179
expected: "JSON value for 'field'",
192180
});
193181
};
194-
set_once(&mut field, (key_span.clone(), jv), &key, &key_span)?;
182+
Self::set_once(&mut field, (key_span.clone(), jv), &key, &key_span)?;
195183
}
196184
"value" => {
197185
let EntryValue::Json(jv) = entry_value else {
@@ -200,7 +188,7 @@ impl<'source> Parser<'source> {
200188
expected: "JSON value for 'value'",
201189
});
202190
};
203-
set_once(&mut value, (key_span.clone(), jv), &key, &key_span)?;
191+
Self::set_once(&mut value, (key_span.clone(), jv), &key, &key_span)?;
204192
}
205193
"count" => {
206194
let EntryValue::CountInner(ci) = entry_value else {
@@ -209,7 +197,7 @@ impl<'source> Parser<'source> {
209197
expected: "object for 'count'",
210198
});
211199
};
212-
set_once(&mut count, (key_span.clone(), ci), &key, &key_span)?;
200+
Self::set_once(&mut count, (key_span.clone(), ci), &key, &key_span)?;
213201
}
214202
_ => {
215203
if let Some(op_kind) = parse_operator_kind(&key.to_lowercase()) {
@@ -288,19 +276,19 @@ impl<'source> Parser<'source> {
288276
match key_lower.as_str() {
289277
"field" => {
290278
let jv = self.parse_json_value()?;
291-
set_once(&mut field, (key_span.clone(), jv), &key_lower, &key_span)?;
279+
Self::set_once(&mut field, (key_span.clone(), jv), &key_lower, &key_span)?;
292280
}
293281
"value" => {
294282
let jv = self.parse_json_value()?;
295-
set_once(&mut value, (key_span.clone(), jv), &key_lower, &key_span)?;
283+
Self::set_once(&mut value, (key_span.clone(), jv), &key_lower, &key_span)?;
296284
}
297285
"name" => {
298286
let jv = self.parse_json_value()?;
299-
set_once(&mut name, (key_span.clone(), jv), &key_lower, &key_span)?;
287+
Self::set_once(&mut name, (key_span.clone(), jv), &key_lower, &key_span)?;
300288
}
301289
"where" => {
302290
let c = self.parse_constraint()?;
303-
set_once(&mut where_, c, &key_lower, &key_span)?;
291+
Self::set_once(&mut where_, c, &key_lower, &key_span)?;
304292
}
305293
_ => {
306294
return Err(ParseError::UnrecognizedKey {

src/languages/azure_policy/parser/core.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,27 @@ impl<'source> Parser<'source> {
346346
})
347347
}
348348

349+
// ========================================================================
350+
// Duplicate-key guard
351+
// ========================================================================
352+
353+
/// Set `slot` to `val`, returning [`ParseError::DuplicateKey`] if already set.
354+
pub(super) fn set_once<T>(
355+
slot: &mut Option<T>,
356+
val: T,
357+
key: &str,
358+
span: &Span,
359+
) -> Result<(), ParseError> {
360+
if slot.is_some() {
361+
return Err(ParseError::DuplicateKey {
362+
span: span.clone(),
363+
key: String::from(key),
364+
});
365+
}
366+
*slot = Some(val);
367+
Ok(())
368+
}
369+
349370
// ========================================================================
350371
// Conversion helpers
351372
// ========================================================================

src/languages/azure_policy/parser/mod.rs

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,42 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4-
//! Recursive-descent JSON parser for Azure Policy rule constraints.
4+
//! Custom recursive-descent JSON parser for Azure Policy rules.
55
//!
66
//! Parses Azure Policy JSON directly from [`Lexer`] tokens, building span-annotated
77
//! AST nodes in a single pass. No intermediate `serde_json::Value` is created.
88
//!
99
//! The parser is policy-aware: when parsing JSON objects, it dispatches on key names
1010
//! (`allOf`, `anyOf`, `not`, `field`, `value`, `count`, operator names) to build
1111
//! the appropriate AST nodes.
12+
//!
13+
//! ## Usage
14+
//!
15+
//! ```ignore
16+
//! use regorus::Source;
17+
//! use regorus::languages::azure_policy::parser;
18+
//!
19+
//! let json = r#"{ "if": { "field": "type", "equals": "Microsoft.Compute/virtualMachines" },
20+
//! "then": { "effect": "deny" } }"#;
21+
//! let source = Source::from_contents("policy.json".into(), json.into())?;
22+
//! let rule = parser::parse_policy_rule(&source)?;
23+
//! ```
1224
1325
mod constraint;
1426
mod core;
1527
mod error;
28+
mod policy_definition;
29+
mod policy_rule;
1630

1731
pub(super) use self::core::json_unescape;
32+
1833
pub use error::ParseError;
1934

2035
use alloc::string::ToString as _;
2136

2237
use crate::lexer::{Source, TokenKind};
2338

24-
use super::ast::{Constraint, FieldKind, OperatorKind};
39+
use super::ast::{Constraint, FieldKind, OperatorKind, PolicyDefinition, PolicyRule};
2540
use super::expr::ExprParser;
2641

2742
use self::core::Parser;
@@ -30,12 +45,56 @@ use self::core::Parser;
3045
// Public API
3146
// ============================================================================
3247

48+
/// Parse an Azure Policy rule from a JSON source.
49+
///
50+
/// The source should contain a complete `policyRule` JSON object:
51+
/// ```json
52+
/// {
53+
/// "if": { "field": "type", "equals": "Microsoft.Compute/virtualMachines" },
54+
/// "then": { "effect": "deny" }
55+
/// }
56+
/// ```
57+
///
58+
/// Returns a span-annotated [`PolicyRule`] AST.
59+
pub fn parse_policy_rule(source: &Source) -> Result<PolicyRule, ParseError> {
60+
let mut parser = Parser::new(source)?;
61+
let rule = parser.parse_policy_rule()?;
62+
63+
if parser.tok.0 != TokenKind::Eof {
64+
return Err(ParseError::UnexpectedToken {
65+
span: parser.tok.1.clone(),
66+
expected: "end of input",
67+
});
68+
}
69+
70+
Ok(rule)
71+
}
72+
73+
/// Parse a full Azure Policy definition from a JSON source.
74+
///
75+
/// Accepts two forms:
76+
/// 1. **Wrapped**: `{ "properties": { "policyRule": ..., ... }, "id": ..., ... }`
77+
/// 2. **Unwrapped**: `{ "displayName": ..., "policyRule": ..., ... }`
78+
///
79+
/// Returns a [`PolicyDefinition`] with typed fields for known properties
80+
/// and a catch-all list of `extra` entries for everything else.
81+
pub fn parse_policy_definition(source: &Source) -> Result<PolicyDefinition, ParseError> {
82+
let mut parser = Parser::new(source)?;
83+
let defn = parser.parse_policy_definition()?;
84+
85+
if parser.tok.0 != TokenKind::Eof {
86+
return Err(ParseError::UnexpectedToken {
87+
span: parser.tok.1.clone(),
88+
expected: "end of input",
89+
});
90+
}
91+
92+
Ok(defn)
93+
}
94+
3395
/// Parse a standalone constraint from a JSON source.
3496
///
35-
/// A constraint is one of:
36-
/// - Logical combinator: `{ "allOf": [...] }`, `{ "anyOf": [...] }`, `{ "not": {...} }`
37-
/// - Leaf condition: `{ "field": "...", "equals": "..." }`
38-
/// - Count condition: `{ "count": { "field": "..." }, "greater": 0 }`
97+
/// Useful for parsing just the `"if"` part of a policy rule.
3998
pub fn parse_constraint(source: &Source) -> Result<Constraint, ParseError> {
4099
let mut parser = Parser::new(source)?;
41100
let constraint = parser.parse_constraint()?;

0 commit comments

Comments
 (0)