mirror of
https://github.com/microsoft/regorus.git
synced 2026-08-05 02:16:11 +00:00
feat(rbac)!: add Azure RBAC engine, FFI API, and cross-language tests (#577)
- add Azure RBAC condition interpreter and builtin evaluation in core (expressions, parser updates, evaluator, and test harness) - introduce comprehensive RBAC YAML test suites and coverage for i - action/suboperation - strings - numbers - bools - IP - GUID - dates - times - lists - quantifiers (ForAnyOfAnyValues, ForAllOfAllValues) - expose RBAC evaluation through FFI with an `rbac` feature flag enabled by default - add C# `RbacEngine` wrapper + P/Invoke entrypoint and document usage in C# README - expand C# tests to execute all RBAC YAML cases with per-case logging - wire test assets into C# test output and centralize YAML dependency versions Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>
This commit is contained in:
committed by
GitHub
parent
8814eda0ae
commit
47cc27ff49
@@ -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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -10,5 +10,6 @@
|
||||
<PackageVersion Include="Regorus" Version="$(RegorusPackageVersion)$(RegorusPackageVersionSuffix)" />
|
||||
<PackageVersion Include="MSTest" Version="3.8.2" />
|
||||
<PackageVersion Include="System.Text.Json" Version="8.0.5" />
|
||||
<PackageVersion Include="YamlDotNet" Version="13.7.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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}");
|
||||
```
|
||||
|
||||
374
bindings/csharp/Regorus.Tests/RbacEngineTests.cs
Normal file
374
bindings/csharp/Regorus.Tests/RbacEngineTests.cs
Normal file
@@ -0,0 +1,374 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Regorus;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace Regorus.Tests;
|
||||
|
||||
[TestClass]
|
||||
public class RbacEngineTests
|
||||
{
|
||||
public TestContext? TestContext { get; set; }
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private const string BaseContextJson = """
|
||||
{
|
||||
"principal": {
|
||||
"id": "user-1",
|
||||
"principal_type": "User",
|
||||
"custom_security_attributes": {
|
||||
"department": "eng",
|
||||
"levels": ["L1", "L2"]
|
||||
}
|
||||
},
|
||||
"resource": {
|
||||
"id": "/subscriptions/s1",
|
||||
"resource_type": "Microsoft.Storage/storageAccounts",
|
||||
"scope": "/subscriptions/s1",
|
||||
"attributes": {
|
||||
"owner": "alice",
|
||||
"tags": ["a", "b"],
|
||||
"count": 5,
|
||||
"enabled": false,
|
||||
"ip": "10.0.0.5",
|
||||
"guid": "a1b2c3d4-0000-0000-0000-000000000000"
|
||||
}
|
||||
},
|
||||
"request": {
|
||||
"action": "Microsoft.Storage/storageAccounts/read",
|
||||
"data_action": "Microsoft.Storage/storageAccounts/read",
|
||||
"attributes": {
|
||||
"owner": "alice",
|
||||
"text": "HelloWorld",
|
||||
"tags": ["prod", "gold"],
|
||||
"count": 10,
|
||||
"ratio": 2.5,
|
||||
"enabled": true,
|
||||
"ip": "10.0.0.8",
|
||||
"guid": "A1B2C3D4-0000-0000-0000-000000000000",
|
||||
"time": "12:30:15",
|
||||
"date": "2023-05-01T12:00:00Z",
|
||||
"numbers": [1, 2, 3],
|
||||
"letters": ["a", "b"]
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
"is_private_link": false,
|
||||
"private_endpoint": null,
|
||||
"subnet": null,
|
||||
"utc_now": "2023-05-01T12:00:00Z"
|
||||
},
|
||||
"action": "Microsoft.Storage/storageAccounts/read",
|
||||
"suboperation": "sub/read"
|
||||
}
|
||||
""";
|
||||
|
||||
[TestMethod]
|
||||
public void Rbac_engine_evaluates_all_yaml_cases()
|
||||
{
|
||||
var cases = LoadEvalTestCases().ToList();
|
||||
Assert.IsTrue(cases.Count > 0, "No RBAC test cases were loaded.");
|
||||
|
||||
foreach (var testCase in cases)
|
||||
{
|
||||
TestContext?.WriteLine($"RBAC case: {testCase.Name} -> {testCase.Condition}");
|
||||
var context = BuildBaseContext();
|
||||
if (testCase.Context != null)
|
||||
{
|
||||
ApplyOverrides(context, testCase.Context);
|
||||
}
|
||||
|
||||
var contextJson = context.ToJsonString(JsonOptions);
|
||||
var result = RbacEngine.EvaluateCondition(testCase.Condition, contextJson);
|
||||
|
||||
Assert.AreEqual(
|
||||
testCase.Expected,
|
||||
result,
|
||||
$"RBAC test '{testCase.Name}' failed for condition '{testCase.Condition}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonObject BuildBaseContext()
|
||||
{
|
||||
var node = JsonNode.Parse(BaseContextJson) as JsonObject;
|
||||
if (node is null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to parse base context JSON.");
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
private static void ApplyOverrides(JsonObject context, EvalContextOverrides overrides)
|
||||
{
|
||||
var principal = (JsonObject?)context["principal"]
|
||||
?? throw new InvalidOperationException("Missing principal section.");
|
||||
var resource = (JsonObject?)context["resource"]
|
||||
?? throw new InvalidOperationException("Missing resource section.");
|
||||
var request = (JsonObject?)context["request"]
|
||||
?? throw new InvalidOperationException("Missing request section.");
|
||||
var environment = (JsonObject?)context["environment"]
|
||||
?? throw new InvalidOperationException("Missing environment section.");
|
||||
|
||||
if (!string.IsNullOrEmpty(overrides.Action))
|
||||
{
|
||||
context["action"] = overrides.Action;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(overrides.Suboperation))
|
||||
{
|
||||
context["suboperation"] = overrides.Suboperation;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(overrides.RequestAction))
|
||||
{
|
||||
request["action"] = overrides.RequestAction;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(overrides.DataAction))
|
||||
{
|
||||
request["data_action"] = overrides.DataAction;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(overrides.PrincipalId))
|
||||
{
|
||||
principal["id"] = overrides.PrincipalId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(overrides.PrincipalType))
|
||||
{
|
||||
principal["principal_type"] = overrides.PrincipalType;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(overrides.ResourceId))
|
||||
{
|
||||
resource["id"] = overrides.ResourceId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(overrides.ResourceType))
|
||||
{
|
||||
resource["resource_type"] = overrides.ResourceType;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(overrides.ResourceScope))
|
||||
{
|
||||
resource["scope"] = overrides.ResourceScope;
|
||||
}
|
||||
|
||||
if (overrides.RequestAttributes != null)
|
||||
{
|
||||
request["attributes"] = ConvertToJsonNode(overrides.RequestAttributes);
|
||||
}
|
||||
|
||||
if (overrides.ResourceAttributes != null)
|
||||
{
|
||||
resource["attributes"] = ConvertToJsonNode(overrides.ResourceAttributes);
|
||||
}
|
||||
|
||||
if (overrides.PrincipalCustomSecurityAttributes != null)
|
||||
{
|
||||
principal["custom_security_attributes"] = ConvertToJsonNode(overrides.PrincipalCustomSecurityAttributes);
|
||||
}
|
||||
|
||||
if (overrides.Environment != null)
|
||||
{
|
||||
if (overrides.Environment.IsPrivateLink.HasValue)
|
||||
{
|
||||
environment["is_private_link"] = overrides.Environment.IsPrivateLink.Value;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(overrides.Environment.PrivateEndpoint))
|
||||
{
|
||||
environment["private_endpoint"] = overrides.Environment.PrivateEndpoint;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(overrides.Environment.Subnet))
|
||||
{
|
||||
environment["subnet"] = overrides.Environment.Subnet;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(overrides.Environment.UtcNow))
|
||||
{
|
||||
environment["utc_now"] = overrides.Environment.UtcNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<EvalTestCase> LoadEvalTestCases()
|
||||
{
|
||||
var baseDir = Path.Combine(AppContext.BaseDirectory, "test_cases");
|
||||
if (!Directory.Exists(baseDir))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"RBAC test case directory not found: {baseDir}");
|
||||
}
|
||||
|
||||
var deserializer = new DeserializerBuilder()
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
|
||||
var files = Directory.EnumerateFiles(baseDir, "*.yaml")
|
||||
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
var yaml = File.ReadAllText(file);
|
||||
var suite = deserializer.Deserialize<EvalTestSuite>(yaml);
|
||||
if (suite?.TestCases is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var testCase in suite.TestCases)
|
||||
{
|
||||
yield return testCase;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonNode? ConvertToJsonNode(object? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (value)
|
||||
{
|
||||
case JsonNode node:
|
||||
return node;
|
||||
case string text:
|
||||
return JsonValue.Create(text);
|
||||
case bool boolean:
|
||||
return JsonValue.Create(boolean);
|
||||
case int intValue:
|
||||
return JsonValue.Create(intValue);
|
||||
case long longValue:
|
||||
return JsonValue.Create(longValue);
|
||||
case double doubleValue:
|
||||
return JsonValue.Create(doubleValue);
|
||||
case float floatValue:
|
||||
return JsonValue.Create(floatValue);
|
||||
case decimal decimalValue:
|
||||
return JsonValue.Create(decimalValue);
|
||||
case DateTime dateTime:
|
||||
return JsonValue.Create(dateTime.ToString("O"));
|
||||
case IDictionary dictionary:
|
||||
{
|
||||
var obj = new JsonObject();
|
||||
foreach (DictionaryEntry entry in dictionary)
|
||||
{
|
||||
var key = entry.Key?.ToString() ?? string.Empty;
|
||||
obj[key] = ConvertToJsonNode(entry.Value);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
case IEnumerable enumerable:
|
||||
{
|
||||
if (value is string)
|
||||
{
|
||||
return JsonValue.Create(value.ToString());
|
||||
}
|
||||
|
||||
var array = new JsonArray();
|
||||
foreach (var item in enumerable)
|
||||
{
|
||||
array.Add(ConvertToJsonNode(item));
|
||||
}
|
||||
return array;
|
||||
}
|
||||
default:
|
||||
return JsonValue.Create(value.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EvalTestSuite
|
||||
{
|
||||
[YamlMember(Alias = "test_cases")]
|
||||
public List<EvalTestCase> TestCases { get; set; } = new();
|
||||
}
|
||||
|
||||
private sealed class EvalTestCase
|
||||
{
|
||||
[YamlMember(Alias = "name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[YamlMember(Alias = "condition")]
|
||||
public string Condition { get; set; } = string.Empty;
|
||||
|
||||
[YamlMember(Alias = "expected")]
|
||||
public bool Expected { get; set; }
|
||||
|
||||
[YamlMember(Alias = "context")]
|
||||
public EvalContextOverrides? Context { get; set; }
|
||||
}
|
||||
|
||||
private sealed class EvalContextOverrides
|
||||
{
|
||||
[YamlMember(Alias = "action")]
|
||||
public string? Action { get; set; }
|
||||
|
||||
[YamlMember(Alias = "suboperation")]
|
||||
public string? Suboperation { get; set; }
|
||||
|
||||
[YamlMember(Alias = "request_action")]
|
||||
public string? RequestAction { get; set; }
|
||||
|
||||
[YamlMember(Alias = "data_action")]
|
||||
public string? DataAction { get; set; }
|
||||
|
||||
[YamlMember(Alias = "principal_id")]
|
||||
public string? PrincipalId { get; set; }
|
||||
|
||||
[YamlMember(Alias = "principal_type")]
|
||||
public string? PrincipalType { get; set; }
|
||||
|
||||
[YamlMember(Alias = "resource_id")]
|
||||
public string? ResourceId { get; set; }
|
||||
|
||||
[YamlMember(Alias = "resource_type")]
|
||||
public string? ResourceType { get; set; }
|
||||
|
||||
[YamlMember(Alias = "resource_scope")]
|
||||
public string? ResourceScope { get; set; }
|
||||
|
||||
[YamlMember(Alias = "request_attributes")]
|
||||
public object? RequestAttributes { get; set; }
|
||||
|
||||
[YamlMember(Alias = "resource_attributes")]
|
||||
public object? ResourceAttributes { get; set; }
|
||||
|
||||
[YamlMember(Alias = "principal_custom_security_attributes")]
|
||||
public object? PrincipalCustomSecurityAttributes { get; set; }
|
||||
|
||||
[YamlMember(Alias = "environment")]
|
||||
public EvalEnvironmentOverrides? Environment { get; set; }
|
||||
}
|
||||
|
||||
private sealed class EvalEnvironmentOverrides
|
||||
{
|
||||
[YamlMember(Alias = "is_private_link")]
|
||||
public bool? IsPrivateLink { get; set; }
|
||||
|
||||
[YamlMember(Alias = "private_endpoint")]
|
||||
public string? PrivateEndpoint { get; set; }
|
||||
|
||||
[YamlMember(Alias = "subnet")]
|
||||
public string? Subnet { get; set; }
|
||||
|
||||
[YamlMember(Alias = "utc_now")]
|
||||
public string? UtcNow { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,14 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MSTest" />
|
||||
<PackageReference Include="YamlDotNet" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Regorus" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="../../../src/languages/azure_rbac/test_cases/*.yaml" Link="test_cases/%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -492,6 +492,16 @@ namespace Regorus.Internal
|
||||
|
||||
#endregion
|
||||
|
||||
#region RBAC Methods
|
||||
|
||||
/// <summary>
|
||||
/// Evaluate an Azure RBAC condition expression against a JSON evaluation context.
|
||||
/// </summary>
|
||||
[DllImport(LibraryName, EntryPoint = "regorus_rbac_engine_eval_condition", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
|
||||
internal static extern RegorusResult regorus_rbac_engine_eval_condition(byte* condition, byte* context_json);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Target Registry Methods
|
||||
|
||||
/// <summary>
|
||||
|
||||
45
bindings/csharp/Regorus/RbacEngine.cs
Normal file
45
bindings/csharp/Regorus/RbacEngine.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
using System;
|
||||
using Regorus.Internal;
|
||||
|
||||
#nullable enable
|
||||
namespace Regorus
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides helpers for evaluating Azure RBAC condition expressions.
|
||||
/// </summary>
|
||||
public static unsafe class RbacEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluate an Azure RBAC condition expression against a JSON evaluation context.
|
||||
/// </summary>
|
||||
/// <param name="condition">Azure RBAC condition expression.</param>
|
||||
/// <param name="contextJson">JSON encoded EvaluationContext.</param>
|
||||
/// <returns>True if the condition evaluates to true; otherwise false.</returns>
|
||||
/// <exception cref="Exception">Thrown when evaluation fails.</exception>
|
||||
public static bool EvaluateCondition(string condition, string contextJson)
|
||||
{
|
||||
if (condition is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(condition));
|
||||
}
|
||||
|
||||
if (contextJson is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(contextJson));
|
||||
}
|
||||
|
||||
return Utf8Marshaller.WithUtf8(condition, conditionPtr =>
|
||||
Utf8Marshaller.WithUtf8(contextJson, contextPtr =>
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
var result = Internal.API.regorus_rbac_engine_eval_condition((byte*)conditionPtr, (byte*)contextPtr);
|
||||
return ResultHelpers.GetBoolResult(result);
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -14,6 +14,8 @@ mod engine;
|
||||
mod limits;
|
||||
mod lock;
|
||||
mod panic_guard;
|
||||
#[cfg(feature = "rbac")]
|
||||
mod rbac;
|
||||
#[cfg(feature = "rvm")]
|
||||
pub(crate) mod rvm;
|
||||
mod schema_registry;
|
||||
|
||||
59
bindings/ffi/src/rbac.rs
Normal file
59
bindings/ffi/src/rbac.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
use crate::common::{from_c_str, RegorusResult, RegorusStatus};
|
||||
use crate::panic_guard::with_unwind_guard;
|
||||
use alloc::format;
|
||||
use core::ffi::c_char;
|
||||
|
||||
use regorus::languages::azure_rbac::ast::EvaluationContext;
|
||||
use regorus::languages::azure_rbac::interpreter::ConditionInterpreter;
|
||||
|
||||
#[no_mangle]
|
||||
/// Evaluate an Azure RBAC condition expression against a JSON evaluation context.
|
||||
///
|
||||
/// * `condition`: RBAC condition string.
|
||||
/// * `context_json`: JSON representation of EvaluationContext.
|
||||
pub extern "C" fn regorus_rbac_engine_eval_condition(
|
||||
condition: *const c_char,
|
||||
context_json: *const c_char,
|
||||
) -> RegorusResult {
|
||||
with_unwind_guard(|| {
|
||||
let condition = match from_c_str(condition) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
return RegorusResult::err_with_message(
|
||||
RegorusStatus::InvalidArgument,
|
||||
format!("{err}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
let context_json = match from_c_str(context_json) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
return RegorusResult::err_with_message(
|
||||
RegorusStatus::InvalidArgument,
|
||||
format!("{err}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
let context: EvaluationContext = match serde_json::from_str(&context_json) {
|
||||
Ok(context) => context,
|
||||
Err(err) => {
|
||||
return RegorusResult::err_with_message(
|
||||
RegorusStatus::InvalidDataFormat,
|
||||
format!("invalid context json: {err}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let interpreter = ConditionInterpreter::new(&context);
|
||||
match interpreter.evaluate_str(&condition) {
|
||||
Ok(result) => RegorusResult::ok_bool(result),
|
||||
Err(err) => RegorusResult::err_with_message(
|
||||
RegorusStatus::Error,
|
||||
format!("condition evaluation failed: {err}"),
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -10,9 +10,10 @@ use serde::{Deserialize, Serialize};
|
||||
use super::literals::{
|
||||
BooleanLiteral, DateTimeLiteral, NullLiteral, NumberLiteral, StringLiteral, TimeLiteral,
|
||||
};
|
||||
use super::operators::{ArrayOperator, ConditionOperator};
|
||||
use super::operators::ArrayOperator;
|
||||
use super::references::AttributeReference;
|
||||
use super::span::EmptySpan;
|
||||
use crate::languages::azure_rbac::builtins::RbacBuiltin;
|
||||
|
||||
/// ABAC condition expression
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -104,7 +105,7 @@ pub enum UnaryOperator {
|
||||
pub struct BinaryExpression {
|
||||
#[serde(skip)]
|
||||
pub span: EmptySpan,
|
||||
pub operator: ConditionOperator,
|
||||
pub operator: RbacBuiltin,
|
||||
pub left: Box<ConditionExpr>,
|
||||
pub right: Box<ConditionExpr>,
|
||||
}
|
||||
|
||||
@@ -12,197 +12,3 @@ pub struct ArrayOperator {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub modifier: Option<String>,
|
||||
}
|
||||
|
||||
/// Condition operator for Azure RBAC expressions
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ConditionOperator {
|
||||
// String operators
|
||||
StringEquals,
|
||||
StringNotEquals,
|
||||
StringEqualsIgnoreCase,
|
||||
StringNotEqualsIgnoreCase,
|
||||
StringLike,
|
||||
StringNotLike,
|
||||
StringStartsWith,
|
||||
StringNotStartsWith,
|
||||
StringEndsWith,
|
||||
StringNotEndsWith,
|
||||
StringContains,
|
||||
StringNotContains,
|
||||
StringMatches,
|
||||
StringNotMatches,
|
||||
|
||||
// Numeric operators
|
||||
NumericEquals,
|
||||
NumericNotEquals,
|
||||
NumericLessThan,
|
||||
NumericLessThanEquals,
|
||||
NumericGreaterThan,
|
||||
NumericGreaterThanEquals,
|
||||
NumericInRange,
|
||||
|
||||
// Boolean operators
|
||||
BoolEquals,
|
||||
BoolNotEquals,
|
||||
|
||||
// DateTime operators
|
||||
DateTimeEquals,
|
||||
DateTimeNotEquals,
|
||||
DateTimeGreaterThan,
|
||||
DateTimeGreaterThanEquals,
|
||||
DateTimeLessThan,
|
||||
DateTimeLessThanEquals,
|
||||
|
||||
// Time operators
|
||||
TimeOfDayEquals,
|
||||
TimeOfDayNotEquals,
|
||||
TimeOfDayGreaterThan,
|
||||
TimeOfDayGreaterThanEquals,
|
||||
TimeOfDayLessThan,
|
||||
TimeOfDayLessThanEquals,
|
||||
TimeOfDayInRange,
|
||||
|
||||
// GUID operators
|
||||
GuidEquals,
|
||||
GuidNotEquals,
|
||||
|
||||
// IP operators
|
||||
IpMatch,
|
||||
IpNotMatch,
|
||||
IpInRange,
|
||||
|
||||
// List operators
|
||||
ListContains,
|
||||
ListNotContains,
|
||||
|
||||
// Array quantifier operators
|
||||
ForAnyOfAnyValues,
|
||||
ForAllOfAnyValues,
|
||||
ForAnyOfAllValues,
|
||||
ForAllOfAllValues,
|
||||
|
||||
// Action operators
|
||||
ActionMatches,
|
||||
SubOperationMatches,
|
||||
}
|
||||
|
||||
impl ConditionOperator {
|
||||
/// Parse a condition operator from string identifier
|
||||
pub fn from_name(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"StringEquals" => Some(Self::StringEquals),
|
||||
"StringNotEquals" => Some(Self::StringNotEquals),
|
||||
"StringEqualsIgnoreCase" => Some(Self::StringEqualsIgnoreCase),
|
||||
"StringNotEqualsIgnoreCase" => Some(Self::StringNotEqualsIgnoreCase),
|
||||
"StringLike" => Some(Self::StringLike),
|
||||
"StringNotLike" => Some(Self::StringNotLike),
|
||||
"StringStartsWith" => Some(Self::StringStartsWith),
|
||||
"StringNotStartsWith" => Some(Self::StringNotStartsWith),
|
||||
"StringEndsWith" => Some(Self::StringEndsWith),
|
||||
"StringNotEndsWith" => Some(Self::StringNotEndsWith),
|
||||
"StringContains" => Some(Self::StringContains),
|
||||
"StringNotContains" => Some(Self::StringNotContains),
|
||||
"StringMatches" => Some(Self::StringMatches),
|
||||
"StringNotMatches" => Some(Self::StringNotMatches),
|
||||
"NumericEquals" => Some(Self::NumericEquals),
|
||||
"NumericNotEquals" => Some(Self::NumericNotEquals),
|
||||
"NumericLessThan" => Some(Self::NumericLessThan),
|
||||
"NumericLessThanEquals" => Some(Self::NumericLessThanEquals),
|
||||
"NumericGreaterThan" => Some(Self::NumericGreaterThan),
|
||||
"NumericGreaterThanEquals" => Some(Self::NumericGreaterThanEquals),
|
||||
"NumericInRange" => Some(Self::NumericInRange),
|
||||
"BoolEquals" => Some(Self::BoolEquals),
|
||||
"BoolNotEquals" => Some(Self::BoolNotEquals),
|
||||
"DateTimeEquals" => Some(Self::DateTimeEquals),
|
||||
"DateTimeNotEquals" => Some(Self::DateTimeNotEquals),
|
||||
"DateTimeGreaterThan" => Some(Self::DateTimeGreaterThan),
|
||||
"DateTimeGreaterThanEquals" => Some(Self::DateTimeGreaterThanEquals),
|
||||
"DateTimeLessThan" => Some(Self::DateTimeLessThan),
|
||||
"DateTimeLessThanEquals" => Some(Self::DateTimeLessThanEquals),
|
||||
"TimeOfDayEquals" => Some(Self::TimeOfDayEquals),
|
||||
"TimeOfDayNotEquals" => Some(Self::TimeOfDayNotEquals),
|
||||
"TimeOfDayGreaterThan" => Some(Self::TimeOfDayGreaterThan),
|
||||
"TimeOfDayGreaterThanEquals" => Some(Self::TimeOfDayGreaterThanEquals),
|
||||
"TimeOfDayLessThan" => Some(Self::TimeOfDayLessThan),
|
||||
"TimeOfDayLessThanEquals" => Some(Self::TimeOfDayLessThanEquals),
|
||||
"TimeOfDayInRange" => Some(Self::TimeOfDayInRange),
|
||||
"GuidEquals" => Some(Self::GuidEquals),
|
||||
"GuidNotEquals" => Some(Self::GuidNotEquals),
|
||||
"IpMatch" => Some(Self::IpMatch),
|
||||
"IpNotMatch" => Some(Self::IpNotMatch),
|
||||
"IpInRange" => Some(Self::IpInRange),
|
||||
"ListContains" => Some(Self::ListContains),
|
||||
"ListNotContains" => Some(Self::ListNotContains),
|
||||
"ForAnyOfAnyValues" => Some(Self::ForAnyOfAnyValues),
|
||||
"ForAllOfAnyValues" => Some(Self::ForAllOfAnyValues),
|
||||
"ForAnyOfAllValues" => Some(Self::ForAnyOfAllValues),
|
||||
"ForAllOfAllValues" => Some(Self::ForAllOfAllValues),
|
||||
"ActionMatches" => Some(Self::ActionMatches),
|
||||
"SubOperationMatches" => Some(Self::SubOperationMatches),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert condition operator to string
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::StringEquals => "StringEquals",
|
||||
Self::StringNotEquals => "StringNotEquals",
|
||||
Self::StringEqualsIgnoreCase => "StringEqualsIgnoreCase",
|
||||
Self::StringNotEqualsIgnoreCase => "StringNotEqualsIgnoreCase",
|
||||
Self::StringLike => "StringLike",
|
||||
Self::StringNotLike => "StringNotLike",
|
||||
Self::StringStartsWith => "StringStartsWith",
|
||||
Self::StringNotStartsWith => "StringNotStartsWith",
|
||||
Self::StringEndsWith => "StringEndsWith",
|
||||
Self::StringNotEndsWith => "StringNotEndsWith",
|
||||
Self::StringContains => "StringContains",
|
||||
Self::StringNotContains => "StringNotContains",
|
||||
Self::StringMatches => "StringMatches",
|
||||
Self::StringNotMatches => "StringNotMatches",
|
||||
Self::NumericEquals => "NumericEquals",
|
||||
Self::NumericNotEquals => "NumericNotEquals",
|
||||
Self::NumericLessThan => "NumericLessThan",
|
||||
Self::NumericLessThanEquals => "NumericLessThanEquals",
|
||||
Self::NumericGreaterThan => "NumericGreaterThan",
|
||||
Self::NumericGreaterThanEquals => "NumericGreaterThanEquals",
|
||||
Self::NumericInRange => "NumericInRange",
|
||||
Self::BoolEquals => "BoolEquals",
|
||||
Self::BoolNotEquals => "BoolNotEquals",
|
||||
Self::DateTimeEquals => "DateTimeEquals",
|
||||
Self::DateTimeNotEquals => "DateTimeNotEquals",
|
||||
Self::DateTimeGreaterThan => "DateTimeGreaterThan",
|
||||
Self::DateTimeGreaterThanEquals => "DateTimeGreaterThanEquals",
|
||||
Self::DateTimeLessThan => "DateTimeLessThan",
|
||||
Self::DateTimeLessThanEquals => "DateTimeLessThanEquals",
|
||||
Self::TimeOfDayEquals => "TimeOfDayEquals",
|
||||
Self::TimeOfDayNotEquals => "TimeOfDayNotEquals",
|
||||
Self::TimeOfDayGreaterThan => "TimeOfDayGreaterThan",
|
||||
Self::TimeOfDayGreaterThanEquals => "TimeOfDayGreaterThanEquals",
|
||||
Self::TimeOfDayLessThan => "TimeOfDayLessThan",
|
||||
Self::TimeOfDayLessThanEquals => "TimeOfDayLessThanEquals",
|
||||
Self::TimeOfDayInRange => "TimeOfDayInRange",
|
||||
Self::GuidEquals => "GuidEquals",
|
||||
Self::GuidNotEquals => "GuidNotEquals",
|
||||
Self::IpMatch => "IpMatch",
|
||||
Self::IpNotMatch => "IpNotMatch",
|
||||
Self::IpInRange => "IpInRange",
|
||||
Self::ListContains => "ListContains",
|
||||
Self::ListNotContains => "ListNotContains",
|
||||
Self::ForAnyOfAnyValues => "ForAnyOfAnyValues",
|
||||
Self::ForAllOfAnyValues => "ForAllOfAnyValues",
|
||||
Self::ForAnyOfAllValues => "ForAnyOfAllValues",
|
||||
Self::ForAllOfAllValues => "ForAllOfAllValues",
|
||||
Self::ActionMatches => "ActionMatches",
|
||||
Self::SubOperationMatches => "SubOperationMatches",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl core::str::FromStr for ConditionOperator {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Self::from_name(s).ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
41
src/languages/azure_rbac/builtins/actions.rs
Normal file
41
src/languages/azure_rbac/builtins/actions.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
// Match action strings against slash-delimited patterns with "*" wildcards.
|
||||
//
|
||||
// Behavior examples:
|
||||
// - action: "Microsoft.Storage/storageAccounts/read"
|
||||
// pattern: "Microsoft.Storage/storageAccounts/read" => true
|
||||
// - action: "Microsoft.Storage/storageAccounts/read"
|
||||
// pattern: "Microsoft.Storage/storageAccounts/*" => true
|
||||
// - action: "Microsoft.Storage/storageAccounts/read"
|
||||
// pattern: "Microsoft.Storage/*/read" => true
|
||||
// - action: "Microsoft.Storage/storageAccounts/read"
|
||||
// pattern: "Microsoft.Storage/*/write" => false
|
||||
// - action: "Microsoft.Storage/storageAccounts/read"
|
||||
// pattern: "Microsoft.Storage/storageAccounts" => false (segment count mismatch)
|
||||
pub(super) fn action_matches_pattern(action: &str, pattern: &str) -> bool {
|
||||
// Split action and pattern into fixed segments (delimited by '/').
|
||||
let action_parts: alloc::vec::Vec<&str> = action.split('/').collect();
|
||||
let pattern_parts: alloc::vec::Vec<&str> = pattern.split('/').collect();
|
||||
|
||||
// Require the same number of segments for a match.
|
||||
if action_parts.len() != pattern_parts.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compare segment-by-segment; "*" matches any single segment.
|
||||
for (action_part, pattern_part) in action_parts.iter().zip(pattern_parts.iter()) {
|
||||
if *pattern_part == "*" {
|
||||
// Wildcard segment matches anything in the corresponding position.
|
||||
continue;
|
||||
}
|
||||
if action_part != pattern_part {
|
||||
// Non-wildcard segment must match exactly.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// All segments matched.
|
||||
true
|
||||
}
|
||||
20
src/languages/azure_rbac/builtins/bools.rs
Normal file
20
src/languages/azure_rbac/builtins/bools.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
use crate::value::Value;
|
||||
|
||||
use super::common;
|
||||
use super::evaluator::RbacBuiltinError;
|
||||
|
||||
// Compare two boolean values for equality.
|
||||
pub(super) fn bool_equals(
|
||||
left: &Value,
|
||||
right: &Value,
|
||||
name: &'static str,
|
||||
) -> Result<bool, RbacBuiltinError> {
|
||||
// Convert both sides to booleans using shared validation.
|
||||
let lhs = common::value_as_bool(left, name)?;
|
||||
let rhs = common::value_as_bool(right, name)?;
|
||||
// Exact boolean equality.
|
||||
Ok(lhs == rhs)
|
||||
}
|
||||
218
src/languages/azure_rbac/builtins/common.rs
Normal file
218
src/languages/azure_rbac/builtins/common.rs
Normal file
@@ -0,0 +1,218 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
use crate::value::Value;
|
||||
use alloc::format;
|
||||
use alloc::string::ToString as _;
|
||||
|
||||
use super::evaluator::RbacBuiltinError;
|
||||
|
||||
// Ensure the value is a string and return a borrowed &str.
|
||||
pub(super) fn ensure_string<'a>(
|
||||
value: &'a Value,
|
||||
name: &'static str,
|
||||
) -> Result<&'a str, RbacBuiltinError> {
|
||||
match *value {
|
||||
// Borrow string contents without allocation.
|
||||
Value::String(ref s) => Ok(s.as_ref()),
|
||||
_ => Err(RbacBuiltinError::new(format!(
|
||||
"{} expects string arguments",
|
||||
name
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
// Extract a boolean value or return a typed builtin error.
|
||||
pub(super) fn value_as_bool(value: &Value, name: &'static str) -> Result<bool, RbacBuiltinError> {
|
||||
match *value {
|
||||
Value::Bool(b) => Ok(b),
|
||||
_ => Err(RbacBuiltinError::new(format!(
|
||||
"{} expects boolean arguments",
|
||||
name
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
// Convert a value into a string for builtin comparisons.
|
||||
// Accepts string values or numbers formatted as decimal text.
|
||||
pub(super) fn value_as_string(
|
||||
value: &Value,
|
||||
name: &'static str,
|
||||
) -> Result<alloc::string::String, RbacBuiltinError> {
|
||||
match *value {
|
||||
// Preserve strings verbatim and render numbers as decimals.
|
||||
Value::String(ref s) => Ok(s.as_ref().to_string()),
|
||||
Value::Number(ref n) => Ok(n.format_decimal()),
|
||||
_ => Err(RbacBuiltinError::new(format!(
|
||||
"{} expects string arguments",
|
||||
name
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
// Parse a value into an i64 for integer-based operations.
|
||||
// Accepts integral numeric values or numeric strings.
|
||||
pub(super) fn value_as_i64(value: &Value, name: &'static str) -> Result<i64, RbacBuiltinError> {
|
||||
match *value {
|
||||
// Accept only integral values for numeric builtins.
|
||||
Value::Number(ref n) => n
|
||||
.as_i64()
|
||||
.ok_or_else(|| RbacBuiltinError::new(format!("{} expects numeric arguments", name))),
|
||||
Value::String(ref s) => s
|
||||
.parse::<i64>()
|
||||
.map_err(|_| RbacBuiltinError::new(format!("{} expects numeric arguments", name))),
|
||||
_ => Err(RbacBuiltinError::new(format!(
|
||||
"{} expects numeric arguments",
|
||||
name
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
// Parse a two-element array or set into numeric bounds (start, end).
|
||||
pub(super) fn range_bounds(
|
||||
value: &Value,
|
||||
name: &'static str,
|
||||
) -> Result<(i64, i64), RbacBuiltinError> {
|
||||
match *value {
|
||||
Value::Array(ref list) => {
|
||||
// Require exactly two elements for range bounds.
|
||||
let mut iter = list.iter();
|
||||
let start = iter.next().ok_or_else(|| {
|
||||
RbacBuiltinError::new(format!("{} expects a 2-element list or set", name))
|
||||
})?;
|
||||
let end = iter.next().ok_or_else(|| {
|
||||
RbacBuiltinError::new(format!("{} expects a 2-element list or set", name))
|
||||
})?;
|
||||
if iter.next().is_some() {
|
||||
return Err(RbacBuiltinError::new(format!(
|
||||
"{} expects a 2-element list or set",
|
||||
name
|
||||
)));
|
||||
}
|
||||
Ok((value_as_i64(start, name)?, value_as_i64(end, name)?))
|
||||
}
|
||||
Value::Set(ref set) => {
|
||||
// Sets must contain exactly two elements (order is arbitrary).
|
||||
let mut iter = set.iter();
|
||||
let start = iter.next().ok_or_else(|| {
|
||||
RbacBuiltinError::new(format!("{} expects a 2-element list or set", name))
|
||||
})?;
|
||||
let end = iter.next().ok_or_else(|| {
|
||||
RbacBuiltinError::new(format!("{} expects a 2-element list or set", name))
|
||||
})?;
|
||||
if iter.next().is_some() {
|
||||
return Err(RbacBuiltinError::new(format!(
|
||||
"{} expects a 2-element list or set",
|
||||
name
|
||||
)));
|
||||
}
|
||||
Ok((value_as_i64(start, name)?, value_as_i64(end, name)?))
|
||||
}
|
||||
_ => Err(RbacBuiltinError::new(format!(
|
||||
"{} expects a 2-element list or set",
|
||||
name
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
// Parse HH:MM[:SS] into seconds since midnight.
|
||||
pub(super) fn parse_time_of_day(value: &str) -> Result<u32, RbacBuiltinError> {
|
||||
// Parse HH:MM[:SS] into numeric components.
|
||||
let mut iter = value.split(':');
|
||||
let hours_str = iter
|
||||
.next()
|
||||
.ok_or_else(|| RbacBuiltinError::new("Invalid time format, expected HH:MM or HH:MM:SS"))?;
|
||||
let minutes_str = iter
|
||||
.next()
|
||||
.ok_or_else(|| RbacBuiltinError::new("Invalid time format, expected HH:MM or HH:MM:SS"))?;
|
||||
let seconds_str = iter.next();
|
||||
if iter.next().is_some() {
|
||||
return Err(RbacBuiltinError::new(
|
||||
"Invalid time format, expected HH:MM or HH:MM:SS",
|
||||
));
|
||||
}
|
||||
|
||||
let hours = hours_str
|
||||
.parse::<u32>()
|
||||
.map_err(|_| RbacBuiltinError::new("Invalid hour value"))?;
|
||||
let minutes = minutes_str
|
||||
.parse::<u32>()
|
||||
.map_err(|_| RbacBuiltinError::new("Invalid minute value"))?;
|
||||
let seconds = match seconds_str {
|
||||
Some(segment) => segment
|
||||
.parse::<u32>()
|
||||
.map_err(|_| RbacBuiltinError::new("Invalid second value"))?,
|
||||
None => 0,
|
||||
};
|
||||
|
||||
if hours > 23 || minutes > 59 || seconds > 59 {
|
||||
return Err(RbacBuiltinError::new("Time value out of range"));
|
||||
}
|
||||
|
||||
// Convert to seconds since midnight with overflow checks.
|
||||
let total = hours
|
||||
.checked_mul(3600)
|
||||
.and_then(|total| total.checked_add(minutes.checked_mul(60)?))
|
||||
.and_then(|total| total.checked_add(seconds))
|
||||
.ok_or_else(|| RbacBuiltinError::new("Time value out of range"))?;
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
// Match a value against a simple '*' wildcard pattern.
|
||||
pub(super) fn simple_wildcard_match(pattern: &str, value: &str) -> bool {
|
||||
// Recursive matcher supporting '*' as multi-character wildcard.
|
||||
fn helper(pattern: &[u8], value: &[u8]) -> bool {
|
||||
match pattern.split_first() {
|
||||
None => value.is_empty(),
|
||||
Some((&b'*', rest)) => {
|
||||
if helper(rest, value) {
|
||||
return true;
|
||||
}
|
||||
match value.split_first() {
|
||||
None => false,
|
||||
Some((_first, rest_value)) => helper(pattern, rest_value),
|
||||
}
|
||||
}
|
||||
Some((byte, rest)) => match value.split_first() {
|
||||
Some((value_byte, rest_value)) if *byte == *value_byte => helper(rest, rest_value),
|
||||
_ => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
helper(pattern.as_bytes(), value.as_bytes())
|
||||
}
|
||||
|
||||
// Return true if the value is an array or set.
|
||||
pub(super) const fn is_collection(value: &Value) -> bool {
|
||||
matches!(value, Value::Array(_) | Value::Set(_))
|
||||
}
|
||||
|
||||
// Check membership in an array or set.
|
||||
pub(super) fn collection_contains(collection: &Value, needle: &Value) -> bool {
|
||||
match *collection {
|
||||
Value::Array(ref list) => list.contains(needle),
|
||||
Value::Set(ref set) => set.contains(needle),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
// Case-insensitive membership check for string elements in arrays or sets.
|
||||
pub(super) fn collection_contains_case_insensitive(collection: &Value, needle: &Value) -> bool {
|
||||
let needle_str = match *needle {
|
||||
Value::String(ref s) => s.to_ascii_lowercase(),
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
match *collection {
|
||||
Value::Array(ref list) => list.iter().any(|value| match *value {
|
||||
Value::String(ref s) => s.to_ascii_lowercase() == needle_str,
|
||||
_ => false,
|
||||
}),
|
||||
Value::Set(ref set) => set.iter().any(|value| match *value {
|
||||
Value::String(ref s) => s.to_ascii_lowercase() == needle_str,
|
||||
_ => false,
|
||||
}),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
64
src/languages/azure_rbac/builtins/datetime.rs
Normal file
64
src/languages/azure_rbac/builtins/datetime.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
use crate::value::Value;
|
||||
use alloc::string::ToString as _;
|
||||
|
||||
use super::common;
|
||||
use super::evaluator::RbacBuiltinError;
|
||||
|
||||
#[cfg(feature = "time")]
|
||||
use chrono::DateTime;
|
||||
|
||||
// Compare RFC3339 timestamps using the requested operator.
|
||||
//
|
||||
// Interesting examples:
|
||||
// - left: @Request[date] = "2023-05-01T12:00:00Z",
|
||||
// right: @Environment[utcNow] = "2023-05-01T11:59:59Z",
|
||||
// op: ">" => true
|
||||
// - left: @Resource[expiry] = "2024-01-01T00:00:00Z",
|
||||
// right: @Environment[utcNow] = "2024-01-01T00:00:00Z",
|
||||
// op: "==" => true
|
||||
// - left: @Request[scheduled] = "2023-12-31T23:59:59-08:00",
|
||||
// right: @Environment[utcNow] = "2024-01-01T07:59:59Z",
|
||||
// op: "==" => true (same instant, different offsets)
|
||||
pub(super) fn datetime_compare(
|
||||
left: &Value,
|
||||
right: &Value,
|
||||
op: &str,
|
||||
name: &'static str,
|
||||
) -> Result<bool, RbacBuiltinError> {
|
||||
// Parse both operands as RFC3339 strings.
|
||||
let lhs = common::value_as_string(left, name)?;
|
||||
let rhs = common::value_as_string(right, name)?;
|
||||
|
||||
#[cfg(feature = "time")]
|
||||
{
|
||||
// Compare instants using chrono's RFC3339 parsing.
|
||||
let left_dt = DateTime::parse_from_rfc3339(&lhs)
|
||||
.map_err(|err| RbacBuiltinError::new(err.to_string()))?;
|
||||
let right_dt = DateTime::parse_from_rfc3339(&rhs)
|
||||
.map_err(|err| RbacBuiltinError::new(err.to_string()))?;
|
||||
Ok(match op {
|
||||
"==" => left_dt == right_dt,
|
||||
"<" => left_dt < right_dt,
|
||||
"<=" => left_dt <= right_dt,
|
||||
">" => left_dt > right_dt,
|
||||
">=" => left_dt >= right_dt,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "time"))]
|
||||
{
|
||||
// Without the time feature, compare lexicographically as strings.
|
||||
Ok(match op {
|
||||
"==" => lhs == rhs,
|
||||
"<" => lhs < rhs,
|
||||
"<=" => lhs <= rhs,
|
||||
">" => lhs > rhs,
|
||||
">=" => lhs >= rhs,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
}
|
||||
461
src/languages/azure_rbac/builtins/evaluator.rs
Normal file
461
src/languages/azure_rbac/builtins/evaluator.rs
Normal file
@@ -0,0 +1,461 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
#![allow(clippy::indexing_slicing)]
|
||||
|
||||
use crate::value::Value;
|
||||
use alloc::format;
|
||||
|
||||
use super::super::ast::context::EvaluationContext;
|
||||
use super::bools;
|
||||
use super::datetime;
|
||||
use super::functions;
|
||||
use super::ids::RbacBuiltin;
|
||||
use super::ip;
|
||||
use super::lists;
|
||||
use super::numbers;
|
||||
use super::quantifiers;
|
||||
use super::strings;
|
||||
use super::time_of_day;
|
||||
|
||||
/// Builtin evaluation context.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct RbacBuiltinContext<'a> {
|
||||
pub evaluation: &'a EvaluationContext,
|
||||
}
|
||||
|
||||
impl<'a> RbacBuiltinContext<'a> {
|
||||
pub const fn new(evaluation: &'a EvaluationContext) -> Self {
|
||||
Self { evaluation }
|
||||
}
|
||||
}
|
||||
|
||||
/// Builtin evaluation error.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RbacBuiltinError {
|
||||
message: alloc::string::String,
|
||||
}
|
||||
|
||||
impl RbacBuiltinError {
|
||||
pub fn new(message: impl Into<alloc::string::String>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl core::fmt::Display for RbacBuiltinError {
|
||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||
write!(f, "{}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
/// Default RBAC builtin evaluator implementation.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct DefaultRbacBuiltinEvaluator;
|
||||
|
||||
impl DefaultRbacBuiltinEvaluator {
|
||||
pub const fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
fn ensure_arg_count(
|
||||
builtin: RbacBuiltin,
|
||||
args: &[Value],
|
||||
expected: usize,
|
||||
) -> Result<(), RbacBuiltinError> {
|
||||
if args.len() == expected {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(RbacBuiltinError::new(format!(
|
||||
"{} expects {} arguments",
|
||||
builtin.name(),
|
||||
expected
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn arg1(builtin: RbacBuiltin, args: &[Value]) -> Result<&Value, RbacBuiltinError> {
|
||||
if args.len() != 1 {
|
||||
return Err(RbacBuiltinError::new(format!(
|
||||
"{} expects 1 argument",
|
||||
builtin.name()
|
||||
)));
|
||||
}
|
||||
args.first()
|
||||
.ok_or_else(|| RbacBuiltinError::new(format!("{} expects 1 argument", builtin.name())))
|
||||
}
|
||||
|
||||
fn arg2(builtin: RbacBuiltin, args: &[Value]) -> Result<(&Value, &Value), RbacBuiltinError> {
|
||||
if args.len() != 2 {
|
||||
return Err(RbacBuiltinError::new(format!(
|
||||
"{} expects 2 arguments",
|
||||
builtin.name()
|
||||
)));
|
||||
}
|
||||
let left = args.first().ok_or_else(|| {
|
||||
RbacBuiltinError::new(format!("{} expects 2 arguments", builtin.name()))
|
||||
})?;
|
||||
let right = args.get(1).ok_or_else(|| {
|
||||
RbacBuiltinError::new(format!("{} expects 2 arguments", builtin.name()))
|
||||
})?;
|
||||
Ok((left, right))
|
||||
}
|
||||
pub fn eval(
|
||||
&self,
|
||||
builtin: RbacBuiltin,
|
||||
args: &[Value],
|
||||
ctx: &RbacBuiltinContext<'_>,
|
||||
) -> Result<Value, RbacBuiltinError> {
|
||||
match builtin {
|
||||
RbacBuiltin::StringEquals => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(strings::string_equals(&args[0], &args[1])?))
|
||||
}
|
||||
RbacBuiltin::StringNotEquals => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(!strings::string_equals(&args[0], &args[1])?))
|
||||
}
|
||||
RbacBuiltin::StringEqualsIgnoreCase => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(strings::string_equals_ignore_case(
|
||||
&args[0], &args[1],
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::StringNotEqualsIgnoreCase => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(!strings::string_equals_ignore_case(
|
||||
&args[0], &args[1],
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::StringLike => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(strings::string_like(&args[0], &args[1])?))
|
||||
}
|
||||
RbacBuiltin::StringNotLike => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(!strings::string_like(&args[0], &args[1])?))
|
||||
}
|
||||
RbacBuiltin::StringStartsWith => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(strings::string_starts_with(
|
||||
&args[0], &args[1],
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::StringNotStartsWith => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(!strings::string_starts_with(
|
||||
&args[0], &args[1],
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::StringEndsWith => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(strings::string_ends_with(&args[0], &args[1])?))
|
||||
}
|
||||
RbacBuiltin::StringNotEndsWith => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(!strings::string_ends_with(&args[0], &args[1])?))
|
||||
}
|
||||
RbacBuiltin::StringContains => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(strings::string_contains(&args[0], &args[1])?))
|
||||
}
|
||||
RbacBuiltin::StringNotContains => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(!strings::string_contains(&args[0], &args[1])?))
|
||||
}
|
||||
RbacBuiltin::StringMatches => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(strings::string_matches(&args[0], &args[1])?))
|
||||
}
|
||||
RbacBuiltin::StringNotMatches => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(!strings::string_matches(&args[0], &args[1])?))
|
||||
}
|
||||
RbacBuiltin::NumericEquals => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(numbers::numeric_compare(
|
||||
&args[0],
|
||||
&args[1],
|
||||
"==",
|
||||
builtin.name(),
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::NumericNotEquals => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(numbers::numeric_compare(
|
||||
&args[0],
|
||||
&args[1],
|
||||
"!=",
|
||||
builtin.name(),
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::NumericLessThan => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(numbers::numeric_compare(
|
||||
&args[0],
|
||||
&args[1],
|
||||
"<",
|
||||
builtin.name(),
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::NumericLessThanEquals => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(numbers::numeric_compare(
|
||||
&args[0],
|
||||
&args[1],
|
||||
"<=",
|
||||
builtin.name(),
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::NumericGreaterThan => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(numbers::numeric_compare(
|
||||
&args[0],
|
||||
&args[1],
|
||||
">",
|
||||
builtin.name(),
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::NumericGreaterThanEquals => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(numbers::numeric_compare(
|
||||
&args[0],
|
||||
&args[1],
|
||||
">=",
|
||||
builtin.name(),
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::NumericInRange => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(numbers::numeric_in_range(
|
||||
&args[0],
|
||||
&args[1],
|
||||
builtin.name(),
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::BoolEquals => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(bools::bool_equals(
|
||||
&args[0],
|
||||
&args[1],
|
||||
builtin.name(),
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::BoolNotEquals => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(!bools::bool_equals(
|
||||
&args[0],
|
||||
&args[1],
|
||||
builtin.name(),
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::DateTimeEquals => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(datetime::datetime_compare(
|
||||
&args[0],
|
||||
&args[1],
|
||||
"==",
|
||||
builtin.name(),
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::DateTimeNotEquals => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(!datetime::datetime_compare(
|
||||
&args[0],
|
||||
&args[1],
|
||||
"==",
|
||||
builtin.name(),
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::DateTimeGreaterThan => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(datetime::datetime_compare(
|
||||
&args[0],
|
||||
&args[1],
|
||||
">",
|
||||
builtin.name(),
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::DateTimeGreaterThanEquals => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(datetime::datetime_compare(
|
||||
&args[0],
|
||||
&args[1],
|
||||
">=",
|
||||
builtin.name(),
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::DateTimeLessThan => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(datetime::datetime_compare(
|
||||
&args[0],
|
||||
&args[1],
|
||||
"<",
|
||||
builtin.name(),
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::DateTimeLessThanEquals => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(datetime::datetime_compare(
|
||||
&args[0],
|
||||
&args[1],
|
||||
"<=",
|
||||
builtin.name(),
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::TimeOfDayEquals => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(time_of_day::time_compare(
|
||||
&args[0],
|
||||
&args[1],
|
||||
"==",
|
||||
builtin.name(),
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::TimeOfDayNotEquals => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(!time_of_day::time_compare(
|
||||
&args[0],
|
||||
&args[1],
|
||||
"==",
|
||||
builtin.name(),
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::TimeOfDayGreaterThan => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(time_of_day::time_compare(
|
||||
&args[0],
|
||||
&args[1],
|
||||
">",
|
||||
builtin.name(),
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::TimeOfDayGreaterThanEquals => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(time_of_day::time_compare(
|
||||
&args[0],
|
||||
&args[1],
|
||||
">=",
|
||||
builtin.name(),
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::TimeOfDayLessThan => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(time_of_day::time_compare(
|
||||
&args[0],
|
||||
&args[1],
|
||||
"<",
|
||||
builtin.name(),
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::TimeOfDayLessThanEquals => {
|
||||
Self::ensure_arg_count(builtin, args, 2)?;
|
||||
Ok(Value::Bool(time_of_day::time_compare(
|
||||
&args[0],
|
||||
&args[1],
|
||||
"<=",
|
||||
builtin.name(),
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::TimeOfDayInRange => {
|
||||
let (left, right) = Self::arg2(builtin, args)?;
|
||||
Ok(Value::Bool(time_of_day::time_in_range(
|
||||
left,
|
||||
right,
|
||||
builtin.name(),
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::GuidEquals => {
|
||||
let (left, right) = Self::arg2(builtin, args)?;
|
||||
Ok(Value::Bool(strings::guid_equals(
|
||||
left,
|
||||
right,
|
||||
builtin.name(),
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::GuidNotEquals => {
|
||||
let (left, right) = Self::arg2(builtin, args)?;
|
||||
Ok(Value::Bool(!strings::guid_equals(
|
||||
left,
|
||||
right,
|
||||
builtin.name(),
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::IpMatch => {
|
||||
let (left, right) = Self::arg2(builtin, args)?;
|
||||
Ok(Value::Bool(ip::ip_match(left, right, builtin.name())?))
|
||||
}
|
||||
RbacBuiltin::IpNotMatch => {
|
||||
let (left, right) = Self::arg2(builtin, args)?;
|
||||
Ok(Value::Bool(!ip::ip_match(left, right, builtin.name())?))
|
||||
}
|
||||
RbacBuiltin::IpInRange => {
|
||||
let (left, right) = Self::arg2(builtin, args)?;
|
||||
Ok(Value::Bool(ip::ip_in_range(left, right, builtin.name())?))
|
||||
}
|
||||
RbacBuiltin::ListContains => {
|
||||
let (left, right) = Self::arg2(builtin, args)?;
|
||||
Ok(Value::Bool(lists::list_contains(left, right)?))
|
||||
}
|
||||
RbacBuiltin::ListNotContains => {
|
||||
let (left, right) = Self::arg2(builtin, args)?;
|
||||
Ok(Value::Bool(!lists::list_contains(left, right)?))
|
||||
}
|
||||
RbacBuiltin::ForAnyOfAnyValues => {
|
||||
let (left, right) = Self::arg2(builtin, args)?;
|
||||
Ok(Value::Bool(quantifiers::for_any_of_any_values(
|
||||
left, right,
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::ForAllOfAnyValues => {
|
||||
let (left, right) = Self::arg2(builtin, args)?;
|
||||
Ok(Value::Bool(quantifiers::for_all_of_any_values(
|
||||
left, right,
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::ForAnyOfAllValues => {
|
||||
let (left, right) = Self::arg2(builtin, args)?;
|
||||
Ok(Value::Bool(quantifiers::for_any_of_all_values(
|
||||
left, right,
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::ForAllOfAllValues => {
|
||||
let (left, right) = Self::arg2(builtin, args)?;
|
||||
Ok(Value::Bool(quantifiers::for_all_of_all_values(
|
||||
left, right,
|
||||
)?))
|
||||
}
|
||||
RbacBuiltin::ActionMatches => functions::action_matches(args, ctx),
|
||||
RbacBuiltin::SubOperationMatches => functions::suboperation_matches(args, ctx),
|
||||
RbacBuiltin::ToLower => {
|
||||
let value = Self::arg1(builtin, args)?;
|
||||
functions::to_lower(value)
|
||||
}
|
||||
RbacBuiltin::ToUpper => {
|
||||
let value = Self::arg1(builtin, args)?;
|
||||
functions::to_upper(value)
|
||||
}
|
||||
RbacBuiltin::Trim => {
|
||||
let value = Self::arg1(builtin, args)?;
|
||||
functions::trim(value)
|
||||
}
|
||||
RbacBuiltin::NormalizeSet => {
|
||||
let value = Self::arg1(builtin, args)?;
|
||||
functions::normalize_set(value)
|
||||
}
|
||||
RbacBuiltin::NormalizeList => {
|
||||
let value = Self::arg1(builtin, args)?;
|
||||
functions::normalize_list(value)
|
||||
}
|
||||
RbacBuiltin::AddDays => {
|
||||
let (left, right) = Self::arg2(builtin, args)?;
|
||||
functions::add_days(left, right)
|
||||
}
|
||||
RbacBuiltin::ToTime => {
|
||||
let value = Self::arg1(builtin, args)?;
|
||||
functions::to_time(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
212
src/languages/azure_rbac/builtins/functions.rs
Normal file
212
src/languages/azure_rbac/builtins/functions.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
use alloc::collections::BTreeSet;
|
||||
use alloc::string::ToString as _;
|
||||
use alloc::vec;
|
||||
|
||||
use crate::value::Value;
|
||||
|
||||
use super::actions;
|
||||
use super::common;
|
||||
use super::evaluator::{RbacBuiltinContext, RbacBuiltinError};
|
||||
|
||||
#[cfg(feature = "time")]
|
||||
use chrono::{DateTime, Duration};
|
||||
|
||||
pub(super) fn to_lower(value: &Value) -> Result<Value, RbacBuiltinError> {
|
||||
// Normalize to ASCII lowercase for RBAC string semantics.
|
||||
let text = common::ensure_string(value, "ToLower")?;
|
||||
Ok(Value::String(text.to_ascii_lowercase().into()))
|
||||
}
|
||||
|
||||
pub(super) fn to_upper(value: &Value) -> Result<Value, RbacBuiltinError> {
|
||||
// Normalize to ASCII uppercase for RBAC string semantics.
|
||||
let text = common::ensure_string(value, "ToUpper")?;
|
||||
Ok(Value::String(text.to_ascii_uppercase().into()))
|
||||
}
|
||||
|
||||
pub(super) fn trim(value: &Value) -> Result<Value, RbacBuiltinError> {
|
||||
// Remove leading/trailing whitespace.
|
||||
let text = common::ensure_string(value, "Trim")?;
|
||||
Ok(Value::String(text.trim().to_string().into()))
|
||||
}
|
||||
|
||||
// Normalize to a set; useful for equality and membership checks.
|
||||
// Examples:
|
||||
// - NormalizeSet(["a", "b", "a"]) => {"a", "b"}
|
||||
// - NormalizeSet("a") => {"a"}
|
||||
pub(super) fn normalize_set(value: &Value) -> Result<Value, RbacBuiltinError> {
|
||||
match *value {
|
||||
// Already a set; return as-is.
|
||||
Value::Set(_) => Ok(value.clone()),
|
||||
Value::Array(ref list) => {
|
||||
// Convert arrays to sets by de-duplicating elements.
|
||||
let set: BTreeSet<Value> = list.iter().cloned().collect();
|
||||
Ok(Value::from(set))
|
||||
}
|
||||
_ => {
|
||||
// Non-collections become singleton sets.
|
||||
let mut set = BTreeSet::new();
|
||||
set.insert(value.clone());
|
||||
Ok(Value::from(set))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize to a list while preserving element values.
|
||||
// Examples:
|
||||
// - NormalizeList({"a", "b"}) => ["a", "b"] (order not guaranteed)
|
||||
// - NormalizeList("a") => ["a"]
|
||||
pub(super) fn normalize_list(value: &Value) -> Result<Value, RbacBuiltinError> {
|
||||
match *value {
|
||||
// Already a list.
|
||||
Value::Array(ref list) => Ok(Value::Array(list.clone())),
|
||||
Value::Set(ref set) => Ok(Value::from(
|
||||
set.iter().cloned().collect::<alloc::vec::Vec<_>>(),
|
||||
)),
|
||||
// Non-collections become singleton lists.
|
||||
_ => Ok(Value::from(vec![value.clone()])),
|
||||
}
|
||||
}
|
||||
|
||||
// Add days to an RFC3339 timestamp string.
|
||||
// Examples:
|
||||
// - AddDays("2024-02-28T10:00:00Z", 1) => "2024-02-29T10:00:00Z"
|
||||
// - AddDays("2023-12-31T23:00:00Z", 1) => "2024-01-01T23:00:00Z"
|
||||
pub(super) fn add_days(date_value: &Value, days_value: &Value) -> Result<Value, RbacBuiltinError> {
|
||||
// Parse arguments as RFC3339 and integer day count.
|
||||
let date_str = common::ensure_string(date_value, "AddDays")?;
|
||||
let days = common::value_as_i64(days_value, "AddDays")?;
|
||||
|
||||
#[cfg(feature = "time")]
|
||||
{
|
||||
// Apply a checked day offset to avoid overflow.
|
||||
let dt = DateTime::parse_from_rfc3339(date_str)
|
||||
.map_err(|err| RbacBuiltinError::new(err.to_string()))?;
|
||||
let updated = dt
|
||||
.checked_add_signed(Duration::days(days))
|
||||
.ok_or_else(|| RbacBuiltinError::new("AddDays overflow"))?;
|
||||
Ok(Value::String(updated.to_rfc3339().into()))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "time"))]
|
||||
{
|
||||
// Feature-gated builtin; return a descriptive error.
|
||||
let _ = (date_str, days);
|
||||
Err(RbacBuiltinError::new(
|
||||
"AddDays builtin has not been enabled",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Coerce a string into a time literal (validated elsewhere).
|
||||
// Example: ToTime("12:30:15") => "12:30:15"
|
||||
pub(super) fn to_time(value: &Value) -> Result<Value, RbacBuiltinError> {
|
||||
match *value {
|
||||
// Time literals are represented as strings in the AST.
|
||||
Value::String(_) => Ok(value.clone()),
|
||||
_ => Err(RbacBuiltinError::new("ToTime expects a string")),
|
||||
}
|
||||
}
|
||||
|
||||
// Match an action against a wildcard pattern.
|
||||
// - With 1 argument, the action comes from the evaluation context.
|
||||
// - Matching is segment-based (split on '/'); '*' matches a single segment and the
|
||||
// action and pattern must have the same number of segments.
|
||||
// Examples:
|
||||
// - ActionMatches("Microsoft.Storage/storageAccounts/read") => uses context action
|
||||
// - ActionMatches("Microsoft.Storage/storageAccounts/read", "Microsoft.Storage/*/read") => true
|
||||
// - ActionMatches("Microsoft.Storage/storageAccounts/read", "Microsoft.Storage/storageAccounts") => false
|
||||
pub(super) fn action_matches(
|
||||
args: &[Value],
|
||||
ctx: &RbacBuiltinContext<'_>,
|
||||
) -> Result<Value, RbacBuiltinError> {
|
||||
// Either use context action (1 arg) or explicit action (2 args).
|
||||
let (action, pattern) = match args.len() {
|
||||
1 => (
|
||||
ctx.evaluation
|
||||
.action
|
||||
.as_deref()
|
||||
.ok_or_else(|| RbacBuiltinError::new("Missing action in context"))?,
|
||||
common::ensure_string(
|
||||
args.first().ok_or_else(|| {
|
||||
RbacBuiltinError::new("ActionMatches expects 1 or 2 arguments")
|
||||
})?,
|
||||
"ActionMatches",
|
||||
)?,
|
||||
),
|
||||
2 => (
|
||||
common::ensure_string(
|
||||
args.first().ok_or_else(|| {
|
||||
RbacBuiltinError::new("ActionMatches expects 1 or 2 arguments")
|
||||
})?,
|
||||
"ActionMatches",
|
||||
)?,
|
||||
common::ensure_string(
|
||||
args.get(1).ok_or_else(|| {
|
||||
RbacBuiltinError::new("ActionMatches expects 1 or 2 arguments")
|
||||
})?,
|
||||
"ActionMatches",
|
||||
)?,
|
||||
),
|
||||
_ => {
|
||||
return Err(RbacBuiltinError::new(
|
||||
"ActionMatches expects 1 or 2 arguments",
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Value::Bool(actions::action_matches_pattern(
|
||||
action, pattern,
|
||||
)))
|
||||
}
|
||||
|
||||
// Match a suboperation against a pattern.
|
||||
// - With 1 argument, the suboperation comes from the evaluation context.
|
||||
// - Matching is exact; no wildcard semantics are supported.
|
||||
// Examples:
|
||||
// - SubOperationMatches("sub/read") => uses context suboperation
|
||||
// - SubOperationMatches("sub/read", "sub/read") => true
|
||||
// - SubOperationMatches("sub/read", "sub/*") => false
|
||||
pub(super) fn suboperation_matches(
|
||||
args: &[Value],
|
||||
ctx: &RbacBuiltinContext<'_>,
|
||||
) -> Result<Value, RbacBuiltinError> {
|
||||
// Either use context suboperation (1 arg) or explicit suboperation (2 args).
|
||||
let (subop, pattern) = match args.len() {
|
||||
1 => (
|
||||
ctx.evaluation
|
||||
.suboperation
|
||||
.as_deref()
|
||||
.ok_or_else(|| RbacBuiltinError::new("Missing suboperation in context"))?,
|
||||
common::ensure_string(
|
||||
args.first().ok_or_else(|| {
|
||||
RbacBuiltinError::new("SubOperationMatches expects 1 or 2 arguments")
|
||||
})?,
|
||||
"SubOperationMatches",
|
||||
)?,
|
||||
),
|
||||
2 => (
|
||||
common::ensure_string(
|
||||
args.first().ok_or_else(|| {
|
||||
RbacBuiltinError::new("SubOperationMatches expects 1 or 2 arguments")
|
||||
})?,
|
||||
"SubOperationMatches",
|
||||
)?,
|
||||
common::ensure_string(
|
||||
args.get(1).ok_or_else(|| {
|
||||
RbacBuiltinError::new("SubOperationMatches expects 1 or 2 arguments")
|
||||
})?,
|
||||
"SubOperationMatches",
|
||||
)?,
|
||||
),
|
||||
_ => {
|
||||
return Err(RbacBuiltinError::new(
|
||||
"SubOperationMatches expects 1 or 2 arguments",
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Value::Bool(subop == pattern))
|
||||
}
|
||||
179
src/languages/azure_rbac/builtins/ids.rs
Normal file
179
src/languages/azure_rbac/builtins/ids.rs
Normal file
@@ -0,0 +1,179 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
macro_rules! rbac_builtins {
|
||||
($( $variant:ident => { name: $name:literal, operator: $is_operator:expr } ),* $(,)?) => {
|
||||
impl RbacBuiltin {
|
||||
/// Parse a builtin name as used in the RBAC condition language.
|
||||
pub fn parse(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
$( $name => Some(Self::$variant), )*
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a binary operator name (excludes function-style builtins).
|
||||
pub fn parse_operator(name: &str) -> Option<Self> {
|
||||
Self::parse(name).filter(|builtin| builtin.is_operator())
|
||||
}
|
||||
|
||||
/// Returns true for infix operator-style builtins.
|
||||
pub const fn is_operator(self) -> bool {
|
||||
match self {
|
||||
$( Self::$variant => $is_operator, )*
|
||||
}
|
||||
}
|
||||
|
||||
/// RBAC builtin name.
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
$( Self::$variant => $name, )*
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// RBAC builtin identifier.
|
||||
#[repr(u8)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum RbacBuiltin {
|
||||
// String operators
|
||||
StringEquals,
|
||||
StringNotEquals,
|
||||
StringEqualsIgnoreCase,
|
||||
StringNotEqualsIgnoreCase,
|
||||
StringLike,
|
||||
StringNotLike,
|
||||
StringStartsWith,
|
||||
StringNotStartsWith,
|
||||
StringEndsWith,
|
||||
StringNotEndsWith,
|
||||
StringContains,
|
||||
StringNotContains,
|
||||
StringMatches,
|
||||
StringNotMatches,
|
||||
|
||||
// Numeric operators
|
||||
NumericEquals,
|
||||
NumericNotEquals,
|
||||
NumericLessThan,
|
||||
NumericLessThanEquals,
|
||||
NumericGreaterThan,
|
||||
NumericGreaterThanEquals,
|
||||
NumericInRange,
|
||||
|
||||
// Boolean operators
|
||||
BoolEquals,
|
||||
BoolNotEquals,
|
||||
|
||||
// DateTime operators
|
||||
DateTimeEquals,
|
||||
DateTimeNotEquals,
|
||||
DateTimeGreaterThan,
|
||||
DateTimeGreaterThanEquals,
|
||||
DateTimeLessThan,
|
||||
DateTimeLessThanEquals,
|
||||
|
||||
// Time operators
|
||||
TimeOfDayEquals,
|
||||
TimeOfDayNotEquals,
|
||||
TimeOfDayGreaterThan,
|
||||
TimeOfDayGreaterThanEquals,
|
||||
TimeOfDayLessThan,
|
||||
TimeOfDayLessThanEquals,
|
||||
TimeOfDayInRange,
|
||||
|
||||
// GUID operators
|
||||
GuidEquals,
|
||||
GuidNotEquals,
|
||||
|
||||
// IP operators
|
||||
IpMatch,
|
||||
IpNotMatch,
|
||||
IpInRange,
|
||||
|
||||
// List operators
|
||||
ListContains,
|
||||
ListNotContains,
|
||||
|
||||
// Cross product operators
|
||||
ForAnyOfAnyValues,
|
||||
ForAllOfAnyValues,
|
||||
ForAnyOfAllValues,
|
||||
ForAllOfAllValues,
|
||||
|
||||
// Action operators
|
||||
ActionMatches,
|
||||
SubOperationMatches,
|
||||
|
||||
// Function-style builtins
|
||||
ToLower,
|
||||
ToUpper,
|
||||
Trim,
|
||||
NormalizeSet,
|
||||
NormalizeList,
|
||||
AddDays,
|
||||
ToTime,
|
||||
}
|
||||
|
||||
rbac_builtins! {
|
||||
StringEquals => { name: "StringEquals", operator: true },
|
||||
StringNotEquals => { name: "StringNotEquals", operator: true },
|
||||
StringEqualsIgnoreCase => { name: "StringEqualsIgnoreCase", operator: true },
|
||||
StringNotEqualsIgnoreCase => { name: "StringNotEqualsIgnoreCase", operator: true },
|
||||
StringLike => { name: "StringLike", operator: true },
|
||||
StringNotLike => { name: "StringNotLike", operator: true },
|
||||
StringStartsWith => { name: "StringStartsWith", operator: true },
|
||||
StringNotStartsWith => { name: "StringNotStartsWith", operator: true },
|
||||
StringEndsWith => { name: "StringEndsWith", operator: true },
|
||||
StringNotEndsWith => { name: "StringNotEndsWith", operator: true },
|
||||
StringContains => { name: "StringContains", operator: true },
|
||||
StringNotContains => { name: "StringNotContains", operator: true },
|
||||
StringMatches => { name: "StringMatches", operator: true },
|
||||
StringNotMatches => { name: "StringNotMatches", operator: true },
|
||||
NumericEquals => { name: "NumericEquals", operator: true },
|
||||
NumericNotEquals => { name: "NumericNotEquals", operator: true },
|
||||
NumericLessThan => { name: "NumericLessThan", operator: true },
|
||||
NumericLessThanEquals => { name: "NumericLessThanEquals", operator: true },
|
||||
NumericGreaterThan => { name: "NumericGreaterThan", operator: true },
|
||||
NumericGreaterThanEquals => { name: "NumericGreaterThanEquals", operator: true },
|
||||
NumericInRange => { name: "NumericInRange", operator: true },
|
||||
BoolEquals => { name: "BoolEquals", operator: true },
|
||||
BoolNotEquals => { name: "BoolNotEquals", operator: true },
|
||||
DateTimeEquals => { name: "DateTimeEquals", operator: true },
|
||||
DateTimeNotEquals => { name: "DateTimeNotEquals", operator: true },
|
||||
DateTimeGreaterThan => { name: "DateTimeGreaterThan", operator: true },
|
||||
DateTimeGreaterThanEquals => { name: "DateTimeGreaterThanEquals", operator: true },
|
||||
DateTimeLessThan => { name: "DateTimeLessThan", operator: true },
|
||||
DateTimeLessThanEquals => { name: "DateTimeLessThanEquals", operator: true },
|
||||
TimeOfDayEquals => { name: "TimeOfDayEquals", operator: true },
|
||||
TimeOfDayNotEquals => { name: "TimeOfDayNotEquals", operator: true },
|
||||
TimeOfDayGreaterThan => { name: "TimeOfDayGreaterThan", operator: true },
|
||||
TimeOfDayGreaterThanEquals => { name: "TimeOfDayGreaterThanEquals", operator: true },
|
||||
TimeOfDayLessThan => { name: "TimeOfDayLessThan", operator: true },
|
||||
TimeOfDayLessThanEquals => { name: "TimeOfDayLessThanEquals", operator: true },
|
||||
TimeOfDayInRange => { name: "TimeOfDayInRange", operator: true },
|
||||
GuidEquals => { name: "GuidEquals", operator: true },
|
||||
GuidNotEquals => { name: "GuidNotEquals", operator: true },
|
||||
IpMatch => { name: "IpMatch", operator: true },
|
||||
IpNotMatch => { name: "IpNotMatch", operator: true },
|
||||
IpInRange => { name: "IpInRange", operator: true },
|
||||
ListContains => { name: "ListContains", operator: true },
|
||||
ListNotContains => { name: "ListNotContains", operator: true },
|
||||
ForAnyOfAnyValues => { name: "ForAnyOfAnyValues", operator: true },
|
||||
ForAllOfAnyValues => { name: "ForAllOfAnyValues", operator: true },
|
||||
ForAnyOfAllValues => { name: "ForAnyOfAllValues", operator: true },
|
||||
ForAllOfAllValues => { name: "ForAllOfAllValues", operator: true },
|
||||
ActionMatches => { name: "ActionMatches", operator: true },
|
||||
SubOperationMatches => { name: "SubOperationMatches", operator: true },
|
||||
ToLower => { name: "ToLower", operator: false },
|
||||
ToUpper => { name: "ToUpper", operator: false },
|
||||
Trim => { name: "Trim", operator: false },
|
||||
NormalizeSet => { name: "NormalizeSet", operator: false },
|
||||
NormalizeList => { name: "NormalizeList", operator: false },
|
||||
AddDays => { name: "AddDays", operator: false },
|
||||
ToTime => { name: "ToTime", operator: false },
|
||||
}
|
||||
106
src/languages/azure_rbac/builtins/ip.rs
Normal file
106
src/languages/azure_rbac/builtins/ip.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
use crate::value::Value;
|
||||
use alloc::string::ToString as _;
|
||||
|
||||
use super::common;
|
||||
use super::evaluator::RbacBuiltinError;
|
||||
|
||||
#[cfg(feature = "net")]
|
||||
use core::net::IpAddr;
|
||||
#[cfg(feature = "net")]
|
||||
use ipnet::IpNet;
|
||||
|
||||
// Match an IP address against a CIDR range.
|
||||
// Examples:
|
||||
// - IpMatch("10.0.0.5", "10.0.0.0/24") => true
|
||||
// - IpMatch("10.0.1.5", "10.0.0.0/24") => false
|
||||
// - IpMatch("2001:db8::1", "2001:db8::/32") => true
|
||||
pub(super) fn ip_match(
|
||||
left: &Value,
|
||||
right: &Value,
|
||||
name: &'static str,
|
||||
) -> Result<bool, RbacBuiltinError> {
|
||||
// Parse inputs as strings before network matching.
|
||||
let ip_str = common::value_as_string(left, name)?;
|
||||
let cidr_str = common::value_as_string(right, name)?;
|
||||
|
||||
#[cfg(feature = "net")]
|
||||
{
|
||||
// Parse CIDR and IP and check containment.
|
||||
let net = cidr_str
|
||||
.parse::<IpNet>()
|
||||
.map_err(|err| RbacBuiltinError::new(err.to_string()))?;
|
||||
let ip = ip_str
|
||||
.parse::<IpAddr>()
|
||||
.map_err(|err| RbacBuiltinError::new(err.to_string()))?;
|
||||
Ok(net.contains(&ip))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "net"))]
|
||||
{
|
||||
Err(RbacBuiltinError::new("IP builtins have not been enabled"))
|
||||
}
|
||||
}
|
||||
|
||||
// Match an IP against a range or a CIDR block.
|
||||
// - If the right operand is a 2-element list, it is treated as [start, end].
|
||||
// - Otherwise, it is treated as a CIDR string.
|
||||
// Examples:
|
||||
// - IpInRange("10.0.0.5", ["10.0.0.1", "10.0.0.10"]) => true
|
||||
// - IpInRange("10.0.0.5", ["10.0.0.6", "10.0.0.10"]) => false
|
||||
// - IpInRange("10.0.0.5", "10.0.0.0/24") => true
|
||||
pub(super) fn ip_in_range(
|
||||
left: &Value,
|
||||
right: &Value,
|
||||
name: &'static str,
|
||||
) -> Result<bool, RbacBuiltinError> {
|
||||
match *right {
|
||||
Value::Array(ref list) if list.len() == 2 => {
|
||||
// Treat list as inclusive [start, end] bounds.
|
||||
let start = list
|
||||
.first()
|
||||
.ok_or_else(|| RbacBuiltinError::new("IpInRange expects 2 values"))?;
|
||||
let end = list
|
||||
.get(1)
|
||||
.ok_or_else(|| RbacBuiltinError::new("IpInRange expects 2 values"))?;
|
||||
let start = common::value_as_string(start, name)?;
|
||||
let end = common::value_as_string(end, name)?;
|
||||
ip_between(left, &start, &end, name)
|
||||
}
|
||||
_ => ip_match(left, right, name),
|
||||
}
|
||||
}
|
||||
|
||||
// Check inclusive range membership: start <= ip <= end.
|
||||
// Example: IpInRange("10.0.0.5", ["10.0.0.1", "10.0.0.10"]) => true
|
||||
fn ip_between(
|
||||
left: &Value,
|
||||
start: &str,
|
||||
end: &str,
|
||||
name: &'static str,
|
||||
) -> Result<bool, RbacBuiltinError> {
|
||||
// Parse the target IP once for range checks.
|
||||
let ip_str = common::value_as_string(left, name)?;
|
||||
|
||||
#[cfg(feature = "net")]
|
||||
{
|
||||
// Compare parsed IP values with inclusive bounds.
|
||||
let ip = ip_str
|
||||
.parse::<IpAddr>()
|
||||
.map_err(|err| RbacBuiltinError::new(err.to_string()))?;
|
||||
let start_ip = start
|
||||
.parse::<IpAddr>()
|
||||
.map_err(|err| RbacBuiltinError::new(err.to_string()))?;
|
||||
let end_ip = end
|
||||
.parse::<IpAddr>()
|
||||
.map_err(|err| RbacBuiltinError::new(err.to_string()))?;
|
||||
Ok(start_ip <= ip && ip <= end_ip)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "net"))]
|
||||
{
|
||||
Err(RbacBuiltinError::new("IP builtins have not been enabled"))
|
||||
}
|
||||
}
|
||||
38
src/languages/azure_rbac/builtins/lists.rs
Normal file
38
src/languages/azure_rbac/builtins/lists.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
use alloc::collections::BTreeSet;
|
||||
|
||||
use crate::value::Value;
|
||||
|
||||
use super::evaluator::RbacBuiltinError;
|
||||
|
||||
// Check whether a list or set contains the provided element(s).
|
||||
pub(super) fn list_contains(left: &Value, right: &Value) -> Result<bool, RbacBuiltinError> {
|
||||
match *left {
|
||||
// Lists and sets share containment semantics.
|
||||
Value::Array(ref list) => Ok(list_contains_values(list, right)),
|
||||
Value::Set(ref set) => Ok(set_contains_values(set, right)),
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
// For lists, treat a list/set needle as "all elements are contained".
|
||||
fn list_contains_values(list: &[Value], needle: &Value) -> bool {
|
||||
match *needle {
|
||||
// For collection needles, require all elements to be present.
|
||||
Value::Array(ref right_list) => right_list.iter().all(|item| list.contains(item)),
|
||||
Value::Set(ref right_set) => right_set.iter().all(|item| list.contains(item)),
|
||||
_ => list.contains(needle),
|
||||
}
|
||||
}
|
||||
|
||||
// For sets, treat a list/set needle as "all elements are contained".
|
||||
fn set_contains_values(set: &BTreeSet<Value>, needle: &Value) -> bool {
|
||||
match *needle {
|
||||
// For collection needles, require all elements to be present.
|
||||
Value::Array(ref right_list) => right_list.iter().all(|item| set.contains(item)),
|
||||
Value::Set(ref right_set) => right_set.iter().all(|item| set.contains(item)),
|
||||
_ => set.contains(needle),
|
||||
}
|
||||
}
|
||||
24
src/languages/azure_rbac/builtins/mod.rs
Normal file
24
src/languages/azure_rbac/builtins/mod.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
//! Azure RBAC builtins.
|
||||
//!
|
||||
//! These builtins are a small, zero-copy focused API intended to be shared by
|
||||
//! the interpreter and future RVM lowering.
|
||||
|
||||
mod actions;
|
||||
mod bools;
|
||||
mod common;
|
||||
mod datetime;
|
||||
mod evaluator;
|
||||
mod functions;
|
||||
mod ids;
|
||||
mod ip;
|
||||
mod lists;
|
||||
mod numbers;
|
||||
mod quantifiers;
|
||||
mod strings;
|
||||
mod time_of_day;
|
||||
|
||||
pub use evaluator::{DefaultRbacBuiltinEvaluator, RbacBuiltinContext, RbacBuiltinError};
|
||||
pub use ids::RbacBuiltin;
|
||||
44
src/languages/azure_rbac/builtins/numbers.rs
Normal file
44
src/languages/azure_rbac/builtins/numbers.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
use crate::value::Value;
|
||||
|
||||
use super::common;
|
||||
use super::evaluator::RbacBuiltinError;
|
||||
|
||||
// Compare numeric values using integer semantics.
|
||||
// Accepts numeric values or numeric strings that fit in i64.
|
||||
// Spec: https://learn.microsoft.com/en-us/azure/role-based-access-control/conditions-format#numeric-comparison-operators
|
||||
pub(super) fn numeric_compare(
|
||||
left: &Value,
|
||||
right: &Value,
|
||||
op: &str,
|
||||
name: &'static str,
|
||||
) -> Result<bool, RbacBuiltinError> {
|
||||
// Convert both operands to i64 before comparing.
|
||||
let lhs = common::value_as_i64(left, name)?;
|
||||
let rhs = common::value_as_i64(right, name)?;
|
||||
Ok(match op {
|
||||
"==" => lhs == rhs,
|
||||
"!=" => lhs != rhs,
|
||||
"<" => lhs < rhs,
|
||||
"<=" => lhs <= rhs,
|
||||
">" => lhs > rhs,
|
||||
">=" => lhs >= rhs,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
// Check whether a numeric value falls within a 2-element range (inclusive).
|
||||
// Bounds are parsed as i64; values must fit in i64.
|
||||
// Spec: https://learn.microsoft.com/en-us/azure/role-based-access-control/conditions-format#numeric-comparison-operators
|
||||
pub(super) fn numeric_in_range(
|
||||
left: &Value,
|
||||
right: &Value,
|
||||
name: &'static str,
|
||||
) -> Result<bool, RbacBuiltinError> {
|
||||
// Parse value and inclusive bounds as i64.
|
||||
let value = common::value_as_i64(left, name)?;
|
||||
let (start, end) = common::range_bounds(right, name)?;
|
||||
Ok(value >= start && value <= end)
|
||||
}
|
||||
62
src/languages/azure_rbac/builtins/quantifiers.rs
Normal file
62
src/languages/azure_rbac/builtins/quantifiers.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
use alloc::vec;
|
||||
use alloc::vec::Vec;
|
||||
|
||||
use crate::value::Value;
|
||||
|
||||
use super::evaluator::RbacBuiltinError;
|
||||
|
||||
// Cross-product operators over collections.
|
||||
//
|
||||
// The operators use value equality across elements. Non-collection values are
|
||||
// treated as singleton collections.
|
||||
|
||||
pub(super) fn for_any_of_any_values(left: &Value, right: &Value) -> Result<bool, RbacBuiltinError> {
|
||||
let left_values = collect_values(left);
|
||||
let right_values = collect_values(right);
|
||||
|
||||
Ok(left_values
|
||||
.iter()
|
||||
.any(|left_value| right_values.contains(left_value)))
|
||||
}
|
||||
|
||||
pub(super) fn for_all_of_any_values(left: &Value, right: &Value) -> Result<bool, RbacBuiltinError> {
|
||||
let left_values = collect_values(left);
|
||||
let right_values = collect_values(right);
|
||||
|
||||
Ok(left_values
|
||||
.iter()
|
||||
.all(|left_value| right_values.contains(left_value)))
|
||||
}
|
||||
|
||||
pub(super) fn for_any_of_all_values(left: &Value, right: &Value) -> Result<bool, RbacBuiltinError> {
|
||||
let left_values = collect_values(left);
|
||||
let right_values = collect_values(right);
|
||||
|
||||
Ok(left_values.iter().any(|left_value| {
|
||||
right_values
|
||||
.iter()
|
||||
.all(|right_value| *left_value == *right_value)
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn for_all_of_all_values(left: &Value, right: &Value) -> Result<bool, RbacBuiltinError> {
|
||||
let left_values = collect_values(left);
|
||||
let right_values = collect_values(right);
|
||||
|
||||
Ok(left_values.iter().all(|left_value| {
|
||||
right_values
|
||||
.iter()
|
||||
.all(|right_value| *left_value == *right_value)
|
||||
}))
|
||||
}
|
||||
|
||||
fn collect_values(value: &Value) -> Vec<&Value> {
|
||||
match *value {
|
||||
Value::Array(ref list) => list.iter().collect(),
|
||||
Value::Set(ref set) => set.iter().collect(),
|
||||
_ => vec![value],
|
||||
}
|
||||
}
|
||||
107
src/languages/azure_rbac/builtins/strings.rs
Normal file
107
src/languages/azure_rbac/builtins/strings.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
use crate::value::Value;
|
||||
use alloc::string::ToString as _;
|
||||
|
||||
use super::common;
|
||||
use super::evaluator::RbacBuiltinError;
|
||||
|
||||
#[cfg(feature = "regex")]
|
||||
use regex::Regex;
|
||||
|
||||
// Equality check with collection containment semantics.
|
||||
// If exactly one operand is a collection, checks whether it contains the other.
|
||||
pub(super) fn string_equals(left: &Value, right: &Value) -> Result<bool, RbacBuiltinError> {
|
||||
// If one side is a collection, perform containment instead of equality.
|
||||
if common::is_collection(right) && !common::is_collection(left) {
|
||||
return Ok(common::collection_contains(right, left));
|
||||
}
|
||||
if common::is_collection(left) && !common::is_collection(right) {
|
||||
return Ok(common::collection_contains(left, right));
|
||||
}
|
||||
Ok(left == right)
|
||||
}
|
||||
|
||||
// Case-insensitive equality with collection containment semantics.
|
||||
// If exactly one operand is a collection, checks whether it contains the other.
|
||||
pub(super) fn string_equals_ignore_case(
|
||||
left: &Value,
|
||||
right: &Value,
|
||||
) -> Result<bool, RbacBuiltinError> {
|
||||
// If one side is a collection, perform case-insensitive containment.
|
||||
if common::is_collection(right) && !common::is_collection(left) {
|
||||
return Ok(common::collection_contains_case_insensitive(right, left));
|
||||
}
|
||||
if common::is_collection(left) && !common::is_collection(right) {
|
||||
return Ok(common::collection_contains_case_insensitive(left, right));
|
||||
}
|
||||
|
||||
let left_str = common::value_as_string(left, "StringEqualsIgnoreCase")?;
|
||||
let right_str = common::value_as_string(right, "StringEqualsIgnoreCase")?;
|
||||
Ok(left_str.eq_ignore_ascii_case(&right_str))
|
||||
}
|
||||
|
||||
// Match with '*' and '?' wildcards.
|
||||
pub(super) fn string_like(left: &Value, right: &Value) -> Result<bool, RbacBuiltinError> {
|
||||
// Use simple wildcard matching for '*' and '?'.
|
||||
let value = common::value_as_string(left, "StringLike")?;
|
||||
let pattern = common::value_as_string(right, "StringLike")?;
|
||||
Ok(common::simple_wildcard_match(&pattern, &value))
|
||||
}
|
||||
|
||||
// Check if the left string starts with the right string.
|
||||
pub(super) fn string_starts_with(left: &Value, right: &Value) -> Result<bool, RbacBuiltinError> {
|
||||
// Compare prefix match.
|
||||
let value = common::value_as_string(left, "StringStartsWith")?;
|
||||
let prefix = common::value_as_string(right, "StringStartsWith")?;
|
||||
Ok(value.starts_with(&prefix))
|
||||
}
|
||||
|
||||
// Check if the left string ends with the right string.
|
||||
pub(super) fn string_ends_with(left: &Value, right: &Value) -> Result<bool, RbacBuiltinError> {
|
||||
// Compare suffix match.
|
||||
let value = common::value_as_string(left, "StringEndsWith")?;
|
||||
let suffix = common::value_as_string(right, "StringEndsWith")?;
|
||||
Ok(value.ends_with(&suffix))
|
||||
}
|
||||
|
||||
// Check if the left string contains the right string.
|
||||
pub(super) fn string_contains(left: &Value, right: &Value) -> Result<bool, RbacBuiltinError> {
|
||||
// Check substring containment.
|
||||
let value = common::value_as_string(left, "StringContains")?;
|
||||
let needle = common::value_as_string(right, "StringContains")?;
|
||||
Ok(value.contains(&needle))
|
||||
}
|
||||
|
||||
// Match using regex when the regex feature is enabled; otherwise uses exact match.
|
||||
pub(super) fn string_matches(left: &Value, right: &Value) -> Result<bool, RbacBuiltinError> {
|
||||
// Parse the pattern and apply regex matching when enabled.
|
||||
let value = common::value_as_string(left, "StringMatches")?;
|
||||
let pattern = common::value_as_string(right, "StringMatches")?;
|
||||
|
||||
#[cfg(feature = "regex")]
|
||||
{
|
||||
// Compile regex each time to match RBAC semantics.
|
||||
let regex = Regex::new(&pattern).map_err(|err| RbacBuiltinError::new(err.to_string()))?;
|
||||
Ok(regex.is_match(&value))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "regex"))]
|
||||
{
|
||||
// Without regex support, fall back to exact match.
|
||||
Ok(value == pattern)
|
||||
}
|
||||
}
|
||||
|
||||
// Case-insensitive GUID equality.
|
||||
pub(super) fn guid_equals(
|
||||
left: &Value,
|
||||
right: &Value,
|
||||
name: &'static str,
|
||||
) -> Result<bool, RbacBuiltinError> {
|
||||
// GUID comparisons are case-insensitive.
|
||||
let lhs = common::value_as_string(left, name)?;
|
||||
let rhs = common::value_as_string(right, name)?;
|
||||
Ok(lhs.eq_ignore_ascii_case(&rhs))
|
||||
}
|
||||
83
src/languages/azure_rbac/builtins/time_of_day.rs
Normal file
83
src/languages/azure_rbac/builtins/time_of_day.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
use crate::value::Value;
|
||||
|
||||
use super::common;
|
||||
use super::evaluator::RbacBuiltinError;
|
||||
|
||||
// Compare times of day using HH:MM[:SS] semantics.
|
||||
pub(super) fn time_compare(
|
||||
left: &Value,
|
||||
right: &Value,
|
||||
op: &str,
|
||||
name: &'static str,
|
||||
) -> Result<bool, RbacBuiltinError> {
|
||||
// Parse both operands as time-of-day strings and compare in seconds.
|
||||
let lhs = common::value_as_string(left, name)?;
|
||||
let rhs = common::value_as_string(right, name)?;
|
||||
let left_secs = common::parse_time_of_day(&lhs)?;
|
||||
let right_secs = common::parse_time_of_day(&rhs)?;
|
||||
Ok(match op {
|
||||
"==" => left_secs == right_secs,
|
||||
"<" => left_secs < right_secs,
|
||||
"<=" => left_secs <= right_secs,
|
||||
">" => left_secs > right_secs,
|
||||
">=" => left_secs >= right_secs,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
// Check whether a time falls within a 2-element range (inclusive).
|
||||
// If the right operand is not a 2-element list/set, falls back to equality.
|
||||
// Examples:
|
||||
// - TimeOfDayInRange("09:30", ["09:00", "10:00"]) => true
|
||||
// - TimeOfDayInRange("23:30:00", {"22:00", "23:00"}) => false
|
||||
// - TimeOfDayInRange("12:00", "12:00") => true
|
||||
pub(super) fn time_in_range(
|
||||
left: &Value,
|
||||
right: &Value,
|
||||
name: &'static str,
|
||||
) -> Result<bool, RbacBuiltinError> {
|
||||
// Parse the target time once to compare against range bounds.
|
||||
let value = common::value_as_string(left, name)?;
|
||||
let value_secs = common::parse_time_of_day(&value)?;
|
||||
|
||||
match *right {
|
||||
Value::Array(ref list) => {
|
||||
// Arrays must contain exactly two bounds.
|
||||
let start = list.first().ok_or_else(|| {
|
||||
RbacBuiltinError::new("TimeOfDayInRange expects a 2-element array")
|
||||
})?;
|
||||
let end = list.get(1).ok_or_else(|| {
|
||||
RbacBuiltinError::new("TimeOfDayInRange expects a 2-element array")
|
||||
})?;
|
||||
if list.len() != 2 {
|
||||
return Err(RbacBuiltinError::new(
|
||||
"TimeOfDayInRange expects a 2-element array",
|
||||
));
|
||||
}
|
||||
// Compare inclusive bounds after parsing to seconds.
|
||||
let start = common::value_as_string(start, name)?;
|
||||
let end = common::value_as_string(end, name)?;
|
||||
let start_secs = common::parse_time_of_day(&start)?;
|
||||
let end_secs = common::parse_time_of_day(&end)?;
|
||||
Ok(value_secs >= start_secs && value_secs <= end_secs)
|
||||
}
|
||||
Value::Set(ref set) => {
|
||||
// Sets must contain exactly two bounds (order is arbitrary).
|
||||
let mut iter = set.iter();
|
||||
let start = iter
|
||||
.next()
|
||||
.ok_or_else(|| RbacBuiltinError::new("TimeOfDayInRange expects 2 values"))?;
|
||||
let end = iter
|
||||
.next()
|
||||
.ok_or_else(|| RbacBuiltinError::new("TimeOfDayInRange expects 2 values"))?;
|
||||
let start_secs = common::parse_time_of_day(&common::value_as_string(start, name)?)?;
|
||||
let end_secs = common::parse_time_of_day(&common::value_as_string(end, name)?)?;
|
||||
Ok(value_secs >= start_secs && value_secs <= end_secs)
|
||||
}
|
||||
// Non-collection RHS falls back to equality.
|
||||
_ => time_compare(left, right, "==", name),
|
||||
}
|
||||
}
|
||||
66
src/languages/azure_rbac/interpreter.rs
Normal file
66
src/languages/azure_rbac/interpreter.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
//! Azure RBAC condition expression interpreter.
|
||||
//!
|
||||
//! This module evaluates parsed condition expressions against an
|
||||
//! [`EvaluationContext`] without compiling to RVM instructions.
|
||||
|
||||
#[path = "interpreter/access.rs"]
|
||||
mod access;
|
||||
#[path = "interpreter/attributes.rs"]
|
||||
mod attributes;
|
||||
#[path = "interpreter/error.rs"]
|
||||
mod error;
|
||||
#[path = "interpreter/eval.rs"]
|
||||
mod eval;
|
||||
#[path = "interpreter/literals.rs"]
|
||||
mod literals;
|
||||
#[path = "interpreter/quantifiers.rs"]
|
||||
mod quantifiers;
|
||||
|
||||
pub use error::ConditionEvalError;
|
||||
|
||||
use crate::languages::azure_rbac::ast::{ConditionExpr, ConditionExpression, EvaluationContext};
|
||||
use crate::value::Value;
|
||||
use eval::Evaluator;
|
||||
|
||||
/// Dynamic interpreter for Azure RBAC conditions.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConditionInterpreter<'a> {
|
||||
context: &'a EvaluationContext,
|
||||
}
|
||||
|
||||
impl<'a> ConditionInterpreter<'a> {
|
||||
/// Create a new interpreter bound to a context.
|
||||
pub const fn new(context: &'a EvaluationContext) -> Self {
|
||||
Self { context }
|
||||
}
|
||||
|
||||
/// Parse and evaluate a condition string.
|
||||
pub fn evaluate_str(&self, condition: &str) -> Result<bool, ConditionEvalError> {
|
||||
let mut evaluator = Evaluator::new(self.context);
|
||||
evaluator.evaluate_str(condition)
|
||||
}
|
||||
|
||||
/// Evaluate a parsed condition expression.
|
||||
pub fn evaluate_condition_expression(
|
||||
&self,
|
||||
condition: &ConditionExpression,
|
||||
) -> Result<bool, ConditionEvalError> {
|
||||
let mut evaluator = Evaluator::new(self.context);
|
||||
evaluator.evaluate_condition_expression(condition)
|
||||
}
|
||||
|
||||
/// Evaluate a condition AST and return a boolean result.
|
||||
pub fn evaluate_bool(&self, expr: &ConditionExpr) -> Result<bool, ConditionEvalError> {
|
||||
let mut evaluator = Evaluator::new(self.context);
|
||||
evaluator.evaluate_bool(expr)
|
||||
}
|
||||
|
||||
/// Evaluate a condition AST and return a value.
|
||||
pub fn evaluate_value(&self, expr: &ConditionExpr) -> Result<Value, ConditionEvalError> {
|
||||
let mut evaluator = Evaluator::new(self.context);
|
||||
evaluator.evaluate_value(expr)
|
||||
}
|
||||
}
|
||||
33
src/languages/azure_rbac/interpreter/access.rs
Normal file
33
src/languages/azure_rbac/interpreter/access.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
use crate::languages::azure_rbac::ast::{PropertyAccessExpression, VariableReference};
|
||||
use crate::value::Value;
|
||||
|
||||
use super::error::ConditionEvalError;
|
||||
use super::eval::Evaluator;
|
||||
|
||||
impl<'a> Evaluator<'a> {
|
||||
pub(super) fn eval_variable_reference(
|
||||
&self,
|
||||
variable: &VariableReference,
|
||||
) -> Result<Value, ConditionEvalError> {
|
||||
Ok(self
|
||||
.lookup_variable(variable.name.as_str())
|
||||
.unwrap_or(Value::Undefined))
|
||||
}
|
||||
|
||||
pub(super) fn eval_property_access(
|
||||
&mut self,
|
||||
access: &PropertyAccessExpression,
|
||||
) -> Result<Value, ConditionEvalError> {
|
||||
let object = self.evaluate_value(&access.object)?;
|
||||
match object {
|
||||
Value::Object(map) => Ok(map
|
||||
.get(&Value::String(access.property.as_str().into()))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Undefined)),
|
||||
_ => Ok(Value::Undefined),
|
||||
}
|
||||
}
|
||||
}
|
||||
260
src/languages/azure_rbac/interpreter/attributes.rs
Normal file
260
src/languages/azure_rbac/interpreter/attributes.rs
Normal file
@@ -0,0 +1,260 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
use alloc::format;
|
||||
use alloc::string::{String, ToString as _};
|
||||
|
||||
use crate::languages::azure_rbac::ast::{
|
||||
AttributePathSegment, AttributeReference, AttributeSource, PrincipalType,
|
||||
};
|
||||
use crate::value::Value;
|
||||
|
||||
use super::error::ConditionEvalError;
|
||||
use super::eval::Evaluator;
|
||||
|
||||
#[cfg(feature = "time")]
|
||||
use chrono::{DateTime, Timelike as _};
|
||||
|
||||
impl<'a> Evaluator<'a> {
|
||||
pub(super) fn eval_attribute_reference(
|
||||
&mut self,
|
||||
attr: &AttributeReference,
|
||||
) -> Result<Value, ConditionEvalError> {
|
||||
let mut value = match attr.source {
|
||||
AttributeSource::Request => self.resolve_request_attribute(attr),
|
||||
AttributeSource::Resource => self.resolve_resource_attribute(attr),
|
||||
AttributeSource::Principal => self.resolve_principal_attribute(attr),
|
||||
AttributeSource::Environment => self.resolve_environment_attribute(attr),
|
||||
AttributeSource::Context => Value::Undefined,
|
||||
};
|
||||
|
||||
for segment in &attr.path {
|
||||
value = Self::apply_path_segment(value, segment)?;
|
||||
}
|
||||
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn resolve_request_attribute(&self, attr: &AttributeReference) -> Value {
|
||||
let attributes = &self.context.request.attributes;
|
||||
let direct = Self::lookup_attribute_value(attributes, attr);
|
||||
if !matches!(direct, Value::Undefined) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
match attr.attribute.as_str() {
|
||||
"action" => self
|
||||
.context
|
||||
.request
|
||||
.action
|
||||
.clone()
|
||||
.map(|s: String| Value::String(s.into()))
|
||||
.unwrap_or(Value::Undefined),
|
||||
"dataAction" => self
|
||||
.context
|
||||
.request
|
||||
.data_action
|
||||
.clone()
|
||||
.map(|s: String| Value::String(s.into()))
|
||||
.unwrap_or(Value::Undefined),
|
||||
_ => Value::Undefined,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_resource_attribute(&self, attr: &AttributeReference) -> Value {
|
||||
let attributes = &self.context.resource.attributes;
|
||||
let direct = Self::lookup_attribute_value(attributes, attr);
|
||||
if !matches!(direct, Value::Undefined) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
match attr.attribute.as_str() {
|
||||
"id" => Value::String(self.context.resource.id.as_str().into()),
|
||||
"type" => Value::String(self.context.resource.resource_type.as_str().into()),
|
||||
"scope" => Value::String(self.context.resource.scope.as_str().into()),
|
||||
_ => Value::Undefined,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_principal_attribute(&self, attr: &AttributeReference) -> Value {
|
||||
let attributes = &self.context.principal.custom_security_attributes;
|
||||
let direct = Self::lookup_attribute_value(attributes, attr);
|
||||
if !matches!(direct, Value::Undefined) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
match attr.attribute.as_str() {
|
||||
"id" => Value::String(self.context.principal.id.as_str().into()),
|
||||
"principalType" => Value::String(self.principal_type_string().into()),
|
||||
_ => Value::Undefined,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_environment_attribute(&self, attr: &AttributeReference) -> Value {
|
||||
let EnvironmentContextValues {
|
||||
is_private_link,
|
||||
private_endpoint,
|
||||
subnet,
|
||||
utc_now,
|
||||
time_of_day,
|
||||
} = self.environment_values();
|
||||
|
||||
match attr.attribute.as_str() {
|
||||
"isPrivateLink" => is_private_link,
|
||||
"privateEndpoint" => private_endpoint,
|
||||
"subnet" => subnet,
|
||||
"utcNow" => utc_now,
|
||||
"timeOfDay" => time_of_day,
|
||||
_ => Value::Undefined,
|
||||
}
|
||||
}
|
||||
|
||||
fn environment_values(&self) -> EnvironmentContextValues {
|
||||
let is_private_link = self
|
||||
.context
|
||||
.environment
|
||||
.is_private_link
|
||||
.map(Value::Bool)
|
||||
.unwrap_or(Value::Undefined);
|
||||
|
||||
let private_endpoint = self
|
||||
.context
|
||||
.environment
|
||||
.private_endpoint
|
||||
.clone()
|
||||
.map(|s: String| Value::String(s.into()))
|
||||
.unwrap_or(Value::Undefined);
|
||||
|
||||
let subnet = self
|
||||
.context
|
||||
.environment
|
||||
.subnet
|
||||
.clone()
|
||||
.map(|s: String| Value::String(s.into()))
|
||||
.unwrap_or(Value::Undefined);
|
||||
|
||||
let utc_now = self
|
||||
.context
|
||||
.environment
|
||||
.utc_now
|
||||
.clone()
|
||||
.map(|s: String| Value::String(s.into()))
|
||||
.unwrap_or(Value::Undefined);
|
||||
|
||||
let time_of_day = self
|
||||
.context
|
||||
.environment
|
||||
.utc_now
|
||||
.as_ref()
|
||||
.and_then(|s| Self::time_of_day_from_rfc3339(s).ok())
|
||||
.map(|s: String| Value::String(s.into()))
|
||||
.unwrap_or(Value::Undefined);
|
||||
|
||||
EnvironmentContextValues {
|
||||
is_private_link,
|
||||
private_endpoint,
|
||||
subnet,
|
||||
utc_now,
|
||||
time_of_day,
|
||||
}
|
||||
}
|
||||
|
||||
const fn principal_type_string(&self) -> &'static str {
|
||||
match self.context.principal.principal_type {
|
||||
PrincipalType::User => "User",
|
||||
PrincipalType::Group => "Group",
|
||||
PrincipalType::ServicePrincipal => "ServicePrincipal",
|
||||
PrincipalType::ManagedServiceIdentity => "ManagedServiceIdentity",
|
||||
}
|
||||
}
|
||||
|
||||
fn lookup_attribute_value(container: &Value, attr: &AttributeReference) -> Value {
|
||||
let key = attr.namespace.as_ref().map(|ns| {
|
||||
let capacity = ns
|
||||
.len()
|
||||
.saturating_add(attr.attribute.len())
|
||||
.saturating_add(1);
|
||||
let mut combined = String::with_capacity(capacity);
|
||||
combined.push_str(ns);
|
||||
combined.push(':');
|
||||
combined.push_str(&attr.attribute);
|
||||
combined
|
||||
});
|
||||
|
||||
match *container {
|
||||
Value::Object(ref map) => {
|
||||
if let Some(ref ns) = attr.namespace {
|
||||
let namespace_key = Value::String(ns.as_str().into());
|
||||
#[allow(clippy::pattern_type_mismatch)]
|
||||
if let Some(Value::Object(ns_obj)) = map.get(&namespace_key) {
|
||||
let attr_key = Value::String(attr.attribute.as_str().into());
|
||||
if let Some(value) = ns_obj.get(&attr_key) {
|
||||
return value.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref combined) = key {
|
||||
let attr_key = Value::String(combined.as_str().into());
|
||||
if let Some(value) = map.get(&attr_key) {
|
||||
return value.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let attr_key = Value::String(attr.attribute.as_str().into());
|
||||
map.get(&attr_key).cloned().unwrap_or(Value::Undefined)
|
||||
}
|
||||
_ => Value::Undefined,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_path_segment(
|
||||
value: Value,
|
||||
segment: &AttributePathSegment,
|
||||
) -> Result<Value, ConditionEvalError> {
|
||||
match *segment {
|
||||
AttributePathSegment::Key(ref key) => match value {
|
||||
Value::Object(map) => Ok(map
|
||||
.get(&Value::String(key.as_str().into()))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Undefined)),
|
||||
_ => Ok(Value::Undefined),
|
||||
},
|
||||
AttributePathSegment::Index(index) => match value {
|
||||
Value::Array(list) => Ok(list.get(index).cloned().unwrap_or(Value::Undefined)),
|
||||
_ => Ok(Value::Undefined),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn time_of_day_from_rfc3339(value: &str) -> Result<String, ConditionEvalError> {
|
||||
#[cfg(feature = "time")]
|
||||
{
|
||||
let dt = DateTime::parse_from_rfc3339(value)
|
||||
.map_err(|err| ConditionEvalError::new(err.to_string()))?;
|
||||
let time = dt.time();
|
||||
Ok(format!(
|
||||
"{:02}:{:02}:{:02}",
|
||||
time.hour(),
|
||||
time.minute(),
|
||||
time.second()
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "time"))]
|
||||
{
|
||||
let _ = value;
|
||||
Err(ConditionEvalError::new(
|
||||
"time feature disabled; cannot derive timeOfDay",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EnvironmentContextValues {
|
||||
is_private_link: Value,
|
||||
private_endpoint: Value,
|
||||
subnet: Value,
|
||||
utc_now: Value,
|
||||
time_of_day: Value,
|
||||
}
|
||||
24
src/languages/azure_rbac/interpreter/error.rs
Normal file
24
src/languages/azure_rbac/interpreter/error.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
use alloc::string::String;
|
||||
|
||||
/// Error returned during condition evaluation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConditionEvalError {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl ConditionEvalError {
|
||||
pub(crate) fn new(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl core::fmt::Display for ConditionEvalError {
|
||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||
write!(f, "{}", self.message)
|
||||
}
|
||||
}
|
||||
188
src/languages/azure_rbac/interpreter/eval.rs
Normal file
188
src/languages/azure_rbac/interpreter/eval.rs
Normal file
@@ -0,0 +1,188 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
use alloc::format;
|
||||
use alloc::string::{String, ToString as _};
|
||||
use alloc::vec::Vec;
|
||||
|
||||
use crate::languages::azure_rbac::ast::EvaluationContext;
|
||||
use crate::languages::azure_rbac::ast::{
|
||||
BinaryExpression, ConditionExpr, ConditionExpression, FunctionCallExpression,
|
||||
LogicalExpression, LogicalOperator, UnaryExpression, UnaryOperator,
|
||||
};
|
||||
use crate::languages::azure_rbac::builtins::{
|
||||
DefaultRbacBuiltinEvaluator, RbacBuiltin, RbacBuiltinContext,
|
||||
};
|
||||
use crate::languages::azure_rbac::parser::parse_condition_expression;
|
||||
use crate::value::Value;
|
||||
|
||||
use super::error::ConditionEvalError;
|
||||
|
||||
pub(super) struct Evaluator<'a> {
|
||||
pub(super) context: &'a EvaluationContext,
|
||||
variables: Vec<VariableBinding>,
|
||||
}
|
||||
|
||||
struct VariableBinding {
|
||||
name: String,
|
||||
value: Value,
|
||||
}
|
||||
|
||||
impl<'a> Evaluator<'a> {
|
||||
pub(super) const fn new(context: &'a EvaluationContext) -> Self {
|
||||
Self {
|
||||
context,
|
||||
variables: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn evaluate_str(&mut self, condition: &str) -> Result<bool, ConditionEvalError> {
|
||||
let parsed = parse_condition_expression(condition)
|
||||
.map_err(|err| ConditionEvalError::new(err.to_string()))?;
|
||||
self.evaluate_condition_expression(&parsed)
|
||||
}
|
||||
|
||||
pub(super) fn evaluate_condition_expression(
|
||||
&mut self,
|
||||
condition: &ConditionExpression,
|
||||
) -> Result<bool, ConditionEvalError> {
|
||||
let expr = condition
|
||||
.expression
|
||||
.as_ref()
|
||||
.ok_or_else(|| ConditionEvalError::new("Condition expression not parsed"))?;
|
||||
self.evaluate_bool(expr)
|
||||
}
|
||||
|
||||
pub(super) fn evaluate_bool(
|
||||
&mut self,
|
||||
expr: &ConditionExpr,
|
||||
) -> Result<bool, ConditionEvalError> {
|
||||
let value = self.evaluate_value(expr)?;
|
||||
match value {
|
||||
Value::Bool(b) => Ok(b),
|
||||
Value::Undefined => Ok(false),
|
||||
_ => Err(ConditionEvalError::new(
|
||||
"Condition did not evaluate to boolean",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn evaluate_value(
|
||||
&mut self,
|
||||
expr: &ConditionExpr,
|
||||
) -> Result<Value, ConditionEvalError> {
|
||||
match *expr {
|
||||
ConditionExpr::Logical(ref logical) => self.eval_logical(logical),
|
||||
ConditionExpr::Unary(ref unary) => self.eval_unary(unary),
|
||||
ConditionExpr::Binary(ref binary) => self.eval_binary(binary),
|
||||
ConditionExpr::FunctionCall(ref call) => self.eval_function(call),
|
||||
ConditionExpr::AttributeReference(ref attr) => self.eval_attribute_reference(attr),
|
||||
ConditionExpr::ArrayExpression(ref array) => self.eval_array_expression(array),
|
||||
ConditionExpr::Identifier(ref identifier) => Ok(self.eval_identifier(identifier)),
|
||||
ConditionExpr::VariableReference(ref variable) => {
|
||||
self.eval_variable_reference(variable)
|
||||
}
|
||||
ConditionExpr::PropertyAccess(ref access) => self.eval_property_access(access),
|
||||
ConditionExpr::StringLiteral(ref lit) => Ok(Value::String(lit.value.as_str().into())),
|
||||
ConditionExpr::NumberLiteral(ref lit) => {
|
||||
let num = lit.raw.parse::<f64>().map_err(|_| {
|
||||
ConditionEvalError::new(format!("Invalid numeric literal: {}", lit.raw))
|
||||
})?;
|
||||
Ok(Value::from(num))
|
||||
}
|
||||
ConditionExpr::BooleanLiteral(ref lit) => Ok(Value::Bool(lit.value)),
|
||||
ConditionExpr::NullLiteral(_) => Ok(Value::Null),
|
||||
ConditionExpr::DateTimeLiteral(ref lit) => Ok(Value::String(lit.value.as_str().into())),
|
||||
ConditionExpr::TimeLiteral(ref lit) => Ok(Value::String(lit.value.as_str().into())),
|
||||
ConditionExpr::SetLiteral(ref set) => self.eval_set_literal(set),
|
||||
ConditionExpr::ListLiteral(ref list) => self.eval_list_literal(&list.elements),
|
||||
}
|
||||
}
|
||||
|
||||
fn eval_logical(&mut self, expr: &LogicalExpression) -> Result<Value, ConditionEvalError> {
|
||||
let left = self.evaluate_bool(&expr.left)?;
|
||||
match expr.operator {
|
||||
LogicalOperator::And => {
|
||||
if !left {
|
||||
return Ok(Value::Bool(false));
|
||||
}
|
||||
let right = self.evaluate_bool(&expr.right)?;
|
||||
Ok(Value::Bool(left && right))
|
||||
}
|
||||
LogicalOperator::Or => {
|
||||
if left {
|
||||
return Ok(Value::Bool(true));
|
||||
}
|
||||
let right = self.evaluate_bool(&expr.right)?;
|
||||
Ok(Value::Bool(left || right))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn eval_unary(&mut self, expr: &UnaryExpression) -> Result<Value, ConditionEvalError> {
|
||||
match expr.operator {
|
||||
UnaryOperator::Not => {
|
||||
let value = self.evaluate_bool(&expr.operand)?;
|
||||
Ok(Value::Bool(!value))
|
||||
}
|
||||
UnaryOperator::Exists => {
|
||||
let value = self.evaluate_value(&expr.operand)?;
|
||||
Ok(Value::Bool(!matches!(value, Value::Undefined)))
|
||||
}
|
||||
UnaryOperator::NotExists => {
|
||||
let value = self.evaluate_value(&expr.operand)?;
|
||||
Ok(Value::Bool(matches!(value, Value::Undefined)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn eval_binary(&mut self, expr: &BinaryExpression) -> Result<Value, ConditionEvalError> {
|
||||
let left = self.evaluate_value(&expr.left)?;
|
||||
let right = self.evaluate_value(&expr.right)?;
|
||||
let builtin = expr.operator;
|
||||
|
||||
let evaluator = DefaultRbacBuiltinEvaluator::new();
|
||||
let ctx = RbacBuiltinContext::new(self.context);
|
||||
let args = [left, right];
|
||||
evaluator
|
||||
.eval(builtin, &args, &ctx)
|
||||
.map_err(|err| ConditionEvalError::new(err.to_string()))
|
||||
}
|
||||
|
||||
fn eval_function(
|
||||
&mut self,
|
||||
call: &FunctionCallExpression,
|
||||
) -> Result<Value, ConditionEvalError> {
|
||||
let mut args = Vec::with_capacity(call.arguments.len());
|
||||
for arg in &call.arguments {
|
||||
args.push(self.evaluate_value(arg)?);
|
||||
}
|
||||
let builtin = RbacBuiltin::parse(call.function.as_str()).ok_or_else(|| {
|
||||
ConditionEvalError::new(format!("Unsupported function: {}", call.function))
|
||||
})?;
|
||||
let evaluator = DefaultRbacBuiltinEvaluator::new();
|
||||
let ctx = RbacBuiltinContext::new(self.context);
|
||||
evaluator
|
||||
.eval(builtin, &args, &ctx)
|
||||
.map_err(|err| ConditionEvalError::new(err.to_string()))
|
||||
}
|
||||
|
||||
pub(super) fn push_variable(&mut self, name: &str, value: Value) {
|
||||
self.variables.push(VariableBinding {
|
||||
name: name.to_string(),
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn pop_variable(&mut self) {
|
||||
let _ = self.variables.pop();
|
||||
}
|
||||
|
||||
pub(super) fn lookup_variable(&self, name: &str) -> Option<Value> {
|
||||
self.variables
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|binding| binding.name == name)
|
||||
.map(|binding| binding.value.clone())
|
||||
}
|
||||
}
|
||||
73
src/languages/azure_rbac/interpreter/literals.rs
Normal file
73
src/languages/azure_rbac/interpreter/literals.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
use alloc::collections::BTreeSet;
|
||||
use alloc::string::String;
|
||||
use alloc::vec::Vec;
|
||||
|
||||
use crate::languages::azure_rbac::ast::{ConditionExpr, IdentifierExpression, SetLiteral};
|
||||
use crate::value::Value;
|
||||
|
||||
use super::error::ConditionEvalError;
|
||||
use super::eval::Evaluator;
|
||||
|
||||
impl<'a> Evaluator<'a> {
|
||||
pub(super) fn eval_set_literal(
|
||||
&mut self,
|
||||
set: &SetLiteral,
|
||||
) -> Result<Value, ConditionEvalError> {
|
||||
let mut values = BTreeSet::new();
|
||||
for elem in &set.elements {
|
||||
let value = self.evaluate_value(elem)?;
|
||||
values.insert(value);
|
||||
}
|
||||
Ok(Value::from(values))
|
||||
}
|
||||
|
||||
pub(super) fn eval_list_literal(
|
||||
&mut self,
|
||||
elements: &[ConditionExpr],
|
||||
) -> Result<Value, ConditionEvalError> {
|
||||
let mut values = Vec::with_capacity(elements.len());
|
||||
for elem in elements {
|
||||
values.push(self.evaluate_value(elem)?);
|
||||
}
|
||||
Ok(Value::from(values))
|
||||
}
|
||||
|
||||
pub(super) fn eval_identifier(&self, identifier: &IdentifierExpression) -> Value {
|
||||
match identifier.name.as_str() {
|
||||
"operationName" => self
|
||||
.context
|
||||
.action
|
||||
.clone()
|
||||
.map(|s: String| Value::String(s.into()))
|
||||
.unwrap_or(Value::Undefined),
|
||||
"subOperationName" => self
|
||||
.context
|
||||
.suboperation
|
||||
.clone()
|
||||
.map(|s: String| Value::String(s.into()))
|
||||
.unwrap_or(Value::Undefined),
|
||||
"action" => self
|
||||
.context
|
||||
.request
|
||||
.action
|
||||
.clone()
|
||||
.map(|s: String| Value::String(s.into()))
|
||||
.unwrap_or(Value::Undefined),
|
||||
"dataAction" => self
|
||||
.context
|
||||
.request
|
||||
.data_action
|
||||
.clone()
|
||||
.map(|s: String| Value::String(s.into()))
|
||||
.unwrap_or(Value::Undefined),
|
||||
"principalId" => Value::String(self.context.principal.id.as_str().into()),
|
||||
"resourceId" => Value::String(self.context.resource.id.as_str().into()),
|
||||
"resourceScope" => Value::String(self.context.resource.scope.as_str().into()),
|
||||
"resourceType" => Value::String(self.context.resource.resource_type.as_str().into()),
|
||||
_ => Value::Undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
63
src/languages/azure_rbac/interpreter/mod.rs
Normal file
63
src/languages/azure_rbac/interpreter/mod.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
//! Azure RBAC condition expression interpreter.
|
||||
//!
|
||||
//! This module evaluates parsed condition expressions against an
|
||||
//! [`EvaluationContext`] without compiling to RVM instructions.
|
||||
|
||||
mod access;
|
||||
mod attributes;
|
||||
mod error;
|
||||
mod eval;
|
||||
mod literals;
|
||||
mod quantifiers;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use error::ConditionEvalError;
|
||||
|
||||
use crate::languages::azure_rbac::ast::{ConditionExpr, ConditionExpression, EvaluationContext};
|
||||
use crate::value::Value;
|
||||
use eval::Evaluator;
|
||||
|
||||
/// Dynamic interpreter for Azure RBAC conditions.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConditionInterpreter<'a> {
|
||||
context: &'a EvaluationContext,
|
||||
}
|
||||
|
||||
impl<'a> ConditionInterpreter<'a> {
|
||||
/// Create a new interpreter bound to a context.
|
||||
pub const fn new(context: &'a EvaluationContext) -> Self {
|
||||
Self { context }
|
||||
}
|
||||
|
||||
/// Parse and evaluate a condition string.
|
||||
pub fn evaluate_str(&self, condition: &str) -> Result<bool, ConditionEvalError> {
|
||||
let mut evaluator = Evaluator::new(self.context);
|
||||
evaluator.evaluate_str(condition)
|
||||
}
|
||||
|
||||
/// Evaluate a parsed condition expression.
|
||||
pub fn evaluate_condition_expression(
|
||||
&self,
|
||||
condition: &ConditionExpression,
|
||||
) -> Result<bool, ConditionEvalError> {
|
||||
let mut evaluator = Evaluator::new(self.context);
|
||||
evaluator.evaluate_condition_expression(condition)
|
||||
}
|
||||
|
||||
/// Evaluate a condition AST and return a boolean result.
|
||||
pub fn evaluate_bool(&self, expr: &ConditionExpr) -> Result<bool, ConditionEvalError> {
|
||||
let mut evaluator = Evaluator::new(self.context);
|
||||
evaluator.evaluate_bool(expr)
|
||||
}
|
||||
|
||||
/// Evaluate a condition AST and return a value.
|
||||
pub fn evaluate_value(&self, expr: &ConditionExpr) -> Result<Value, ConditionEvalError> {
|
||||
let mut evaluator = Evaluator::new(self.context);
|
||||
evaluator.evaluate_value(expr)
|
||||
}
|
||||
}
|
||||
77
src/languages/azure_rbac/interpreter/quantifiers.rs
Normal file
77
src/languages/azure_rbac/interpreter/quantifiers.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
use alloc::format;
|
||||
use alloc::vec::Vec;
|
||||
|
||||
use crate::languages::azure_rbac::ast::ArrayExpression;
|
||||
use crate::value::Value;
|
||||
|
||||
use super::error::ConditionEvalError;
|
||||
use super::eval::Evaluator;
|
||||
|
||||
impl<'a> Evaluator<'a> {
|
||||
pub(super) fn eval_array_expression(
|
||||
&mut self,
|
||||
array: &ArrayExpression,
|
||||
) -> Result<Value, ConditionEvalError> {
|
||||
let collection = self.evaluate_value(&array.array)?;
|
||||
let values: Vec<&Value> = match collection {
|
||||
Value::Array(ref list) => list.iter().collect(),
|
||||
Value::Set(ref set) => set.iter().collect(),
|
||||
Value::Undefined => return Ok(Value::Bool(false)),
|
||||
_ => {
|
||||
return Err(ConditionEvalError::new(
|
||||
"Array expression expects a list or set",
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let is_any = array.operator.name.eq_ignore_ascii_case("ANY");
|
||||
let is_all = array.operator.name.eq_ignore_ascii_case("ALL");
|
||||
if !is_any && !is_all {
|
||||
return Err(ConditionEvalError::new(format!(
|
||||
"Unsupported array operator: {}",
|
||||
array.operator.name
|
||||
)));
|
||||
}
|
||||
|
||||
let variable = array.variable.as_deref();
|
||||
if is_any {
|
||||
for value in &values {
|
||||
let matched = self.eval_array_condition(variable, value, &array.condition)?;
|
||||
if matched {
|
||||
return Ok(Value::Bool(true));
|
||||
}
|
||||
}
|
||||
return Ok(Value::Bool(false));
|
||||
}
|
||||
|
||||
let mut saw_value = false;
|
||||
for value in &values {
|
||||
saw_value = true;
|
||||
let matched = self.eval_array_condition(variable, value, &array.condition)?;
|
||||
if !matched {
|
||||
return Ok(Value::Bool(false));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Value::Bool(!saw_value || is_all))
|
||||
}
|
||||
|
||||
fn eval_array_condition(
|
||||
&mut self,
|
||||
variable: Option<&str>,
|
||||
value: &Value,
|
||||
condition: &crate::languages::azure_rbac::ast::ConditionExpr,
|
||||
) -> Result<bool, ConditionEvalError> {
|
||||
if let Some(name) = variable {
|
||||
self.push_variable(name, value.clone());
|
||||
let result = self.evaluate_bool(condition);
|
||||
self.pop_variable();
|
||||
result
|
||||
} else {
|
||||
self.evaluate_bool(condition)
|
||||
}
|
||||
}
|
||||
}
|
||||
187
src/languages/azure_rbac/interpreter/tests.rs
Normal file
187
src/languages/azure_rbac/interpreter/tests.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
#![allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use alloc::boxed::Box;
|
||||
use alloc::string::ToString as _;
|
||||
use alloc::vec;
|
||||
use alloc::vec::Vec;
|
||||
|
||||
use super::ConditionInterpreter;
|
||||
use crate::languages::azure_rbac::ast::{
|
||||
ArrayExpression, ArrayOperator, AttributeReference, AttributeSource, BinaryExpression,
|
||||
ConditionExpr, EmptySpan, EnvironmentContext, EvaluationContext, ListLiteral, Principal,
|
||||
PrincipalType, PropertyAccessExpression, RequestContext, Resource, StringLiteral,
|
||||
VariableReference,
|
||||
};
|
||||
use crate::languages::azure_rbac::builtins::RbacBuiltin;
|
||||
use crate::value::Value;
|
||||
|
||||
fn basic_context() -> EvaluationContext {
|
||||
EvaluationContext {
|
||||
principal: Principal {
|
||||
id: "user-1".to_string(),
|
||||
principal_type: PrincipalType::User,
|
||||
custom_security_attributes: Value::new_object(),
|
||||
},
|
||||
resource: Resource {
|
||||
id: "/subscriptions/s1".to_string(),
|
||||
resource_type: "Microsoft.Storage/storageAccounts".to_string(),
|
||||
scope: "/subscriptions/s1".to_string(),
|
||||
attributes: Value::from_json_str(
|
||||
r#"{"owner":"alice","tags":["a","b"],"confidential":true}"#,
|
||||
)
|
||||
.unwrap(),
|
||||
},
|
||||
request: RequestContext {
|
||||
action: Some("Microsoft.Storage/storageAccounts/read".to_string()),
|
||||
data_action: None,
|
||||
attributes: Value::from_json_str(r#"{"clientIP":"10.0.0.1"}"#).unwrap(),
|
||||
},
|
||||
environment: EnvironmentContext {
|
||||
is_private_link: None,
|
||||
private_endpoint: None,
|
||||
subnet: None,
|
||||
utc_now: Some("2023-05-01T12:00:00Z".to_string()),
|
||||
},
|
||||
action: Some("Microsoft.Storage/storageAccounts/read".to_string()),
|
||||
suboperation: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluates_basic_condition() {
|
||||
let ctx = basic_context();
|
||||
let interpreter = ConditionInterpreter::new(&ctx);
|
||||
let result = interpreter
|
||||
.evaluate_str("@Resource[owner] StringEquals 'alice'")
|
||||
.unwrap();
|
||||
assert!(result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluates_logical_condition() {
|
||||
let ctx = basic_context();
|
||||
let interpreter = ConditionInterpreter::new(&ctx);
|
||||
let result = interpreter
|
||||
.evaluate_str(
|
||||
"@Resource[owner] StringEquals 'alice' AND @Resource[confidential] BoolEquals true",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluates_property_access() {
|
||||
let mut ctx = basic_context();
|
||||
ctx.request.attributes = Value::from_json_str(r#"{"obj":{"key":"value"}}"#).unwrap();
|
||||
|
||||
let expr = ConditionExpr::Binary(BinaryExpression {
|
||||
span: EmptySpan,
|
||||
operator: RbacBuiltin::StringEquals,
|
||||
left: Box::new(ConditionExpr::PropertyAccess(PropertyAccessExpression {
|
||||
span: EmptySpan,
|
||||
object: Box::new(ConditionExpr::AttributeReference(AttributeReference {
|
||||
span: EmptySpan,
|
||||
source: AttributeSource::Request,
|
||||
namespace: None,
|
||||
attribute: "obj".to_string(),
|
||||
path: Vec::new(),
|
||||
})),
|
||||
property: "key".to_string(),
|
||||
})),
|
||||
right: Box::new(ConditionExpr::StringLiteral(StringLiteral {
|
||||
span: EmptySpan,
|
||||
value: "value".to_string(),
|
||||
})),
|
||||
});
|
||||
|
||||
let interpreter = ConditionInterpreter::new(&ctx);
|
||||
let result = interpreter.evaluate_bool(&expr).unwrap();
|
||||
assert!(result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluates_any_quantifier_with_variable() {
|
||||
let ctx = basic_context();
|
||||
let expr = ConditionExpr::ArrayExpression(ArrayExpression {
|
||||
span: EmptySpan,
|
||||
operator: ArrayOperator {
|
||||
name: "ANY".to_string(),
|
||||
modifier: None,
|
||||
},
|
||||
array: Box::new(ConditionExpr::ListLiteral(ListLiteral {
|
||||
span: EmptySpan,
|
||||
elements: vec![
|
||||
ConditionExpr::StringLiteral(StringLiteral {
|
||||
span: EmptySpan,
|
||||
value: "a".to_string(),
|
||||
}),
|
||||
ConditionExpr::StringLiteral(StringLiteral {
|
||||
span: EmptySpan,
|
||||
value: "b".to_string(),
|
||||
}),
|
||||
],
|
||||
})),
|
||||
variable: Some("item".to_string()),
|
||||
condition: Box::new(ConditionExpr::Binary(BinaryExpression {
|
||||
span: EmptySpan,
|
||||
operator: RbacBuiltin::StringEquals,
|
||||
left: Box::new(ConditionExpr::VariableReference(VariableReference {
|
||||
span: EmptySpan,
|
||||
name: "item".to_string(),
|
||||
})),
|
||||
right: Box::new(ConditionExpr::StringLiteral(StringLiteral {
|
||||
span: EmptySpan,
|
||||
value: "b".to_string(),
|
||||
})),
|
||||
})),
|
||||
});
|
||||
|
||||
let interpreter = ConditionInterpreter::new(&ctx);
|
||||
let result = interpreter.evaluate_bool(&expr).unwrap();
|
||||
assert!(result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluates_all_quantifier_with_variable() {
|
||||
let ctx = basic_context();
|
||||
let expr = ConditionExpr::ArrayExpression(ArrayExpression {
|
||||
span: EmptySpan,
|
||||
operator: ArrayOperator {
|
||||
name: "ALL".to_string(),
|
||||
modifier: None,
|
||||
},
|
||||
array: Box::new(ConditionExpr::ListLiteral(ListLiteral {
|
||||
span: EmptySpan,
|
||||
elements: vec![
|
||||
ConditionExpr::StringLiteral(StringLiteral {
|
||||
span: EmptySpan,
|
||||
value: "a".to_string(),
|
||||
}),
|
||||
ConditionExpr::StringLiteral(StringLiteral {
|
||||
span: EmptySpan,
|
||||
value: "a".to_string(),
|
||||
}),
|
||||
],
|
||||
})),
|
||||
variable: Some("item".to_string()),
|
||||
condition: Box::new(ConditionExpr::Binary(BinaryExpression {
|
||||
span: EmptySpan,
|
||||
operator: RbacBuiltin::StringEquals,
|
||||
left: Box::new(ConditionExpr::VariableReference(VariableReference {
|
||||
span: EmptySpan,
|
||||
name: "item".to_string(),
|
||||
})),
|
||||
right: Box::new(ConditionExpr::StringLiteral(StringLiteral {
|
||||
span: EmptySpan,
|
||||
value: "a".to_string(),
|
||||
})),
|
||||
})),
|
||||
});
|
||||
|
||||
let interpreter = ConditionInterpreter::new(&ctx);
|
||||
let result = interpreter.evaluate_bool(&expr).unwrap();
|
||||
assert!(result);
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
7
src/languages/azure_rbac/test_cases/ActionMatches.yaml
Normal file
7
src/languages/azure_rbac/test_cases/ActionMatches.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: action_matches_true
|
||||
condition: "ActionMatches('Microsoft.Storage/storageAccounts/*')"
|
||||
expected: true
|
||||
- name: action_matches_false
|
||||
condition: "ActionMatches('Microsoft.Storage/storageAccounts/delete')"
|
||||
expected: false
|
||||
7
src/languages/azure_rbac/test_cases/AddDays.yaml
Normal file
7
src/languages/azure_rbac/test_cases/AddDays.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: add_days_true
|
||||
condition: "AddDays('2023-05-01T00:00:00Z', 2) DateTimeEquals '2023-05-03T00:00:00+00:00'"
|
||||
expected: true
|
||||
- name: add_days_false
|
||||
condition: "AddDays('2023-05-01T00:00:00Z', 1) DateTimeEquals '2023-05-03T00:00:00+00:00'"
|
||||
expected: false
|
||||
7
src/languages/azure_rbac/test_cases/BoolEquals.yaml
Normal file
7
src/languages/azure_rbac/test_cases/BoolEquals.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: bool_equals_true
|
||||
condition: "@Request[enabled] BoolEquals true"
|
||||
expected: true
|
||||
- name: bool_equals_false
|
||||
condition: "@Resource[enabled] BoolEquals true"
|
||||
expected: false
|
||||
7
src/languages/azure_rbac/test_cases/BoolNotEquals.yaml
Normal file
7
src/languages/azure_rbac/test_cases/BoolNotEquals.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: bool_not_equals_true
|
||||
condition: "@Resource[enabled] BoolNotEquals true"
|
||||
expected: true
|
||||
- name: bool_not_equals_false
|
||||
condition: "@Request[enabled] BoolNotEquals true"
|
||||
expected: false
|
||||
51
src/languages/azure_rbac/test_cases/CrossProducts.yaml
Normal file
51
src/languages/azure_rbac/test_cases/CrossProducts.yaml
Normal file
@@ -0,0 +1,51 @@
|
||||
test_cases:
|
||||
- name: for_any_of_any_values_true
|
||||
condition: "@Resource[tags] ForAnyOfAnyValues @Request[allowedTags]"
|
||||
expected: true
|
||||
context:
|
||||
request_attributes:
|
||||
allowedTags: ["b", "c"]
|
||||
- name: for_any_of_any_values_false
|
||||
condition: "@Resource[tags] ForAnyOfAnyValues @Request[allowedTags]"
|
||||
expected: false
|
||||
context:
|
||||
request_attributes:
|
||||
allowedTags: ["c", "d"]
|
||||
- name: for_all_of_any_values_true
|
||||
condition: "@Resource[tags] ForAllOfAnyValues @Request[requiredTags]"
|
||||
expected: true
|
||||
context:
|
||||
request_attributes:
|
||||
requiredTags: ["a", "b", "c"]
|
||||
- name: for_all_of_any_values_false
|
||||
condition: "@Resource[tags] ForAllOfAnyValues @Request[requiredTags]"
|
||||
expected: false
|
||||
context:
|
||||
request_attributes:
|
||||
requiredTags: ["a"]
|
||||
- name: for_any_of_all_values_true
|
||||
condition: "@Resource[tags] ForAnyOfAllValues @Request[restrictedTags]"
|
||||
expected: true
|
||||
context:
|
||||
request_attributes:
|
||||
restrictedTags: ["b", "b"]
|
||||
- name: for_any_of_all_values_false
|
||||
condition: "@Resource[tags] ForAnyOfAllValues @Request[restrictedTags]"
|
||||
expected: false
|
||||
context:
|
||||
request_attributes:
|
||||
restrictedTags: ["a", "b"]
|
||||
- name: for_all_of_all_values_true
|
||||
condition: "@Resource[tags] ForAllOfAllValues @Request[mandatoryTags]"
|
||||
expected: true
|
||||
context:
|
||||
resource_attributes:
|
||||
tags: ["a"]
|
||||
request_attributes:
|
||||
mandatoryTags: ["a", "a"]
|
||||
- name: for_all_of_all_values_false
|
||||
condition: "@Resource[tags] ForAllOfAllValues @Request[mandatoryTags]"
|
||||
expected: false
|
||||
context:
|
||||
request_attributes:
|
||||
mandatoryTags: ["a", "b"]
|
||||
7
src/languages/azure_rbac/test_cases/DateTimeEquals.yaml
Normal file
7
src/languages/azure_rbac/test_cases/DateTimeEquals.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: datetime_equals_true
|
||||
condition: "@Request[date] DateTimeEquals '2023-05-01T12:00:00Z'"
|
||||
expected: true
|
||||
- name: datetime_equals_false
|
||||
condition: "@Request[date] DateTimeEquals '2023-05-02T12:00:00Z'"
|
||||
expected: false
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
7
src/languages/azure_rbac/test_cases/GuidEquals.yaml
Normal file
7
src/languages/azure_rbac/test_cases/GuidEquals.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: guid_equals_true
|
||||
condition: "@Request[guid] GuidEquals 'a1b2c3d4-0000-0000-0000-000000000000'"
|
||||
expected: true
|
||||
- name: guid_equals_false
|
||||
condition: "@Request[guid] GuidEquals 'bbbbbbbb-0000-0000-0000-000000000000'"
|
||||
expected: false
|
||||
7
src/languages/azure_rbac/test_cases/GuidNotEquals.yaml
Normal file
7
src/languages/azure_rbac/test_cases/GuidNotEquals.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: guid_not_equals_true
|
||||
condition: "@Request[guid] GuidNotEquals 'bbbbbbbb-0000-0000-0000-000000000000'"
|
||||
expected: true
|
||||
- name: guid_not_equals_false
|
||||
condition: "@Request[guid] GuidNotEquals 'a1b2c3d4-0000-0000-0000-000000000000'"
|
||||
expected: false
|
||||
7
src/languages/azure_rbac/test_cases/IpInRange.yaml
Normal file
7
src/languages/azure_rbac/test_cases/IpInRange.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: ip_in_range_true
|
||||
condition: "@Request[ip] IpInRange ['10.0.0.1', '10.0.0.10']"
|
||||
expected: true
|
||||
- name: ip_in_range_false
|
||||
condition: "@Request[ip] IpInRange ['10.0.0.9', '10.0.0.10']"
|
||||
expected: false
|
||||
7
src/languages/azure_rbac/test_cases/IpMatch.yaml
Normal file
7
src/languages/azure_rbac/test_cases/IpMatch.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: ip_match_true
|
||||
condition: "@Request[ip] IpMatch '10.0.0.0/24'"
|
||||
expected: true
|
||||
- name: ip_match_false
|
||||
condition: "@Request[ip] IpMatch '192.168.0.0/16'"
|
||||
expected: false
|
||||
7
src/languages/azure_rbac/test_cases/IpNotMatch.yaml
Normal file
7
src/languages/azure_rbac/test_cases/IpNotMatch.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: ip_not_match_true
|
||||
condition: "@Request[ip] IpNotMatch '192.168.0.0/16'"
|
||||
expected: true
|
||||
- name: ip_not_match_false
|
||||
condition: "@Request[ip] IpNotMatch '10.0.0.0/24'"
|
||||
expected: false
|
||||
7
src/languages/azure_rbac/test_cases/ListContains.yaml
Normal file
7
src/languages/azure_rbac/test_cases/ListContains.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: list_contains_true
|
||||
condition: "@Request[tags] ListContains 'prod'"
|
||||
expected: true
|
||||
- name: list_contains_false
|
||||
condition: "@Request[tags] ListContains 'dev'"
|
||||
expected: false
|
||||
7
src/languages/azure_rbac/test_cases/ListNotContains.yaml
Normal file
7
src/languages/azure_rbac/test_cases/ListNotContains.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: list_not_contains_true
|
||||
condition: "@Request[tags] ListNotContains 'dev'"
|
||||
expected: true
|
||||
- name: list_not_contains_false
|
||||
condition: "@Request[tags] ListNotContains 'gold'"
|
||||
expected: false
|
||||
7
src/languages/azure_rbac/test_cases/NormalizeList.yaml
Normal file
7
src/languages/azure_rbac/test_cases/NormalizeList.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: normalize_list_contains
|
||||
condition: "NormalizeList({'a','b'}) ListContains 'a'"
|
||||
expected: true
|
||||
- name: normalize_list_not_contains
|
||||
condition: "NormalizeList({'a','b'}) ListNotContains 'c'"
|
||||
expected: true
|
||||
7
src/languages/azure_rbac/test_cases/NormalizeSet.yaml
Normal file
7
src/languages/azure_rbac/test_cases/NormalizeSet.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: normalize_set_contains
|
||||
condition: "NormalizeSet(['a','a','b']) ListContains 'b'"
|
||||
expected: true
|
||||
- name: normalize_set_not_contains
|
||||
condition: "NormalizeSet(['a','a','b']) ListNotContains 'c'"
|
||||
expected: true
|
||||
7
src/languages/azure_rbac/test_cases/NumericEquals.yaml
Normal file
7
src/languages/azure_rbac/test_cases/NumericEquals.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: numeric_equals_true
|
||||
condition: "@Request[count] NumericEquals 10"
|
||||
expected: true
|
||||
- name: numeric_equals_false
|
||||
condition: "@Request[count] NumericEquals 11"
|
||||
expected: false
|
||||
@@ -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
|
||||
@@ -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
|
||||
7
src/languages/azure_rbac/test_cases/NumericInRange.yaml
Normal file
7
src/languages/azure_rbac/test_cases/NumericInRange.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: numeric_in_range_true
|
||||
condition: "@Request[count] NumericInRange [5, 15]"
|
||||
expected: true
|
||||
- name: numeric_in_range_false
|
||||
condition: "@Request[count] NumericInRange [11, 12]"
|
||||
expected: false
|
||||
7
src/languages/azure_rbac/test_cases/NumericLessThan.yaml
Normal file
7
src/languages/azure_rbac/test_cases/NumericLessThan.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: numeric_less_than_true
|
||||
condition: "@Resource[count] NumericLessThan 10"
|
||||
expected: true
|
||||
- name: numeric_less_than_false
|
||||
condition: "@Request[count] NumericLessThan 5"
|
||||
expected: false
|
||||
@@ -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
|
||||
@@ -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
|
||||
7
src/languages/azure_rbac/test_cases/StringContains.yaml
Normal file
7
src/languages/azure_rbac/test_cases/StringContains.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: string_contains_true
|
||||
condition: "@Request[text] StringContains 'loWo'"
|
||||
expected: true
|
||||
- name: string_contains_false
|
||||
condition: "@Request[text] StringContains 'xyz'"
|
||||
expected: false
|
||||
7
src/languages/azure_rbac/test_cases/StringEndsWith.yaml
Normal file
7
src/languages/azure_rbac/test_cases/StringEndsWith.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: string_ends_with_true
|
||||
condition: "@Request[text] StringEndsWith 'World'"
|
||||
expected: true
|
||||
- name: string_ends_with_false
|
||||
condition: "@Request[text] StringEndsWith 'Hello'"
|
||||
expected: false
|
||||
7
src/languages/azure_rbac/test_cases/StringEquals.yaml
Normal file
7
src/languages/azure_rbac/test_cases/StringEquals.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: string_equals_true
|
||||
condition: "@Request[owner] StringEquals 'alice'"
|
||||
expected: true
|
||||
- name: string_equals_false
|
||||
condition: "@Request[owner] StringEquals 'bob'"
|
||||
expected: false
|
||||
@@ -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
|
||||
7
src/languages/azure_rbac/test_cases/StringLike.yaml
Normal file
7
src/languages/azure_rbac/test_cases/StringLike.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: string_like_true
|
||||
condition: "@Request[text] StringLike 'Hello*'"
|
||||
expected: true
|
||||
- name: string_like_false
|
||||
condition: "@Request[text] StringLike 'World*'"
|
||||
expected: false
|
||||
7
src/languages/azure_rbac/test_cases/StringMatches.yaml
Normal file
7
src/languages/azure_rbac/test_cases/StringMatches.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: string_matches_true
|
||||
condition: "@Request[text] StringMatches 'Hello.*'"
|
||||
expected: true
|
||||
- name: string_matches_false
|
||||
condition: "@Request[text] StringMatches '^World'"
|
||||
expected: false
|
||||
@@ -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
|
||||
@@ -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
|
||||
7
src/languages/azure_rbac/test_cases/StringNotEquals.yaml
Normal file
7
src/languages/azure_rbac/test_cases/StringNotEquals.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: string_not_equals_true
|
||||
condition: "@Request[owner] StringNotEquals 'bob'"
|
||||
expected: true
|
||||
- name: string_not_equals_false
|
||||
condition: "@Request[owner] StringNotEquals 'alice'"
|
||||
expected: false
|
||||
@@ -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
|
||||
7
src/languages/azure_rbac/test_cases/StringNotLike.yaml
Normal file
7
src/languages/azure_rbac/test_cases/StringNotLike.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: string_not_like_true
|
||||
condition: "@Request[text] StringNotLike 'World*'"
|
||||
expected: true
|
||||
- name: string_not_like_false
|
||||
condition: "@Request[text] StringNotLike 'Hello*'"
|
||||
expected: false
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
7
src/languages/azure_rbac/test_cases/TimeOfDayEquals.yaml
Normal file
7
src/languages/azure_rbac/test_cases/TimeOfDayEquals.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: time_of_day_equals_true
|
||||
condition: "@Request[time] TimeOfDayEquals '12:30:15'"
|
||||
expected: true
|
||||
- name: time_of_day_equals_false
|
||||
condition: "@Request[time] TimeOfDayEquals '12:00:00'"
|
||||
expected: false
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
7
src/languages/azure_rbac/test_cases/ToLower.yaml
Normal file
7
src/languages/azure_rbac/test_cases/ToLower.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: to_lower_true
|
||||
condition: "ToLower('AbC') StringEquals 'abc'"
|
||||
expected: true
|
||||
- name: to_lower_false
|
||||
condition: "ToLower('AbC') StringEquals 'ABC'"
|
||||
expected: false
|
||||
7
src/languages/azure_rbac/test_cases/ToTime.yaml
Normal file
7
src/languages/azure_rbac/test_cases/ToTime.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: to_time_true
|
||||
condition: "ToTime('12:00:00') TimeOfDayEquals '12:00:00'"
|
||||
expected: true
|
||||
- name: to_time_false
|
||||
condition: "ToTime('12:00:00') TimeOfDayEquals '13:00:00'"
|
||||
expected: false
|
||||
7
src/languages/azure_rbac/test_cases/ToUpper.yaml
Normal file
7
src/languages/azure_rbac/test_cases/ToUpper.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: to_upper_true
|
||||
condition: "ToUpper('AbC') StringEquals 'ABC'"
|
||||
expected: true
|
||||
- name: to_upper_false
|
||||
condition: "ToUpper('AbC') StringEquals 'abc'"
|
||||
expected: false
|
||||
7
src/languages/azure_rbac/test_cases/Trim.yaml
Normal file
7
src/languages/azure_rbac/test_cases/Trim.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
test_cases:
|
||||
- name: trim_true
|
||||
condition: "Trim(' hi ') StringEquals 'hi'"
|
||||
expected: true
|
||||
- name: trim_false
|
||||
condition: "Trim(' hi ') StringEquals ' hi'"
|
||||
expected: false
|
||||
@@ -153,3 +153,214 @@ mod condition_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rbac_builtin_tests {
|
||||
#![allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)] // tests unwrap/expect to assert evaluation
|
||||
use alloc::string::{String, ToString as _};
|
||||
use alloc::vec::Vec;
|
||||
use serde_json;
|
||||
use serde_yaml;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::languages::azure_rbac::ast::{
|
||||
EnvironmentContext, EvaluationContext, Principal, PrincipalType, RequestContext, Resource,
|
||||
};
|
||||
use crate::languages::azure_rbac::interpreter::ConditionInterpreter;
|
||||
use crate::languages::azure_rbac::parser::parse_condition_expression;
|
||||
use crate::value::Value;
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct EvalTestCase {
|
||||
name: String,
|
||||
condition: String,
|
||||
expected: bool,
|
||||
#[serde(default)]
|
||||
context: Option<EvalContextOverrides>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct EvalTestCases {
|
||||
test_cases: Vec<EvalTestCase>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, Default)]
|
||||
struct EvalContextOverrides {
|
||||
action: Option<String>,
|
||||
suboperation: Option<String>,
|
||||
request_action: Option<String>,
|
||||
data_action: Option<String>,
|
||||
principal_id: Option<String>,
|
||||
principal_type: Option<String>,
|
||||
resource_id: Option<String>,
|
||||
resource_type: Option<String>,
|
||||
resource_scope: Option<String>,
|
||||
request_attributes: Option<serde_json::Value>,
|
||||
resource_attributes: Option<serde_json::Value>,
|
||||
principal_custom_security_attributes: Option<serde_json::Value>,
|
||||
environment: Option<EvalEnvironmentOverrides>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, Default)]
|
||||
struct EvalEnvironmentOverrides {
|
||||
is_private_link: Option<bool>,
|
||||
private_endpoint: Option<String>,
|
||||
subnet: Option<String>,
|
||||
utc_now: Option<String>,
|
||||
}
|
||||
|
||||
fn default_context() -> EvaluationContext {
|
||||
EvaluationContext {
|
||||
principal: Principal {
|
||||
id: "user-1".to_string(),
|
||||
principal_type: PrincipalType::User,
|
||||
custom_security_attributes: Value::from_json_str(
|
||||
r#"{"department":"eng","levels":["L1","L2"]}"#,
|
||||
)
|
||||
.unwrap(),
|
||||
},
|
||||
resource: Resource {
|
||||
id: "/subscriptions/s1".to_string(),
|
||||
resource_type: "Microsoft.Storage/storageAccounts".to_string(),
|
||||
scope: "/subscriptions/s1".to_string(),
|
||||
attributes: Value::from_json_str(
|
||||
r#"{"owner":"alice","tags":["a","b"],"count":5,"enabled":false,"ip":"10.0.0.5","guid":"a1b2c3d4-0000-0000-0000-000000000000"}"#,
|
||||
)
|
||||
.unwrap(),
|
||||
},
|
||||
request: RequestContext {
|
||||
action: Some("Microsoft.Storage/storageAccounts/read".to_string()),
|
||||
data_action: Some("Microsoft.Storage/storageAccounts/read".to_string()),
|
||||
attributes: Value::from_json_str(
|
||||
r#"{"owner":"alice","text":"HelloWorld","tags":["prod","gold"],"count":10,"ratio":2.5,"enabled":true,"ip":"10.0.0.8","guid":"A1B2C3D4-0000-0000-0000-000000000000","time":"12:30:15","date":"2023-05-01T12:00:00Z","numbers":[1,2,3],"letters":["a","b"]}"#,
|
||||
)
|
||||
.unwrap(),
|
||||
},
|
||||
environment: EnvironmentContext {
|
||||
is_private_link: Some(false),
|
||||
private_endpoint: None,
|
||||
subnet: None,
|
||||
utc_now: Some("2023-05-01T12:00:00Z".to_string()),
|
||||
},
|
||||
action: Some("Microsoft.Storage/storageAccounts/read".to_string()),
|
||||
suboperation: Some("sub/read".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn value_from_json(value: Option<serde_json::Value>, default: Value) -> Value {
|
||||
value
|
||||
.map(|v| Value::from_json_str(&serde_json::to_string(&v).unwrap()).unwrap())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn apply_overrides(
|
||||
mut context: EvaluationContext,
|
||||
overrides: EvalContextOverrides,
|
||||
) -> EvaluationContext {
|
||||
if let Some(action) = overrides.action {
|
||||
context.action = Some(action);
|
||||
}
|
||||
if let Some(suboperation) = overrides.suboperation {
|
||||
context.suboperation = Some(suboperation);
|
||||
}
|
||||
if let Some(action) = overrides.request_action {
|
||||
context.request.action = Some(action);
|
||||
}
|
||||
if let Some(data_action) = overrides.data_action {
|
||||
context.request.data_action = Some(data_action);
|
||||
}
|
||||
if let Some(principal_id) = overrides.principal_id {
|
||||
context.principal.id = principal_id;
|
||||
}
|
||||
if let Some(principal_type) = overrides.principal_type {
|
||||
context.principal.principal_type = match principal_type.as_str() {
|
||||
"User" => PrincipalType::User,
|
||||
"Group" => PrincipalType::Group,
|
||||
"ServicePrincipal" => PrincipalType::ServicePrincipal,
|
||||
"ManagedServiceIdentity" => PrincipalType::ManagedServiceIdentity,
|
||||
_ => PrincipalType::User,
|
||||
};
|
||||
}
|
||||
if let Some(resource_id) = overrides.resource_id {
|
||||
context.resource.id = resource_id;
|
||||
}
|
||||
if let Some(resource_type) = overrides.resource_type {
|
||||
context.resource.resource_type = resource_type;
|
||||
}
|
||||
if let Some(resource_scope) = overrides.resource_scope {
|
||||
context.resource.scope = resource_scope;
|
||||
}
|
||||
|
||||
context.request.attributes =
|
||||
value_from_json(overrides.request_attributes, context.request.attributes);
|
||||
context.resource.attributes =
|
||||
value_from_json(overrides.resource_attributes, context.resource.attributes);
|
||||
context.principal.custom_security_attributes = value_from_json(
|
||||
overrides.principal_custom_security_attributes,
|
||||
context.principal.custom_security_attributes,
|
||||
);
|
||||
|
||||
if let Some(env) = overrides.environment {
|
||||
if let Some(is_private_link) = env.is_private_link {
|
||||
context.environment.is_private_link = Some(is_private_link);
|
||||
}
|
||||
if let Some(private_endpoint) = env.private_endpoint {
|
||||
context.environment.private_endpoint = Some(private_endpoint);
|
||||
}
|
||||
if let Some(subnet) = env.subnet {
|
||||
context.environment.subnet = Some(subnet);
|
||||
}
|
||||
if let Some(utc_now) = env.utc_now {
|
||||
context.environment.utc_now = Some(utc_now);
|
||||
}
|
||||
}
|
||||
|
||||
context
|
||||
}
|
||||
|
||||
fn load_eval_test_cases() -> Vec<EvalTestCase> {
|
||||
let mut dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
dir.push("src/languages/azure_rbac/test_cases");
|
||||
|
||||
let mut entries: Vec<PathBuf> = fs::read_dir(&dir)
|
||||
.expect("Failed to read test_cases directory")
|
||||
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
|
||||
.filter(|path| path.extension().map(|ext| ext == "yaml").unwrap_or(false))
|
||||
.collect();
|
||||
entries.sort();
|
||||
|
||||
let mut cases = Vec::new();
|
||||
for path in entries {
|
||||
let yaml = fs::read_to_string(&path).expect("Failed to read test case YAML");
|
||||
let suite: EvalTestCases =
|
||||
serde_yaml::from_str(&yaml).expect("Failed to parse evaluation cases YAML");
|
||||
cases.extend(suite.test_cases);
|
||||
}
|
||||
cases
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rbac_builtins() {
|
||||
let cases = load_eval_test_cases();
|
||||
|
||||
for case in cases {
|
||||
let mut context = default_context();
|
||||
if let Some(overrides) = case.context {
|
||||
context = apply_overrides(context, overrides);
|
||||
}
|
||||
|
||||
let parsed = parse_condition_expression(&case.condition).unwrap();
|
||||
let expr = parsed.expression.expect("Missing parsed expression");
|
||||
|
||||
let interpreter = ConditionInterpreter::new(&context);
|
||||
let result = interpreter.evaluate_bool(&expr).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result, case.expected,
|
||||
"Test '{}' failed for condition '{}'",
|
||||
case.name, case.condition
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user