feat: Add Azure RBAC condition parser (#496)

* feat: Add Azure RBAC condition parser

- declare an `azure-rbac` feature and expose the Azure RBAC module with parser, AST, and YAML-driven tests
- extend the shared lexer with RBAC-specific tokens, single-quoted strings, and corrected raw-string spans
- verify the parser via comprehensive test cases covering every operator and complex chaining

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>



---------

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>
This commit is contained in:
Anand Krishnamoorthi
2025-11-17 14:15:35 -06:00
committed by GitHub
parent 49bd3c22f3
commit ad8c543fb5
21 changed files with 2502 additions and 5 deletions

View File

@@ -49,3 +49,5 @@ jobs:
- name: Run tests (Azure Policy)
run: >-
cargo test --frozen --features azure_policy
- name: Run tests (Azure RBAC)
run: cargo test -r --frozen --features azure-rbac

View File

@@ -42,3 +42,5 @@ jobs:
- name: Run tests (OPA Conformance)
run: >-
cargo test --test opa --frozen --features opa-testutil,serde_json/arbitrary_precision -- $(tr '\n' ' ' < tests/opa.passing)
- name: Run tests (Azure RBAC)
run: cargo test --frozen --features azure-rbac

View File

@@ -25,6 +25,7 @@ default = ["full-opa", "arc", "rvm"]
arc = ["scientific/arc"]
ast = []
azure_policy = ["dep:jsonschema", "arc", "dashmap"]
azure-rbac = []
base64 = ["dep:data-encoding"]
base64url = ["dep:data-encoding"]
coverage = []

View File

@@ -0,0 +1,61 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use alloc::string::String;
use serde::{Deserialize, Serialize};
use crate::value::Value;
/// Principal type
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum PrincipalType {
User,
Group,
ServicePrincipal,
ManagedServiceIdentity,
}
/// Evaluation context - what information is available when evaluating RBAC policies
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EvaluationContext {
pub principal: Principal,
pub resource: Resource,
pub request: RequestContext,
pub environment: EnvironmentContext,
pub action: Option<String>,
pub suboperation: Option<String>,
}
/// Principal information (user, group, service principal, etc.)
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Principal {
pub id: String,
pub principal_type: PrincipalType,
pub custom_security_attributes: Value,
}
/// Resource information
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Resource {
pub id: String,
pub resource_type: String,
pub scope: String,
pub attributes: Value,
}
/// Request context information
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RequestContext {
pub action: Option<String>,
pub data_action: Option<String>,
pub attributes: Value,
}
/// Environment context information
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EnvironmentContext {
pub is_private_link: Option<bool>,
pub private_endpoint: Option<String>,
pub subnet: Option<String>,
pub utc_now: Option<String>,
}

View File

@@ -0,0 +1,171 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use alloc::boxed::Box;
use alloc::string::String;
use alloc::vec::Vec;
use serde::{Deserialize, Serialize};
use super::literals::{
BooleanLiteral, DateTimeLiteral, NullLiteral, NumberLiteral, StringLiteral, TimeLiteral,
};
use super::operators::{ArrayOperator, ConditionOperator};
use super::references::AttributeReference;
use super::span::EmptySpan;
/// ABAC condition expression
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConditionExpression {
#[serde(skip)]
pub span: EmptySpan,
pub raw_expression: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expression: Option<ConditionExpr>,
}
impl ConditionExpression {
pub fn new(span: EmptySpan, expression: String) -> Self {
Self {
span,
raw_expression: expression,
expression: None,
}
}
pub fn with_parsed(span: EmptySpan, raw_expression: String, parsed: ConditionExpr) -> Self {
Self {
span,
raw_expression,
expression: Some(parsed),
}
}
}
/// Condition expression node
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ConditionExpr {
Logical(LogicalExpression),
Unary(UnaryExpression),
Binary(BinaryExpression),
FunctionCall(FunctionCallExpression),
AttributeReference(AttributeReference),
ArrayExpression(ArrayExpression),
Identifier(IdentifierExpression),
VariableReference(VariableReference),
PropertyAccess(PropertyAccessExpression),
StringLiteral(StringLiteral),
NumberLiteral(NumberLiteral),
BooleanLiteral(BooleanLiteral),
NullLiteral(NullLiteral),
DateTimeLiteral(DateTimeLiteral),
TimeLiteral(TimeLiteral),
SetLiteral(SetLiteral),
ListLiteral(ListLiteral),
}
/// Logical (AND/OR) expression
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LogicalExpression {
#[serde(skip)]
pub span: EmptySpan,
pub operator: LogicalOperator,
pub left: Box<ConditionExpr>,
pub right: Box<ConditionExpr>,
}
/// Logical operator kinds
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum LogicalOperator {
And,
Or,
}
/// Unary expression (e.g., NOT)
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UnaryExpression {
#[serde(skip)]
pub span: EmptySpan,
pub operator: UnaryOperator,
pub operand: Box<ConditionExpr>,
}
/// Unary operator kinds
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum UnaryOperator {
Not,
Exists,
NotExists,
}
/// Binary expression with an operator and two operands
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BinaryExpression {
#[serde(skip)]
pub span: EmptySpan,
pub operator: ConditionOperator,
pub left: Box<ConditionExpr>,
pub right: Box<ConditionExpr>,
}
/// Function call expression (e.g. ToLower(expr))
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FunctionCallExpression {
#[serde(skip)]
pub span: EmptySpan,
pub function: String,
pub arguments: Vec<ConditionExpr>,
}
/// Array expression with quantifiers (e.g. ANY tag : ...)
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ArrayExpression {
#[serde(skip)]
pub span: EmptySpan,
pub operator: ArrayOperator,
pub array: Box<ConditionExpr>,
#[serde(skip_serializing_if = "Option::is_none")]
pub variable: Option<String>,
pub condition: Box<ConditionExpr>,
}
/// Set literal value (e.g. {'a', 'b'})
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SetLiteral {
#[serde(skip)]
pub span: EmptySpan,
pub elements: Vec<ConditionExpr>,
}
/// List literal value (e.g. ['start', 'end'])
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ListLiteral {
#[serde(skip)]
pub span: EmptySpan,
pub elements: Vec<ConditionExpr>,
}
/// Identifier expression (unqualified name)
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct IdentifierExpression {
#[serde(skip)]
pub span: EmptySpan,
pub name: String,
}
/// Variable reference (e.g. loop variable in ANY clauses)
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct VariableReference {
#[serde(skip)]
pub span: EmptySpan,
pub name: String,
}
/// Property access expression (e.g. tag.key)
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PropertyAccessExpression {
#[serde(skip)]
pub span: EmptySpan,
pub object: Box<ConditionExpr>,
pub property: String,
}

View File

@@ -0,0 +1,56 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use alloc::string::String;
use serde::{Deserialize, Serialize};
use super::span::EmptySpan;
/// String literal value
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StringLiteral {
#[serde(skip)]
pub span: EmptySpan,
pub value: String,
}
/// Number literal value (keeps raw representation)
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct NumberLiteral {
#[serde(skip)]
pub span: EmptySpan,
pub raw: String,
}
/// Boolean literal value
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BooleanLiteral {
#[serde(skip)]
pub span: EmptySpan,
pub value: bool,
}
/// Null literal
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct NullLiteral {
#[serde(skip)]
pub span: EmptySpan,
}
/// Date-time literal value (ISO-8601 formatted)
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DateTimeLiteral {
#[serde(skip)]
pub span: EmptySpan,
pub value: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub normalized: Option<String>,
}
/// Time literal value (HH:MM or HH:MM:SS)
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TimeLiteral {
#[serde(skip)]
pub span: EmptySpan,
pub value: String,
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
pub mod context;
pub mod expr;
pub mod literals;
pub mod operators;
pub mod references;
pub mod span;
pub use context::*;
pub use expr::*;
pub use literals::*;
pub use operators::*;
pub use references::*;
pub use span::*;

View File

@@ -0,0 +1,207 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use alloc::string::String;
use serde::{Deserialize, Serialize};
/// Array operator descriptor (e.g. ANY, ForAnyOfAnyValues:StringEquals)
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ArrayOperator {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub modifier: Option<String>,
}
/// Condition operator for Azure RBAC expressions
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ConditionOperator {
// String operators
StringEquals,
StringNotEquals,
StringEqualsIgnoreCase,
StringNotEqualsIgnoreCase,
StringLike,
StringNotLike,
StringStartsWith,
StringNotStartsWith,
StringEndsWith,
StringNotEndsWith,
StringContains,
StringNotContains,
StringMatches,
StringNotMatches,
// Numeric operators
NumericEquals,
NumericNotEquals,
NumericLessThan,
NumericLessThanEquals,
NumericGreaterThan,
NumericGreaterThanEquals,
NumericInRange,
// Boolean operators
BoolEquals,
BoolNotEquals,
// DateTime operators
DateTimeEquals,
DateTimeNotEquals,
DateTimeGreaterThan,
DateTimeGreaterThanEquals,
DateTimeLessThan,
DateTimeLessThanEquals,
// Time operators
TimeOfDayEquals,
TimeOfDayNotEquals,
TimeOfDayGreaterThan,
TimeOfDayGreaterThanEquals,
TimeOfDayLessThan,
TimeOfDayLessThanEquals,
TimeOfDayInRange,
// GUID operators
GuidEquals,
GuidNotEquals,
// IP operators
IpMatch,
IpNotMatch,
IpInRange,
// List operators
ListContains,
ListNotContains,
// Array quantifier operators
ForAnyOfAnyValues,
ForAllOfAnyValues,
ForAnyOfAllValues,
ForAllOfAllValues,
// Action operators
ActionMatches,
SubOperationMatches,
}
impl ConditionOperator {
/// Parse a condition operator from string identifier
pub fn from_name(s: &str) -> Option<Self> {
match s {
"StringEquals" => Some(Self::StringEquals),
"StringNotEquals" => Some(Self::StringNotEquals),
"StringEqualsIgnoreCase" => Some(Self::StringEqualsIgnoreCase),
"StringNotEqualsIgnoreCase" => Some(Self::StringNotEqualsIgnoreCase),
"StringLike" => Some(Self::StringLike),
"StringNotLike" => Some(Self::StringNotLike),
"StringStartsWith" => Some(Self::StringStartsWith),
"StringNotStartsWith" => Some(Self::StringNotStartsWith),
"StringEndsWith" => Some(Self::StringEndsWith),
"StringNotEndsWith" => Some(Self::StringNotEndsWith),
"StringContains" => Some(Self::StringContains),
"StringNotContains" => Some(Self::StringNotContains),
"StringMatches" => Some(Self::StringMatches),
"StringNotMatches" => Some(Self::StringNotMatches),
"NumericEquals" => Some(Self::NumericEquals),
"NumericNotEquals" => Some(Self::NumericNotEquals),
"NumericLessThan" => Some(Self::NumericLessThan),
"NumericLessThanEquals" => Some(Self::NumericLessThanEquals),
"NumericGreaterThan" => Some(Self::NumericGreaterThan),
"NumericGreaterThanEquals" => Some(Self::NumericGreaterThanEquals),
"NumericInRange" => Some(Self::NumericInRange),
"BoolEquals" => Some(Self::BoolEquals),
"BoolNotEquals" => Some(Self::BoolNotEquals),
"DateTimeEquals" => Some(Self::DateTimeEquals),
"DateTimeNotEquals" => Some(Self::DateTimeNotEquals),
"DateTimeGreaterThan" => Some(Self::DateTimeGreaterThan),
"DateTimeGreaterThanEquals" => Some(Self::DateTimeGreaterThanEquals),
"DateTimeLessThan" => Some(Self::DateTimeLessThan),
"DateTimeLessThanEquals" => Some(Self::DateTimeLessThanEquals),
"TimeOfDayEquals" => Some(Self::TimeOfDayEquals),
"TimeOfDayNotEquals" => Some(Self::TimeOfDayNotEquals),
"TimeOfDayGreaterThan" => Some(Self::TimeOfDayGreaterThan),
"TimeOfDayGreaterThanEquals" => Some(Self::TimeOfDayGreaterThanEquals),
"TimeOfDayLessThan" => Some(Self::TimeOfDayLessThan),
"TimeOfDayLessThanEquals" => Some(Self::TimeOfDayLessThanEquals),
"TimeOfDayInRange" => Some(Self::TimeOfDayInRange),
"GuidEquals" => Some(Self::GuidEquals),
"GuidNotEquals" => Some(Self::GuidNotEquals),
"IpMatch" => Some(Self::IpMatch),
"IpNotMatch" => Some(Self::IpNotMatch),
"IpInRange" => Some(Self::IpInRange),
"ListContains" => Some(Self::ListContains),
"ListNotContains" => Some(Self::ListNotContains),
"ForAnyOfAnyValues" => Some(Self::ForAnyOfAnyValues),
"ForAllOfAnyValues" => Some(Self::ForAllOfAnyValues),
"ForAnyOfAllValues" => Some(Self::ForAnyOfAllValues),
"ForAllOfAllValues" => Some(Self::ForAllOfAllValues),
"ActionMatches" => Some(Self::ActionMatches),
"SubOperationMatches" => Some(Self::SubOperationMatches),
_ => None,
}
}
/// Convert condition operator to string
pub fn as_str(&self) -> &'static str {
match self {
Self::StringEquals => "StringEquals",
Self::StringNotEquals => "StringNotEquals",
Self::StringEqualsIgnoreCase => "StringEqualsIgnoreCase",
Self::StringNotEqualsIgnoreCase => "StringNotEqualsIgnoreCase",
Self::StringLike => "StringLike",
Self::StringNotLike => "StringNotLike",
Self::StringStartsWith => "StringStartsWith",
Self::StringNotStartsWith => "StringNotStartsWith",
Self::StringEndsWith => "StringEndsWith",
Self::StringNotEndsWith => "StringNotEndsWith",
Self::StringContains => "StringContains",
Self::StringNotContains => "StringNotContains",
Self::StringMatches => "StringMatches",
Self::StringNotMatches => "StringNotMatches",
Self::NumericEquals => "NumericEquals",
Self::NumericNotEquals => "NumericNotEquals",
Self::NumericLessThan => "NumericLessThan",
Self::NumericLessThanEquals => "NumericLessThanEquals",
Self::NumericGreaterThan => "NumericGreaterThan",
Self::NumericGreaterThanEquals => "NumericGreaterThanEquals",
Self::NumericInRange => "NumericInRange",
Self::BoolEquals => "BoolEquals",
Self::BoolNotEquals => "BoolNotEquals",
Self::DateTimeEquals => "DateTimeEquals",
Self::DateTimeNotEquals => "DateTimeNotEquals",
Self::DateTimeGreaterThan => "DateTimeGreaterThan",
Self::DateTimeGreaterThanEquals => "DateTimeGreaterThanEquals",
Self::DateTimeLessThan => "DateTimeLessThan",
Self::DateTimeLessThanEquals => "DateTimeLessThanEquals",
Self::TimeOfDayEquals => "TimeOfDayEquals",
Self::TimeOfDayNotEquals => "TimeOfDayNotEquals",
Self::TimeOfDayGreaterThan => "TimeOfDayGreaterThan",
Self::TimeOfDayGreaterThanEquals => "TimeOfDayGreaterThanEquals",
Self::TimeOfDayLessThan => "TimeOfDayLessThan",
Self::TimeOfDayLessThanEquals => "TimeOfDayLessThanEquals",
Self::TimeOfDayInRange => "TimeOfDayInRange",
Self::GuidEquals => "GuidEquals",
Self::GuidNotEquals => "GuidNotEquals",
Self::IpMatch => "IpMatch",
Self::IpNotMatch => "IpNotMatch",
Self::IpInRange => "IpInRange",
Self::ListContains => "ListContains",
Self::ListNotContains => "ListNotContains",
Self::ForAnyOfAnyValues => "ForAnyOfAnyValues",
Self::ForAllOfAnyValues => "ForAllOfAnyValues",
Self::ForAnyOfAllValues => "ForAnyOfAllValues",
Self::ForAllOfAllValues => "ForAllOfAllValues",
Self::ActionMatches => "ActionMatches",
Self::SubOperationMatches => "SubOperationMatches",
}
}
}
impl core::str::FromStr for ConditionOperator {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::from_name(s).ok_or(())
}
}

View File

@@ -0,0 +1,38 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use alloc::string::String;
use alloc::vec::Vec;
use serde::{Deserialize, Serialize};
use super::span::EmptySpan;
/// Attribute reference like @Request[namespace:attribute]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AttributeReference {
#[serde(skip)]
pub span: EmptySpan,
pub source: AttributeSource,
#[serde(skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
pub attribute: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub path: Vec<AttributePathSegment>,
}
/// Source of an attribute reference
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum AttributeSource {
Request,
Resource,
Principal,
Environment,
Context,
}
/// A segment of an attribute path (e.g. metadata, 0, category)
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum AttributePathSegment {
Key(String),
Index(usize),
}

View File

@@ -0,0 +1,8 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use serde::{Deserialize, Serialize};
/// Empty span placeholder since we don't need spans for RBAC
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct EmptySpan;

View File

@@ -0,0 +1,8 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
pub mod ast;
pub mod parser;
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,200 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use alloc::boxed::Box;
use alloc::format;
use alloc::string::ToString;
use crate::languages::azure_rbac::ast::{
BinaryExpression, ConditionExpr, ConditionExpression, ConditionOperator, EmptySpan,
LogicalExpression, LogicalOperator, UnaryExpression, UnaryOperator,
};
use crate::lexer::{AzureRbacTokenKind, Lexer, Source, Token, TokenKind};
use super::error::ConditionParseError;
/// Parse a condition expression string into AST
pub fn parse_condition_expression(
condition_str: &str,
) -> Result<ConditionExpression, ConditionParseError> {
if condition_str.trim().is_empty() {
return Err(ConditionParseError::UnsupportedCondition(
"Empty condition expression".to_string(),
));
}
let source = Source::from_contents("condition".to_string(), condition_str.to_string())
.map_err(|e| ConditionParseError::InvalidExpression(e.to_string()))?;
let parsed_expr = ConditionParser::parse(&source)?;
Ok(ConditionExpression::with_parsed(
EmptySpan,
condition_str.to_string(),
parsed_expr,
))
}
/// Condition expression parser
pub struct ConditionParser<'source> {
pub(super) lexer: Lexer<'source>,
pub(super) current: Token,
}
impl<'source> ConditionParser<'source> {
/// Parse a condition expression from source
pub fn parse(source: &'source Source) -> Result<ConditionExpr, ConditionParseError> {
let mut lexer = Lexer::new(source);
lexer.set_enable_rbac_tokens(true);
lexer.set_allow_single_quoted_strings(true);
let current = lexer
.next_token()
.map_err(|e| ConditionParseError::InvalidExpression(e.to_string()))?;
let mut parser = Self { lexer, current };
let expression = parser.parse_or_expression()?;
if parser.current.0 != TokenKind::Eof {
return Err(ConditionParseError::UnsupportedCondition(format!(
"Unexpected token after expression: {:?} '{}'",
parser.current.0,
parser.current_text()
)));
}
Ok(expression)
}
/// Get current token text
pub(super) fn current_text(&self) -> &str {
self.current.1.text()
}
/// Advance to next token
pub(super) fn advance(&mut self) -> Result<(), ConditionParseError> {
self.current = self
.lexer
.next_token()
.map_err(|e| ConditionParseError::InvalidExpression(e.to_string()))?;
Ok(())
}
/// Check if current token matches expected
pub(super) fn expect(&mut self, expected: TokenKind) -> Result<(), ConditionParseError> {
if self.current.0 != expected {
return Err(ConditionParseError::UnsupportedCondition(format!(
"Expected {:?}, found {:?} at {}",
expected,
self.current.0,
self.current_text()
)));
}
self.advance()?;
Ok(())
}
/// Parse OR expression (lowest precedence)
pub(super) fn parse_or_expression(&mut self) -> Result<ConditionExpr, ConditionParseError> {
let mut left = self.parse_and_expression()?;
while self.current.0 == TokenKind::AzureRbac(AzureRbacTokenKind::LogicalOr)
|| (self.current.0 == TokenKind::Ident && self.current_text() == "OR")
{
self.advance()?;
let right = self.parse_and_expression()?;
left = ConditionExpr::Logical(LogicalExpression {
span: EmptySpan,
operator: LogicalOperator::Or,
left: Box::new(left),
right: Box::new(right),
});
}
Ok(left)
}
/// Parse AND expression (higher precedence than OR)
pub(super) fn parse_and_expression(&mut self) -> Result<ConditionExpr, ConditionParseError> {
let mut left = self.parse_unary_expression()?;
while self.current.0 == TokenKind::AzureRbac(AzureRbacTokenKind::LogicalAnd)
|| (self.current.0 == TokenKind::Ident && self.current_text() == "AND")
{
self.advance()?;
let right = self.parse_unary_expression()?;
left = ConditionExpr::Logical(LogicalExpression {
span: EmptySpan,
operator: LogicalOperator::And,
left: Box::new(left),
right: Box::new(right),
});
}
Ok(left)
}
/// Parse unary expression (NOT, Exists)
pub(super) fn parse_unary_expression(&mut self) -> Result<ConditionExpr, ConditionParseError> {
if self.current.0 == TokenKind::Ident {
let text = self.current_text();
match text {
"NOT" | "!" => {
self.advance()?;
let operand = self.parse_unary_expression()?;
return Ok(ConditionExpr::Unary(UnaryExpression {
span: EmptySpan,
operator: UnaryOperator::Not,
operand: Box::new(operand),
}));
}
"Exists" => {
self.advance()?;
let operand = self.parse_primary_expression()?;
return Ok(ConditionExpr::Unary(UnaryExpression {
span: EmptySpan,
operator: UnaryOperator::Exists,
operand: Box::new(operand),
}));
}
"NotExists" => {
self.advance()?;
let operand = self.parse_primary_expression()?;
return Ok(ConditionExpr::Unary(UnaryExpression {
span: EmptySpan,
operator: UnaryOperator::NotExists,
operand: Box::new(operand),
}));
}
_ => {}
}
}
self.parse_comparison_expression()
}
/// Parse comparison/binary expression
pub(super) fn parse_comparison_expression(
&mut self,
) -> Result<ConditionExpr, ConditionParseError> {
let left = self.parse_primary_expression()?;
// Check for binary operator
if self.current.0 == TokenKind::Ident {
let op_text = self.current_text().to_string();
// Check if this is a known operator
if let Some(operator) = ConditionOperator::from_name(&op_text) {
self.advance()?;
let right = self.parse_primary_expression()?;
return Ok(ConditionExpr::Binary(BinaryExpression {
span: EmptySpan,
operator,
left: Box::new(left),
right: Box::new(right),
}));
}
}
Ok(left)
}
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use alloc::string::String;
/// Error types for condition expression parsing
#[derive(Debug, Clone)]
pub enum ConditionParseError {
InvalidExpression(String),
UnsupportedCondition(String),
}
impl core::fmt::Display for ConditionParseError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
ConditionParseError::InvalidExpression(msg) => {
write!(f, "Invalid expression: {}", msg)
}
ConditionParseError::UnsupportedCondition(expr) => {
write!(f, "Unsupported condition expression: {}", expr)
}
}
}
}

View File

@@ -0,0 +1,9 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
mod condition_parser;
mod error;
mod primary;
pub use condition_parser::{parse_condition_expression, ConditionParser};
pub use error::ConditionParseError;

View File

@@ -0,0 +1,302 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use crate::languages::azure_rbac::ast::{
AttributeReference, AttributeSource, BooleanLiteral, ConditionExpr, ConditionOperator,
EmptySpan, FunctionCallExpression, IdentifierExpression, ListLiteral, NullLiteral,
NumberLiteral, SetLiteral, StringLiteral,
};
use crate::lexer::{AzureRbacTokenKind, TokenKind};
use super::condition_parser::ConditionParser;
use super::ConditionParseError;
impl<'source> ConditionParser<'source> {
/// Parse primary expression (literals, attribute refs, function calls, parentheses)
pub(super) fn parse_primary_expression(
&mut self,
) -> Result<ConditionExpr, ConditionParseError> {
match self.current.0 {
// Parenthesized expression
TokenKind::Symbol if self.current_text() == "(" => {
self.advance()?;
let expr = self.parse_or_expression()?;
self.expect_symbol(")")?;
Ok(expr)
}
// Attribute reference (@Source[...])
TokenKind::AzureRbac(AzureRbacTokenKind::At) => self.parse_attribute_reference(),
// String literal
TokenKind::String => {
let value = self.current_text().to_string();
self.advance()?;
Ok(ConditionExpr::StringLiteral(StringLiteral {
span: EmptySpan,
value,
}))
}
// Raw string literal
TokenKind::RawString => {
let value = self.current_text().to_string();
self.advance()?;
Ok(ConditionExpr::StringLiteral(StringLiteral {
span: EmptySpan,
value,
}))
}
// Number literal
TokenKind::Number => {
let raw = self.current_text().to_string();
self.advance()?;
Ok(ConditionExpr::NumberLiteral(NumberLiteral {
span: EmptySpan,
raw,
}))
}
// Set literal {'a', 'b', 'c'}
TokenKind::Symbol if self.current_text() == "{" => self.parse_set_literal(),
// List literal ['a', 'b']
TokenKind::Symbol if self.current_text() == "[" => self.parse_list_literal(),
// Identifier (function call, boolean, identifier)
TokenKind::Ident => {
let text = self.current_text();
match text {
"true" => {
self.advance()?;
Ok(ConditionExpr::BooleanLiteral(BooleanLiteral {
span: EmptySpan,
value: true,
}))
}
"false" => {
self.advance()?;
Ok(ConditionExpr::BooleanLiteral(BooleanLiteral {
span: EmptySpan,
value: false,
}))
}
"null" => {
self.advance()?;
Ok(ConditionExpr::NullLiteral(NullLiteral { span: EmptySpan }))
}
_ => {
let name = text.to_string();
self.advance()?;
// Check for function call
if self.current.0 == TokenKind::Symbol && self.current_text() == "(" {
self.parse_function_call(name)
} else {
if ConditionOperator::from_name(&name).is_some() {
return Err(ConditionParseError::UnsupportedCondition(format!(
"Operator '{}' missing operands",
name
)));
}
// Just an identifier
Ok(ConditionExpr::Identifier(IdentifierExpression {
span: EmptySpan,
name,
}))
}
}
}
}
_ => Err(ConditionParseError::UnsupportedCondition(format!(
"Unexpected token: {:?} '{}'",
self.current.0,
self.current_text()
))),
}
}
/// Parse attribute reference @Source[namespace:attribute]
pub(super) fn parse_attribute_reference(
&mut self,
) -> Result<ConditionExpr, ConditionParseError> {
self.expect(TokenKind::AzureRbac(AzureRbacTokenKind::At))?;
// Parse source (Request, Resource, Principal, Environment, Context)
if self.current.0 != TokenKind::Ident {
return Err(ConditionParseError::UnsupportedCondition(
"Expected attribute source after @".to_string(),
));
}
let source_text = self.current_text();
let source = match source_text {
"Request" => AttributeSource::Request,
"Resource" => AttributeSource::Resource,
"Principal" => AttributeSource::Principal,
"Environment" => AttributeSource::Environment,
"Context" => AttributeSource::Context,
_ => {
return Err(ConditionParseError::UnsupportedCondition(format!(
"Unknown attribute source: {}",
source_text
)))
}
};
self.advance()?;
// Expect [
self.expect_symbol("[")?;
// Parse namespace:attribute or just attribute
let mut namespace = None;
// Read until ] - this handles namespace:attribute and complex paths
let mut parts = Vec::new();
loop {
match self.current.0 {
TokenKind::Symbol if self.current_text() == "]" => break,
TokenKind::Symbol if self.current_text() == ":" => {
// Colon separator
parts.push(":".to_string());
self.advance()?;
}
TokenKind::Ident | TokenKind::String | TokenKind::Symbol => {
parts.push(self.current_text().to_string());
self.advance()?;
}
_ => {
return Err(ConditionParseError::UnsupportedCondition(format!(
"Unexpected token in attribute reference: {:?}",
self.current.0
)))
}
}
}
// Join parts and split on last colon
let full_path = parts.join("");
let attribute = if let Some(colon_pos) = full_path.rfind(':') {
namespace = Some(full_path[..colon_pos].to_string());
full_path[colon_pos + 1..].to_string()
} else {
full_path
};
self.expect_symbol("]")?;
Ok(ConditionExpr::AttributeReference(AttributeReference {
span: EmptySpan,
source,
namespace,
attribute,
path: Vec::new(), // Path parsing can be added later if needed
}))
}
/// Parse function call
pub(super) fn parse_function_call(
&mut self,
function: String,
) -> Result<ConditionExpr, ConditionParseError> {
self.expect_symbol("(")?;
let mut arguments = Vec::new();
// Parse arguments
if self.current.0 != TokenKind::Symbol || self.current_text() != ")" {
loop {
let arg = self.parse_or_expression()?;
arguments.push(arg);
if self.current.0 == TokenKind::Symbol && self.current_text() == "," {
self.advance()?;
} else {
break;
}
}
}
self.expect_symbol(")")?;
Ok(ConditionExpr::FunctionCall(FunctionCallExpression {
span: EmptySpan,
function,
arguments,
}))
}
/// Parse set literal {'a', 'b', 'c'}
pub(super) fn parse_set_literal(&mut self) -> Result<ConditionExpr, ConditionParseError> {
self.expect_symbol("{")?;
let mut elements = Vec::new();
if self.current.0 != TokenKind::Symbol || self.current_text() != "}" {
loop {
let elem = self.parse_or_expression()?;
elements.push(elem);
if self.current.0 == TokenKind::Symbol && self.current_text() == "," {
self.advance()?;
} else {
break;
}
}
}
self.expect_symbol("}")?;
Ok(ConditionExpr::SetLiteral(SetLiteral {
span: EmptySpan,
elements,
}))
}
/// Parse list literal ['a', 'b']
pub(super) fn parse_list_literal(&mut self) -> Result<ConditionExpr, ConditionParseError> {
self.expect_symbol("[")?;
let mut elements = Vec::new();
if self.current.0 != TokenKind::Symbol || self.current_text() != "]" {
loop {
let elem = self.parse_or_expression()?;
elements.push(elem);
if self.current.0 == TokenKind::Symbol && self.current_text() == "," {
self.advance()?;
} else {
break;
}
}
}
self.expect_symbol("]")?;
Ok(ConditionExpr::ListLiteral(ListLiteral {
span: EmptySpan,
elements,
}))
}
/// Expect a specific symbol
pub(super) fn expect_symbol(&mut self, expected: &str) -> Result<(), ConditionParseError> {
if self.current.0 != TokenKind::Symbol || self.current_text() != expected {
return Err(ConditionParseError::UnsupportedCondition(format!(
"Expected '{}', found '{}'",
expected,
self.current_text()
)));
}
self.advance()?;
Ok(())
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,154 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! Tests for Azure RBAC condition expression parsing
#[cfg(test)]
mod condition_tests {
use crate::languages::azure_rbac::parser::*;
use alloc::string::String;
use alloc::vec;
use alloc::vec::Vec;
use serde_json;
use serde_yaml;
#[derive(Debug, serde::Deserialize)]
struct TestCase {
name: String,
condition: String,
expected: serde_json::Value,
}
#[derive(Debug, serde::Deserialize)]
struct TestCases {
test_cases: Vec<TestCase>,
}
fn load_test_cases() -> TestCases {
let yaml_content = include_str!("test_cases.yaml");
serde_yaml::from_str(yaml_content).expect("Failed to parse test cases YAML")
}
#[test]
fn test_condition_expression_parsing() {
let test_cases = load_test_cases();
for test_case in test_cases.test_cases {
// Parse the condition expression
let result = parse_condition_expression(&test_case.condition);
assert!(
result.is_ok(),
"Failed to parse condition '{}' for test '{}': {:?}",
test_case.condition,
test_case.name,
result.err()
);
let parsed_condition = result.unwrap();
// Verify we have a parsed expression
assert!(
parsed_condition.expression.is_some(),
"No parsed expression for test '{}', condition: '{}'",
test_case.name,
test_case.condition
);
let actual_expr = parsed_condition.expression.unwrap();
// Deserialize the expected JSON into our AST type
let expected_expr: crate::languages::azure_rbac::ast::ConditionExpr =
serde_json::from_value(test_case.expected.clone()).unwrap_or_else(|_| {
panic!(
"Failed to deserialize expected JSON for test '{}': {}",
test_case.name,
serde_json::to_string_pretty(&test_case.expected).unwrap()
)
});
// Compare the actual parsed expression with the expected expression
assert_eq!(
actual_expr, expected_expr,
"\nTest '{}' failed!\nCondition: '{}'\nExpected: {:#?}\nActual: {:#?}",
test_case.name, test_case.condition, expected_expr, actual_expr
);
}
}
// Test for edge cases and error conditions
#[test]
fn test_parsing_errors() {
// The parser funnels every binary/logical keyword through the same branches,
// so this list of malformed expressions covers all operators without duplication.
let invalid_expressions = vec![
("", "Empty expression"),
("@", "Incomplete attribute reference"),
("@Request", "Missing attribute brackets"),
("@Request[", "Unclosed attribute brackets"),
("@InvalidSource[attr]", "Invalid attribute source"),
("@Request[attr] InvalidOperator 'value'", "Invalid operator"),
("@Request[attr] StringEquals", "Missing right operand"),
("StringEquals 'value'", "Missing left operand"),
(
"@Request[attr] 'value'",
"Missing operator between operands",
),
("StringEquals 'value'", "Missing left operand"),
("StringEquals", "Binary operator without any operands"),
(
"@Request[attr] StringEquals 'value' AND",
"Missing right operand after AND",
),
(
"AND @Request[attr] StringEquals 'value'",
"Leading AND without left operand",
),
(
"@Request[attr] StringEquals 'value' OR",
"Missing right operand after OR",
),
(
"OR @Request[attr] StringEquals 'value'",
"Leading OR without left operand",
),
("NOT", "Standalone NOT without operand"),
("Exists", "Exists without operand"),
("NotExists", "NotExists without operand"),
(
"(@Request[attr] StringEquals 'value'",
"Unmatched parenthesis",
),
("@Request[attr] StringEquals 'unclosed", "Unclosed string"),
(
"@Request[attr] StringEquals 'value')",
"Unexpected closing parenthesis",
),
];
for (expression, description) in invalid_expressions {
let result = parse_condition_expression(expression);
assert!(
result.is_err(),
"Expected error for {} but got success: {:?}",
description,
result
);
}
}
#[test]
fn test_raw_expression_preservation() {
let test_cases = load_test_cases();
for test_case in test_cases.test_cases.iter().take(5) {
// Test first 5 cases
let result = parse_condition_expression(&test_case.condition).unwrap();
assert_eq!(
result.raw_expression, test_case.condition,
"Raw expression not preserved for test '{}'",
test_case.name
);
}
}
}

7
src/languages/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! Language-specific modules for specialized parsing and evaluation
#[cfg(feature = "azure-rbac")]
pub mod azure_rbac;

View File

@@ -274,6 +274,14 @@ impl Debug for Span {
}
}
#[cfg(feature = "azure-rbac")]
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum AzureRbacTokenKind {
At, // @ symbol for attribute sources (@Request, @Resource, etc.)
LogicalAnd, // && operator
LogicalOr, // || operator
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum TokenKind {
Symbol,
@@ -282,6 +290,9 @@ pub enum TokenKind {
Number,
Ident,
Eof,
// Azure RBAC-specific tokens
#[cfg(feature = "azure-rbac")]
AzureRbac(AzureRbacTokenKind),
}
#[derive(Debug, Clone)]
@@ -297,6 +308,10 @@ pub struct Lexer<'source> {
allow_slash_star_escape: bool,
comment_starts_with_double_slash: bool,
double_colon_token: bool,
#[cfg(feature = "azure-rbac")]
enable_rbac_tokens: bool,
#[cfg(feature = "azure-rbac")]
allow_single_quoted_strings: bool,
}
impl<'source> Lexer<'source> {
@@ -310,6 +325,10 @@ impl<'source> Lexer<'source> {
allow_slash_star_escape: false,
comment_starts_with_double_slash: false,
double_colon_token: false,
#[cfg(feature = "azure-rbac")]
enable_rbac_tokens: false,
#[cfg(feature = "azure-rbac")]
allow_single_quoted_strings: false,
}
}
@@ -329,6 +348,16 @@ impl<'source> Lexer<'source> {
self.double_colon_token = b;
}
#[cfg(feature = "azure-rbac")]
pub fn set_enable_rbac_tokens(&mut self, b: bool) {
self.enable_rbac_tokens = b;
}
#[cfg(feature = "azure-rbac")]
pub fn set_allow_single_quoted_strings(&mut self, b: bool) {
self.allow_single_quoted_strings = b;
}
fn peek(&mut self) -> (usize, char) {
match self.iter.peek() {
Some((index, chr)) => (*index, *chr),
@@ -572,6 +601,60 @@ impl<'source> Lexer<'source> {
))
}
#[cfg(feature = "azure-rbac")]
fn read_single_quoted_string(&mut self) -> Result<Token> {
let (line, col) = (self.line, self.col);
self.iter.next();
self.col += 1;
let (start, _) = self.peek();
loop {
let (offset, ch) = self.peek();
let col = self.col + (offset - start) as u32;
match ch {
'\'' | '\x00' => {
break;
}
'\\' => {
self.iter.next();
let (_, ch) = self.peek();
self.iter.next();
match ch {
// Basic escape sequences for single-quoted strings
'\'' | '\\' | 'n' | 'r' | 't' => (),
_ => return Err(self.source.error(line, col, "invalid escape sequence")),
}
}
_ => {
// check for valid chars
let col = self.col + (offset - start) as u32;
if !('\u{0020}'..='\u{10FFFF}').contains(&ch) {
return Err(self.source.error(line, col, "invalid character in string"));
}
self.iter.next();
}
}
}
if self.peek().1 != '\'' {
return Err(self.source.error(line, col, "unmatched '"));
}
self.iter.next();
let end = self.peek().0;
self.col += (end - start) as u32;
Ok(Token(
TokenKind::String,
Span {
source: self.source.clone(),
line,
col: col + 1,
start: start as u32,
end: end as u32 - 1,
},
))
}
#[inline]
fn skip_past_newline(&mut self) -> Result<()> {
self.iter.next();
@@ -638,8 +721,6 @@ impl<'source> Lexer<'source> {
'{' | '}' | '[' | ']' | '(' | ')' |
// arith operator
'+' | '-' | '*' | '/' | '%' |
// bin operator
'&' | '|' |
// separators
',' | ';' | '.' => {
self.col += 1;
@@ -652,6 +733,46 @@ impl<'source> Lexer<'source> {
end: start as u32 + 1,
}))
}
#[cfg(feature = "azure-rbac")]
// RBAC logical AND operator (&&)
'&' if self.enable_rbac_tokens && self.peekahead(1).1 == '&' => {
self.col += 2;
self.iter.next();
self.iter.next();
Ok(Token(TokenKind::AzureRbac(AzureRbacTokenKind::LogicalAnd), Span {
source: self.source.clone(),
line: self.line,
col,
start: start as u32,
end: start as u32 + 2,
}))
}
#[cfg(feature = "azure-rbac")]
// RBAC logical OR operator (||)
'|' if self.enable_rbac_tokens && self.peekahead(1).1 == '|' => {
self.col += 2;
self.iter.next();
self.iter.next();
Ok(Token(TokenKind::AzureRbac(AzureRbacTokenKind::LogicalOr), Span {
source: self.source.clone(),
line: self.line,
col,
start: start as u32,
end: start as u32 + 2,
}))
}
// Generic bin operators (when RBAC tokens not enabled or single & |)
'&' | '|' => {
self.col += 1;
self.iter.next();
Ok(Token(TokenKind::Symbol, Span {
source: self.source.clone(),
line: self.line,
col,
start: start as u32,
end: start as u32 + 1,
}))
}
':' => {
self.col += 1;
self.iter.next();
@@ -697,7 +818,22 @@ impl<'source> Lexer<'source> {
end: self.peek().0 as u32,
}))
}
#[cfg(feature = "azure-rbac")]
// RBAC @ token for attribute references
'@' if self.enable_rbac_tokens => {
self.col += 1;
self.iter.next();
Ok(Token(TokenKind::AzureRbac(AzureRbacTokenKind::At), Span {
source: self.source.clone(),
line: self.line,
col,
start: start as u32,
end: start as u32 + 1,
}))
}
'"' => self.read_string(),
#[cfg(feature = "azure-rbac")]
'\'' if self.allow_single_quoted_strings => self.read_single_quoted_string(),
'`' => self.read_raw_string(),
'\x00' => Ok(Token(TokenKind::Eof, Span {
source: self.source.clone(),

View File

@@ -30,6 +30,12 @@ mod compiler;
mod engine;
mod indexchecker;
mod interpreter;
pub mod languages {
#[cfg(feature = "azure-rbac")]
pub mod azure_rbac;
}
mod lexer;
mod lookup;
mod number;

View File

@@ -82,10 +82,8 @@ impl<'source> Parser<'source> {
pub fn token_text(&self) -> &str {
match self.tok.0 {
TokenKind::Symbol | TokenKind::Number | TokenKind::Ident | TokenKind::Eof => {
self.tok.1.text()
}
TokenKind::String | TokenKind::RawString => "",
_ => self.tok.1.text(),
}
}