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