diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ba3abf7..065fc56 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -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 diff --git a/.github/workflows/tests-debug.yml b/.github/workflows/tests-debug.yml index 21f5e9b..889c511 100644 --- a/.github/workflows/tests-debug.yml +++ b/.github/workflows/tests-debug.yml @@ -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 diff --git a/Cargo.toml b/Cargo.toml index f2113bc..bf9a2ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 = [] diff --git a/src/languages/azure_rbac/ast/context.rs b/src/languages/azure_rbac/ast/context.rs new file mode 100644 index 0000000..f24cfa6 --- /dev/null +++ b/src/languages/azure_rbac/ast/context.rs @@ -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, + pub suboperation: Option, +} + +/// 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, + pub data_action: Option, + pub attributes: Value, +} + +/// Environment context information +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EnvironmentContext { + pub is_private_link: Option, + pub private_endpoint: Option, + pub subnet: Option, + pub utc_now: Option, +} diff --git a/src/languages/azure_rbac/ast/expr.rs b/src/languages/azure_rbac/ast/expr.rs new file mode 100644 index 0000000..3bc3f03 --- /dev/null +++ b/src/languages/azure_rbac/ast/expr.rs @@ -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, +} + +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, + pub right: Box, +} + +/// 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, +} + +/// 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, + pub right: Box, +} + +/// 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, +} + +/// 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub variable: Option, + pub condition: Box, +} + +/// Set literal value (e.g. {'a', 'b'}) +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SetLiteral { + #[serde(skip)] + pub span: EmptySpan, + pub elements: Vec, +} + +/// List literal value (e.g. ['start', 'end']) +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ListLiteral { + #[serde(skip)] + pub span: EmptySpan, + pub elements: Vec, +} + +/// 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, + pub property: String, +} diff --git a/src/languages/azure_rbac/ast/literals.rs b/src/languages/azure_rbac/ast/literals.rs new file mode 100644 index 0000000..9007c1a --- /dev/null +++ b/src/languages/azure_rbac/ast/literals.rs @@ -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, +} + +/// 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, +} diff --git a/src/languages/azure_rbac/ast/mod.rs b/src/languages/azure_rbac/ast/mod.rs new file mode 100644 index 0000000..12bbf7b --- /dev/null +++ b/src/languages/azure_rbac/ast/mod.rs @@ -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::*; diff --git a/src/languages/azure_rbac/ast/operators.rs b/src/languages/azure_rbac/ast/operators.rs new file mode 100644 index 0000000..c9a87f1 --- /dev/null +++ b/src/languages/azure_rbac/ast/operators.rs @@ -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, +} + +/// 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 { + 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::from_name(s).ok_or(()) + } +} diff --git a/src/languages/azure_rbac/ast/references.rs b/src/languages/azure_rbac/ast/references.rs new file mode 100644 index 0000000..316e3fe --- /dev/null +++ b/src/languages/azure_rbac/ast/references.rs @@ -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, + pub attribute: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub path: Vec, +} + +/// 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), +} diff --git a/src/languages/azure_rbac/ast/span.rs b/src/languages/azure_rbac/ast/span.rs new file mode 100644 index 0000000..6d72c28 --- /dev/null +++ b/src/languages/azure_rbac/ast/span.rs @@ -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; diff --git a/src/languages/azure_rbac/mod.rs b/src/languages/azure_rbac/mod.rs new file mode 100644 index 0000000..65b9b68 --- /dev/null +++ b/src/languages/azure_rbac/mod.rs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +pub mod ast; +pub mod parser; + +#[cfg(test)] +mod tests; diff --git a/src/languages/azure_rbac/parser/condition_parser.rs b/src/languages/azure_rbac/parser/condition_parser.rs new file mode 100644 index 0000000..309828b --- /dev/null +++ b/src/languages/azure_rbac/parser/condition_parser.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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) + } +} diff --git a/src/languages/azure_rbac/parser/error.rs b/src/languages/azure_rbac/parser/error.rs new file mode 100644 index 0000000..ff211e8 --- /dev/null +++ b/src/languages/azure_rbac/parser/error.rs @@ -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) + } + } + } +} diff --git a/src/languages/azure_rbac/parser/mod.rs b/src/languages/azure_rbac/parser/mod.rs new file mode 100644 index 0000000..6e4af50 --- /dev/null +++ b/src/languages/azure_rbac/parser/mod.rs @@ -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; diff --git a/src/languages/azure_rbac/parser/primary.rs b/src/languages/azure_rbac/parser/primary.rs new file mode 100644 index 0000000..c216e28 --- /dev/null +++ b/src/languages/azure_rbac/parser/primary.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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(()) + } +} diff --git a/src/languages/azure_rbac/test_cases.yaml b/src/languages/azure_rbac/test_cases.yaml new file mode 100644 index 0000000..a9abd77 --- /dev/null +++ b/src/languages/azure_rbac/test_cases.yaml @@ -0,0 +1,1091 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Azure RBAC Condition Expression Test Cases +# Each test case contains: +# - name: descriptive test name +# - condition: the condition expression to parse +# - expected: the expected JSON structure + +test_cases: + - name: simple_string_equals + condition: "@Request[Microsoft.Storage:storageAccount] StringEquals 'test-account'" + expected: + type: Binary + operator: StringEquals + left: + type: AttributeReference + source: Request + namespace: Microsoft.Storage + attribute: storageAccount + right: + type: StringLiteral + value: test-account + + - name: numeric_greater_than + condition: "@Resource[size] NumericGreaterThan 1024" + expected: + type: Binary + operator: NumericGreaterThan + left: + type: AttributeReference + source: Resource + attribute: size + right: + type: NumberLiteral + raw: "1024" + + - name: boolean_equals_true + condition: "@Principal[isActive] BoolEquals true" + expected: + type: Binary + operator: BoolEquals + left: + type: AttributeReference + source: Principal + attribute: isActive + right: + type: BooleanLiteral + value: true + + - name: boolean_equals_false + condition: "@Environment[isPrivateLink] BoolEquals false" + expected: + type: Binary + operator: BoolEquals + left: + type: AttributeReference + source: Environment + attribute: isPrivateLink + right: + type: BooleanLiteral + value: false + + - name: logical_and_expression + condition: "@Request[action] StringEquals 'read' AND @Resource[type] StringEquals 'blob'" + expected: + type: Logical + operator: And + left: + type: Binary + operator: StringEquals + left: + type: AttributeReference + source: Request + attribute: action + right: + type: StringLiteral + value: read + right: + type: Binary + operator: StringEquals + left: + type: AttributeReference + source: Resource + attribute: type + right: + type: StringLiteral + value: blob + + - name: logical_or_expression + condition: "@Principal[department] StringEquals 'IT' OR @Principal[department] StringEquals 'Security'" + expected: + type: Logical + operator: Or + left: + type: Binary + operator: StringEquals + left: + type: AttributeReference + source: Principal + attribute: department + right: + type: StringLiteral + value: IT + right: + type: Binary + operator: StringEquals + left: + type: AttributeReference + source: Principal + attribute: department + right: + type: StringLiteral + value: Security + + - name: unary_not_expression + condition: "NOT @Resource[encrypted] BoolEquals false" + expected: + type: Unary + operator: Not + operand: + type: Binary + operator: BoolEquals + left: + type: AttributeReference + source: Resource + attribute: encrypted + right: + type: BooleanLiteral + value: false + + - name: exists_expression + condition: "Exists @Principal[Microsoft.Directory:department]" + expected: + type: Unary + operator: Exists + operand: + type: AttributeReference + source: Principal + namespace: Microsoft.Directory + attribute: department + + - name: not_exists_expression + condition: "NotExists @Request[customAttribute]" + expected: + type: Unary + operator: NotExists + operand: + type: AttributeReference + source: Request + attribute: customAttribute + + - name: parenthesized_expression + condition: "(@Principal[role] StringEquals 'admin' OR @Principal[role] StringEquals 'owner') AND @Resource[confidential] BoolEquals true" + expected: + type: Logical + operator: And + left: + type: Logical + operator: Or + left: + type: Binary + operator: StringEquals + left: + type: AttributeReference + source: Principal + attribute: role + right: + type: StringLiteral + value: admin + right: + type: Binary + operator: StringEquals + left: + type: AttributeReference + source: Principal + attribute: role + right: + type: StringLiteral + value: owner + right: + type: Binary + operator: BoolEquals + left: + type: AttributeReference + source: Resource + attribute: confidential + right: + type: BooleanLiteral + value: true + + - name: function_call_expression + condition: "ToLower(@Principal[email]) StringContains '@company.com'" + expected: + type: Binary + operator: StringContains + left: + type: FunctionCall + function: ToLower + arguments: + - type: AttributeReference + source: Principal + attribute: email + right: + type: StringLiteral + value: "@company.com" + + - name: set_literal_expression + condition: "@Principal[role] StringEquals {'admin', 'owner', 'contributor'}" + expected: + type: Binary + operator: StringEquals + left: + type: AttributeReference + source: Principal + attribute: role + right: + type: SetLiteral + elements: + - type: StringLiteral + value: admin + - type: StringLiteral + value: owner + - type: StringLiteral + value: contributor + + - name: list_literal_expression + condition: "@Request[allowedActions] ListContains ['read', 'write']" + expected: + type: Binary + operator: ListContains + left: + type: AttributeReference + source: Request + attribute: allowedActions + right: + type: ListLiteral + elements: + - type: StringLiteral + value: read + - type: StringLiteral + value: write + + - name: datetime_comparison + condition: "@Environment[utcNow] DateTimeGreaterThan '2023-01-01T00:00:00Z'" + expected: + type: Binary + operator: DateTimeGreaterThan + left: + type: AttributeReference + source: Environment + attribute: utcNow + right: + type: StringLiteral + value: "2023-01-01T00:00:00Z" + + - name: ip_range_check + condition: "@Request[clientIP] IpInRange '192.168.1.0/24'" + expected: + type: Binary + operator: IpInRange + left: + type: AttributeReference + source: Request + attribute: clientIP + right: + type: StringLiteral + value: "192.168.1.0/24" + + - name: null_literal + condition: "@Resource[metadata] StringEquals null" + expected: + type: Binary + operator: StringEquals + left: + type: AttributeReference + source: Resource + attribute: metadata + right: + type: NullLiteral + + - name: string_case_insensitive + condition: "@Principal[displayName] StringEqualsIgnoreCase 'John Doe'" + expected: + type: Binary + operator: StringEqualsIgnoreCase + left: + type: AttributeReference + source: Principal + attribute: displayName + right: + type: StringLiteral + value: "John Doe" + + - name: time_range_check + condition: "@Environment[timeOfDay] TimeOfDayInRange '09:00:00'" + expected: + type: Binary + operator: TimeOfDayInRange + left: + type: AttributeReference + source: Environment + attribute: timeOfDay + right: + type: StringLiteral + value: "09:00:00" + + - name: guid_comparison + condition: "@Principal[objectId] GuidEquals '12345678-1234-1234-1234-123456789012'" + expected: + type: Binary + operator: GuidEquals + left: + type: AttributeReference + source: Principal + attribute: objectId + right: + type: StringLiteral + value: "12345678-1234-1234-1234-123456789012" + + - name: complex_logical_precedence + condition: "NOT (@Request[action] StringEquals 'write' AND (@Resource[type] StringEquals 'blob' OR @Resource[type] StringEquals 'file')) OR @Environment[Microsoft.Time:weekday] StringEquals 'Saturday'" + expected: + type: Logical + operator: Or + left: + type: Unary + operator: Not + operand: + type: Logical + operator: And + left: + type: Binary + operator: StringEquals + left: + type: AttributeReference + source: Request + attribute: action + right: + type: StringLiteral + value: write + right: + type: Logical + operator: Or + left: + type: Binary + operator: StringEquals + left: + type: AttributeReference + source: Resource + attribute: type + right: + type: StringLiteral + value: blob + right: + type: Binary + operator: StringEquals + left: + type: AttributeReference + source: Resource + attribute: type + right: + type: StringLiteral + value: file + right: + type: Binary + operator: StringEquals + left: + type: AttributeReference + source: Environment + namespace: Microsoft.Time + attribute: weekday + right: + type: StringLiteral + value: Saturday + + - name: numeric_in_range_set + condition: "@Resource[size] NumericInRange {0, 10}" + expected: + type: Binary + operator: NumericInRange + left: + type: AttributeReference + source: Resource + attribute: size + right: + type: SetLiteral + elements: + - type: NumberLiteral + raw: "0" + - type: NumberLiteral + raw: "10" + + - name: time_of_day_less_than_equals_function + condition: "@Environment[timeOfDay] TimeOfDayLessThanEquals ToTime('18:30:00')" + expected: + type: Binary + operator: TimeOfDayLessThanEquals + left: + type: AttributeReference + source: Environment + attribute: timeOfDay + right: + type: FunctionCall + function: ToTime + arguments: + - type: StringLiteral + value: "18:30:00" + + - name: list_not_contains + condition: "@Request[allowedIPRanges] ListNotContains ['10.0.0.1', '10.0.0.2']" + expected: + type: Binary + operator: ListNotContains + left: + type: AttributeReference + source: Request + attribute: allowedIPRanges + right: + type: ListLiteral + elements: + - type: StringLiteral + value: "10.0.0.1" + - type: StringLiteral + value: "10.0.0.2" + + - name: ip_not_match + condition: "@Request[clientIP] IpNotMatch '10.0.0.0/24'" + expected: + type: Binary + operator: IpNotMatch + left: + type: AttributeReference + source: Request + attribute: clientIP + right: + type: StringLiteral + value: "10.0.0.0/24" + + - name: guid_not_equals + condition: "@Principal[objectId] GuidNotEquals '87654321-4321-4321-4321-210987654321'" + expected: + type: Binary + operator: GuidNotEquals + left: + type: AttributeReference + source: Principal + attribute: objectId + right: + type: StringLiteral + value: "87654321-4321-4321-4321-210987654321" + + - name: string_complex_operators + condition: "(@Resource[category] StringStartsWith 'finance' AND (@Resource[label] StringNotContains 'deprecated' AND @Resource[name] StringEndsWith '-prod')) OR @Resource[description] StringMatches '^FIN-[0-9]{4}$'" + expected: + type: Logical + operator: Or + left: + type: Logical + operator: And + left: + type: Binary + operator: StringStartsWith + left: + type: AttributeReference + source: Resource + attribute: category + right: + type: StringLiteral + value: finance + right: + type: Logical + operator: And + left: + type: Binary + operator: StringNotContains + left: + type: AttributeReference + source: Resource + attribute: label + right: + type: StringLiteral + value: deprecated + right: + type: Binary + operator: StringEndsWith + left: + type: AttributeReference + source: Resource + attribute: name + right: + type: StringLiteral + value: -prod + right: + type: Binary + operator: StringMatches + left: + type: AttributeReference + source: Resource + attribute: description + right: + type: StringLiteral + value: "^FIN-[0-9]{4}$" + + - name: identifier_action_matches + condition: "operationName ActionMatches 'Microsoft.Storage/storageAccounts/write'" + expected: + type: Binary + operator: ActionMatches + left: + type: Identifier + name: operationName + right: + type: StringLiteral + value: "Microsoft.Storage/storageAccounts/write" + + - name: function_call_chain + condition: "ToUpper(Trim(@Principal[displayName])) StringNotEqualsIgnoreCase 'SERVICE PRINCIPAL'" + expected: + type: Binary + operator: StringNotEqualsIgnoreCase + left: + type: FunctionCall + function: ToUpper + arguments: + - type: FunctionCall + function: Trim + arguments: + - type: AttributeReference + source: Principal + attribute: displayName + right: + type: StringLiteral + value: "SERVICE PRINCIPAL" + + - name: time_of_day_in_range_set + condition: "@Environment[timeOfDay] TimeOfDayInRange {'08:00:00', '17:00:00'}" + expected: + type: Binary + operator: TimeOfDayInRange + left: + type: AttributeReference + source: Environment + attribute: timeOfDay + right: + type: SetLiteral + elements: + - type: StringLiteral + value: "08:00:00" + - type: StringLiteral + value: "17:00:00" + + - name: datetime_less_than_equals_function + condition: "@Environment[utcNow] DateTimeLessThanEquals AddDays('2023-03-15T00:00:00Z', 5)" + expected: + type: Binary + operator: DateTimeLessThanEquals + left: + type: AttributeReference + source: Environment + attribute: utcNow + right: + type: FunctionCall + function: AddDays + arguments: + - type: StringLiteral + value: "2023-03-15T00:00:00Z" + - type: NumberLiteral + raw: "5" + + - name: boolean_not_equals + condition: "@Environment[isHoliday] BoolNotEquals true" + expected: + type: Binary + operator: BoolNotEquals + left: + type: AttributeReference + source: Environment + attribute: isHoliday + right: + type: BooleanLiteral + value: true + + - name: exists_function_operand + condition: "Exists ToLower(@Principal[Microsoft.Directory:department])" + expected: + type: Unary + operator: Exists + operand: + type: FunctionCall + function: ToLower + arguments: + - type: AttributeReference + source: Principal + namespace: Microsoft.Directory + attribute: department + + - name: not_exists_namespace_context + condition: "NotExists @Context[Custom.Extension:claimType]" + expected: + type: Unary + operator: NotExists + operand: + type: AttributeReference + source: Context + namespace: Custom.Extension + attribute: claimType + + - name: string_not_equals + condition: "@Resource[classification] StringNotEquals 'internal'" + expected: + type: Binary + operator: StringNotEquals + left: + type: AttributeReference + source: Resource + attribute: classification + right: + type: StringLiteral + value: internal + + - name: string_like_pattern + condition: "@Resource[name] StringLike 'prod-*'" + expected: + type: Binary + operator: StringLike + left: + type: AttributeReference + source: Resource + attribute: name + right: + type: StringLiteral + value: prod-* + + - name: string_not_like_pattern + condition: "@Resource[name] StringNotLike 'dev-*'" + expected: + type: Binary + operator: StringNotLike + left: + type: AttributeReference + source: Resource + attribute: name + right: + type: StringLiteral + value: dev-* + + - name: string_not_starts_with + condition: "@Resource[path] StringNotStartsWith '/secure/'" + expected: + type: Binary + operator: StringNotStartsWith + left: + type: AttributeReference + source: Resource + attribute: path + right: + type: StringLiteral + value: "/secure/" + + - name: string_not_ends_with + condition: "@Resource[fileName] StringNotEndsWith '.tmp'" + expected: + type: Binary + operator: StringNotEndsWith + left: + type: AttributeReference + source: Resource + attribute: fileName + right: + type: StringLiteral + value: .tmp + + - name: string_not_matches + condition: "@Resource[identifier] StringNotMatches 'TEMP-[0-9]+'" + expected: + type: Binary + operator: StringNotMatches + left: + type: AttributeReference + source: Resource + attribute: identifier + right: + type: StringLiteral + value: "TEMP-[0-9]+" + + - name: numeric_equals + condition: "@Resource[version] NumericEquals 2" + expected: + type: Binary + operator: NumericEquals + left: + type: AttributeReference + source: Resource + attribute: version + right: + type: NumberLiteral + raw: "2" + + - name: numeric_not_equals + condition: "@Resource[count] NumericNotEquals 5" + expected: + type: Binary + operator: NumericNotEquals + left: + type: AttributeReference + source: Resource + attribute: count + right: + type: NumberLiteral + raw: "5" + + - name: numeric_less_than + condition: "@Request[latency] NumericLessThan 100" + expected: + type: Binary + operator: NumericLessThan + left: + type: AttributeReference + source: Request + attribute: latency + right: + type: NumberLiteral + raw: "100" + + - name: numeric_less_than_equals + condition: "@Request[latency] NumericLessThanEquals 200" + expected: + type: Binary + operator: NumericLessThanEquals + left: + type: AttributeReference + source: Request + attribute: latency + right: + type: NumberLiteral + raw: "200" + + - name: numeric_greater_than_equals + condition: "@Resource[replicaCount] NumericGreaterThanEquals 3" + expected: + type: Binary + operator: NumericGreaterThanEquals + left: + type: AttributeReference + source: Resource + attribute: replicaCount + right: + type: NumberLiteral + raw: "3" + + - name: datetime_equals + condition: "@Environment[utcNow] DateTimeEquals '2023-05-01T12:00:00Z'" + expected: + type: Binary + operator: DateTimeEquals + left: + type: AttributeReference + source: Environment + attribute: utcNow + right: + type: StringLiteral + value: "2023-05-01T12:00:00Z" + + - name: datetime_not_equals + condition: "@Environment[utcNow] DateTimeNotEquals '2023-06-01T12:00:00Z'" + expected: + type: Binary + operator: DateTimeNotEquals + left: + type: AttributeReference + source: Environment + attribute: utcNow + right: + type: StringLiteral + value: "2023-06-01T12:00:00Z" + + - name: datetime_greater_than_equals + condition: "@Environment[utcNow] DateTimeGreaterThanEquals '2023-04-01T00:00:00Z'" + expected: + type: Binary + operator: DateTimeGreaterThanEquals + left: + type: AttributeReference + source: Environment + attribute: utcNow + right: + type: StringLiteral + value: "2023-04-01T00:00:00Z" + + - name: datetime_less_than + condition: "@Environment[utcNow] DateTimeLessThan '2023-12-31T23:59:59Z'" + expected: + type: Binary + operator: DateTimeLessThan + left: + type: AttributeReference + source: Environment + attribute: utcNow + right: + type: StringLiteral + value: "2023-12-31T23:59:59Z" + + - name: time_of_day_equals + condition: "@Environment[timeOfDay] TimeOfDayEquals '10:15:00'" + expected: + type: Binary + operator: TimeOfDayEquals + left: + type: AttributeReference + source: Environment + attribute: timeOfDay + right: + type: StringLiteral + value: "10:15:00" + + - name: time_of_day_not_equals + condition: "@Environment[timeOfDay] TimeOfDayNotEquals '20:00:00'" + expected: + type: Binary + operator: TimeOfDayNotEquals + left: + type: AttributeReference + source: Environment + attribute: timeOfDay + right: + type: StringLiteral + value: "20:00:00" + + - name: time_of_day_greater_than + condition: "@Environment[timeOfDay] TimeOfDayGreaterThan '17:00:00'" + expected: + type: Binary + operator: TimeOfDayGreaterThan + left: + type: AttributeReference + source: Environment + attribute: timeOfDay + right: + type: StringLiteral + value: "17:00:00" + + - name: time_of_day_greater_than_equals + condition: "@Environment[timeOfDay] TimeOfDayGreaterThanEquals '09:00:00'" + expected: + type: Binary + operator: TimeOfDayGreaterThanEquals + left: + type: AttributeReference + source: Environment + attribute: timeOfDay + right: + type: StringLiteral + value: "09:00:00" + + - name: time_of_day_less_than + condition: "@Environment[timeOfDay] TimeOfDayLessThan '12:00:00'" + expected: + type: Binary + operator: TimeOfDayLessThan + left: + type: AttributeReference + source: Environment + attribute: timeOfDay + right: + type: StringLiteral + value: "12:00:00" + + - name: ip_match + condition: "@Request[clientIP] IpMatch '10.1.0.0/16'" + expected: + type: Binary + operator: IpMatch + left: + type: AttributeReference + source: Request + attribute: clientIP + right: + type: StringLiteral + value: "10.1.0.0/16" + + - name: sub_operation_matches + condition: "subOperationName SubOperationMatches 'Microsoft.Storage/storageAccounts/write/sas'" + expected: + type: Binary + operator: SubOperationMatches + left: + type: Identifier + name: subOperationName + right: + type: StringLiteral + value: "Microsoft.Storage/storageAccounts/write/sas" + + - name: for_any_of_any_values + condition: "@Resource[tags] ForAnyOfAnyValues @Request[allowedTags]" + expected: + type: Binary + operator: ForAnyOfAnyValues + left: + type: AttributeReference + source: Resource + attribute: tags + right: + type: AttributeReference + source: Request + attribute: allowedTags + + - name: for_all_of_any_values + condition: "@Resource[tags] ForAllOfAnyValues @Request[requiredTags]" + expected: + type: Binary + operator: ForAllOfAnyValues + left: + type: AttributeReference + source: Resource + attribute: tags + right: + type: AttributeReference + source: Request + attribute: requiredTags + + - name: for_any_of_all_values + condition: "@Resource[tags] ForAnyOfAllValues @Request[restrictedTags]" + expected: + type: Binary + operator: ForAnyOfAllValues + left: + type: AttributeReference + source: Resource + attribute: tags + right: + type: AttributeReference + source: Request + attribute: restrictedTags + + - name: for_all_of_all_values + condition: "@Resource[tags] ForAllOfAllValues @Request[mandatoryTags]" + expected: + type: Binary + operator: ForAllOfAllValues + left: + type: AttributeReference + source: Resource + attribute: tags + right: + type: AttributeReference + source: Request + attribute: mandatoryTags + + - name: deeply_nested_quantifiers + condition: "((@Resource[tags] ForAllOfAnyValues @Request[requiredTags]) AND (NormalizeSet(@Resource[regions]) ForAnyOfAllValues NormalizeSet(@Environment[allowedRegions]))) OR (NOT Exists ToLower(@Principal[manager]) AND (@Resource[sensitivity] StringNotEqualsIgnoreCase 'high'))" + expected: + type: Logical + operator: Or + left: + type: Logical + operator: And + left: + type: Binary + operator: ForAllOfAnyValues + left: + type: AttributeReference + source: Resource + attribute: tags + right: + type: AttributeReference + source: Request + attribute: requiredTags + right: + type: Binary + operator: ForAnyOfAllValues + left: + type: FunctionCall + function: NormalizeSet + arguments: + - type: AttributeReference + source: Resource + attribute: regions + right: + type: FunctionCall + function: NormalizeSet + arguments: + - type: AttributeReference + source: Environment + attribute: allowedRegions + right: + type: Logical + operator: And + left: + type: Unary + operator: Not + operand: + type: Unary + operator: Exists + operand: + type: FunctionCall + function: ToLower + arguments: + - type: AttributeReference + source: Principal + attribute: manager + right: + type: Binary + operator: StringNotEqualsIgnoreCase + left: + type: AttributeReference + source: Resource + attribute: sensitivity + right: + type: StringLiteral + value: high + + - name: multi_level_mixed_chains + condition: "(ToUpper(@Principal[role]) StringEquals 'OWNER' AND (@Resource[tags] ForAllOfAllValues @Request[mandatoryTags]) AND (NormalizeList(@Resource[scopes]) ForAnyOfAnyValues NormalizeList(@Principal[assignableScopes]))) OR (Exists @Resource[metadata] AND NOT (@Request[operations] ListContains ['read', 'list']))" + expected: + type: Logical + operator: Or + left: + type: Logical + operator: And + left: + type: Logical + operator: And + left: + type: Binary + operator: StringEquals + left: + type: FunctionCall + function: ToUpper + arguments: + - type: AttributeReference + source: Principal + attribute: role + right: + type: StringLiteral + value: OWNER + right: + type: Binary + operator: ForAllOfAllValues + left: + type: AttributeReference + source: Resource + attribute: tags + right: + type: AttributeReference + source: Request + attribute: mandatoryTags + right: + type: Binary + operator: ForAnyOfAnyValues + left: + type: FunctionCall + function: NormalizeList + arguments: + - type: AttributeReference + source: Resource + attribute: scopes + right: + type: FunctionCall + function: NormalizeList + arguments: + - type: AttributeReference + source: Principal + attribute: assignableScopes + right: + type: Logical + operator: And + left: + type: Unary + operator: Exists + operand: + type: AttributeReference + source: Resource + attribute: metadata + right: + type: Unary + operator: Not + operand: + type: Binary + operator: ListContains + left: + type: AttributeReference + source: Request + attribute: operations + right: + type: ListLiteral + elements: + - type: StringLiteral + value: read + - type: StringLiteral + value: list diff --git a/src/languages/azure_rbac/tests.rs b/src/languages/azure_rbac/tests.rs new file mode 100644 index 0000000..6c52a2a --- /dev/null +++ b/src/languages/azure_rbac/tests.rs @@ -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, + } + + 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 + ); + } + } +} diff --git a/src/languages/mod.rs b/src/languages/mod.rs new file mode 100644 index 0000000..ecc421a --- /dev/null +++ b/src/languages/mod.rs @@ -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; \ No newline at end of file diff --git a/src/lexer.rs b/src/lexer.rs index e792cb5..442c38f 100644 --- a/src/lexer.rs +++ b/src/lexer.rs @@ -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 { + 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(), diff --git a/src/lib.rs b/src/lib.rs index b20190f..11b8d01 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/parser.rs b/src/parser.rs index db1ecd7..61f2fb2 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -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(), } }