diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5df91d66..002b6c2e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+- Azure RBAC condition interpreter with builtin evaluation coverage and YAML test suite, including quantifier (ForAnyOfAnyValues/ForAllOfAllValues), datetime (DateTimeEquals), IP (IpInRange), GUID (GuidEquals), list (ListContains), and string (StringEquals) semantics.
+- FFI surface for Azure RBAC condition evaluation (see bindings changelog for language-specific wrappers).
+
## [0.9.1](https://github.com/microsoft/regorus/compare/regorus-v0.9.0...regorus-v0.9.1) - 2026-02-06
### Fixed
diff --git a/Cargo.toml b/Cargo.toml
index 400250c7..191e88e6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -25,7 +25,7 @@ default = ["full-opa", "arc", "rvm"]
arc = []
ast = []
azure_policy = ["dep:jsonschema", "arc", "dashmap"]
-azure-rbac = []
+azure-rbac = ["regex", "time", "net"]
base64 = ["dep:data-encoding"]
base64url = ["dep:data-encoding"]
coverage = []
diff --git a/bindings/csharp/Directory.Packages.props b/bindings/csharp/Directory.Packages.props
index 6cb753e8..879957c7 100644
--- a/bindings/csharp/Directory.Packages.props
+++ b/bindings/csharp/Directory.Packages.props
@@ -10,5 +10,6 @@
+
diff --git a/bindings/csharp/README.md b/bindings/csharp/README.md
index ad6cf077..74c0aa52 100644
--- a/bindings/csharp/README.md
+++ b/bindings/csharp/README.md
@@ -104,3 +104,49 @@ vm.SetInputJson(Input);
var result = vm.Execute();
Console.WriteLine($"allow: {result}");
```
+
+## Azure RBAC Condition Evaluation
+
+Evaluate Azure RBAC condition expressions directly with a JSON evaluation context:
+
+```csharp
+using Regorus;
+
+const string Condition = "@Resource[owner] StringEquals 'alice'";
+const string ContextJson = """
+{
+ "principal": {
+ "id": "user-1",
+ "principal_type": "User",
+ "custom_security_attributes": {}
+ },
+ "resource": {
+ "id": "/subscriptions/s1",
+ "resource_type": "Microsoft.Storage/storageAccounts",
+ "scope": "/subscriptions/s1",
+ "attributes": {
+ "owner": "alice",
+ "confidential": true
+ }
+ },
+ "request": {
+ "action": "Microsoft.Storage/storageAccounts/read",
+ "data_action": null,
+ "attributes": {
+ "clientIP": "10.0.0.1"
+ }
+ },
+ "environment": {
+ "is_private_link": null,
+ "private_endpoint": null,
+ "subnet": null,
+ "utc_now": "2023-05-01T12:00:00Z"
+ },
+ "action": "Microsoft.Storage/storageAccounts/read",
+ "suboperation": null
+}
+""";
+
+var allowed = RbacEngine.EvaluateCondition(Condition, ContextJson);
+Console.WriteLine($"RBAC condition allowed: {allowed}");
+```
diff --git a/bindings/csharp/Regorus.Tests/RbacEngineTests.cs b/bindings/csharp/Regorus.Tests/RbacEngineTests.cs
new file mode 100644
index 00000000..fe23dc78
--- /dev/null
+++ b/bindings/csharp/Regorus.Tests/RbacEngineTests.cs
@@ -0,0 +1,374 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Regorus;
+using YamlDotNet.Serialization;
+
+namespace Regorus.Tests;
+
+[TestClass]
+public class RbacEngineTests
+{
+ public TestContext? TestContext { get; set; }
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = false
+ };
+
+ private const string BaseContextJson = """
+{
+ "principal": {
+ "id": "user-1",
+ "principal_type": "User",
+ "custom_security_attributes": {
+ "department": "eng",
+ "levels": ["L1", "L2"]
+ }
+ },
+ "resource": {
+ "id": "/subscriptions/s1",
+ "resource_type": "Microsoft.Storage/storageAccounts",
+ "scope": "/subscriptions/s1",
+ "attributes": {
+ "owner": "alice",
+ "tags": ["a", "b"],
+ "count": 5,
+ "enabled": false,
+ "ip": "10.0.0.5",
+ "guid": "a1b2c3d4-0000-0000-0000-000000000000"
+ }
+ },
+ "request": {
+ "action": "Microsoft.Storage/storageAccounts/read",
+ "data_action": "Microsoft.Storage/storageAccounts/read",
+ "attributes": {
+ "owner": "alice",
+ "text": "HelloWorld",
+ "tags": ["prod", "gold"],
+ "count": 10,
+ "ratio": 2.5,
+ "enabled": true,
+ "ip": "10.0.0.8",
+ "guid": "A1B2C3D4-0000-0000-0000-000000000000",
+ "time": "12:30:15",
+ "date": "2023-05-01T12:00:00Z",
+ "numbers": [1, 2, 3],
+ "letters": ["a", "b"]
+ }
+ },
+ "environment": {
+ "is_private_link": false,
+ "private_endpoint": null,
+ "subnet": null,
+ "utc_now": "2023-05-01T12:00:00Z"
+ },
+ "action": "Microsoft.Storage/storageAccounts/read",
+ "suboperation": "sub/read"
+}
+""";
+
+ [TestMethod]
+ public void Rbac_engine_evaluates_all_yaml_cases()
+ {
+ var cases = LoadEvalTestCases().ToList();
+ Assert.IsTrue(cases.Count > 0, "No RBAC test cases were loaded.");
+
+ foreach (var testCase in cases)
+ {
+ TestContext?.WriteLine($"RBAC case: {testCase.Name} -> {testCase.Condition}");
+ var context = BuildBaseContext();
+ if (testCase.Context != null)
+ {
+ ApplyOverrides(context, testCase.Context);
+ }
+
+ var contextJson = context.ToJsonString(JsonOptions);
+ var result = RbacEngine.EvaluateCondition(testCase.Condition, contextJson);
+
+ Assert.AreEqual(
+ testCase.Expected,
+ result,
+ $"RBAC test '{testCase.Name}' failed for condition '{testCase.Condition}'.");
+ }
+ }
+
+ private static JsonObject BuildBaseContext()
+ {
+ var node = JsonNode.Parse(BaseContextJson) as JsonObject;
+ if (node is null)
+ {
+ throw new InvalidOperationException("Failed to parse base context JSON.");
+ }
+
+ return node;
+ }
+
+ private static void ApplyOverrides(JsonObject context, EvalContextOverrides overrides)
+ {
+ var principal = (JsonObject?)context["principal"]
+ ?? throw new InvalidOperationException("Missing principal section.");
+ var resource = (JsonObject?)context["resource"]
+ ?? throw new InvalidOperationException("Missing resource section.");
+ var request = (JsonObject?)context["request"]
+ ?? throw new InvalidOperationException("Missing request section.");
+ var environment = (JsonObject?)context["environment"]
+ ?? throw new InvalidOperationException("Missing environment section.");
+
+ if (!string.IsNullOrEmpty(overrides.Action))
+ {
+ context["action"] = overrides.Action;
+ }
+
+ if (!string.IsNullOrEmpty(overrides.Suboperation))
+ {
+ context["suboperation"] = overrides.Suboperation;
+ }
+
+ if (!string.IsNullOrEmpty(overrides.RequestAction))
+ {
+ request["action"] = overrides.RequestAction;
+ }
+
+ if (!string.IsNullOrEmpty(overrides.DataAction))
+ {
+ request["data_action"] = overrides.DataAction;
+ }
+
+ if (!string.IsNullOrEmpty(overrides.PrincipalId))
+ {
+ principal["id"] = overrides.PrincipalId;
+ }
+
+ if (!string.IsNullOrEmpty(overrides.PrincipalType))
+ {
+ principal["principal_type"] = overrides.PrincipalType;
+ }
+
+ if (!string.IsNullOrEmpty(overrides.ResourceId))
+ {
+ resource["id"] = overrides.ResourceId;
+ }
+
+ if (!string.IsNullOrEmpty(overrides.ResourceType))
+ {
+ resource["resource_type"] = overrides.ResourceType;
+ }
+
+ if (!string.IsNullOrEmpty(overrides.ResourceScope))
+ {
+ resource["scope"] = overrides.ResourceScope;
+ }
+
+ if (overrides.RequestAttributes != null)
+ {
+ request["attributes"] = ConvertToJsonNode(overrides.RequestAttributes);
+ }
+
+ if (overrides.ResourceAttributes != null)
+ {
+ resource["attributes"] = ConvertToJsonNode(overrides.ResourceAttributes);
+ }
+
+ if (overrides.PrincipalCustomSecurityAttributes != null)
+ {
+ principal["custom_security_attributes"] = ConvertToJsonNode(overrides.PrincipalCustomSecurityAttributes);
+ }
+
+ if (overrides.Environment != null)
+ {
+ if (overrides.Environment.IsPrivateLink.HasValue)
+ {
+ environment["is_private_link"] = overrides.Environment.IsPrivateLink.Value;
+ }
+
+ if (!string.IsNullOrEmpty(overrides.Environment.PrivateEndpoint))
+ {
+ environment["private_endpoint"] = overrides.Environment.PrivateEndpoint;
+ }
+
+ if (!string.IsNullOrEmpty(overrides.Environment.Subnet))
+ {
+ environment["subnet"] = overrides.Environment.Subnet;
+ }
+
+ if (!string.IsNullOrEmpty(overrides.Environment.UtcNow))
+ {
+ environment["utc_now"] = overrides.Environment.UtcNow;
+ }
+ }
+ }
+
+ private static IEnumerable LoadEvalTestCases()
+ {
+ var baseDir = Path.Combine(AppContext.BaseDirectory, "test_cases");
+ if (!Directory.Exists(baseDir))
+ {
+ throw new DirectoryNotFoundException($"RBAC test case directory not found: {baseDir}");
+ }
+
+ var deserializer = new DeserializerBuilder()
+ .IgnoreUnmatchedProperties()
+ .Build();
+
+ var files = Directory.EnumerateFiles(baseDir, "*.yaml")
+ .OrderBy(path => path, StringComparer.OrdinalIgnoreCase);
+
+ foreach (var file in files)
+ {
+ var yaml = File.ReadAllText(file);
+ var suite = deserializer.Deserialize(yaml);
+ if (suite?.TestCases is null)
+ {
+ continue;
+ }
+
+ foreach (var testCase in suite.TestCases)
+ {
+ yield return testCase;
+ }
+ }
+ }
+
+ private static JsonNode? ConvertToJsonNode(object? value)
+ {
+ if (value is null)
+ {
+ return null;
+ }
+
+ switch (value)
+ {
+ case JsonNode node:
+ return node;
+ case string text:
+ return JsonValue.Create(text);
+ case bool boolean:
+ return JsonValue.Create(boolean);
+ case int intValue:
+ return JsonValue.Create(intValue);
+ case long longValue:
+ return JsonValue.Create(longValue);
+ case double doubleValue:
+ return JsonValue.Create(doubleValue);
+ case float floatValue:
+ return JsonValue.Create(floatValue);
+ case decimal decimalValue:
+ return JsonValue.Create(decimalValue);
+ case DateTime dateTime:
+ return JsonValue.Create(dateTime.ToString("O"));
+ case IDictionary dictionary:
+ {
+ var obj = new JsonObject();
+ foreach (DictionaryEntry entry in dictionary)
+ {
+ var key = entry.Key?.ToString() ?? string.Empty;
+ obj[key] = ConvertToJsonNode(entry.Value);
+ }
+ return obj;
+ }
+ case IEnumerable enumerable:
+ {
+ if (value is string)
+ {
+ return JsonValue.Create(value.ToString());
+ }
+
+ var array = new JsonArray();
+ foreach (var item in enumerable)
+ {
+ array.Add(ConvertToJsonNode(item));
+ }
+ return array;
+ }
+ default:
+ return JsonValue.Create(value.ToString());
+ }
+ }
+
+ private sealed class EvalTestSuite
+ {
+ [YamlMember(Alias = "test_cases")]
+ public List TestCases { get; set; } = new();
+ }
+
+ private sealed class EvalTestCase
+ {
+ [YamlMember(Alias = "name")]
+ public string Name { get; set; } = string.Empty;
+
+ [YamlMember(Alias = "condition")]
+ public string Condition { get; set; } = string.Empty;
+
+ [YamlMember(Alias = "expected")]
+ public bool Expected { get; set; }
+
+ [YamlMember(Alias = "context")]
+ public EvalContextOverrides? Context { get; set; }
+ }
+
+ private sealed class EvalContextOverrides
+ {
+ [YamlMember(Alias = "action")]
+ public string? Action { get; set; }
+
+ [YamlMember(Alias = "suboperation")]
+ public string? Suboperation { get; set; }
+
+ [YamlMember(Alias = "request_action")]
+ public string? RequestAction { get; set; }
+
+ [YamlMember(Alias = "data_action")]
+ public string? DataAction { get; set; }
+
+ [YamlMember(Alias = "principal_id")]
+ public string? PrincipalId { get; set; }
+
+ [YamlMember(Alias = "principal_type")]
+ public string? PrincipalType { get; set; }
+
+ [YamlMember(Alias = "resource_id")]
+ public string? ResourceId { get; set; }
+
+ [YamlMember(Alias = "resource_type")]
+ public string? ResourceType { get; set; }
+
+ [YamlMember(Alias = "resource_scope")]
+ public string? ResourceScope { get; set; }
+
+ [YamlMember(Alias = "request_attributes")]
+ public object? RequestAttributes { get; set; }
+
+ [YamlMember(Alias = "resource_attributes")]
+ public object? ResourceAttributes { get; set; }
+
+ [YamlMember(Alias = "principal_custom_security_attributes")]
+ public object? PrincipalCustomSecurityAttributes { get; set; }
+
+ [YamlMember(Alias = "environment")]
+ public EvalEnvironmentOverrides? Environment { get; set; }
+ }
+
+ private sealed class EvalEnvironmentOverrides
+ {
+ [YamlMember(Alias = "is_private_link")]
+ public bool? IsPrivateLink { get; set; }
+
+ [YamlMember(Alias = "private_endpoint")]
+ public string? PrivateEndpoint { get; set; }
+
+ [YamlMember(Alias = "subnet")]
+ public string? Subnet { get; set; }
+
+ [YamlMember(Alias = "utc_now")]
+ public string? UtcNow { get; set; }
+ }
+}
diff --git a/bindings/csharp/Regorus.Tests/Regorus.Tests.csproj b/bindings/csharp/Regorus.Tests/Regorus.Tests.csproj
index bcd030b4..b243902c 100644
--- a/bindings/csharp/Regorus.Tests/Regorus.Tests.csproj
+++ b/bindings/csharp/Regorus.Tests/Regorus.Tests.csproj
@@ -20,9 +20,14 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/bindings/csharp/Regorus/NativeMethods.cs b/bindings/csharp/Regorus/NativeMethods.cs
index 44a6249d..80424505 100644
--- a/bindings/csharp/Regorus/NativeMethods.cs
+++ b/bindings/csharp/Regorus/NativeMethods.cs
@@ -492,6 +492,16 @@ internal static unsafe partial class API
#endregion
+ #region RBAC Methods
+
+ ///
+ /// Evaluate an Azure RBAC condition expression against a JSON evaluation context.
+ ///
+ [DllImport(LibraryName, EntryPoint = "regorus_rbac_engine_eval_condition", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
+ internal static extern RegorusResult regorus_rbac_engine_eval_condition(byte* condition, byte* context_json);
+
+ #endregion
+
#region Target Registry Methods
///
diff --git a/bindings/csharp/Regorus/RbacEngine.cs b/bindings/csharp/Regorus/RbacEngine.cs
new file mode 100644
index 00000000..e3693f94
--- /dev/null
+++ b/bindings/csharp/Regorus/RbacEngine.cs
@@ -0,0 +1,45 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using Regorus.Internal;
+
+#nullable enable
+namespace Regorus
+{
+ ///
+ /// Provides helpers for evaluating Azure RBAC condition expressions.
+ ///
+ public static unsafe class RbacEngine
+ {
+ ///
+ /// Evaluate an Azure RBAC condition expression against a JSON evaluation context.
+ ///
+ /// Azure RBAC condition expression.
+ /// JSON encoded EvaluationContext.
+ /// True if the condition evaluates to true; otherwise false.
+ /// Thrown when evaluation fails.
+ public static bool EvaluateCondition(string condition, string contextJson)
+ {
+ if (condition is null)
+ {
+ throw new ArgumentNullException(nameof(condition));
+ }
+
+ if (contextJson is null)
+ {
+ throw new ArgumentNullException(nameof(contextJson));
+ }
+
+ return Utf8Marshaller.WithUtf8(condition, conditionPtr =>
+ Utf8Marshaller.WithUtf8(contextJson, contextPtr =>
+ {
+ unsafe
+ {
+ var result = Internal.API.regorus_rbac_engine_eval_condition((byte*)conditionPtr, (byte*)contextPtr);
+ return ResultHelpers.GetBoolResult(result);
+ }
+ }));
+ }
+ }
+}
diff --git a/bindings/ffi/Cargo.toml b/bindings/ffi/Cargo.toml
index 6d9764d0..a787910b 100644
--- a/bindings/ffi/Cargo.toml
+++ b/bindings/ffi/Cargo.toml
@@ -32,6 +32,7 @@ default = [
"coverage",
"allocator-memory-limits",
"rvm",
+ "rbac",
"regorus/arc",
"regorus/full-opa",
"contention_checks",
@@ -43,6 +44,7 @@ coverage = ["regorus/coverage"]
allocator-memory-limits = ["regorus/allocator-memory-limits"]
contention_checks = ["parking_lot"]
rvm = ["regorus/rvm"]
+rbac = ["regorus/azure-rbac"]
custom_allocator = []
[build-dependencies]
diff --git a/bindings/ffi/src/lib.rs b/bindings/ffi/src/lib.rs
index 84f3d6ec..996f2f37 100644
--- a/bindings/ffi/src/lib.rs
+++ b/bindings/ffi/src/lib.rs
@@ -14,6 +14,8 @@ mod engine;
mod limits;
mod lock;
mod panic_guard;
+#[cfg(feature = "rbac")]
+mod rbac;
#[cfg(feature = "rvm")]
pub(crate) mod rvm;
mod schema_registry;
diff --git a/bindings/ffi/src/rbac.rs b/bindings/ffi/src/rbac.rs
new file mode 100644
index 00000000..825fe784
--- /dev/null
+++ b/bindings/ffi/src/rbac.rs
@@ -0,0 +1,59 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use crate::common::{from_c_str, RegorusResult, RegorusStatus};
+use crate::panic_guard::with_unwind_guard;
+use alloc::format;
+use core::ffi::c_char;
+
+use regorus::languages::azure_rbac::ast::EvaluationContext;
+use regorus::languages::azure_rbac::interpreter::ConditionInterpreter;
+
+#[no_mangle]
+/// Evaluate an Azure RBAC condition expression against a JSON evaluation context.
+///
+/// * `condition`: RBAC condition string.
+/// * `context_json`: JSON representation of EvaluationContext.
+pub extern "C" fn regorus_rbac_engine_eval_condition(
+ condition: *const c_char,
+ context_json: *const c_char,
+) -> RegorusResult {
+ with_unwind_guard(|| {
+ let condition = match from_c_str(condition) {
+ Ok(value) => value,
+ Err(err) => {
+ return RegorusResult::err_with_message(
+ RegorusStatus::InvalidArgument,
+ format!("{err}"),
+ )
+ }
+ };
+ let context_json = match from_c_str(context_json) {
+ Ok(value) => value,
+ Err(err) => {
+ return RegorusResult::err_with_message(
+ RegorusStatus::InvalidArgument,
+ format!("{err}"),
+ )
+ }
+ };
+ let context: EvaluationContext = match serde_json::from_str(&context_json) {
+ Ok(context) => context,
+ Err(err) => {
+ return RegorusResult::err_with_message(
+ RegorusStatus::InvalidDataFormat,
+ format!("invalid context json: {err}"),
+ )
+ }
+ };
+
+ let interpreter = ConditionInterpreter::new(&context);
+ match interpreter.evaluate_str(&condition) {
+ Ok(result) => RegorusResult::ok_bool(result),
+ Err(err) => RegorusResult::err_with_message(
+ RegorusStatus::Error,
+ format!("condition evaluation failed: {err}"),
+ ),
+ }
+ })
+}
diff --git a/src/languages/azure_rbac/ast/expr.rs b/src/languages/azure_rbac/ast/expr.rs
index d979d746..95b52de9 100644
--- a/src/languages/azure_rbac/ast/expr.rs
+++ b/src/languages/azure_rbac/ast/expr.rs
@@ -10,9 +10,10 @@ use serde::{Deserialize, Serialize};
use super::literals::{
BooleanLiteral, DateTimeLiteral, NullLiteral, NumberLiteral, StringLiteral, TimeLiteral,
};
-use super::operators::{ArrayOperator, ConditionOperator};
+use super::operators::ArrayOperator;
use super::references::AttributeReference;
use super::span::EmptySpan;
+use crate::languages::azure_rbac::builtins::RbacBuiltin;
/// ABAC condition expression
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -104,7 +105,7 @@ pub enum UnaryOperator {
pub struct BinaryExpression {
#[serde(skip)]
pub span: EmptySpan,
- pub operator: ConditionOperator,
+ pub operator: RbacBuiltin,
pub left: Box,
pub right: Box,
}
diff --git a/src/languages/azure_rbac/ast/operators.rs b/src/languages/azure_rbac/ast/operators.rs
index ef052185..f8776249 100644
--- a/src/languages/azure_rbac/ast/operators.rs
+++ b/src/languages/azure_rbac/ast/operators.rs
@@ -12,197 +12,3 @@ pub struct ArrayOperator {
#[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/builtins/actions.rs b/src/languages/azure_rbac/builtins/actions.rs
new file mode 100644
index 00000000..da01bdc0
--- /dev/null
+++ b/src/languages/azure_rbac/builtins/actions.rs
@@ -0,0 +1,41 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+// Match action strings against slash-delimited patterns with "*" wildcards.
+//
+// Behavior examples:
+// - action: "Microsoft.Storage/storageAccounts/read"
+// pattern: "Microsoft.Storage/storageAccounts/read" => true
+// - action: "Microsoft.Storage/storageAccounts/read"
+// pattern: "Microsoft.Storage/storageAccounts/*" => true
+// - action: "Microsoft.Storage/storageAccounts/read"
+// pattern: "Microsoft.Storage/*/read" => true
+// - action: "Microsoft.Storage/storageAccounts/read"
+// pattern: "Microsoft.Storage/*/write" => false
+// - action: "Microsoft.Storage/storageAccounts/read"
+// pattern: "Microsoft.Storage/storageAccounts" => false (segment count mismatch)
+pub(super) fn action_matches_pattern(action: &str, pattern: &str) -> bool {
+ // Split action and pattern into fixed segments (delimited by '/').
+ let action_parts: alloc::vec::Vec<&str> = action.split('/').collect();
+ let pattern_parts: alloc::vec::Vec<&str> = pattern.split('/').collect();
+
+ // Require the same number of segments for a match.
+ if action_parts.len() != pattern_parts.len() {
+ return false;
+ }
+
+ // Compare segment-by-segment; "*" matches any single segment.
+ for (action_part, pattern_part) in action_parts.iter().zip(pattern_parts.iter()) {
+ if *pattern_part == "*" {
+ // Wildcard segment matches anything in the corresponding position.
+ continue;
+ }
+ if action_part != pattern_part {
+ // Non-wildcard segment must match exactly.
+ return false;
+ }
+ }
+
+ // All segments matched.
+ true
+}
diff --git a/src/languages/azure_rbac/builtins/bools.rs b/src/languages/azure_rbac/builtins/bools.rs
new file mode 100644
index 00000000..8f9fd403
--- /dev/null
+++ b/src/languages/azure_rbac/builtins/bools.rs
@@ -0,0 +1,20 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use crate::value::Value;
+
+use super::common;
+use super::evaluator::RbacBuiltinError;
+
+// Compare two boolean values for equality.
+pub(super) fn bool_equals(
+ left: &Value,
+ right: &Value,
+ name: &'static str,
+) -> Result {
+ // Convert both sides to booleans using shared validation.
+ let lhs = common::value_as_bool(left, name)?;
+ let rhs = common::value_as_bool(right, name)?;
+ // Exact boolean equality.
+ Ok(lhs == rhs)
+}
diff --git a/src/languages/azure_rbac/builtins/common.rs b/src/languages/azure_rbac/builtins/common.rs
new file mode 100644
index 00000000..8439bcd6
--- /dev/null
+++ b/src/languages/azure_rbac/builtins/common.rs
@@ -0,0 +1,218 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use crate::value::Value;
+use alloc::format;
+use alloc::string::ToString as _;
+
+use super::evaluator::RbacBuiltinError;
+
+// Ensure the value is a string and return a borrowed &str.
+pub(super) fn ensure_string<'a>(
+ value: &'a Value,
+ name: &'static str,
+) -> Result<&'a str, RbacBuiltinError> {
+ match *value {
+ // Borrow string contents without allocation.
+ Value::String(ref s) => Ok(s.as_ref()),
+ _ => Err(RbacBuiltinError::new(format!(
+ "{} expects string arguments",
+ name
+ ))),
+ }
+}
+
+// Extract a boolean value or return a typed builtin error.
+pub(super) fn value_as_bool(value: &Value, name: &'static str) -> Result {
+ match *value {
+ Value::Bool(b) => Ok(b),
+ _ => Err(RbacBuiltinError::new(format!(
+ "{} expects boolean arguments",
+ name
+ ))),
+ }
+}
+
+// Convert a value into a string for builtin comparisons.
+// Accepts string values or numbers formatted as decimal text.
+pub(super) fn value_as_string(
+ value: &Value,
+ name: &'static str,
+) -> Result {
+ match *value {
+ // Preserve strings verbatim and render numbers as decimals.
+ Value::String(ref s) => Ok(s.as_ref().to_string()),
+ Value::Number(ref n) => Ok(n.format_decimal()),
+ _ => Err(RbacBuiltinError::new(format!(
+ "{} expects string arguments",
+ name
+ ))),
+ }
+}
+
+// Parse a value into an i64 for integer-based operations.
+// Accepts integral numeric values or numeric strings.
+pub(super) fn value_as_i64(value: &Value, name: &'static str) -> Result {
+ match *value {
+ // Accept only integral values for numeric builtins.
+ Value::Number(ref n) => n
+ .as_i64()
+ .ok_or_else(|| RbacBuiltinError::new(format!("{} expects numeric arguments", name))),
+ Value::String(ref s) => s
+ .parse::()
+ .map_err(|_| RbacBuiltinError::new(format!("{} expects numeric arguments", name))),
+ _ => Err(RbacBuiltinError::new(format!(
+ "{} expects numeric arguments",
+ name
+ ))),
+ }
+}
+
+// Parse a two-element array or set into numeric bounds (start, end).
+pub(super) fn range_bounds(
+ value: &Value,
+ name: &'static str,
+) -> Result<(i64, i64), RbacBuiltinError> {
+ match *value {
+ Value::Array(ref list) => {
+ // Require exactly two elements for range bounds.
+ let mut iter = list.iter();
+ let start = iter.next().ok_or_else(|| {
+ RbacBuiltinError::new(format!("{} expects a 2-element list or set", name))
+ })?;
+ let end = iter.next().ok_or_else(|| {
+ RbacBuiltinError::new(format!("{} expects a 2-element list or set", name))
+ })?;
+ if iter.next().is_some() {
+ return Err(RbacBuiltinError::new(format!(
+ "{} expects a 2-element list or set",
+ name
+ )));
+ }
+ Ok((value_as_i64(start, name)?, value_as_i64(end, name)?))
+ }
+ Value::Set(ref set) => {
+ // Sets must contain exactly two elements (order is arbitrary).
+ let mut iter = set.iter();
+ let start = iter.next().ok_or_else(|| {
+ RbacBuiltinError::new(format!("{} expects a 2-element list or set", name))
+ })?;
+ let end = iter.next().ok_or_else(|| {
+ RbacBuiltinError::new(format!("{} expects a 2-element list or set", name))
+ })?;
+ if iter.next().is_some() {
+ return Err(RbacBuiltinError::new(format!(
+ "{} expects a 2-element list or set",
+ name
+ )));
+ }
+ Ok((value_as_i64(start, name)?, value_as_i64(end, name)?))
+ }
+ _ => Err(RbacBuiltinError::new(format!(
+ "{} expects a 2-element list or set",
+ name
+ ))),
+ }
+}
+
+// Parse HH:MM[:SS] into seconds since midnight.
+pub(super) fn parse_time_of_day(value: &str) -> Result {
+ // Parse HH:MM[:SS] into numeric components.
+ let mut iter = value.split(':');
+ let hours_str = iter
+ .next()
+ .ok_or_else(|| RbacBuiltinError::new("Invalid time format, expected HH:MM or HH:MM:SS"))?;
+ let minutes_str = iter
+ .next()
+ .ok_or_else(|| RbacBuiltinError::new("Invalid time format, expected HH:MM or HH:MM:SS"))?;
+ let seconds_str = iter.next();
+ if iter.next().is_some() {
+ return Err(RbacBuiltinError::new(
+ "Invalid time format, expected HH:MM or HH:MM:SS",
+ ));
+ }
+
+ let hours = hours_str
+ .parse::()
+ .map_err(|_| RbacBuiltinError::new("Invalid hour value"))?;
+ let minutes = minutes_str
+ .parse::()
+ .map_err(|_| RbacBuiltinError::new("Invalid minute value"))?;
+ let seconds = match seconds_str {
+ Some(segment) => segment
+ .parse::()
+ .map_err(|_| RbacBuiltinError::new("Invalid second value"))?,
+ None => 0,
+ };
+
+ if hours > 23 || minutes > 59 || seconds > 59 {
+ return Err(RbacBuiltinError::new("Time value out of range"));
+ }
+
+ // Convert to seconds since midnight with overflow checks.
+ let total = hours
+ .checked_mul(3600)
+ .and_then(|total| total.checked_add(minutes.checked_mul(60)?))
+ .and_then(|total| total.checked_add(seconds))
+ .ok_or_else(|| RbacBuiltinError::new("Time value out of range"))?;
+ Ok(total)
+}
+
+// Match a value against a simple '*' wildcard pattern.
+pub(super) fn simple_wildcard_match(pattern: &str, value: &str) -> bool {
+ // Recursive matcher supporting '*' as multi-character wildcard.
+ fn helper(pattern: &[u8], value: &[u8]) -> bool {
+ match pattern.split_first() {
+ None => value.is_empty(),
+ Some((&b'*', rest)) => {
+ if helper(rest, value) {
+ return true;
+ }
+ match value.split_first() {
+ None => false,
+ Some((_first, rest_value)) => helper(pattern, rest_value),
+ }
+ }
+ Some((byte, rest)) => match value.split_first() {
+ Some((value_byte, rest_value)) if *byte == *value_byte => helper(rest, rest_value),
+ _ => false,
+ },
+ }
+ }
+
+ helper(pattern.as_bytes(), value.as_bytes())
+}
+
+// Return true if the value is an array or set.
+pub(super) const fn is_collection(value: &Value) -> bool {
+ matches!(value, Value::Array(_) | Value::Set(_))
+}
+
+// Check membership in an array or set.
+pub(super) fn collection_contains(collection: &Value, needle: &Value) -> bool {
+ match *collection {
+ Value::Array(ref list) => list.contains(needle),
+ Value::Set(ref set) => set.contains(needle),
+ _ => false,
+ }
+}
+
+// Case-insensitive membership check for string elements in arrays or sets.
+pub(super) fn collection_contains_case_insensitive(collection: &Value, needle: &Value) -> bool {
+ let needle_str = match *needle {
+ Value::String(ref s) => s.to_ascii_lowercase(),
+ _ => return false,
+ };
+
+ match *collection {
+ Value::Array(ref list) => list.iter().any(|value| match *value {
+ Value::String(ref s) => s.to_ascii_lowercase() == needle_str,
+ _ => false,
+ }),
+ Value::Set(ref set) => set.iter().any(|value| match *value {
+ Value::String(ref s) => s.to_ascii_lowercase() == needle_str,
+ _ => false,
+ }),
+ _ => false,
+ }
+}
diff --git a/src/languages/azure_rbac/builtins/datetime.rs b/src/languages/azure_rbac/builtins/datetime.rs
new file mode 100644
index 00000000..899cada9
--- /dev/null
+++ b/src/languages/azure_rbac/builtins/datetime.rs
@@ -0,0 +1,64 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use crate::value::Value;
+use alloc::string::ToString as _;
+
+use super::common;
+use super::evaluator::RbacBuiltinError;
+
+#[cfg(feature = "time")]
+use chrono::DateTime;
+
+// Compare RFC3339 timestamps using the requested operator.
+//
+// Interesting examples:
+// - left: @Request[date] = "2023-05-01T12:00:00Z",
+// right: @Environment[utcNow] = "2023-05-01T11:59:59Z",
+// op: ">" => true
+// - left: @Resource[expiry] = "2024-01-01T00:00:00Z",
+// right: @Environment[utcNow] = "2024-01-01T00:00:00Z",
+// op: "==" => true
+// - left: @Request[scheduled] = "2023-12-31T23:59:59-08:00",
+// right: @Environment[utcNow] = "2024-01-01T07:59:59Z",
+// op: "==" => true (same instant, different offsets)
+pub(super) fn datetime_compare(
+ left: &Value,
+ right: &Value,
+ op: &str,
+ name: &'static str,
+) -> Result {
+ // Parse both operands as RFC3339 strings.
+ let lhs = common::value_as_string(left, name)?;
+ let rhs = common::value_as_string(right, name)?;
+
+ #[cfg(feature = "time")]
+ {
+ // Compare instants using chrono's RFC3339 parsing.
+ let left_dt = DateTime::parse_from_rfc3339(&lhs)
+ .map_err(|err| RbacBuiltinError::new(err.to_string()))?;
+ let right_dt = DateTime::parse_from_rfc3339(&rhs)
+ .map_err(|err| RbacBuiltinError::new(err.to_string()))?;
+ Ok(match op {
+ "==" => left_dt == right_dt,
+ "<" => left_dt < right_dt,
+ "<=" => left_dt <= right_dt,
+ ">" => left_dt > right_dt,
+ ">=" => left_dt >= right_dt,
+ _ => false,
+ })
+ }
+
+ #[cfg(not(feature = "time"))]
+ {
+ // Without the time feature, compare lexicographically as strings.
+ Ok(match op {
+ "==" => lhs == rhs,
+ "<" => lhs < rhs,
+ "<=" => lhs <= rhs,
+ ">" => lhs > rhs,
+ ">=" => lhs >= rhs,
+ _ => false,
+ })
+ }
+}
diff --git a/src/languages/azure_rbac/builtins/evaluator.rs b/src/languages/azure_rbac/builtins/evaluator.rs
new file mode 100644
index 00000000..5bf21e66
--- /dev/null
+++ b/src/languages/azure_rbac/builtins/evaluator.rs
@@ -0,0 +1,461 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+#![allow(clippy::indexing_slicing)]
+
+use crate::value::Value;
+use alloc::format;
+
+use super::super::ast::context::EvaluationContext;
+use super::bools;
+use super::datetime;
+use super::functions;
+use super::ids::RbacBuiltin;
+use super::ip;
+use super::lists;
+use super::numbers;
+use super::quantifiers;
+use super::strings;
+use super::time_of_day;
+
+/// Builtin evaluation context.
+#[derive(Debug, Clone, Copy)]
+pub struct RbacBuiltinContext<'a> {
+ pub evaluation: &'a EvaluationContext,
+}
+
+impl<'a> RbacBuiltinContext<'a> {
+ pub const fn new(evaluation: &'a EvaluationContext) -> Self {
+ Self { evaluation }
+ }
+}
+
+/// Builtin evaluation error.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RbacBuiltinError {
+ message: alloc::string::String,
+}
+
+impl RbacBuiltinError {
+ pub fn new(message: impl Into) -> Self {
+ Self {
+ message: message.into(),
+ }
+ }
+}
+
+impl core::fmt::Display for RbacBuiltinError {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ write!(f, "{}", self.message)
+ }
+}
+
+/// Default RBAC builtin evaluator implementation.
+#[derive(Debug, Default, Clone, Copy)]
+pub struct DefaultRbacBuiltinEvaluator;
+
+impl DefaultRbacBuiltinEvaluator {
+ pub const fn new() -> Self {
+ Self
+ }
+
+ fn ensure_arg_count(
+ builtin: RbacBuiltin,
+ args: &[Value],
+ expected: usize,
+ ) -> Result<(), RbacBuiltinError> {
+ if args.len() == expected {
+ Ok(())
+ } else {
+ Err(RbacBuiltinError::new(format!(
+ "{} expects {} arguments",
+ builtin.name(),
+ expected
+ )))
+ }
+ }
+
+ fn arg1(builtin: RbacBuiltin, args: &[Value]) -> Result<&Value, RbacBuiltinError> {
+ if args.len() != 1 {
+ return Err(RbacBuiltinError::new(format!(
+ "{} expects 1 argument",
+ builtin.name()
+ )));
+ }
+ args.first()
+ .ok_or_else(|| RbacBuiltinError::new(format!("{} expects 1 argument", builtin.name())))
+ }
+
+ fn arg2(builtin: RbacBuiltin, args: &[Value]) -> Result<(&Value, &Value), RbacBuiltinError> {
+ if args.len() != 2 {
+ return Err(RbacBuiltinError::new(format!(
+ "{} expects 2 arguments",
+ builtin.name()
+ )));
+ }
+ let left = args.first().ok_or_else(|| {
+ RbacBuiltinError::new(format!("{} expects 2 arguments", builtin.name()))
+ })?;
+ let right = args.get(1).ok_or_else(|| {
+ RbacBuiltinError::new(format!("{} expects 2 arguments", builtin.name()))
+ })?;
+ Ok((left, right))
+ }
+ pub fn eval(
+ &self,
+ builtin: RbacBuiltin,
+ args: &[Value],
+ ctx: &RbacBuiltinContext<'_>,
+ ) -> Result {
+ match builtin {
+ RbacBuiltin::StringEquals => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(strings::string_equals(&args[0], &args[1])?))
+ }
+ RbacBuiltin::StringNotEquals => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(!strings::string_equals(&args[0], &args[1])?))
+ }
+ RbacBuiltin::StringEqualsIgnoreCase => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(strings::string_equals_ignore_case(
+ &args[0], &args[1],
+ )?))
+ }
+ RbacBuiltin::StringNotEqualsIgnoreCase => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(!strings::string_equals_ignore_case(
+ &args[0], &args[1],
+ )?))
+ }
+ RbacBuiltin::StringLike => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(strings::string_like(&args[0], &args[1])?))
+ }
+ RbacBuiltin::StringNotLike => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(!strings::string_like(&args[0], &args[1])?))
+ }
+ RbacBuiltin::StringStartsWith => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(strings::string_starts_with(
+ &args[0], &args[1],
+ )?))
+ }
+ RbacBuiltin::StringNotStartsWith => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(!strings::string_starts_with(
+ &args[0], &args[1],
+ )?))
+ }
+ RbacBuiltin::StringEndsWith => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(strings::string_ends_with(&args[0], &args[1])?))
+ }
+ RbacBuiltin::StringNotEndsWith => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(!strings::string_ends_with(&args[0], &args[1])?))
+ }
+ RbacBuiltin::StringContains => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(strings::string_contains(&args[0], &args[1])?))
+ }
+ RbacBuiltin::StringNotContains => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(!strings::string_contains(&args[0], &args[1])?))
+ }
+ RbacBuiltin::StringMatches => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(strings::string_matches(&args[0], &args[1])?))
+ }
+ RbacBuiltin::StringNotMatches => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(!strings::string_matches(&args[0], &args[1])?))
+ }
+ RbacBuiltin::NumericEquals => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(numbers::numeric_compare(
+ &args[0],
+ &args[1],
+ "==",
+ builtin.name(),
+ )?))
+ }
+ RbacBuiltin::NumericNotEquals => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(numbers::numeric_compare(
+ &args[0],
+ &args[1],
+ "!=",
+ builtin.name(),
+ )?))
+ }
+ RbacBuiltin::NumericLessThan => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(numbers::numeric_compare(
+ &args[0],
+ &args[1],
+ "<",
+ builtin.name(),
+ )?))
+ }
+ RbacBuiltin::NumericLessThanEquals => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(numbers::numeric_compare(
+ &args[0],
+ &args[1],
+ "<=",
+ builtin.name(),
+ )?))
+ }
+ RbacBuiltin::NumericGreaterThan => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(numbers::numeric_compare(
+ &args[0],
+ &args[1],
+ ">",
+ builtin.name(),
+ )?))
+ }
+ RbacBuiltin::NumericGreaterThanEquals => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(numbers::numeric_compare(
+ &args[0],
+ &args[1],
+ ">=",
+ builtin.name(),
+ )?))
+ }
+ RbacBuiltin::NumericInRange => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(numbers::numeric_in_range(
+ &args[0],
+ &args[1],
+ builtin.name(),
+ )?))
+ }
+ RbacBuiltin::BoolEquals => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(bools::bool_equals(
+ &args[0],
+ &args[1],
+ builtin.name(),
+ )?))
+ }
+ RbacBuiltin::BoolNotEquals => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(!bools::bool_equals(
+ &args[0],
+ &args[1],
+ builtin.name(),
+ )?))
+ }
+ RbacBuiltin::DateTimeEquals => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(datetime::datetime_compare(
+ &args[0],
+ &args[1],
+ "==",
+ builtin.name(),
+ )?))
+ }
+ RbacBuiltin::DateTimeNotEquals => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(!datetime::datetime_compare(
+ &args[0],
+ &args[1],
+ "==",
+ builtin.name(),
+ )?))
+ }
+ RbacBuiltin::DateTimeGreaterThan => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(datetime::datetime_compare(
+ &args[0],
+ &args[1],
+ ">",
+ builtin.name(),
+ )?))
+ }
+ RbacBuiltin::DateTimeGreaterThanEquals => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(datetime::datetime_compare(
+ &args[0],
+ &args[1],
+ ">=",
+ builtin.name(),
+ )?))
+ }
+ RbacBuiltin::DateTimeLessThan => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(datetime::datetime_compare(
+ &args[0],
+ &args[1],
+ "<",
+ builtin.name(),
+ )?))
+ }
+ RbacBuiltin::DateTimeLessThanEquals => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(datetime::datetime_compare(
+ &args[0],
+ &args[1],
+ "<=",
+ builtin.name(),
+ )?))
+ }
+ RbacBuiltin::TimeOfDayEquals => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(time_of_day::time_compare(
+ &args[0],
+ &args[1],
+ "==",
+ builtin.name(),
+ )?))
+ }
+ RbacBuiltin::TimeOfDayNotEquals => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(!time_of_day::time_compare(
+ &args[0],
+ &args[1],
+ "==",
+ builtin.name(),
+ )?))
+ }
+ RbacBuiltin::TimeOfDayGreaterThan => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(time_of_day::time_compare(
+ &args[0],
+ &args[1],
+ ">",
+ builtin.name(),
+ )?))
+ }
+ RbacBuiltin::TimeOfDayGreaterThanEquals => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(time_of_day::time_compare(
+ &args[0],
+ &args[1],
+ ">=",
+ builtin.name(),
+ )?))
+ }
+ RbacBuiltin::TimeOfDayLessThan => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(time_of_day::time_compare(
+ &args[0],
+ &args[1],
+ "<",
+ builtin.name(),
+ )?))
+ }
+ RbacBuiltin::TimeOfDayLessThanEquals => {
+ Self::ensure_arg_count(builtin, args, 2)?;
+ Ok(Value::Bool(time_of_day::time_compare(
+ &args[0],
+ &args[1],
+ "<=",
+ builtin.name(),
+ )?))
+ }
+ RbacBuiltin::TimeOfDayInRange => {
+ let (left, right) = Self::arg2(builtin, args)?;
+ Ok(Value::Bool(time_of_day::time_in_range(
+ left,
+ right,
+ builtin.name(),
+ )?))
+ }
+ RbacBuiltin::GuidEquals => {
+ let (left, right) = Self::arg2(builtin, args)?;
+ Ok(Value::Bool(strings::guid_equals(
+ left,
+ right,
+ builtin.name(),
+ )?))
+ }
+ RbacBuiltin::GuidNotEquals => {
+ let (left, right) = Self::arg2(builtin, args)?;
+ Ok(Value::Bool(!strings::guid_equals(
+ left,
+ right,
+ builtin.name(),
+ )?))
+ }
+ RbacBuiltin::IpMatch => {
+ let (left, right) = Self::arg2(builtin, args)?;
+ Ok(Value::Bool(ip::ip_match(left, right, builtin.name())?))
+ }
+ RbacBuiltin::IpNotMatch => {
+ let (left, right) = Self::arg2(builtin, args)?;
+ Ok(Value::Bool(!ip::ip_match(left, right, builtin.name())?))
+ }
+ RbacBuiltin::IpInRange => {
+ let (left, right) = Self::arg2(builtin, args)?;
+ Ok(Value::Bool(ip::ip_in_range(left, right, builtin.name())?))
+ }
+ RbacBuiltin::ListContains => {
+ let (left, right) = Self::arg2(builtin, args)?;
+ Ok(Value::Bool(lists::list_contains(left, right)?))
+ }
+ RbacBuiltin::ListNotContains => {
+ let (left, right) = Self::arg2(builtin, args)?;
+ Ok(Value::Bool(!lists::list_contains(left, right)?))
+ }
+ RbacBuiltin::ForAnyOfAnyValues => {
+ let (left, right) = Self::arg2(builtin, args)?;
+ Ok(Value::Bool(quantifiers::for_any_of_any_values(
+ left, right,
+ )?))
+ }
+ RbacBuiltin::ForAllOfAnyValues => {
+ let (left, right) = Self::arg2(builtin, args)?;
+ Ok(Value::Bool(quantifiers::for_all_of_any_values(
+ left, right,
+ )?))
+ }
+ RbacBuiltin::ForAnyOfAllValues => {
+ let (left, right) = Self::arg2(builtin, args)?;
+ Ok(Value::Bool(quantifiers::for_any_of_all_values(
+ left, right,
+ )?))
+ }
+ RbacBuiltin::ForAllOfAllValues => {
+ let (left, right) = Self::arg2(builtin, args)?;
+ Ok(Value::Bool(quantifiers::for_all_of_all_values(
+ left, right,
+ )?))
+ }
+ RbacBuiltin::ActionMatches => functions::action_matches(args, ctx),
+ RbacBuiltin::SubOperationMatches => functions::suboperation_matches(args, ctx),
+ RbacBuiltin::ToLower => {
+ let value = Self::arg1(builtin, args)?;
+ functions::to_lower(value)
+ }
+ RbacBuiltin::ToUpper => {
+ let value = Self::arg1(builtin, args)?;
+ functions::to_upper(value)
+ }
+ RbacBuiltin::Trim => {
+ let value = Self::arg1(builtin, args)?;
+ functions::trim(value)
+ }
+ RbacBuiltin::NormalizeSet => {
+ let value = Self::arg1(builtin, args)?;
+ functions::normalize_set(value)
+ }
+ RbacBuiltin::NormalizeList => {
+ let value = Self::arg1(builtin, args)?;
+ functions::normalize_list(value)
+ }
+ RbacBuiltin::AddDays => {
+ let (left, right) = Self::arg2(builtin, args)?;
+ functions::add_days(left, right)
+ }
+ RbacBuiltin::ToTime => {
+ let value = Self::arg1(builtin, args)?;
+ functions::to_time(value)
+ }
+ }
+ }
+}
diff --git a/src/languages/azure_rbac/builtins/functions.rs b/src/languages/azure_rbac/builtins/functions.rs
new file mode 100644
index 00000000..4710075f
--- /dev/null
+++ b/src/languages/azure_rbac/builtins/functions.rs
@@ -0,0 +1,212 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use alloc::collections::BTreeSet;
+use alloc::string::ToString as _;
+use alloc::vec;
+
+use crate::value::Value;
+
+use super::actions;
+use super::common;
+use super::evaluator::{RbacBuiltinContext, RbacBuiltinError};
+
+#[cfg(feature = "time")]
+use chrono::{DateTime, Duration};
+
+pub(super) fn to_lower(value: &Value) -> Result {
+ // Normalize to ASCII lowercase for RBAC string semantics.
+ let text = common::ensure_string(value, "ToLower")?;
+ Ok(Value::String(text.to_ascii_lowercase().into()))
+}
+
+pub(super) fn to_upper(value: &Value) -> Result {
+ // Normalize to ASCII uppercase for RBAC string semantics.
+ let text = common::ensure_string(value, "ToUpper")?;
+ Ok(Value::String(text.to_ascii_uppercase().into()))
+}
+
+pub(super) fn trim(value: &Value) -> Result {
+ // Remove leading/trailing whitespace.
+ let text = common::ensure_string(value, "Trim")?;
+ Ok(Value::String(text.trim().to_string().into()))
+}
+
+// Normalize to a set; useful for equality and membership checks.
+// Examples:
+// - NormalizeSet(["a", "b", "a"]) => {"a", "b"}
+// - NormalizeSet("a") => {"a"}
+pub(super) fn normalize_set(value: &Value) -> Result {
+ match *value {
+ // Already a set; return as-is.
+ Value::Set(_) => Ok(value.clone()),
+ Value::Array(ref list) => {
+ // Convert arrays to sets by de-duplicating elements.
+ let set: BTreeSet = list.iter().cloned().collect();
+ Ok(Value::from(set))
+ }
+ _ => {
+ // Non-collections become singleton sets.
+ let mut set = BTreeSet::new();
+ set.insert(value.clone());
+ Ok(Value::from(set))
+ }
+ }
+}
+
+// Normalize to a list while preserving element values.
+// Examples:
+// - NormalizeList({"a", "b"}) => ["a", "b"] (order not guaranteed)
+// - NormalizeList("a") => ["a"]
+pub(super) fn normalize_list(value: &Value) -> Result {
+ match *value {
+ // Already a list.
+ Value::Array(ref list) => Ok(Value::Array(list.clone())),
+ Value::Set(ref set) => Ok(Value::from(
+ set.iter().cloned().collect::>(),
+ )),
+ // Non-collections become singleton lists.
+ _ => Ok(Value::from(vec![value.clone()])),
+ }
+}
+
+// Add days to an RFC3339 timestamp string.
+// Examples:
+// - AddDays("2024-02-28T10:00:00Z", 1) => "2024-02-29T10:00:00Z"
+// - AddDays("2023-12-31T23:00:00Z", 1) => "2024-01-01T23:00:00Z"
+pub(super) fn add_days(date_value: &Value, days_value: &Value) -> Result {
+ // Parse arguments as RFC3339 and integer day count.
+ let date_str = common::ensure_string(date_value, "AddDays")?;
+ let days = common::value_as_i64(days_value, "AddDays")?;
+
+ #[cfg(feature = "time")]
+ {
+ // Apply a checked day offset to avoid overflow.
+ let dt = DateTime::parse_from_rfc3339(date_str)
+ .map_err(|err| RbacBuiltinError::new(err.to_string()))?;
+ let updated = dt
+ .checked_add_signed(Duration::days(days))
+ .ok_or_else(|| RbacBuiltinError::new("AddDays overflow"))?;
+ Ok(Value::String(updated.to_rfc3339().into()))
+ }
+
+ #[cfg(not(feature = "time"))]
+ {
+ // Feature-gated builtin; return a descriptive error.
+ let _ = (date_str, days);
+ Err(RbacBuiltinError::new(
+ "AddDays builtin has not been enabled",
+ ))
+ }
+}
+
+// Coerce a string into a time literal (validated elsewhere).
+// Example: ToTime("12:30:15") => "12:30:15"
+pub(super) fn to_time(value: &Value) -> Result {
+ match *value {
+ // Time literals are represented as strings in the AST.
+ Value::String(_) => Ok(value.clone()),
+ _ => Err(RbacBuiltinError::new("ToTime expects a string")),
+ }
+}
+
+// Match an action against a wildcard pattern.
+// - With 1 argument, the action comes from the evaluation context.
+// - Matching is segment-based (split on '/'); '*' matches a single segment and the
+// action and pattern must have the same number of segments.
+// Examples:
+// - ActionMatches("Microsoft.Storage/storageAccounts/read") => uses context action
+// - ActionMatches("Microsoft.Storage/storageAccounts/read", "Microsoft.Storage/*/read") => true
+// - ActionMatches("Microsoft.Storage/storageAccounts/read", "Microsoft.Storage/storageAccounts") => false
+pub(super) fn action_matches(
+ args: &[Value],
+ ctx: &RbacBuiltinContext<'_>,
+) -> Result {
+ // Either use context action (1 arg) or explicit action (2 args).
+ let (action, pattern) = match args.len() {
+ 1 => (
+ ctx.evaluation
+ .action
+ .as_deref()
+ .ok_or_else(|| RbacBuiltinError::new("Missing action in context"))?,
+ common::ensure_string(
+ args.first().ok_or_else(|| {
+ RbacBuiltinError::new("ActionMatches expects 1 or 2 arguments")
+ })?,
+ "ActionMatches",
+ )?,
+ ),
+ 2 => (
+ common::ensure_string(
+ args.first().ok_or_else(|| {
+ RbacBuiltinError::new("ActionMatches expects 1 or 2 arguments")
+ })?,
+ "ActionMatches",
+ )?,
+ common::ensure_string(
+ args.get(1).ok_or_else(|| {
+ RbacBuiltinError::new("ActionMatches expects 1 or 2 arguments")
+ })?,
+ "ActionMatches",
+ )?,
+ ),
+ _ => {
+ return Err(RbacBuiltinError::new(
+ "ActionMatches expects 1 or 2 arguments",
+ ))
+ }
+ };
+
+ Ok(Value::Bool(actions::action_matches_pattern(
+ action, pattern,
+ )))
+}
+
+// Match a suboperation against a pattern.
+// - With 1 argument, the suboperation comes from the evaluation context.
+// - Matching is exact; no wildcard semantics are supported.
+// Examples:
+// - SubOperationMatches("sub/read") => uses context suboperation
+// - SubOperationMatches("sub/read", "sub/read") => true
+// - SubOperationMatches("sub/read", "sub/*") => false
+pub(super) fn suboperation_matches(
+ args: &[Value],
+ ctx: &RbacBuiltinContext<'_>,
+) -> Result {
+ // Either use context suboperation (1 arg) or explicit suboperation (2 args).
+ let (subop, pattern) = match args.len() {
+ 1 => (
+ ctx.evaluation
+ .suboperation
+ .as_deref()
+ .ok_or_else(|| RbacBuiltinError::new("Missing suboperation in context"))?,
+ common::ensure_string(
+ args.first().ok_or_else(|| {
+ RbacBuiltinError::new("SubOperationMatches expects 1 or 2 arguments")
+ })?,
+ "SubOperationMatches",
+ )?,
+ ),
+ 2 => (
+ common::ensure_string(
+ args.first().ok_or_else(|| {
+ RbacBuiltinError::new("SubOperationMatches expects 1 or 2 arguments")
+ })?,
+ "SubOperationMatches",
+ )?,
+ common::ensure_string(
+ args.get(1).ok_or_else(|| {
+ RbacBuiltinError::new("SubOperationMatches expects 1 or 2 arguments")
+ })?,
+ "SubOperationMatches",
+ )?,
+ ),
+ _ => {
+ return Err(RbacBuiltinError::new(
+ "SubOperationMatches expects 1 or 2 arguments",
+ ))
+ }
+ };
+
+ Ok(Value::Bool(subop == pattern))
+}
diff --git a/src/languages/azure_rbac/builtins/ids.rs b/src/languages/azure_rbac/builtins/ids.rs
new file mode 100644
index 00000000..1d8b174d
--- /dev/null
+++ b/src/languages/azure_rbac/builtins/ids.rs
@@ -0,0 +1,179 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use serde::{Deserialize, Serialize};
+
+macro_rules! rbac_builtins {
+ ($( $variant:ident => { name: $name:literal, operator: $is_operator:expr } ),* $(,)?) => {
+ impl RbacBuiltin {
+ /// Parse a builtin name as used in the RBAC condition language.
+ pub fn parse(name: &str) -> Option {
+ match name {
+ $( $name => Some(Self::$variant), )*
+ _ => None,
+ }
+ }
+
+ /// Parse a binary operator name (excludes function-style builtins).
+ pub fn parse_operator(name: &str) -> Option {
+ Self::parse(name).filter(|builtin| builtin.is_operator())
+ }
+
+ /// Returns true for infix operator-style builtins.
+ pub const fn is_operator(self) -> bool {
+ match self {
+ $( Self::$variant => $is_operator, )*
+ }
+ }
+
+ /// RBAC builtin name.
+ pub const fn name(self) -> &'static str {
+ match self {
+ $( Self::$variant => $name, )*
+ }
+ }
+ }
+ };
+}
+
+/// RBAC builtin identifier.
+#[repr(u8)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub enum RbacBuiltin {
+ // 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,
+
+ // Cross product operators
+ ForAnyOfAnyValues,
+ ForAllOfAnyValues,
+ ForAnyOfAllValues,
+ ForAllOfAllValues,
+
+ // Action operators
+ ActionMatches,
+ SubOperationMatches,
+
+ // Function-style builtins
+ ToLower,
+ ToUpper,
+ Trim,
+ NormalizeSet,
+ NormalizeList,
+ AddDays,
+ ToTime,
+}
+
+rbac_builtins! {
+ StringEquals => { name: "StringEquals", operator: true },
+ StringNotEquals => { name: "StringNotEquals", operator: true },
+ StringEqualsIgnoreCase => { name: "StringEqualsIgnoreCase", operator: true },
+ StringNotEqualsIgnoreCase => { name: "StringNotEqualsIgnoreCase", operator: true },
+ StringLike => { name: "StringLike", operator: true },
+ StringNotLike => { name: "StringNotLike", operator: true },
+ StringStartsWith => { name: "StringStartsWith", operator: true },
+ StringNotStartsWith => { name: "StringNotStartsWith", operator: true },
+ StringEndsWith => { name: "StringEndsWith", operator: true },
+ StringNotEndsWith => { name: "StringNotEndsWith", operator: true },
+ StringContains => { name: "StringContains", operator: true },
+ StringNotContains => { name: "StringNotContains", operator: true },
+ StringMatches => { name: "StringMatches", operator: true },
+ StringNotMatches => { name: "StringNotMatches", operator: true },
+ NumericEquals => { name: "NumericEquals", operator: true },
+ NumericNotEquals => { name: "NumericNotEquals", operator: true },
+ NumericLessThan => { name: "NumericLessThan", operator: true },
+ NumericLessThanEquals => { name: "NumericLessThanEquals", operator: true },
+ NumericGreaterThan => { name: "NumericGreaterThan", operator: true },
+ NumericGreaterThanEquals => { name: "NumericGreaterThanEquals", operator: true },
+ NumericInRange => { name: "NumericInRange", operator: true },
+ BoolEquals => { name: "BoolEquals", operator: true },
+ BoolNotEquals => { name: "BoolNotEquals", operator: true },
+ DateTimeEquals => { name: "DateTimeEquals", operator: true },
+ DateTimeNotEquals => { name: "DateTimeNotEquals", operator: true },
+ DateTimeGreaterThan => { name: "DateTimeGreaterThan", operator: true },
+ DateTimeGreaterThanEquals => { name: "DateTimeGreaterThanEquals", operator: true },
+ DateTimeLessThan => { name: "DateTimeLessThan", operator: true },
+ DateTimeLessThanEquals => { name: "DateTimeLessThanEquals", operator: true },
+ TimeOfDayEquals => { name: "TimeOfDayEquals", operator: true },
+ TimeOfDayNotEquals => { name: "TimeOfDayNotEquals", operator: true },
+ TimeOfDayGreaterThan => { name: "TimeOfDayGreaterThan", operator: true },
+ TimeOfDayGreaterThanEquals => { name: "TimeOfDayGreaterThanEquals", operator: true },
+ TimeOfDayLessThan => { name: "TimeOfDayLessThan", operator: true },
+ TimeOfDayLessThanEquals => { name: "TimeOfDayLessThanEquals", operator: true },
+ TimeOfDayInRange => { name: "TimeOfDayInRange", operator: true },
+ GuidEquals => { name: "GuidEquals", operator: true },
+ GuidNotEquals => { name: "GuidNotEquals", operator: true },
+ IpMatch => { name: "IpMatch", operator: true },
+ IpNotMatch => { name: "IpNotMatch", operator: true },
+ IpInRange => { name: "IpInRange", operator: true },
+ ListContains => { name: "ListContains", operator: true },
+ ListNotContains => { name: "ListNotContains", operator: true },
+ ForAnyOfAnyValues => { name: "ForAnyOfAnyValues", operator: true },
+ ForAllOfAnyValues => { name: "ForAllOfAnyValues", operator: true },
+ ForAnyOfAllValues => { name: "ForAnyOfAllValues", operator: true },
+ ForAllOfAllValues => { name: "ForAllOfAllValues", operator: true },
+ ActionMatches => { name: "ActionMatches", operator: true },
+ SubOperationMatches => { name: "SubOperationMatches", operator: true },
+ ToLower => { name: "ToLower", operator: false },
+ ToUpper => { name: "ToUpper", operator: false },
+ Trim => { name: "Trim", operator: false },
+ NormalizeSet => { name: "NormalizeSet", operator: false },
+ NormalizeList => { name: "NormalizeList", operator: false },
+ AddDays => { name: "AddDays", operator: false },
+ ToTime => { name: "ToTime", operator: false },
+}
diff --git a/src/languages/azure_rbac/builtins/ip.rs b/src/languages/azure_rbac/builtins/ip.rs
new file mode 100644
index 00000000..917f6b81
--- /dev/null
+++ b/src/languages/azure_rbac/builtins/ip.rs
@@ -0,0 +1,106 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use crate::value::Value;
+use alloc::string::ToString as _;
+
+use super::common;
+use super::evaluator::RbacBuiltinError;
+
+#[cfg(feature = "net")]
+use core::net::IpAddr;
+#[cfg(feature = "net")]
+use ipnet::IpNet;
+
+// Match an IP address against a CIDR range.
+// Examples:
+// - IpMatch("10.0.0.5", "10.0.0.0/24") => true
+// - IpMatch("10.0.1.5", "10.0.0.0/24") => false
+// - IpMatch("2001:db8::1", "2001:db8::/32") => true
+pub(super) fn ip_match(
+ left: &Value,
+ right: &Value,
+ name: &'static str,
+) -> Result {
+ // Parse inputs as strings before network matching.
+ let ip_str = common::value_as_string(left, name)?;
+ let cidr_str = common::value_as_string(right, name)?;
+
+ #[cfg(feature = "net")]
+ {
+ // Parse CIDR and IP and check containment.
+ let net = cidr_str
+ .parse::()
+ .map_err(|err| RbacBuiltinError::new(err.to_string()))?;
+ let ip = ip_str
+ .parse::()
+ .map_err(|err| RbacBuiltinError::new(err.to_string()))?;
+ Ok(net.contains(&ip))
+ }
+
+ #[cfg(not(feature = "net"))]
+ {
+ Err(RbacBuiltinError::new("IP builtins have not been enabled"))
+ }
+}
+
+// Match an IP against a range or a CIDR block.
+// - If the right operand is a 2-element list, it is treated as [start, end].
+// - Otherwise, it is treated as a CIDR string.
+// Examples:
+// - IpInRange("10.0.0.5", ["10.0.0.1", "10.0.0.10"]) => true
+// - IpInRange("10.0.0.5", ["10.0.0.6", "10.0.0.10"]) => false
+// - IpInRange("10.0.0.5", "10.0.0.0/24") => true
+pub(super) fn ip_in_range(
+ left: &Value,
+ right: &Value,
+ name: &'static str,
+) -> Result {
+ match *right {
+ Value::Array(ref list) if list.len() == 2 => {
+ // Treat list as inclusive [start, end] bounds.
+ let start = list
+ .first()
+ .ok_or_else(|| RbacBuiltinError::new("IpInRange expects 2 values"))?;
+ let end = list
+ .get(1)
+ .ok_or_else(|| RbacBuiltinError::new("IpInRange expects 2 values"))?;
+ let start = common::value_as_string(start, name)?;
+ let end = common::value_as_string(end, name)?;
+ ip_between(left, &start, &end, name)
+ }
+ _ => ip_match(left, right, name),
+ }
+}
+
+// Check inclusive range membership: start <= ip <= end.
+// Example: IpInRange("10.0.0.5", ["10.0.0.1", "10.0.0.10"]) => true
+fn ip_between(
+ left: &Value,
+ start: &str,
+ end: &str,
+ name: &'static str,
+) -> Result {
+ // Parse the target IP once for range checks.
+ let ip_str = common::value_as_string(left, name)?;
+
+ #[cfg(feature = "net")]
+ {
+ // Compare parsed IP values with inclusive bounds.
+ let ip = ip_str
+ .parse::()
+ .map_err(|err| RbacBuiltinError::new(err.to_string()))?;
+ let start_ip = start
+ .parse::()
+ .map_err(|err| RbacBuiltinError::new(err.to_string()))?;
+ let end_ip = end
+ .parse::()
+ .map_err(|err| RbacBuiltinError::new(err.to_string()))?;
+ Ok(start_ip <= ip && ip <= end_ip)
+ }
+
+ #[cfg(not(feature = "net"))]
+ {
+ Err(RbacBuiltinError::new("IP builtins have not been enabled"))
+ }
+}
diff --git a/src/languages/azure_rbac/builtins/lists.rs b/src/languages/azure_rbac/builtins/lists.rs
new file mode 100644
index 00000000..96eb3d89
--- /dev/null
+++ b/src/languages/azure_rbac/builtins/lists.rs
@@ -0,0 +1,38 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use alloc::collections::BTreeSet;
+
+use crate::value::Value;
+
+use super::evaluator::RbacBuiltinError;
+
+// Check whether a list or set contains the provided element(s).
+pub(super) fn list_contains(left: &Value, right: &Value) -> Result {
+ match *left {
+ // Lists and sets share containment semantics.
+ Value::Array(ref list) => Ok(list_contains_values(list, right)),
+ Value::Set(ref set) => Ok(set_contains_values(set, right)),
+ _ => Ok(false),
+ }
+}
+
+// For lists, treat a list/set needle as "all elements are contained".
+fn list_contains_values(list: &[Value], needle: &Value) -> bool {
+ match *needle {
+ // For collection needles, require all elements to be present.
+ Value::Array(ref right_list) => right_list.iter().all(|item| list.contains(item)),
+ Value::Set(ref right_set) => right_set.iter().all(|item| list.contains(item)),
+ _ => list.contains(needle),
+ }
+}
+
+// For sets, treat a list/set needle as "all elements are contained".
+fn set_contains_values(set: &BTreeSet, needle: &Value) -> bool {
+ match *needle {
+ // For collection needles, require all elements to be present.
+ Value::Array(ref right_list) => right_list.iter().all(|item| set.contains(item)),
+ Value::Set(ref right_set) => right_set.iter().all(|item| set.contains(item)),
+ _ => set.contains(needle),
+ }
+}
diff --git a/src/languages/azure_rbac/builtins/mod.rs b/src/languages/azure_rbac/builtins/mod.rs
new file mode 100644
index 00000000..7827226f
--- /dev/null
+++ b/src/languages/azure_rbac/builtins/mod.rs
@@ -0,0 +1,24 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Azure RBAC builtins.
+//!
+//! These builtins are a small, zero-copy focused API intended to be shared by
+//! the interpreter and future RVM lowering.
+
+mod actions;
+mod bools;
+mod common;
+mod datetime;
+mod evaluator;
+mod functions;
+mod ids;
+mod ip;
+mod lists;
+mod numbers;
+mod quantifiers;
+mod strings;
+mod time_of_day;
+
+pub use evaluator::{DefaultRbacBuiltinEvaluator, RbacBuiltinContext, RbacBuiltinError};
+pub use ids::RbacBuiltin;
diff --git a/src/languages/azure_rbac/builtins/numbers.rs b/src/languages/azure_rbac/builtins/numbers.rs
new file mode 100644
index 00000000..0a9e8b83
--- /dev/null
+++ b/src/languages/azure_rbac/builtins/numbers.rs
@@ -0,0 +1,44 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use crate::value::Value;
+
+use super::common;
+use super::evaluator::RbacBuiltinError;
+
+// Compare numeric values using integer semantics.
+// Accepts numeric values or numeric strings that fit in i64.
+// Spec: https://learn.microsoft.com/en-us/azure/role-based-access-control/conditions-format#numeric-comparison-operators
+pub(super) fn numeric_compare(
+ left: &Value,
+ right: &Value,
+ op: &str,
+ name: &'static str,
+) -> Result {
+ // Convert both operands to i64 before comparing.
+ let lhs = common::value_as_i64(left, name)?;
+ let rhs = common::value_as_i64(right, name)?;
+ Ok(match op {
+ "==" => lhs == rhs,
+ "!=" => lhs != rhs,
+ "<" => lhs < rhs,
+ "<=" => lhs <= rhs,
+ ">" => lhs > rhs,
+ ">=" => lhs >= rhs,
+ _ => false,
+ })
+}
+
+// Check whether a numeric value falls within a 2-element range (inclusive).
+// Bounds are parsed as i64; values must fit in i64.
+// Spec: https://learn.microsoft.com/en-us/azure/role-based-access-control/conditions-format#numeric-comparison-operators
+pub(super) fn numeric_in_range(
+ left: &Value,
+ right: &Value,
+ name: &'static str,
+) -> Result {
+ // Parse value and inclusive bounds as i64.
+ let value = common::value_as_i64(left, name)?;
+ let (start, end) = common::range_bounds(right, name)?;
+ Ok(value >= start && value <= end)
+}
diff --git a/src/languages/azure_rbac/builtins/quantifiers.rs b/src/languages/azure_rbac/builtins/quantifiers.rs
new file mode 100644
index 00000000..2c518ce9
--- /dev/null
+++ b/src/languages/azure_rbac/builtins/quantifiers.rs
@@ -0,0 +1,62 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use alloc::vec;
+use alloc::vec::Vec;
+
+use crate::value::Value;
+
+use super::evaluator::RbacBuiltinError;
+
+// Cross-product operators over collections.
+//
+// The operators use value equality across elements. Non-collection values are
+// treated as singleton collections.
+
+pub(super) fn for_any_of_any_values(left: &Value, right: &Value) -> Result {
+ let left_values = collect_values(left);
+ let right_values = collect_values(right);
+
+ Ok(left_values
+ .iter()
+ .any(|left_value| right_values.contains(left_value)))
+}
+
+pub(super) fn for_all_of_any_values(left: &Value, right: &Value) -> Result {
+ let left_values = collect_values(left);
+ let right_values = collect_values(right);
+
+ Ok(left_values
+ .iter()
+ .all(|left_value| right_values.contains(left_value)))
+}
+
+pub(super) fn for_any_of_all_values(left: &Value, right: &Value) -> Result {
+ let left_values = collect_values(left);
+ let right_values = collect_values(right);
+
+ Ok(left_values.iter().any(|left_value| {
+ right_values
+ .iter()
+ .all(|right_value| *left_value == *right_value)
+ }))
+}
+
+pub(super) fn for_all_of_all_values(left: &Value, right: &Value) -> Result {
+ let left_values = collect_values(left);
+ let right_values = collect_values(right);
+
+ Ok(left_values.iter().all(|left_value| {
+ right_values
+ .iter()
+ .all(|right_value| *left_value == *right_value)
+ }))
+}
+
+fn collect_values(value: &Value) -> Vec<&Value> {
+ match *value {
+ Value::Array(ref list) => list.iter().collect(),
+ Value::Set(ref set) => set.iter().collect(),
+ _ => vec![value],
+ }
+}
diff --git a/src/languages/azure_rbac/builtins/strings.rs b/src/languages/azure_rbac/builtins/strings.rs
new file mode 100644
index 00000000..dfb6c65a
--- /dev/null
+++ b/src/languages/azure_rbac/builtins/strings.rs
@@ -0,0 +1,107 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use crate::value::Value;
+use alloc::string::ToString as _;
+
+use super::common;
+use super::evaluator::RbacBuiltinError;
+
+#[cfg(feature = "regex")]
+use regex::Regex;
+
+// Equality check with collection containment semantics.
+// If exactly one operand is a collection, checks whether it contains the other.
+pub(super) fn string_equals(left: &Value, right: &Value) -> Result {
+ // If one side is a collection, perform containment instead of equality.
+ if common::is_collection(right) && !common::is_collection(left) {
+ return Ok(common::collection_contains(right, left));
+ }
+ if common::is_collection(left) && !common::is_collection(right) {
+ return Ok(common::collection_contains(left, right));
+ }
+ Ok(left == right)
+}
+
+// Case-insensitive equality with collection containment semantics.
+// If exactly one operand is a collection, checks whether it contains the other.
+pub(super) fn string_equals_ignore_case(
+ left: &Value,
+ right: &Value,
+) -> Result {
+ // If one side is a collection, perform case-insensitive containment.
+ if common::is_collection(right) && !common::is_collection(left) {
+ return Ok(common::collection_contains_case_insensitive(right, left));
+ }
+ if common::is_collection(left) && !common::is_collection(right) {
+ return Ok(common::collection_contains_case_insensitive(left, right));
+ }
+
+ let left_str = common::value_as_string(left, "StringEqualsIgnoreCase")?;
+ let right_str = common::value_as_string(right, "StringEqualsIgnoreCase")?;
+ Ok(left_str.eq_ignore_ascii_case(&right_str))
+}
+
+// Match with '*' and '?' wildcards.
+pub(super) fn string_like(left: &Value, right: &Value) -> Result {
+ // Use simple wildcard matching for '*' and '?'.
+ let value = common::value_as_string(left, "StringLike")?;
+ let pattern = common::value_as_string(right, "StringLike")?;
+ Ok(common::simple_wildcard_match(&pattern, &value))
+}
+
+// Check if the left string starts with the right string.
+pub(super) fn string_starts_with(left: &Value, right: &Value) -> Result {
+ // Compare prefix match.
+ let value = common::value_as_string(left, "StringStartsWith")?;
+ let prefix = common::value_as_string(right, "StringStartsWith")?;
+ Ok(value.starts_with(&prefix))
+}
+
+// Check if the left string ends with the right string.
+pub(super) fn string_ends_with(left: &Value, right: &Value) -> Result {
+ // Compare suffix match.
+ let value = common::value_as_string(left, "StringEndsWith")?;
+ let suffix = common::value_as_string(right, "StringEndsWith")?;
+ Ok(value.ends_with(&suffix))
+}
+
+// Check if the left string contains the right string.
+pub(super) fn string_contains(left: &Value, right: &Value) -> Result {
+ // Check substring containment.
+ let value = common::value_as_string(left, "StringContains")?;
+ let needle = common::value_as_string(right, "StringContains")?;
+ Ok(value.contains(&needle))
+}
+
+// Match using regex when the regex feature is enabled; otherwise uses exact match.
+pub(super) fn string_matches(left: &Value, right: &Value) -> Result {
+ // Parse the pattern and apply regex matching when enabled.
+ let value = common::value_as_string(left, "StringMatches")?;
+ let pattern = common::value_as_string(right, "StringMatches")?;
+
+ #[cfg(feature = "regex")]
+ {
+ // Compile regex each time to match RBAC semantics.
+ let regex = Regex::new(&pattern).map_err(|err| RbacBuiltinError::new(err.to_string()))?;
+ Ok(regex.is_match(&value))
+ }
+
+ #[cfg(not(feature = "regex"))]
+ {
+ // Without regex support, fall back to exact match.
+ Ok(value == pattern)
+ }
+}
+
+// Case-insensitive GUID equality.
+pub(super) fn guid_equals(
+ left: &Value,
+ right: &Value,
+ name: &'static str,
+) -> Result {
+ // GUID comparisons are case-insensitive.
+ let lhs = common::value_as_string(left, name)?;
+ let rhs = common::value_as_string(right, name)?;
+ Ok(lhs.eq_ignore_ascii_case(&rhs))
+}
diff --git a/src/languages/azure_rbac/builtins/time_of_day.rs b/src/languages/azure_rbac/builtins/time_of_day.rs
new file mode 100644
index 00000000..2e63f67b
--- /dev/null
+++ b/src/languages/azure_rbac/builtins/time_of_day.rs
@@ -0,0 +1,83 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use crate::value::Value;
+
+use super::common;
+use super::evaluator::RbacBuiltinError;
+
+// Compare times of day using HH:MM[:SS] semantics.
+pub(super) fn time_compare(
+ left: &Value,
+ right: &Value,
+ op: &str,
+ name: &'static str,
+) -> Result {
+ // Parse both operands as time-of-day strings and compare in seconds.
+ let lhs = common::value_as_string(left, name)?;
+ let rhs = common::value_as_string(right, name)?;
+ let left_secs = common::parse_time_of_day(&lhs)?;
+ let right_secs = common::parse_time_of_day(&rhs)?;
+ Ok(match op {
+ "==" => left_secs == right_secs,
+ "<" => left_secs < right_secs,
+ "<=" => left_secs <= right_secs,
+ ">" => left_secs > right_secs,
+ ">=" => left_secs >= right_secs,
+ _ => false,
+ })
+}
+
+// Check whether a time falls within a 2-element range (inclusive).
+// If the right operand is not a 2-element list/set, falls back to equality.
+// Examples:
+// - TimeOfDayInRange("09:30", ["09:00", "10:00"]) => true
+// - TimeOfDayInRange("23:30:00", {"22:00", "23:00"}) => false
+// - TimeOfDayInRange("12:00", "12:00") => true
+pub(super) fn time_in_range(
+ left: &Value,
+ right: &Value,
+ name: &'static str,
+) -> Result {
+ // Parse the target time once to compare against range bounds.
+ let value = common::value_as_string(left, name)?;
+ let value_secs = common::parse_time_of_day(&value)?;
+
+ match *right {
+ Value::Array(ref list) => {
+ // Arrays must contain exactly two bounds.
+ let start = list.first().ok_or_else(|| {
+ RbacBuiltinError::new("TimeOfDayInRange expects a 2-element array")
+ })?;
+ let end = list.get(1).ok_or_else(|| {
+ RbacBuiltinError::new("TimeOfDayInRange expects a 2-element array")
+ })?;
+ if list.len() != 2 {
+ return Err(RbacBuiltinError::new(
+ "TimeOfDayInRange expects a 2-element array",
+ ));
+ }
+ // Compare inclusive bounds after parsing to seconds.
+ let start = common::value_as_string(start, name)?;
+ let end = common::value_as_string(end, name)?;
+ let start_secs = common::parse_time_of_day(&start)?;
+ let end_secs = common::parse_time_of_day(&end)?;
+ Ok(value_secs >= start_secs && value_secs <= end_secs)
+ }
+ Value::Set(ref set) => {
+ // Sets must contain exactly two bounds (order is arbitrary).
+ let mut iter = set.iter();
+ let start = iter
+ .next()
+ .ok_or_else(|| RbacBuiltinError::new("TimeOfDayInRange expects 2 values"))?;
+ let end = iter
+ .next()
+ .ok_or_else(|| RbacBuiltinError::new("TimeOfDayInRange expects 2 values"))?;
+ let start_secs = common::parse_time_of_day(&common::value_as_string(start, name)?)?;
+ let end_secs = common::parse_time_of_day(&common::value_as_string(end, name)?)?;
+ Ok(value_secs >= start_secs && value_secs <= end_secs)
+ }
+ // Non-collection RHS falls back to equality.
+ _ => time_compare(left, right, "==", name),
+ }
+}
diff --git a/src/languages/azure_rbac/interpreter.rs b/src/languages/azure_rbac/interpreter.rs
new file mode 100644
index 00000000..c0673306
--- /dev/null
+++ b/src/languages/azure_rbac/interpreter.rs
@@ -0,0 +1,66 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Azure RBAC condition expression interpreter.
+//!
+//! This module evaluates parsed condition expressions against an
+//! [`EvaluationContext`] without compiling to RVM instructions.
+
+#[path = "interpreter/access.rs"]
+mod access;
+#[path = "interpreter/attributes.rs"]
+mod attributes;
+#[path = "interpreter/error.rs"]
+mod error;
+#[path = "interpreter/eval.rs"]
+mod eval;
+#[path = "interpreter/literals.rs"]
+mod literals;
+#[path = "interpreter/quantifiers.rs"]
+mod quantifiers;
+
+pub use error::ConditionEvalError;
+
+use crate::languages::azure_rbac::ast::{ConditionExpr, ConditionExpression, EvaluationContext};
+use crate::value::Value;
+use eval::Evaluator;
+
+/// Dynamic interpreter for Azure RBAC conditions.
+#[derive(Debug, Clone)]
+pub struct ConditionInterpreter<'a> {
+ context: &'a EvaluationContext,
+}
+
+impl<'a> ConditionInterpreter<'a> {
+ /// Create a new interpreter bound to a context.
+ pub const fn new(context: &'a EvaluationContext) -> Self {
+ Self { context }
+ }
+
+ /// Parse and evaluate a condition string.
+ pub fn evaluate_str(&self, condition: &str) -> Result {
+ let mut evaluator = Evaluator::new(self.context);
+ evaluator.evaluate_str(condition)
+ }
+
+ /// Evaluate a parsed condition expression.
+ pub fn evaluate_condition_expression(
+ &self,
+ condition: &ConditionExpression,
+ ) -> Result {
+ let mut evaluator = Evaluator::new(self.context);
+ evaluator.evaluate_condition_expression(condition)
+ }
+
+ /// Evaluate a condition AST and return a boolean result.
+ pub fn evaluate_bool(&self, expr: &ConditionExpr) -> Result {
+ let mut evaluator = Evaluator::new(self.context);
+ evaluator.evaluate_bool(expr)
+ }
+
+ /// Evaluate a condition AST and return a value.
+ pub fn evaluate_value(&self, expr: &ConditionExpr) -> Result {
+ let mut evaluator = Evaluator::new(self.context);
+ evaluator.evaluate_value(expr)
+ }
+}
diff --git a/src/languages/azure_rbac/interpreter/access.rs b/src/languages/azure_rbac/interpreter/access.rs
new file mode 100644
index 00000000..d2eb9018
--- /dev/null
+++ b/src/languages/azure_rbac/interpreter/access.rs
@@ -0,0 +1,33 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use crate::languages::azure_rbac::ast::{PropertyAccessExpression, VariableReference};
+use crate::value::Value;
+
+use super::error::ConditionEvalError;
+use super::eval::Evaluator;
+
+impl<'a> Evaluator<'a> {
+ pub(super) fn eval_variable_reference(
+ &self,
+ variable: &VariableReference,
+ ) -> Result {
+ Ok(self
+ .lookup_variable(variable.name.as_str())
+ .unwrap_or(Value::Undefined))
+ }
+
+ pub(super) fn eval_property_access(
+ &mut self,
+ access: &PropertyAccessExpression,
+ ) -> Result {
+ let object = self.evaluate_value(&access.object)?;
+ match object {
+ Value::Object(map) => Ok(map
+ .get(&Value::String(access.property.as_str().into()))
+ .cloned()
+ .unwrap_or(Value::Undefined)),
+ _ => Ok(Value::Undefined),
+ }
+ }
+}
diff --git a/src/languages/azure_rbac/interpreter/attributes.rs b/src/languages/azure_rbac/interpreter/attributes.rs
new file mode 100644
index 00000000..5523dea4
--- /dev/null
+++ b/src/languages/azure_rbac/interpreter/attributes.rs
@@ -0,0 +1,260 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use alloc::format;
+use alloc::string::{String, ToString as _};
+
+use crate::languages::azure_rbac::ast::{
+ AttributePathSegment, AttributeReference, AttributeSource, PrincipalType,
+};
+use crate::value::Value;
+
+use super::error::ConditionEvalError;
+use super::eval::Evaluator;
+
+#[cfg(feature = "time")]
+use chrono::{DateTime, Timelike as _};
+
+impl<'a> Evaluator<'a> {
+ pub(super) fn eval_attribute_reference(
+ &mut self,
+ attr: &AttributeReference,
+ ) -> Result {
+ let mut value = match attr.source {
+ AttributeSource::Request => self.resolve_request_attribute(attr),
+ AttributeSource::Resource => self.resolve_resource_attribute(attr),
+ AttributeSource::Principal => self.resolve_principal_attribute(attr),
+ AttributeSource::Environment => self.resolve_environment_attribute(attr),
+ AttributeSource::Context => Value::Undefined,
+ };
+
+ for segment in &attr.path {
+ value = Self::apply_path_segment(value, segment)?;
+ }
+
+ Ok(value)
+ }
+
+ fn resolve_request_attribute(&self, attr: &AttributeReference) -> Value {
+ let attributes = &self.context.request.attributes;
+ let direct = Self::lookup_attribute_value(attributes, attr);
+ if !matches!(direct, Value::Undefined) {
+ return direct;
+ }
+
+ match attr.attribute.as_str() {
+ "action" => self
+ .context
+ .request
+ .action
+ .clone()
+ .map(|s: String| Value::String(s.into()))
+ .unwrap_or(Value::Undefined),
+ "dataAction" => self
+ .context
+ .request
+ .data_action
+ .clone()
+ .map(|s: String| Value::String(s.into()))
+ .unwrap_or(Value::Undefined),
+ _ => Value::Undefined,
+ }
+ }
+
+ fn resolve_resource_attribute(&self, attr: &AttributeReference) -> Value {
+ let attributes = &self.context.resource.attributes;
+ let direct = Self::lookup_attribute_value(attributes, attr);
+ if !matches!(direct, Value::Undefined) {
+ return direct;
+ }
+
+ match attr.attribute.as_str() {
+ "id" => Value::String(self.context.resource.id.as_str().into()),
+ "type" => Value::String(self.context.resource.resource_type.as_str().into()),
+ "scope" => Value::String(self.context.resource.scope.as_str().into()),
+ _ => Value::Undefined,
+ }
+ }
+
+ fn resolve_principal_attribute(&self, attr: &AttributeReference) -> Value {
+ let attributes = &self.context.principal.custom_security_attributes;
+ let direct = Self::lookup_attribute_value(attributes, attr);
+ if !matches!(direct, Value::Undefined) {
+ return direct;
+ }
+
+ match attr.attribute.as_str() {
+ "id" => Value::String(self.context.principal.id.as_str().into()),
+ "principalType" => Value::String(self.principal_type_string().into()),
+ _ => Value::Undefined,
+ }
+ }
+
+ fn resolve_environment_attribute(&self, attr: &AttributeReference) -> Value {
+ let EnvironmentContextValues {
+ is_private_link,
+ private_endpoint,
+ subnet,
+ utc_now,
+ time_of_day,
+ } = self.environment_values();
+
+ match attr.attribute.as_str() {
+ "isPrivateLink" => is_private_link,
+ "privateEndpoint" => private_endpoint,
+ "subnet" => subnet,
+ "utcNow" => utc_now,
+ "timeOfDay" => time_of_day,
+ _ => Value::Undefined,
+ }
+ }
+
+ fn environment_values(&self) -> EnvironmentContextValues {
+ let is_private_link = self
+ .context
+ .environment
+ .is_private_link
+ .map(Value::Bool)
+ .unwrap_or(Value::Undefined);
+
+ let private_endpoint = self
+ .context
+ .environment
+ .private_endpoint
+ .clone()
+ .map(|s: String| Value::String(s.into()))
+ .unwrap_or(Value::Undefined);
+
+ let subnet = self
+ .context
+ .environment
+ .subnet
+ .clone()
+ .map(|s: String| Value::String(s.into()))
+ .unwrap_or(Value::Undefined);
+
+ let utc_now = self
+ .context
+ .environment
+ .utc_now
+ .clone()
+ .map(|s: String| Value::String(s.into()))
+ .unwrap_or(Value::Undefined);
+
+ let time_of_day = self
+ .context
+ .environment
+ .utc_now
+ .as_ref()
+ .and_then(|s| Self::time_of_day_from_rfc3339(s).ok())
+ .map(|s: String| Value::String(s.into()))
+ .unwrap_or(Value::Undefined);
+
+ EnvironmentContextValues {
+ is_private_link,
+ private_endpoint,
+ subnet,
+ utc_now,
+ time_of_day,
+ }
+ }
+
+ const fn principal_type_string(&self) -> &'static str {
+ match self.context.principal.principal_type {
+ PrincipalType::User => "User",
+ PrincipalType::Group => "Group",
+ PrincipalType::ServicePrincipal => "ServicePrincipal",
+ PrincipalType::ManagedServiceIdentity => "ManagedServiceIdentity",
+ }
+ }
+
+ fn lookup_attribute_value(container: &Value, attr: &AttributeReference) -> Value {
+ let key = attr.namespace.as_ref().map(|ns| {
+ let capacity = ns
+ .len()
+ .saturating_add(attr.attribute.len())
+ .saturating_add(1);
+ let mut combined = String::with_capacity(capacity);
+ combined.push_str(ns);
+ combined.push(':');
+ combined.push_str(&attr.attribute);
+ combined
+ });
+
+ match *container {
+ Value::Object(ref map) => {
+ if let Some(ref ns) = attr.namespace {
+ let namespace_key = Value::String(ns.as_str().into());
+ #[allow(clippy::pattern_type_mismatch)]
+ if let Some(Value::Object(ns_obj)) = map.get(&namespace_key) {
+ let attr_key = Value::String(attr.attribute.as_str().into());
+ if let Some(value) = ns_obj.get(&attr_key) {
+ return value.clone();
+ }
+ }
+ }
+
+ if let Some(ref combined) = key {
+ let attr_key = Value::String(combined.as_str().into());
+ if let Some(value) = map.get(&attr_key) {
+ return value.clone();
+ }
+ }
+
+ let attr_key = Value::String(attr.attribute.as_str().into());
+ map.get(&attr_key).cloned().unwrap_or(Value::Undefined)
+ }
+ _ => Value::Undefined,
+ }
+ }
+
+ fn apply_path_segment(
+ value: Value,
+ segment: &AttributePathSegment,
+ ) -> Result {
+ match *segment {
+ AttributePathSegment::Key(ref key) => match value {
+ Value::Object(map) => Ok(map
+ .get(&Value::String(key.as_str().into()))
+ .cloned()
+ .unwrap_or(Value::Undefined)),
+ _ => Ok(Value::Undefined),
+ },
+ AttributePathSegment::Index(index) => match value {
+ Value::Array(list) => Ok(list.get(index).cloned().unwrap_or(Value::Undefined)),
+ _ => Ok(Value::Undefined),
+ },
+ }
+ }
+
+ fn time_of_day_from_rfc3339(value: &str) -> Result {
+ #[cfg(feature = "time")]
+ {
+ let dt = DateTime::parse_from_rfc3339(value)
+ .map_err(|err| ConditionEvalError::new(err.to_string()))?;
+ let time = dt.time();
+ Ok(format!(
+ "{:02}:{:02}:{:02}",
+ time.hour(),
+ time.minute(),
+ time.second()
+ ))
+ }
+
+ #[cfg(not(feature = "time"))]
+ {
+ let _ = value;
+ Err(ConditionEvalError::new(
+ "time feature disabled; cannot derive timeOfDay",
+ ))
+ }
+ }
+}
+
+struct EnvironmentContextValues {
+ is_private_link: Value,
+ private_endpoint: Value,
+ subnet: Value,
+ utc_now: Value,
+ time_of_day: Value,
+}
diff --git a/src/languages/azure_rbac/interpreter/error.rs b/src/languages/azure_rbac/interpreter/error.rs
new file mode 100644
index 00000000..0ae948c5
--- /dev/null
+++ b/src/languages/azure_rbac/interpreter/error.rs
@@ -0,0 +1,24 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use alloc::string::String;
+
+/// Error returned during condition evaluation.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ConditionEvalError {
+ message: String,
+}
+
+impl ConditionEvalError {
+ pub(crate) fn new(message: impl Into) -> Self {
+ Self {
+ message: message.into(),
+ }
+ }
+}
+
+impl core::fmt::Display for ConditionEvalError {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ write!(f, "{}", self.message)
+ }
+}
diff --git a/src/languages/azure_rbac/interpreter/eval.rs b/src/languages/azure_rbac/interpreter/eval.rs
new file mode 100644
index 00000000..d8922f53
--- /dev/null
+++ b/src/languages/azure_rbac/interpreter/eval.rs
@@ -0,0 +1,188 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use alloc::format;
+use alloc::string::{String, ToString as _};
+use alloc::vec::Vec;
+
+use crate::languages::azure_rbac::ast::EvaluationContext;
+use crate::languages::azure_rbac::ast::{
+ BinaryExpression, ConditionExpr, ConditionExpression, FunctionCallExpression,
+ LogicalExpression, LogicalOperator, UnaryExpression, UnaryOperator,
+};
+use crate::languages::azure_rbac::builtins::{
+ DefaultRbacBuiltinEvaluator, RbacBuiltin, RbacBuiltinContext,
+};
+use crate::languages::azure_rbac::parser::parse_condition_expression;
+use crate::value::Value;
+
+use super::error::ConditionEvalError;
+
+pub(super) struct Evaluator<'a> {
+ pub(super) context: &'a EvaluationContext,
+ variables: Vec,
+}
+
+struct VariableBinding {
+ name: String,
+ value: Value,
+}
+
+impl<'a> Evaluator<'a> {
+ pub(super) const fn new(context: &'a EvaluationContext) -> Self {
+ Self {
+ context,
+ variables: Vec::new(),
+ }
+ }
+
+ pub(super) fn evaluate_str(&mut self, condition: &str) -> Result {
+ let parsed = parse_condition_expression(condition)
+ .map_err(|err| ConditionEvalError::new(err.to_string()))?;
+ self.evaluate_condition_expression(&parsed)
+ }
+
+ pub(super) fn evaluate_condition_expression(
+ &mut self,
+ condition: &ConditionExpression,
+ ) -> Result {
+ let expr = condition
+ .expression
+ .as_ref()
+ .ok_or_else(|| ConditionEvalError::new("Condition expression not parsed"))?;
+ self.evaluate_bool(expr)
+ }
+
+ pub(super) fn evaluate_bool(
+ &mut self,
+ expr: &ConditionExpr,
+ ) -> Result {
+ let value = self.evaluate_value(expr)?;
+ match value {
+ Value::Bool(b) => Ok(b),
+ Value::Undefined => Ok(false),
+ _ => Err(ConditionEvalError::new(
+ "Condition did not evaluate to boolean",
+ )),
+ }
+ }
+
+ pub(super) fn evaluate_value(
+ &mut self,
+ expr: &ConditionExpr,
+ ) -> Result {
+ match *expr {
+ ConditionExpr::Logical(ref logical) => self.eval_logical(logical),
+ ConditionExpr::Unary(ref unary) => self.eval_unary(unary),
+ ConditionExpr::Binary(ref binary) => self.eval_binary(binary),
+ ConditionExpr::FunctionCall(ref call) => self.eval_function(call),
+ ConditionExpr::AttributeReference(ref attr) => self.eval_attribute_reference(attr),
+ ConditionExpr::ArrayExpression(ref array) => self.eval_array_expression(array),
+ ConditionExpr::Identifier(ref identifier) => Ok(self.eval_identifier(identifier)),
+ ConditionExpr::VariableReference(ref variable) => {
+ self.eval_variable_reference(variable)
+ }
+ ConditionExpr::PropertyAccess(ref access) => self.eval_property_access(access),
+ ConditionExpr::StringLiteral(ref lit) => Ok(Value::String(lit.value.as_str().into())),
+ ConditionExpr::NumberLiteral(ref lit) => {
+ let num = lit.raw.parse::().map_err(|_| {
+ ConditionEvalError::new(format!("Invalid numeric literal: {}", lit.raw))
+ })?;
+ Ok(Value::from(num))
+ }
+ ConditionExpr::BooleanLiteral(ref lit) => Ok(Value::Bool(lit.value)),
+ ConditionExpr::NullLiteral(_) => Ok(Value::Null),
+ ConditionExpr::DateTimeLiteral(ref lit) => Ok(Value::String(lit.value.as_str().into())),
+ ConditionExpr::TimeLiteral(ref lit) => Ok(Value::String(lit.value.as_str().into())),
+ ConditionExpr::SetLiteral(ref set) => self.eval_set_literal(set),
+ ConditionExpr::ListLiteral(ref list) => self.eval_list_literal(&list.elements),
+ }
+ }
+
+ fn eval_logical(&mut self, expr: &LogicalExpression) -> Result {
+ let left = self.evaluate_bool(&expr.left)?;
+ match expr.operator {
+ LogicalOperator::And => {
+ if !left {
+ return Ok(Value::Bool(false));
+ }
+ let right = self.evaluate_bool(&expr.right)?;
+ Ok(Value::Bool(left && right))
+ }
+ LogicalOperator::Or => {
+ if left {
+ return Ok(Value::Bool(true));
+ }
+ let right = self.evaluate_bool(&expr.right)?;
+ Ok(Value::Bool(left || right))
+ }
+ }
+ }
+
+ fn eval_unary(&mut self, expr: &UnaryExpression) -> Result {
+ match expr.operator {
+ UnaryOperator::Not => {
+ let value = self.evaluate_bool(&expr.operand)?;
+ Ok(Value::Bool(!value))
+ }
+ UnaryOperator::Exists => {
+ let value = self.evaluate_value(&expr.operand)?;
+ Ok(Value::Bool(!matches!(value, Value::Undefined)))
+ }
+ UnaryOperator::NotExists => {
+ let value = self.evaluate_value(&expr.operand)?;
+ Ok(Value::Bool(matches!(value, Value::Undefined)))
+ }
+ }
+ }
+
+ fn eval_binary(&mut self, expr: &BinaryExpression) -> Result {
+ let left = self.evaluate_value(&expr.left)?;
+ let right = self.evaluate_value(&expr.right)?;
+ let builtin = expr.operator;
+
+ let evaluator = DefaultRbacBuiltinEvaluator::new();
+ let ctx = RbacBuiltinContext::new(self.context);
+ let args = [left, right];
+ evaluator
+ .eval(builtin, &args, &ctx)
+ .map_err(|err| ConditionEvalError::new(err.to_string()))
+ }
+
+ fn eval_function(
+ &mut self,
+ call: &FunctionCallExpression,
+ ) -> Result {
+ let mut args = Vec::with_capacity(call.arguments.len());
+ for arg in &call.arguments {
+ args.push(self.evaluate_value(arg)?);
+ }
+ let builtin = RbacBuiltin::parse(call.function.as_str()).ok_or_else(|| {
+ ConditionEvalError::new(format!("Unsupported function: {}", call.function))
+ })?;
+ let evaluator = DefaultRbacBuiltinEvaluator::new();
+ let ctx = RbacBuiltinContext::new(self.context);
+ evaluator
+ .eval(builtin, &args, &ctx)
+ .map_err(|err| ConditionEvalError::new(err.to_string()))
+ }
+
+ pub(super) fn push_variable(&mut self, name: &str, value: Value) {
+ self.variables.push(VariableBinding {
+ name: name.to_string(),
+ value,
+ });
+ }
+
+ pub(super) fn pop_variable(&mut self) {
+ let _ = self.variables.pop();
+ }
+
+ pub(super) fn lookup_variable(&self, name: &str) -> Option {
+ self.variables
+ .iter()
+ .rev()
+ .find(|binding| binding.name == name)
+ .map(|binding| binding.value.clone())
+ }
+}
diff --git a/src/languages/azure_rbac/interpreter/literals.rs b/src/languages/azure_rbac/interpreter/literals.rs
new file mode 100644
index 00000000..01f35174
--- /dev/null
+++ b/src/languages/azure_rbac/interpreter/literals.rs
@@ -0,0 +1,73 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use alloc::collections::BTreeSet;
+use alloc::string::String;
+use alloc::vec::Vec;
+
+use crate::languages::azure_rbac::ast::{ConditionExpr, IdentifierExpression, SetLiteral};
+use crate::value::Value;
+
+use super::error::ConditionEvalError;
+use super::eval::Evaluator;
+
+impl<'a> Evaluator<'a> {
+ pub(super) fn eval_set_literal(
+ &mut self,
+ set: &SetLiteral,
+ ) -> Result {
+ let mut values = BTreeSet::new();
+ for elem in &set.elements {
+ let value = self.evaluate_value(elem)?;
+ values.insert(value);
+ }
+ Ok(Value::from(values))
+ }
+
+ pub(super) fn eval_list_literal(
+ &mut self,
+ elements: &[ConditionExpr],
+ ) -> Result {
+ let mut values = Vec::with_capacity(elements.len());
+ for elem in elements {
+ values.push(self.evaluate_value(elem)?);
+ }
+ Ok(Value::from(values))
+ }
+
+ pub(super) fn eval_identifier(&self, identifier: &IdentifierExpression) -> Value {
+ match identifier.name.as_str() {
+ "operationName" => self
+ .context
+ .action
+ .clone()
+ .map(|s: String| Value::String(s.into()))
+ .unwrap_or(Value::Undefined),
+ "subOperationName" => self
+ .context
+ .suboperation
+ .clone()
+ .map(|s: String| Value::String(s.into()))
+ .unwrap_or(Value::Undefined),
+ "action" => self
+ .context
+ .request
+ .action
+ .clone()
+ .map(|s: String| Value::String(s.into()))
+ .unwrap_or(Value::Undefined),
+ "dataAction" => self
+ .context
+ .request
+ .data_action
+ .clone()
+ .map(|s: String| Value::String(s.into()))
+ .unwrap_or(Value::Undefined),
+ "principalId" => Value::String(self.context.principal.id.as_str().into()),
+ "resourceId" => Value::String(self.context.resource.id.as_str().into()),
+ "resourceScope" => Value::String(self.context.resource.scope.as_str().into()),
+ "resourceType" => Value::String(self.context.resource.resource_type.as_str().into()),
+ _ => Value::Undefined,
+ }
+ }
+}
diff --git a/src/languages/azure_rbac/interpreter/mod.rs b/src/languages/azure_rbac/interpreter/mod.rs
new file mode 100644
index 00000000..d454b34a
--- /dev/null
+++ b/src/languages/azure_rbac/interpreter/mod.rs
@@ -0,0 +1,63 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Azure RBAC condition expression interpreter.
+//!
+//! This module evaluates parsed condition expressions against an
+//! [`EvaluationContext`] without compiling to RVM instructions.
+
+mod access;
+mod attributes;
+mod error;
+mod eval;
+mod literals;
+mod quantifiers;
+
+#[cfg(test)]
+mod tests;
+
+pub use error::ConditionEvalError;
+
+use crate::languages::azure_rbac::ast::{ConditionExpr, ConditionExpression, EvaluationContext};
+use crate::value::Value;
+use eval::Evaluator;
+
+/// Dynamic interpreter for Azure RBAC conditions.
+#[derive(Debug, Clone)]
+pub struct ConditionInterpreter<'a> {
+ context: &'a EvaluationContext,
+}
+
+impl<'a> ConditionInterpreter<'a> {
+ /// Create a new interpreter bound to a context.
+ pub const fn new(context: &'a EvaluationContext) -> Self {
+ Self { context }
+ }
+
+ /// Parse and evaluate a condition string.
+ pub fn evaluate_str(&self, condition: &str) -> Result {
+ let mut evaluator = Evaluator::new(self.context);
+ evaluator.evaluate_str(condition)
+ }
+
+ /// Evaluate a parsed condition expression.
+ pub fn evaluate_condition_expression(
+ &self,
+ condition: &ConditionExpression,
+ ) -> Result {
+ let mut evaluator = Evaluator::new(self.context);
+ evaluator.evaluate_condition_expression(condition)
+ }
+
+ /// Evaluate a condition AST and return a boolean result.
+ pub fn evaluate_bool(&self, expr: &ConditionExpr) -> Result {
+ let mut evaluator = Evaluator::new(self.context);
+ evaluator.evaluate_bool(expr)
+ }
+
+ /// Evaluate a condition AST and return a value.
+ pub fn evaluate_value(&self, expr: &ConditionExpr) -> Result {
+ let mut evaluator = Evaluator::new(self.context);
+ evaluator.evaluate_value(expr)
+ }
+}
diff --git a/src/languages/azure_rbac/interpreter/quantifiers.rs b/src/languages/azure_rbac/interpreter/quantifiers.rs
new file mode 100644
index 00000000..ee92f6c2
--- /dev/null
+++ b/src/languages/azure_rbac/interpreter/quantifiers.rs
@@ -0,0 +1,77 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use alloc::format;
+use alloc::vec::Vec;
+
+use crate::languages::azure_rbac::ast::ArrayExpression;
+use crate::value::Value;
+
+use super::error::ConditionEvalError;
+use super::eval::Evaluator;
+
+impl<'a> Evaluator<'a> {
+ pub(super) fn eval_array_expression(
+ &mut self,
+ array: &ArrayExpression,
+ ) -> Result {
+ let collection = self.evaluate_value(&array.array)?;
+ let values: Vec<&Value> = match collection {
+ Value::Array(ref list) => list.iter().collect(),
+ Value::Set(ref set) => set.iter().collect(),
+ Value::Undefined => return Ok(Value::Bool(false)),
+ _ => {
+ return Err(ConditionEvalError::new(
+ "Array expression expects a list or set",
+ ))
+ }
+ };
+
+ let is_any = array.operator.name.eq_ignore_ascii_case("ANY");
+ let is_all = array.operator.name.eq_ignore_ascii_case("ALL");
+ if !is_any && !is_all {
+ return Err(ConditionEvalError::new(format!(
+ "Unsupported array operator: {}",
+ array.operator.name
+ )));
+ }
+
+ let variable = array.variable.as_deref();
+ if is_any {
+ for value in &values {
+ let matched = self.eval_array_condition(variable, value, &array.condition)?;
+ if matched {
+ return Ok(Value::Bool(true));
+ }
+ }
+ return Ok(Value::Bool(false));
+ }
+
+ let mut saw_value = false;
+ for value in &values {
+ saw_value = true;
+ let matched = self.eval_array_condition(variable, value, &array.condition)?;
+ if !matched {
+ return Ok(Value::Bool(false));
+ }
+ }
+
+ Ok(Value::Bool(!saw_value || is_all))
+ }
+
+ fn eval_array_condition(
+ &mut self,
+ variable: Option<&str>,
+ value: &Value,
+ condition: &crate::languages::azure_rbac::ast::ConditionExpr,
+ ) -> Result {
+ if let Some(name) = variable {
+ self.push_variable(name, value.clone());
+ let result = self.evaluate_bool(condition);
+ self.pop_variable();
+ result
+ } else {
+ self.evaluate_bool(condition)
+ }
+ }
+}
diff --git a/src/languages/azure_rbac/interpreter/tests.rs b/src/languages/azure_rbac/interpreter/tests.rs
new file mode 100644
index 00000000..19175e53
--- /dev/null
+++ b/src/languages/azure_rbac/interpreter/tests.rs
@@ -0,0 +1,187 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+#![allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)]
+
+use alloc::boxed::Box;
+use alloc::string::ToString as _;
+use alloc::vec;
+use alloc::vec::Vec;
+
+use super::ConditionInterpreter;
+use crate::languages::azure_rbac::ast::{
+ ArrayExpression, ArrayOperator, AttributeReference, AttributeSource, BinaryExpression,
+ ConditionExpr, EmptySpan, EnvironmentContext, EvaluationContext, ListLiteral, Principal,
+ PrincipalType, PropertyAccessExpression, RequestContext, Resource, StringLiteral,
+ VariableReference,
+};
+use crate::languages::azure_rbac::builtins::RbacBuiltin;
+use crate::value::Value;
+
+fn basic_context() -> EvaluationContext {
+ EvaluationContext {
+ principal: Principal {
+ id: "user-1".to_string(),
+ principal_type: PrincipalType::User,
+ custom_security_attributes: Value::new_object(),
+ },
+ resource: Resource {
+ id: "/subscriptions/s1".to_string(),
+ resource_type: "Microsoft.Storage/storageAccounts".to_string(),
+ scope: "/subscriptions/s1".to_string(),
+ attributes: Value::from_json_str(
+ r#"{"owner":"alice","tags":["a","b"],"confidential":true}"#,
+ )
+ .unwrap(),
+ },
+ request: RequestContext {
+ action: Some("Microsoft.Storage/storageAccounts/read".to_string()),
+ data_action: None,
+ attributes: Value::from_json_str(r#"{"clientIP":"10.0.0.1"}"#).unwrap(),
+ },
+ environment: EnvironmentContext {
+ is_private_link: None,
+ private_endpoint: None,
+ subnet: None,
+ utc_now: Some("2023-05-01T12:00:00Z".to_string()),
+ },
+ action: Some("Microsoft.Storage/storageAccounts/read".to_string()),
+ suboperation: None,
+ }
+}
+
+#[test]
+fn evaluates_basic_condition() {
+ let ctx = basic_context();
+ let interpreter = ConditionInterpreter::new(&ctx);
+ let result = interpreter
+ .evaluate_str("@Resource[owner] StringEquals 'alice'")
+ .unwrap();
+ assert!(result);
+}
+
+#[test]
+fn evaluates_logical_condition() {
+ let ctx = basic_context();
+ let interpreter = ConditionInterpreter::new(&ctx);
+ let result = interpreter
+ .evaluate_str(
+ "@Resource[owner] StringEquals 'alice' AND @Resource[confidential] BoolEquals true",
+ )
+ .unwrap();
+ assert!(result);
+}
+
+#[test]
+fn evaluates_property_access() {
+ let mut ctx = basic_context();
+ ctx.request.attributes = Value::from_json_str(r#"{"obj":{"key":"value"}}"#).unwrap();
+
+ let expr = ConditionExpr::Binary(BinaryExpression {
+ span: EmptySpan,
+ operator: RbacBuiltin::StringEquals,
+ left: Box::new(ConditionExpr::PropertyAccess(PropertyAccessExpression {
+ span: EmptySpan,
+ object: Box::new(ConditionExpr::AttributeReference(AttributeReference {
+ span: EmptySpan,
+ source: AttributeSource::Request,
+ namespace: None,
+ attribute: "obj".to_string(),
+ path: Vec::new(),
+ })),
+ property: "key".to_string(),
+ })),
+ right: Box::new(ConditionExpr::StringLiteral(StringLiteral {
+ span: EmptySpan,
+ value: "value".to_string(),
+ })),
+ });
+
+ let interpreter = ConditionInterpreter::new(&ctx);
+ let result = interpreter.evaluate_bool(&expr).unwrap();
+ assert!(result);
+}
+
+#[test]
+fn evaluates_any_quantifier_with_variable() {
+ let ctx = basic_context();
+ let expr = ConditionExpr::ArrayExpression(ArrayExpression {
+ span: EmptySpan,
+ operator: ArrayOperator {
+ name: "ANY".to_string(),
+ modifier: None,
+ },
+ array: Box::new(ConditionExpr::ListLiteral(ListLiteral {
+ span: EmptySpan,
+ elements: vec![
+ ConditionExpr::StringLiteral(StringLiteral {
+ span: EmptySpan,
+ value: "a".to_string(),
+ }),
+ ConditionExpr::StringLiteral(StringLiteral {
+ span: EmptySpan,
+ value: "b".to_string(),
+ }),
+ ],
+ })),
+ variable: Some("item".to_string()),
+ condition: Box::new(ConditionExpr::Binary(BinaryExpression {
+ span: EmptySpan,
+ operator: RbacBuiltin::StringEquals,
+ left: Box::new(ConditionExpr::VariableReference(VariableReference {
+ span: EmptySpan,
+ name: "item".to_string(),
+ })),
+ right: Box::new(ConditionExpr::StringLiteral(StringLiteral {
+ span: EmptySpan,
+ value: "b".to_string(),
+ })),
+ })),
+ });
+
+ let interpreter = ConditionInterpreter::new(&ctx);
+ let result = interpreter.evaluate_bool(&expr).unwrap();
+ assert!(result);
+}
+
+#[test]
+fn evaluates_all_quantifier_with_variable() {
+ let ctx = basic_context();
+ let expr = ConditionExpr::ArrayExpression(ArrayExpression {
+ span: EmptySpan,
+ operator: ArrayOperator {
+ name: "ALL".to_string(),
+ modifier: None,
+ },
+ array: Box::new(ConditionExpr::ListLiteral(ListLiteral {
+ span: EmptySpan,
+ elements: vec![
+ ConditionExpr::StringLiteral(StringLiteral {
+ span: EmptySpan,
+ value: "a".to_string(),
+ }),
+ ConditionExpr::StringLiteral(StringLiteral {
+ span: EmptySpan,
+ value: "a".to_string(),
+ }),
+ ],
+ })),
+ variable: Some("item".to_string()),
+ condition: Box::new(ConditionExpr::Binary(BinaryExpression {
+ span: EmptySpan,
+ operator: RbacBuiltin::StringEquals,
+ left: Box::new(ConditionExpr::VariableReference(VariableReference {
+ span: EmptySpan,
+ name: "item".to_string(),
+ })),
+ right: Box::new(ConditionExpr::StringLiteral(StringLiteral {
+ span: EmptySpan,
+ value: "a".to_string(),
+ })),
+ })),
+ });
+
+ let interpreter = ConditionInterpreter::new(&ctx);
+ let result = interpreter.evaluate_bool(&expr).unwrap();
+ assert!(result);
+}
diff --git a/src/languages/azure_rbac/mod.rs b/src/languages/azure_rbac/mod.rs
index 65b9b688..179f56af 100644
--- a/src/languages/azure_rbac/mod.rs
+++ b/src/languages/azure_rbac/mod.rs
@@ -2,6 +2,9 @@
// Licensed under the MIT License.
pub mod ast;
+pub mod builtins;
+#[path = "interpreter.rs"]
+pub mod interpreter;
pub mod parser;
#[cfg(test)]
diff --git a/src/languages/azure_rbac/parser/condition_parser.rs b/src/languages/azure_rbac/parser/condition_parser.rs
index 61e85e51..183b8eb7 100644
--- a/src/languages/azure_rbac/parser/condition_parser.rs
+++ b/src/languages/azure_rbac/parser/condition_parser.rs
@@ -8,9 +8,10 @@ use alloc::format;
use alloc::string::ToString;
use crate::languages::azure_rbac::ast::{
- BinaryExpression, ConditionExpr, ConditionExpression, ConditionOperator, EmptySpan,
- LogicalExpression, LogicalOperator, UnaryExpression, UnaryOperator,
+ BinaryExpression, ConditionExpr, ConditionExpression, EmptySpan, LogicalExpression,
+ LogicalOperator, UnaryExpression, UnaryOperator,
};
+use crate::languages::azure_rbac::builtins::RbacBuiltin;
use crate::lexer::{AzureRbacTokenKind, Lexer, Source, Token, TokenKind};
use super::error::ConditionParseError;
@@ -185,7 +186,7 @@ impl<'source> ConditionParser<'source> {
let op_text = self.current_text().to_string();
// Check if this is a known operator
- if let Some(operator) = ConditionOperator::from_name(&op_text) {
+ if let Some(operator) = RbacBuiltin::parse_operator(&op_text) {
self.advance()?;
let right = self.parse_primary_expression()?;
return Ok(ConditionExpr::Binary(BinaryExpression {
diff --git a/src/languages/azure_rbac/parser/primary.rs b/src/languages/azure_rbac/parser/primary.rs
index dc02cbb2..83997e4d 100644
--- a/src/languages/azure_rbac/parser/primary.rs
+++ b/src/languages/azure_rbac/parser/primary.rs
@@ -7,10 +7,11 @@ 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,
+ AttributeReference, AttributeSource, BooleanLiteral, ConditionExpr, EmptySpan,
+ FunctionCallExpression, IdentifierExpression, ListLiteral, NullLiteral, NumberLiteral,
+ SetLiteral, StringLiteral,
};
+use crate::languages::azure_rbac::builtins::RbacBuiltin;
use crate::lexer::{AzureRbacTokenKind, TokenKind};
use super::condition_parser::ConditionParser;
@@ -99,7 +100,7 @@ impl<'source> ConditionParser<'source> {
if self.current.0 == TokenKind::Symbol && self.current_text() == "(" {
self.parse_function_call(name)
} else {
- if ConditionOperator::from_name(&name).is_some() {
+ if RbacBuiltin::parse_operator(&name).is_some() {
return Err(ConditionParseError::UnsupportedCondition(format!(
"Operator '{}' missing operands",
name
diff --git a/src/languages/azure_rbac/test_cases/ActionMatches.yaml b/src/languages/azure_rbac/test_cases/ActionMatches.yaml
new file mode 100644
index 00000000..e79fbbed
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/ActionMatches.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: action_matches_true
+ condition: "ActionMatches('Microsoft.Storage/storageAccounts/*')"
+ expected: true
+ - name: action_matches_false
+ condition: "ActionMatches('Microsoft.Storage/storageAccounts/delete')"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/AddDays.yaml b/src/languages/azure_rbac/test_cases/AddDays.yaml
new file mode 100644
index 00000000..1dafb596
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/AddDays.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: add_days_true
+ condition: "AddDays('2023-05-01T00:00:00Z', 2) DateTimeEquals '2023-05-03T00:00:00+00:00'"
+ expected: true
+ - name: add_days_false
+ condition: "AddDays('2023-05-01T00:00:00Z', 1) DateTimeEquals '2023-05-03T00:00:00+00:00'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/BoolEquals.yaml b/src/languages/azure_rbac/test_cases/BoolEquals.yaml
new file mode 100644
index 00000000..e9df305d
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/BoolEquals.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: bool_equals_true
+ condition: "@Request[enabled] BoolEquals true"
+ expected: true
+ - name: bool_equals_false
+ condition: "@Resource[enabled] BoolEquals true"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/BoolNotEquals.yaml b/src/languages/azure_rbac/test_cases/BoolNotEquals.yaml
new file mode 100644
index 00000000..322409e5
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/BoolNotEquals.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: bool_not_equals_true
+ condition: "@Resource[enabled] BoolNotEquals true"
+ expected: true
+ - name: bool_not_equals_false
+ condition: "@Request[enabled] BoolNotEquals true"
+ expected: false
\ No newline at end of file
diff --git a/src/languages/azure_rbac/test_cases/CrossProducts.yaml b/src/languages/azure_rbac/test_cases/CrossProducts.yaml
new file mode 100644
index 00000000..6c8988e9
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/CrossProducts.yaml
@@ -0,0 +1,51 @@
+test_cases:
+ - name: for_any_of_any_values_true
+ condition: "@Resource[tags] ForAnyOfAnyValues @Request[allowedTags]"
+ expected: true
+ context:
+ request_attributes:
+ allowedTags: ["b", "c"]
+ - name: for_any_of_any_values_false
+ condition: "@Resource[tags] ForAnyOfAnyValues @Request[allowedTags]"
+ expected: false
+ context:
+ request_attributes:
+ allowedTags: ["c", "d"]
+ - name: for_all_of_any_values_true
+ condition: "@Resource[tags] ForAllOfAnyValues @Request[requiredTags]"
+ expected: true
+ context:
+ request_attributes:
+ requiredTags: ["a", "b", "c"]
+ - name: for_all_of_any_values_false
+ condition: "@Resource[tags] ForAllOfAnyValues @Request[requiredTags]"
+ expected: false
+ context:
+ request_attributes:
+ requiredTags: ["a"]
+ - name: for_any_of_all_values_true
+ condition: "@Resource[tags] ForAnyOfAllValues @Request[restrictedTags]"
+ expected: true
+ context:
+ request_attributes:
+ restrictedTags: ["b", "b"]
+ - name: for_any_of_all_values_false
+ condition: "@Resource[tags] ForAnyOfAllValues @Request[restrictedTags]"
+ expected: false
+ context:
+ request_attributes:
+ restrictedTags: ["a", "b"]
+ - name: for_all_of_all_values_true
+ condition: "@Resource[tags] ForAllOfAllValues @Request[mandatoryTags]"
+ expected: true
+ context:
+ resource_attributes:
+ tags: ["a"]
+ request_attributes:
+ mandatoryTags: ["a", "a"]
+ - name: for_all_of_all_values_false
+ condition: "@Resource[tags] ForAllOfAllValues @Request[mandatoryTags]"
+ expected: false
+ context:
+ request_attributes:
+ mandatoryTags: ["a", "b"]
diff --git a/src/languages/azure_rbac/test_cases/DateTimeEquals.yaml b/src/languages/azure_rbac/test_cases/DateTimeEquals.yaml
new file mode 100644
index 00000000..bdf52025
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/DateTimeEquals.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: datetime_equals_true
+ condition: "@Request[date] DateTimeEquals '2023-05-01T12:00:00Z'"
+ expected: true
+ - name: datetime_equals_false
+ condition: "@Request[date] DateTimeEquals '2023-05-02T12:00:00Z'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/DateTimeGreaterThan.yaml b/src/languages/azure_rbac/test_cases/DateTimeGreaterThan.yaml
new file mode 100644
index 00000000..f6fe20df
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/DateTimeGreaterThan.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: datetime_greater_than_true
+ condition: "@Request[date] DateTimeGreaterThan '2023-05-01T00:00:00Z'"
+ expected: true
+ - name: datetime_greater_than_false
+ condition: "@Request[date] DateTimeGreaterThan '2023-05-02T00:00:00Z'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/DateTimeGreaterThanEquals.yaml b/src/languages/azure_rbac/test_cases/DateTimeGreaterThanEquals.yaml
new file mode 100644
index 00000000..b114a514
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/DateTimeGreaterThanEquals.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: datetime_greater_than_equals_true
+ condition: "@Request[date] DateTimeGreaterThanEquals '2023-05-01T12:00:00Z'"
+ expected: true
+ - name: datetime_greater_than_equals_false
+ condition: "@Request[date] DateTimeGreaterThanEquals '2023-05-02T00:00:00Z'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/DateTimeLessThan.yaml b/src/languages/azure_rbac/test_cases/DateTimeLessThan.yaml
new file mode 100644
index 00000000..1aebd478
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/DateTimeLessThan.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: datetime_less_than_true
+ condition: "@Request[date] DateTimeLessThan '2023-05-02T00:00:00Z'"
+ expected: true
+ - name: datetime_less_than_false
+ condition: "@Request[date] DateTimeLessThan '2023-05-01T00:00:00Z'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/DateTimeLessThanEquals.yaml b/src/languages/azure_rbac/test_cases/DateTimeLessThanEquals.yaml
new file mode 100644
index 00000000..d103804a
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/DateTimeLessThanEquals.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: datetime_less_than_equals_true
+ condition: "@Request[date] DateTimeLessThanEquals '2023-05-01T12:00:00Z'"
+ expected: true
+ - name: datetime_less_than_equals_false
+ condition: "@Request[date] DateTimeLessThanEquals '2023-05-01T00:00:00Z'"
+ expected: false
\ No newline at end of file
diff --git a/src/languages/azure_rbac/test_cases/DateTimeNotEquals.yaml b/src/languages/azure_rbac/test_cases/DateTimeNotEquals.yaml
new file mode 100644
index 00000000..2d5741c3
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/DateTimeNotEquals.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: datetime_not_equals_true
+ condition: "@Request[date] DateTimeNotEquals '2023-05-02T12:00:00Z'"
+ expected: true
+ - name: datetime_not_equals_false
+ condition: "@Request[date] DateTimeNotEquals '2023-05-01T12:00:00Z'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/GuidEquals.yaml b/src/languages/azure_rbac/test_cases/GuidEquals.yaml
new file mode 100644
index 00000000..0d625cde
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/GuidEquals.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: guid_equals_true
+ condition: "@Request[guid] GuidEquals 'a1b2c3d4-0000-0000-0000-000000000000'"
+ expected: true
+ - name: guid_equals_false
+ condition: "@Request[guid] GuidEquals 'bbbbbbbb-0000-0000-0000-000000000000'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/GuidNotEquals.yaml b/src/languages/azure_rbac/test_cases/GuidNotEquals.yaml
new file mode 100644
index 00000000..bff8e197
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/GuidNotEquals.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: guid_not_equals_true
+ condition: "@Request[guid] GuidNotEquals 'bbbbbbbb-0000-0000-0000-000000000000'"
+ expected: true
+ - name: guid_not_equals_false
+ condition: "@Request[guid] GuidNotEquals 'a1b2c3d4-0000-0000-0000-000000000000'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/IpInRange.yaml b/src/languages/azure_rbac/test_cases/IpInRange.yaml
new file mode 100644
index 00000000..610c688e
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/IpInRange.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: ip_in_range_true
+ condition: "@Request[ip] IpInRange ['10.0.0.1', '10.0.0.10']"
+ expected: true
+ - name: ip_in_range_false
+ condition: "@Request[ip] IpInRange ['10.0.0.9', '10.0.0.10']"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/IpMatch.yaml b/src/languages/azure_rbac/test_cases/IpMatch.yaml
new file mode 100644
index 00000000..89aa86eb
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/IpMatch.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: ip_match_true
+ condition: "@Request[ip] IpMatch '10.0.0.0/24'"
+ expected: true
+ - name: ip_match_false
+ condition: "@Request[ip] IpMatch '192.168.0.0/16'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/IpNotMatch.yaml b/src/languages/azure_rbac/test_cases/IpNotMatch.yaml
new file mode 100644
index 00000000..ccedab98
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/IpNotMatch.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: ip_not_match_true
+ condition: "@Request[ip] IpNotMatch '192.168.0.0/16'"
+ expected: true
+ - name: ip_not_match_false
+ condition: "@Request[ip] IpNotMatch '10.0.0.0/24'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/ListContains.yaml b/src/languages/azure_rbac/test_cases/ListContains.yaml
new file mode 100644
index 00000000..2daff71e
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/ListContains.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: list_contains_true
+ condition: "@Request[tags] ListContains 'prod'"
+ expected: true
+ - name: list_contains_false
+ condition: "@Request[tags] ListContains 'dev'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/ListNotContains.yaml b/src/languages/azure_rbac/test_cases/ListNotContains.yaml
new file mode 100644
index 00000000..39ef2d47
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/ListNotContains.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: list_not_contains_true
+ condition: "@Request[tags] ListNotContains 'dev'"
+ expected: true
+ - name: list_not_contains_false
+ condition: "@Request[tags] ListNotContains 'gold'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/NormalizeList.yaml b/src/languages/azure_rbac/test_cases/NormalizeList.yaml
new file mode 100644
index 00000000..2ca9d60b
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/NormalizeList.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: normalize_list_contains
+ condition: "NormalizeList({'a','b'}) ListContains 'a'"
+ expected: true
+ - name: normalize_list_not_contains
+ condition: "NormalizeList({'a','b'}) ListNotContains 'c'"
+ expected: true
diff --git a/src/languages/azure_rbac/test_cases/NormalizeSet.yaml b/src/languages/azure_rbac/test_cases/NormalizeSet.yaml
new file mode 100644
index 00000000..dbd40346
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/NormalizeSet.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: normalize_set_contains
+ condition: "NormalizeSet(['a','a','b']) ListContains 'b'"
+ expected: true
+ - name: normalize_set_not_contains
+ condition: "NormalizeSet(['a','a','b']) ListNotContains 'c'"
+ expected: true
diff --git a/src/languages/azure_rbac/test_cases/NumericEquals.yaml b/src/languages/azure_rbac/test_cases/NumericEquals.yaml
new file mode 100644
index 00000000..01726b82
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/NumericEquals.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: numeric_equals_true
+ condition: "@Request[count] NumericEquals 10"
+ expected: true
+ - name: numeric_equals_false
+ condition: "@Request[count] NumericEquals 11"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/NumericGreaterThan.yaml b/src/languages/azure_rbac/test_cases/NumericGreaterThan.yaml
new file mode 100644
index 00000000..4ef99be2
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/NumericGreaterThan.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: numeric_greater_than_true
+ condition: "@Request[count] NumericGreaterThan 5"
+ expected: true
+ - name: numeric_greater_than_false
+ condition: "@Resource[count] NumericGreaterThan 10"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/NumericGreaterThanEquals.yaml b/src/languages/azure_rbac/test_cases/NumericGreaterThanEquals.yaml
new file mode 100644
index 00000000..8eda89e3
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/NumericGreaterThanEquals.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: numeric_greater_than_equals_true
+ condition: "@Request[count] NumericGreaterThanEquals 10"
+ expected: true
+ - name: numeric_greater_than_equals_false
+ condition: "@Resource[count] NumericGreaterThanEquals 10"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/NumericInRange.yaml b/src/languages/azure_rbac/test_cases/NumericInRange.yaml
new file mode 100644
index 00000000..e35e24fc
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/NumericInRange.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: numeric_in_range_true
+ condition: "@Request[count] NumericInRange [5, 15]"
+ expected: true
+ - name: numeric_in_range_false
+ condition: "@Request[count] NumericInRange [11, 12]"
+ expected: false
\ No newline at end of file
diff --git a/src/languages/azure_rbac/test_cases/NumericLessThan.yaml b/src/languages/azure_rbac/test_cases/NumericLessThan.yaml
new file mode 100644
index 00000000..89864b01
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/NumericLessThan.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: numeric_less_than_true
+ condition: "@Resource[count] NumericLessThan 10"
+ expected: true
+ - name: numeric_less_than_false
+ condition: "@Request[count] NumericLessThan 5"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/NumericLessThanEquals.yaml b/src/languages/azure_rbac/test_cases/NumericLessThanEquals.yaml
new file mode 100644
index 00000000..4469d2fe
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/NumericLessThanEquals.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: numeric_less_than_equals_true
+ condition: "@Resource[count] NumericLessThanEquals 5"
+ expected: true
+ - name: numeric_less_than_equals_false
+ condition: "@Request[count] NumericLessThanEquals 5"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/NumericNotEquals.yaml b/src/languages/azure_rbac/test_cases/NumericNotEquals.yaml
new file mode 100644
index 00000000..639b2f02
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/NumericNotEquals.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: numeric_not_equals_true
+ condition: "@Request[count] NumericNotEquals 11"
+ expected: true
+ - name: numeric_not_equals_false
+ condition: "@Request[count] NumericNotEquals 10"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/StringContains.yaml b/src/languages/azure_rbac/test_cases/StringContains.yaml
new file mode 100644
index 00000000..92de8fd6
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/StringContains.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: string_contains_true
+ condition: "@Request[text] StringContains 'loWo'"
+ expected: true
+ - name: string_contains_false
+ condition: "@Request[text] StringContains 'xyz'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/StringEndsWith.yaml b/src/languages/azure_rbac/test_cases/StringEndsWith.yaml
new file mode 100644
index 00000000..5797e750
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/StringEndsWith.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: string_ends_with_true
+ condition: "@Request[text] StringEndsWith 'World'"
+ expected: true
+ - name: string_ends_with_false
+ condition: "@Request[text] StringEndsWith 'Hello'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/StringEquals.yaml b/src/languages/azure_rbac/test_cases/StringEquals.yaml
new file mode 100644
index 00000000..15e04c12
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/StringEquals.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: string_equals_true
+ condition: "@Request[owner] StringEquals 'alice'"
+ expected: true
+ - name: string_equals_false
+ condition: "@Request[owner] StringEquals 'bob'"
+ expected: false
\ No newline at end of file
diff --git a/src/languages/azure_rbac/test_cases/StringEqualsIgnoreCase.yaml b/src/languages/azure_rbac/test_cases/StringEqualsIgnoreCase.yaml
new file mode 100644
index 00000000..4f31ef66
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/StringEqualsIgnoreCase.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: string_equals_ignore_case_true
+ condition: "@Request[owner] StringEqualsIgnoreCase 'ALICE'"
+ expected: true
+ - name: string_equals_ignore_case_false
+ condition: "@Request[owner] StringEqualsIgnoreCase 'BOB'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/StringLike.yaml b/src/languages/azure_rbac/test_cases/StringLike.yaml
new file mode 100644
index 00000000..671a13b1
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/StringLike.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: string_like_true
+ condition: "@Request[text] StringLike 'Hello*'"
+ expected: true
+ - name: string_like_false
+ condition: "@Request[text] StringLike 'World*'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/StringMatches.yaml b/src/languages/azure_rbac/test_cases/StringMatches.yaml
new file mode 100644
index 00000000..51c0950c
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/StringMatches.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: string_matches_true
+ condition: "@Request[text] StringMatches 'Hello.*'"
+ expected: true
+ - name: string_matches_false
+ condition: "@Request[text] StringMatches '^World'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/StringNotContains.yaml b/src/languages/azure_rbac/test_cases/StringNotContains.yaml
new file mode 100644
index 00000000..efeaab84
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/StringNotContains.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: string_not_contains_true
+ condition: "@Request[text] StringNotContains 'xyz'"
+ expected: true
+ - name: string_not_contains_false
+ condition: "@Request[text] StringNotContains 'Hello'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/StringNotEndsWith.yaml b/src/languages/azure_rbac/test_cases/StringNotEndsWith.yaml
new file mode 100644
index 00000000..33eca3e9
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/StringNotEndsWith.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: string_not_ends_with_true
+ condition: "@Request[text] StringNotEndsWith 'Hello'"
+ expected: true
+ - name: string_not_ends_with_false
+ condition: "@Request[text] StringNotEndsWith 'World'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/StringNotEquals.yaml b/src/languages/azure_rbac/test_cases/StringNotEquals.yaml
new file mode 100644
index 00000000..df158516
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/StringNotEquals.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: string_not_equals_true
+ condition: "@Request[owner] StringNotEquals 'bob'"
+ expected: true
+ - name: string_not_equals_false
+ condition: "@Request[owner] StringNotEquals 'alice'"
+ expected: false
\ No newline at end of file
diff --git a/src/languages/azure_rbac/test_cases/StringNotEqualsIgnoreCase.yaml b/src/languages/azure_rbac/test_cases/StringNotEqualsIgnoreCase.yaml
new file mode 100644
index 00000000..8af81945
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/StringNotEqualsIgnoreCase.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: string_not_equals_ignore_case_true
+ condition: "@Request[owner] StringNotEqualsIgnoreCase 'BOB'"
+ expected: true
+ - name: string_not_equals_ignore_case_false
+ condition: "@Request[owner] StringNotEqualsIgnoreCase 'ALICE'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/StringNotLike.yaml b/src/languages/azure_rbac/test_cases/StringNotLike.yaml
new file mode 100644
index 00000000..1698341b
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/StringNotLike.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: string_not_like_true
+ condition: "@Request[text] StringNotLike 'World*'"
+ expected: true
+ - name: string_not_like_false
+ condition: "@Request[text] StringNotLike 'Hello*'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/StringNotMatches.yaml b/src/languages/azure_rbac/test_cases/StringNotMatches.yaml
new file mode 100644
index 00000000..37420fb9
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/StringNotMatches.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: string_not_matches_true
+ condition: "@Request[text] StringNotMatches '^World'"
+ expected: true
+ - name: string_not_matches_false
+ condition: "@Request[text] StringNotMatches 'Hello.*'"
+ expected: false
\ No newline at end of file
diff --git a/src/languages/azure_rbac/test_cases/StringNotStartsWith.yaml b/src/languages/azure_rbac/test_cases/StringNotStartsWith.yaml
new file mode 100644
index 00000000..a2fd22d9
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/StringNotStartsWith.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: string_not_starts_with_true
+ condition: "@Request[text] StringNotStartsWith 'World'"
+ expected: true
+ - name: string_not_starts_with_false
+ condition: "@Request[text] StringNotStartsWith 'Hello'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/StringStartsWith.yaml b/src/languages/azure_rbac/test_cases/StringStartsWith.yaml
new file mode 100644
index 00000000..04b3a622
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/StringStartsWith.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: string_starts_with_true
+ condition: "@Request[text] StringStartsWith 'Hello'"
+ expected: true
+ - name: string_starts_with_false
+ condition: "@Request[text] StringStartsWith 'World'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/SubOperationMatches.yaml b/src/languages/azure_rbac/test_cases/SubOperationMatches.yaml
new file mode 100644
index 00000000..77dc1e7c
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/SubOperationMatches.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: suboperation_matches_true
+ condition: "SubOperationMatches('sub/read')"
+ expected: true
+ - name: suboperation_matches_false
+ condition: "SubOperationMatches('sub/write')"
+ expected: false
\ No newline at end of file
diff --git a/src/languages/azure_rbac/test_cases/TimeOfDayEquals.yaml b/src/languages/azure_rbac/test_cases/TimeOfDayEquals.yaml
new file mode 100644
index 00000000..7c691e7a
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/TimeOfDayEquals.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: time_of_day_equals_true
+ condition: "@Request[time] TimeOfDayEquals '12:30:15'"
+ expected: true
+ - name: time_of_day_equals_false
+ condition: "@Request[time] TimeOfDayEquals '12:00:00'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/TimeOfDayGreaterThan.yaml b/src/languages/azure_rbac/test_cases/TimeOfDayGreaterThan.yaml
new file mode 100644
index 00000000..33b87757
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/TimeOfDayGreaterThan.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: time_of_day_greater_than_true
+ condition: "@Request[time] TimeOfDayGreaterThan '11:00:00'"
+ expected: true
+ - name: time_of_day_greater_than_false
+ condition: "@Request[time] TimeOfDayGreaterThan '13:00:00'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/TimeOfDayGreaterThanEquals.yaml b/src/languages/azure_rbac/test_cases/TimeOfDayGreaterThanEquals.yaml
new file mode 100644
index 00000000..4e846d28
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/TimeOfDayGreaterThanEquals.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: time_of_day_greater_than_equals_true
+ condition: "@Request[time] TimeOfDayGreaterThanEquals '12:30:15'"
+ expected: true
+ - name: time_of_day_greater_than_equals_false
+ condition: "@Request[time] TimeOfDayGreaterThanEquals '13:00:00'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/TimeOfDayInRange.yaml b/src/languages/azure_rbac/test_cases/TimeOfDayInRange.yaml
new file mode 100644
index 00000000..7e12568a
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/TimeOfDayInRange.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: time_of_day_in_range_true
+ condition: "@Request[time] TimeOfDayInRange ['12:00:00', '13:00:00']"
+ expected: true
+ - name: time_of_day_in_range_false
+ condition: "@Request[time] TimeOfDayInRange ['13:00:00', '14:00:00']"
+ expected: false
\ No newline at end of file
diff --git a/src/languages/azure_rbac/test_cases/TimeOfDayLessThan.yaml b/src/languages/azure_rbac/test_cases/TimeOfDayLessThan.yaml
new file mode 100644
index 00000000..67f1a340
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/TimeOfDayLessThan.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: time_of_day_less_than_true
+ condition: "@Request[time] TimeOfDayLessThan '13:00:00'"
+ expected: true
+ - name: time_of_day_less_than_false
+ condition: "@Request[time] TimeOfDayLessThan '11:00:00'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/TimeOfDayLessThanEquals.yaml b/src/languages/azure_rbac/test_cases/TimeOfDayLessThanEquals.yaml
new file mode 100644
index 00000000..3fd525c9
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/TimeOfDayLessThanEquals.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: time_of_day_less_than_equals_true
+ condition: "@Request[time] TimeOfDayLessThanEquals '12:30:15'"
+ expected: true
+ - name: time_of_day_less_than_equals_false
+ condition: "@Request[time] TimeOfDayLessThanEquals '11:00:00'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/TimeOfDayNotEquals.yaml b/src/languages/azure_rbac/test_cases/TimeOfDayNotEquals.yaml
new file mode 100644
index 00000000..efb427b3
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/TimeOfDayNotEquals.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: time_of_day_not_equals_true
+ condition: "@Request[time] TimeOfDayNotEquals '12:00:00'"
+ expected: true
+ - name: time_of_day_not_equals_false
+ condition: "@Request[time] TimeOfDayNotEquals '12:30:15'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/ToLower.yaml b/src/languages/azure_rbac/test_cases/ToLower.yaml
new file mode 100644
index 00000000..abbbf129
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/ToLower.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: to_lower_true
+ condition: "ToLower('AbC') StringEquals 'abc'"
+ expected: true
+ - name: to_lower_false
+ condition: "ToLower('AbC') StringEquals 'ABC'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/ToTime.yaml b/src/languages/azure_rbac/test_cases/ToTime.yaml
new file mode 100644
index 00000000..a9b085c3
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/ToTime.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: to_time_true
+ condition: "ToTime('12:00:00') TimeOfDayEquals '12:00:00'"
+ expected: true
+ - name: to_time_false
+ condition: "ToTime('12:00:00') TimeOfDayEquals '13:00:00'"
+ expected: false
\ No newline at end of file
diff --git a/src/languages/azure_rbac/test_cases/ToUpper.yaml b/src/languages/azure_rbac/test_cases/ToUpper.yaml
new file mode 100644
index 00000000..1e9cc8c7
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/ToUpper.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: to_upper_true
+ condition: "ToUpper('AbC') StringEquals 'ABC'"
+ expected: true
+ - name: to_upper_false
+ condition: "ToUpper('AbC') StringEquals 'abc'"
+ expected: false
diff --git a/src/languages/azure_rbac/test_cases/Trim.yaml b/src/languages/azure_rbac/test_cases/Trim.yaml
new file mode 100644
index 00000000..e3183636
--- /dev/null
+++ b/src/languages/azure_rbac/test_cases/Trim.yaml
@@ -0,0 +1,7 @@
+test_cases:
+ - name: trim_true
+ condition: "Trim(' hi ') StringEquals 'hi'"
+ expected: true
+ - name: trim_false
+ condition: "Trim(' hi ') StringEquals ' hi'"
+ expected: false
diff --git a/src/languages/azure_rbac/tests.rs b/src/languages/azure_rbac/tests.rs
index 961cb8e2..7cb93677 100644
--- a/src/languages/azure_rbac/tests.rs
+++ b/src/languages/azure_rbac/tests.rs
@@ -153,3 +153,214 @@ mod condition_tests {
}
}
}
+
+#[cfg(test)]
+mod rbac_builtin_tests {
+ #![allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)] // tests unwrap/expect to assert evaluation
+ use alloc::string::{String, ToString as _};
+ use alloc::vec::Vec;
+ use serde_json;
+ use serde_yaml;
+ use std::fs;
+ use std::path::PathBuf;
+
+ use crate::languages::azure_rbac::ast::{
+ EnvironmentContext, EvaluationContext, Principal, PrincipalType, RequestContext, Resource,
+ };
+ use crate::languages::azure_rbac::interpreter::ConditionInterpreter;
+ use crate::languages::azure_rbac::parser::parse_condition_expression;
+ use crate::value::Value;
+
+ #[derive(Debug, serde::Deserialize)]
+ struct EvalTestCase {
+ name: String,
+ condition: String,
+ expected: bool,
+ #[serde(default)]
+ context: Option,
+ }
+
+ #[derive(Debug, serde::Deserialize)]
+ struct EvalTestCases {
+ test_cases: Vec,
+ }
+
+ #[derive(Debug, serde::Deserialize, Default)]
+ struct EvalContextOverrides {
+ action: Option,
+ suboperation: Option,
+ request_action: Option,
+ data_action: Option,
+ principal_id: Option,
+ principal_type: Option,
+ resource_id: Option,
+ resource_type: Option,
+ resource_scope: Option,
+ request_attributes: Option,
+ resource_attributes: Option,
+ principal_custom_security_attributes: Option,
+ environment: Option,
+ }
+
+ #[derive(Debug, serde::Deserialize, Default)]
+ struct EvalEnvironmentOverrides {
+ is_private_link: Option,
+ private_endpoint: Option,
+ subnet: Option,
+ utc_now: Option,
+ }
+
+ fn default_context() -> EvaluationContext {
+ EvaluationContext {
+ principal: Principal {
+ id: "user-1".to_string(),
+ principal_type: PrincipalType::User,
+ custom_security_attributes: Value::from_json_str(
+ r#"{"department":"eng","levels":["L1","L2"]}"#,
+ )
+ .unwrap(),
+ },
+ resource: Resource {
+ id: "/subscriptions/s1".to_string(),
+ resource_type: "Microsoft.Storage/storageAccounts".to_string(),
+ scope: "/subscriptions/s1".to_string(),
+ attributes: Value::from_json_str(
+ r#"{"owner":"alice","tags":["a","b"],"count":5,"enabled":false,"ip":"10.0.0.5","guid":"a1b2c3d4-0000-0000-0000-000000000000"}"#,
+ )
+ .unwrap(),
+ },
+ request: RequestContext {
+ action: Some("Microsoft.Storage/storageAccounts/read".to_string()),
+ data_action: Some("Microsoft.Storage/storageAccounts/read".to_string()),
+ attributes: Value::from_json_str(
+ r#"{"owner":"alice","text":"HelloWorld","tags":["prod","gold"],"count":10,"ratio":2.5,"enabled":true,"ip":"10.0.0.8","guid":"A1B2C3D4-0000-0000-0000-000000000000","time":"12:30:15","date":"2023-05-01T12:00:00Z","numbers":[1,2,3],"letters":["a","b"]}"#,
+ )
+ .unwrap(),
+ },
+ environment: EnvironmentContext {
+ is_private_link: Some(false),
+ private_endpoint: None,
+ subnet: None,
+ utc_now: Some("2023-05-01T12:00:00Z".to_string()),
+ },
+ action: Some("Microsoft.Storage/storageAccounts/read".to_string()),
+ suboperation: Some("sub/read".to_string()),
+ }
+ }
+
+ fn value_from_json(value: Option, default: Value) -> Value {
+ value
+ .map(|v| Value::from_json_str(&serde_json::to_string(&v).unwrap()).unwrap())
+ .unwrap_or(default)
+ }
+
+ fn apply_overrides(
+ mut context: EvaluationContext,
+ overrides: EvalContextOverrides,
+ ) -> EvaluationContext {
+ if let Some(action) = overrides.action {
+ context.action = Some(action);
+ }
+ if let Some(suboperation) = overrides.suboperation {
+ context.suboperation = Some(suboperation);
+ }
+ if let Some(action) = overrides.request_action {
+ context.request.action = Some(action);
+ }
+ if let Some(data_action) = overrides.data_action {
+ context.request.data_action = Some(data_action);
+ }
+ if let Some(principal_id) = overrides.principal_id {
+ context.principal.id = principal_id;
+ }
+ if let Some(principal_type) = overrides.principal_type {
+ context.principal.principal_type = match principal_type.as_str() {
+ "User" => PrincipalType::User,
+ "Group" => PrincipalType::Group,
+ "ServicePrincipal" => PrincipalType::ServicePrincipal,
+ "ManagedServiceIdentity" => PrincipalType::ManagedServiceIdentity,
+ _ => PrincipalType::User,
+ };
+ }
+ if let Some(resource_id) = overrides.resource_id {
+ context.resource.id = resource_id;
+ }
+ if let Some(resource_type) = overrides.resource_type {
+ context.resource.resource_type = resource_type;
+ }
+ if let Some(resource_scope) = overrides.resource_scope {
+ context.resource.scope = resource_scope;
+ }
+
+ context.request.attributes =
+ value_from_json(overrides.request_attributes, context.request.attributes);
+ context.resource.attributes =
+ value_from_json(overrides.resource_attributes, context.resource.attributes);
+ context.principal.custom_security_attributes = value_from_json(
+ overrides.principal_custom_security_attributes,
+ context.principal.custom_security_attributes,
+ );
+
+ if let Some(env) = overrides.environment {
+ if let Some(is_private_link) = env.is_private_link {
+ context.environment.is_private_link = Some(is_private_link);
+ }
+ if let Some(private_endpoint) = env.private_endpoint {
+ context.environment.private_endpoint = Some(private_endpoint);
+ }
+ if let Some(subnet) = env.subnet {
+ context.environment.subnet = Some(subnet);
+ }
+ if let Some(utc_now) = env.utc_now {
+ context.environment.utc_now = Some(utc_now);
+ }
+ }
+
+ context
+ }
+
+ fn load_eval_test_cases() -> Vec {
+ let mut dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+ dir.push("src/languages/azure_rbac/test_cases");
+
+ let mut entries: Vec = fs::read_dir(&dir)
+ .expect("Failed to read test_cases directory")
+ .filter_map(|entry| entry.ok().map(|entry| entry.path()))
+ .filter(|path| path.extension().map(|ext| ext == "yaml").unwrap_or(false))
+ .collect();
+ entries.sort();
+
+ let mut cases = Vec::new();
+ for path in entries {
+ let yaml = fs::read_to_string(&path).expect("Failed to read test case YAML");
+ let suite: EvalTestCases =
+ serde_yaml::from_str(&yaml).expect("Failed to parse evaluation cases YAML");
+ cases.extend(suite.test_cases);
+ }
+ cases
+ }
+
+ #[test]
+ fn rbac_builtins() {
+ let cases = load_eval_test_cases();
+
+ for case in cases {
+ let mut context = default_context();
+ if let Some(overrides) = case.context {
+ context = apply_overrides(context, overrides);
+ }
+
+ let parsed = parse_condition_expression(&case.condition).unwrap();
+ let expr = parsed.expression.expect("Missing parsed expression");
+
+ let interpreter = ConditionInterpreter::new(&context);
+ let result = interpreter.evaluate_bool(&expr).unwrap();
+
+ assert_eq!(
+ result, case.expected,
+ "Test '{}' failed for condition '{}'",
+ case.name, case.condition
+ );
+ }
+ }
+}