feat(rbac)!: add Azure RBAC engine, FFI API, and cross-language tests (#577)

- add Azure RBAC condition interpreter and builtin evaluation in core (expressions, parser updates, evaluator, and test harness)
- introduce comprehensive RBAC YAML test suites and coverage for i
  - action/suboperation
  - strings
  - numbers
  - bools
  - IP
  - GUID
  - dates
  - times
  - lists
  - quantifiers (ForAnyOfAnyValues, ForAllOfAllValues)
- expose RBAC evaluation through FFI with an `rbac` feature flag enabled by default
- add C# `RbacEngine` wrapper + P/Invoke entrypoint and document usage in C# README
- expand C# tests to execute all RBAC YAML cases with per-case logging
- wire test assets into C# test output and centralize YAML dependency versions

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>
This commit is contained in:
Anand Krishnamoorthi
2026-02-19 15:30:03 -06:00
committed by GitHub
parent 8814eda0ae
commit 47cc27ff49
93 changed files with 3820 additions and 204 deletions

View File

@@ -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

View File

@@ -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 = []

View File

@@ -10,5 +10,6 @@
<PackageVersion Include="Regorus" Version="$(RegorusPackageVersion)$(RegorusPackageVersionSuffix)" />
<PackageVersion Include="MSTest" Version="3.8.2" />
<PackageVersion Include="System.Text.Json" Version="8.0.5" />
<PackageVersion Include="YamlDotNet" Version="13.7.0" />
</ItemGroup>
</Project>

View File

@@ -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}");
```

View File

@@ -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<EvalTestCase> 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<EvalTestSuite>(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<EvalTestCase> 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; }
}
}

View File

@@ -20,9 +20,14 @@
<ItemGroup>
<PackageReference Include="MSTest" />
<PackageReference Include="YamlDotNet" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Regorus" />
</ItemGroup>
<ItemGroup>
<None Include="../../../src/languages/azure_rbac/test_cases/*.yaml" Link="test_cases/%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -492,6 +492,16 @@ namespace Regorus.Internal
#endregion
#region RBAC Methods
/// <summary>
/// Evaluate an Azure RBAC condition expression against a JSON evaluation context.
/// </summary>
[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
/// <summary>

View File

@@ -0,0 +1,45 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System;
using Regorus.Internal;
#nullable enable
namespace Regorus
{
/// <summary>
/// Provides helpers for evaluating Azure RBAC condition expressions.
/// </summary>
public static unsafe class RbacEngine
{
/// <summary>
/// Evaluate an Azure RBAC condition expression against a JSON evaluation context.
/// </summary>
/// <param name="condition">Azure RBAC condition expression.</param>
/// <param name="contextJson">JSON encoded EvaluationContext.</param>
/// <returns>True if the condition evaluates to true; otherwise false.</returns>
/// <exception cref="Exception">Thrown when evaluation fails.</exception>
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);
}
}));
}
}
}

View File

@@ -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]

View File

@@ -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;

59
bindings/ffi/src/rbac.rs Normal file
View File

@@ -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}"),
),
}
})
}

View File

@@ -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<ConditionExpr>,
pub right: Box<ConditionExpr>,
}

View File

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

View File

@@ -0,0 +1,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
}

View File

@@ -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<bool, RbacBuiltinError> {
// 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)
}

View File

@@ -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<bool, RbacBuiltinError> {
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<alloc::string::String, RbacBuiltinError> {
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<i64, RbacBuiltinError> {
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::<i64>()
.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<u32, RbacBuiltinError> {
// 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::<u32>()
.map_err(|_| RbacBuiltinError::new("Invalid hour value"))?;
let minutes = minutes_str
.parse::<u32>()
.map_err(|_| RbacBuiltinError::new("Invalid minute value"))?;
let seconds = match seconds_str {
Some(segment) => segment
.parse::<u32>()
.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,
}
}

View File

@@ -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<bool, RbacBuiltinError> {
// 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,
})
}
}

View File

@@ -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<alloc::string::String>) -> 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<Value, RbacBuiltinError> {
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)
}
}
}
}

View File

@@ -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<Value, RbacBuiltinError> {
// 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<Value, RbacBuiltinError> {
// 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<Value, RbacBuiltinError> {
// 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<Value, RbacBuiltinError> {
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<Value> = 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<Value, RbacBuiltinError> {
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::<alloc::vec::Vec<_>>(),
)),
// 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<Value, RbacBuiltinError> {
// 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<Value, RbacBuiltinError> {
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<Value, RbacBuiltinError> {
// 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<Value, RbacBuiltinError> {
// 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))
}

View File

@@ -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<Self> {
match name {
$( $name => Some(Self::$variant), )*
_ => None,
}
}
/// Parse a binary operator name (excludes function-style builtins).
pub fn parse_operator(name: &str) -> Option<Self> {
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 },
}

View File

@@ -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<bool, RbacBuiltinError> {
// 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::<IpNet>()
.map_err(|err| RbacBuiltinError::new(err.to_string()))?;
let ip = ip_str
.parse::<IpAddr>()
.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<bool, RbacBuiltinError> {
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<bool, RbacBuiltinError> {
// 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::<IpAddr>()
.map_err(|err| RbacBuiltinError::new(err.to_string()))?;
let start_ip = start
.parse::<IpAddr>()
.map_err(|err| RbacBuiltinError::new(err.to_string()))?;
let end_ip = end
.parse::<IpAddr>()
.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"))
}
}

View File

@@ -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<bool, RbacBuiltinError> {
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<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| set.contains(item)),
Value::Set(ref right_set) => right_set.iter().all(|item| set.contains(item)),
_ => set.contains(needle),
}
}

View File

@@ -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;

View File

@@ -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<bool, RbacBuiltinError> {
// 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<bool, RbacBuiltinError> {
// 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)
}

View File

@@ -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<bool, RbacBuiltinError> {
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<bool, RbacBuiltinError> {
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<bool, RbacBuiltinError> {
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<bool, RbacBuiltinError> {
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],
}
}

View File

@@ -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<bool, RbacBuiltinError> {
// 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<bool, RbacBuiltinError> {
// 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<bool, RbacBuiltinError> {
// 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<bool, RbacBuiltinError> {
// 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<bool, RbacBuiltinError> {
// 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<bool, RbacBuiltinError> {
// 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<bool, RbacBuiltinError> {
// 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<bool, RbacBuiltinError> {
// 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))
}

View File

@@ -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<bool, RbacBuiltinError> {
// 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<bool, RbacBuiltinError> {
// 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),
}
}

View File

@@ -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<bool, ConditionEvalError> {
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<bool, ConditionEvalError> {
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<bool, ConditionEvalError> {
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<Value, ConditionEvalError> {
let mut evaluator = Evaluator::new(self.context);
evaluator.evaluate_value(expr)
}
}

View File

@@ -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<Value, ConditionEvalError> {
Ok(self
.lookup_variable(variable.name.as_str())
.unwrap_or(Value::Undefined))
}
pub(super) fn eval_property_access(
&mut self,
access: &PropertyAccessExpression,
) -> Result<Value, ConditionEvalError> {
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),
}
}
}

View File

@@ -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<Value, ConditionEvalError> {
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<Value, ConditionEvalError> {
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<String, ConditionEvalError> {
#[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,
}

View File

@@ -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<String>) -> 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)
}
}

View File

@@ -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<VariableBinding>,
}
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<bool, ConditionEvalError> {
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<bool, ConditionEvalError> {
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<bool, ConditionEvalError> {
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<Value, ConditionEvalError> {
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::<f64>().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<Value, ConditionEvalError> {
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<Value, ConditionEvalError> {
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<Value, ConditionEvalError> {
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<Value, ConditionEvalError> {
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<Value> {
self.variables
.iter()
.rev()
.find(|binding| binding.name == name)
.map(|binding| binding.value.clone())
}
}

View File

@@ -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<Value, ConditionEvalError> {
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<Value, ConditionEvalError> {
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,
}
}
}

View File

@@ -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<bool, ConditionEvalError> {
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<bool, ConditionEvalError> {
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<bool, ConditionEvalError> {
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<Value, ConditionEvalError> {
let mut evaluator = Evaluator::new(self.context);
evaluator.evaluate_value(expr)
}
}

View File

@@ -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<Value, ConditionEvalError> {
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<bool, ConditionEvalError> {
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)
}
}
}

View File

@@ -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);
}

View File

@@ -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)]

View File

@@ -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 {

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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<EvalContextOverrides>,
}
#[derive(Debug, serde::Deserialize)]
struct EvalTestCases {
test_cases: Vec<EvalTestCase>,
}
#[derive(Debug, serde::Deserialize, Default)]
struct EvalContextOverrides {
action: Option<String>,
suboperation: Option<String>,
request_action: Option<String>,
data_action: Option<String>,
principal_id: Option<String>,
principal_type: Option<String>,
resource_id: Option<String>,
resource_type: Option<String>,
resource_scope: Option<String>,
request_attributes: Option<serde_json::Value>,
resource_attributes: Option<serde_json::Value>,
principal_custom_security_attributes: Option<serde_json::Value>,
environment: Option<EvalEnvironmentOverrides>,
}
#[derive(Debug, serde::Deserialize, Default)]
struct EvalEnvironmentOverrides {
is_private_link: Option<bool>,
private_endpoint: Option<String>,
subnet: Option<String>,
utc_now: Option<String>,
}
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<serde_json::Value>, 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<EvalTestCase> {
let mut dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
dir.push("src/languages/azure_rbac/test_cases");
let mut entries: Vec<PathBuf> = 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
);
}
}
}