feat: Complete target system with C# bindings and resource inference (#458)

* feat: Add Schema Registry and Validation Framework

This commit introduces a comprehensive schema registry and validation framework, providing schema-based validation of resources and policy effects.

- Thread-safe, in-memory registry for schema storage and management
- Global registry patterns for effects and resources
- Concurrent access with proper error handling
- Unicode schema names support

- JSON Schema-compliant validation for all primitive types
- Advanced constraint validation (patterns, ranges, length limits)
- Discriminated union support with anyOf schemas
- Detailed error reporting with nested validation paths
- Discriminated subobject validation for polymorphic schemas

- **Registry Tests**: All registry operations
- **Effect Tests**: Policy effect validation
- **Resource Tests**: Resource validation
- **Validation Tests**: Core validation engine
- Thread-safety, error handling, integration scenarios, edge cases

- **Dependencies**: dashmap, once_cell, regex
- **Thread Safety**: Minimal locking with Rc<Schema> sharing
- **Error Types**: TypeMismatch, OutOfRange, PatternMismatch, etc.

- Complete schema registry and validation subsystem
- Comprehensive test coverage
- Foundation for policy validation in Regorus

Benchmarks:

- Criterion benchmarks for basic types, effects and Azure resources
- Performance range: 3.22ns (string) to 34.74µs (Azure VM resource schema validation)
- String withs patterns validation: 30.2µs. Need to explore whether regex caching helps
  bring this down.
- Azure policy effects: 188ns-1.4µs

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>

* feat: Complete target system with C# bindings and resource inference

- Add comprehensive target system with TargetRegistry and target-aware compilation
- Implement resource type inference from policy equality expressions
- Create modular C# bindings with separate wrapper classes for each concept
- Add thread-safe CompiledPolicy with reference counting for safe disposal
- Enhance FFI with detailed error propagation and target functionality
- Create TargetExampleApp demonstrating Azure Policy integration
- Add CI/CD pipeline testing for all C# applications
- Support target definitions with schema validation and resource selectors
- Implement PolicyModule struct and target-aware compilation methods
- Add comprehensive test coverage for target functionality

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>

---------

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>
This commit is contained in:
Anand Krishnamoorthi
2025-08-19 20:23:43 -05:00
committed by GitHub
parent 3c33d31d08
commit cc917ea75d
71 changed files with 10278 additions and 1000 deletions

View File

@@ -465,6 +465,9 @@ pub struct Module {
#[cfg_attr(feature = "ast", serde(rename(serialize = "rules")))]
pub policy: Vec<Ref<Rule>>,
pub rego_v1: bool,
// Target name if specified via __target__ rule
#[cfg_attr(feature = "ast", serde(skip_serializing_if = "Option::is_none"))]
pub target: Option<String>,
// Number of expressions in the module.
pub num_expressions: u32,
// Number of statements in the module.

View File

@@ -280,19 +280,11 @@ fn sprintf(span: &Span, params: &[Ref<Expr>], args: &[Value], _strict: bool) ->
args_idx += 1;
// Handle Golang flags.
let mut emit_sign = false;
let mut leave_space_for_elided_sign = false;
match chars.peek() {
Some('+') => {
emit_sign = true;
chars.next();
}
Some(' ') => {
leave_space_for_elided_sign = true;
chars.next();
}
_ => (),
}
let emit_sign = false;
let leave_space_for_elided_sign = false;
// Note: Golang flags come BEFORE the format verb, not after.
// This code was incorrectly consuming characters after the verb.
// Removing the incorrect flag handling to fix sprintf spacing.
let get_sign_value = |f: &Number| match (emit_sign, f) {
(_, v) if v < &Number::from(0.0) => ("-", v.clone()),

91
src/compile.rs Normal file
View File

@@ -0,0 +1,91 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use crate::compiled_policy::CompiledPolicy;
use crate::engine::Engine;
use crate::value::Value;
use crate::*;
use anyhow::Result;
/// Represents a Rego policy module with an identifier and content.
#[derive(Debug, Clone)]
pub struct PolicyModule {
pub id: Rc<str>,
pub content: Rc<str>,
}
/// Compiles a target-aware policy from data and modules.
///
/// This is a convenience function that sets up an [`Engine`] and calls
/// [`Engine::compile_for_target`]. For more control over the compilation process
/// or to reuse an engine, use the engine method directly.
///
/// # Arguments
///
/// * `data` - Static data to be available during policy evaluation
/// * `modules` - Array of Rego policy modules to compile together
///
/// # Returns
///
/// Returns a [`CompiledPolicy`] for target-aware evaluation.
///
/// # Note
///
/// This function is only available when the `azure_policy` feature is enabled.
///
/// # See Also
///
/// - [`Engine::compile_for_target`] for detailed documentation and examples
/// - [`compile_policy_with_entrypoint`] for explicit rule-based compilation
#[cfg(feature = "azure_policy")]
#[cfg_attr(docsrs, doc(cfg(feature = "azure_policy")))]
pub fn compile_policy_for_target(data: Value, modules: &[PolicyModule]) -> Result<CompiledPolicy> {
let mut engine = setup_engine_with_modules(data, modules)?;
engine.compile_for_target()
}
/// Compiles a policy from data and modules with a specific entry point rule.
///
/// This is a convenience function that sets up an [`Engine`] and calls
/// [`Engine::compile_with_entrypoint`]. For more control over the compilation process
/// or to reuse an engine, use the engine method directly.
///
/// # Arguments
///
/// * `data` - Static data to be available during policy evaluation
/// * `modules` - Array of Rego policy modules to compile together
/// * `entry_point_rule` - The specific rule path to evaluate (e.g., "data.policy.allow")
///
/// # Returns
///
/// Returns a [`CompiledPolicy`] focused on the specified entry point rule.
///
/// # See Also
///
/// - [`Engine::compile_with_entrypoint`] for detailed documentation and examples
/// - [`compile_policy_for_target`] for target-aware compilation
pub fn compile_policy_with_entrypoint(
data: Value,
modules: &[PolicyModule],
entry_point_rule: Rc<str>,
) -> Result<CompiledPolicy> {
let mut engine = setup_engine_with_modules(data, modules)?;
engine.compile_with_entrypoint(&entry_point_rule)
}
/// Helper function to set up an engine with data and modules.
fn setup_engine_with_modules(data: Value, modules: &[PolicyModule]) -> Result<Engine> {
let mut engine = Engine::new();
// Add data to the engine
engine.add_data(data)?;
engine.set_gather_prints(true);
// Add all modules to the engine
for module in modules {
engine.add_policy(module.id.to_string(), module.content.to_string())?;
}
Ok(engine)
}

215
src/compiled_policy.rs Normal file
View File

@@ -0,0 +1,215 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use crate::ast::*;
use crate::engine::Engine;
use crate::scheduler::*;
use crate::utils::*;
use crate::*;
use alloc::collections::BTreeMap;
use anyhow::Result;
#[cfg(feature = "azure_policy")]
use crate::target::Target;
pub(crate) type DefaultRuleInfo = (Ref<Rule>, Option<crate::String>);
#[cfg(feature = "azure_policy")]
pub(crate) type ResourceTypeInfo = (Rc<str>, Rc<Schema>);
#[cfg(feature = "azure_policy")]
pub(crate) type InferredResourceTypes = BTreeMap<Ref<Query>, ResourceTypeInfo>;
/// Wrapper around CompiledPolicyData that holds an Rc reference.
#[derive(Debug, Clone)]
pub struct CompiledPolicy {
inner: Rc<CompiledPolicyData>,
}
impl CompiledPolicy {
/// Create a new CompiledPolicy from CompiledPolicyData.
pub(crate) fn new(inner: Rc<CompiledPolicyData>) -> Self {
Self { inner }
}
}
impl CompiledPolicy {
/// Evaluate the compiled policy with the given input.
///
/// For target policies, evaluates the target's effect rule.
/// For regular policies, evaluates the originally compiled rule.
///
/// * `input`: Input data (resource) to validate against the policy.
///
/// Returns the result of evaluating the rule.
pub fn eval_with_input(&self, input: Value) -> Result<Value> {
let mut engine = Engine::new_from_compiled_policy(self.inner.clone());
// Set input
engine.set_input(input);
// Evaluate the rule
#[cfg(feature = "azure_policy")]
if let Some(target_info) = self.inner.target_info.as_ref() {
return engine.eval_rule(target_info.effect_path.to_string());
}
engine.eval_rule(self.inner.rule_to_evaluate.to_string())
}
/// Get information about the compiled policy including metadata about modules,
/// target configuration, and resource types.
///
/// Returns a [`crate::policy_info::PolicyInfo`] struct containing comprehensive
/// information about the compiled policy such as module IDs, target name,
/// applicable resource types, entry point rule, and parameters.
///
/// # Examples
///
/// ```no_run
/// use regorus::*;
/// # use std::sync::Arc;
///
/// # fn main() -> anyhow::Result<()> {
/// # // Register a target for the example
/// # #[cfg(feature = "azure_policy")]
/// # {
/// # let target = regorus::target::Target::from_json_file("tests/interpreter/cases/target/definitions/sample_target.json")?;
/// # regorus::registry::targets::register(std::sync::Arc::new(target))?;
/// # }
///
/// // Compile the policy
/// let policy_rego = r#"
/// package policy.example
/// import rego.v1
/// __target__ := "target.tests.sample_test_target"
///
/// effect := "allow" if {
/// input.type == "storage_account"
/// input.location in ["eastus", "westus"]
/// }
/// "#;
///
/// let modules = vec![regorus::PolicyModule {
/// id: "policy.rego".into(),
/// content: policy_rego.into(),
/// }];
///
/// #[cfg(feature = "azure_policy")]
/// let compiled = regorus::compile_policy_for_target(Value::new_object(), &modules)?;
/// #[cfg(not(feature = "azure_policy"))]
/// let compiled = regorus::compile_policy_with_entrypoint(Value::new_object(), &modules, "allow".into())?;
/// let info = compiled.get_policy_info()?;
///
/// assert_eq!(info.target_name, Some("target.tests.sample_test_target".into()));
/// assert_eq!(info.effect_rule, Some("effect".into()));
/// assert!(info.module_ids.len() > 0);
/// # Ok(())
/// # }
/// ```
pub fn get_policy_info(&self) -> Result<crate::policy_info::PolicyInfo> {
// Extract module IDs from the compiled policy
let module_ids: Vec<Rc<str>> = self
.inner
.modules
.iter()
.enumerate()
.map(|(i, module)| {
// Use source file path if available, otherwise generate an ID
let source_path = module.package.span.source.get_path();
if source_path.is_empty() {
format!("module_{}", i).into()
} else {
source_path.clone().into()
}
})
.collect();
// Extract target name and effect rule
#[cfg(feature = "azure_policy")]
let (target_name, effect_rule) = if let Some(target_info) = &self.inner.target_info {
(
Some(target_info.target.name.clone()),
Some(target_info.effect_name.clone()),
)
} else {
(None, None)
};
#[cfg(not(feature = "azure_policy"))]
let (target_name, effect_rule) = (None, None);
// Extract applicable resource types from inferred types
#[cfg(feature = "azure_policy")]
let applicable_resource_types: Vec<Rc<str>> =
if let Some(inferred_types) = &self.inner.inferred_resource_types {
inferred_types
.values()
.map(|(resource_type, _schema)| resource_type.clone())
.collect::<std::collections::BTreeSet<_>>() // Remove duplicates
.into_iter()
.collect()
} else {
Vec::new()
};
#[cfg(not(feature = "azure_policy"))]
let applicable_resource_types: Vec<Rc<str>> = Vec::new();
// Get parameters from the modules
#[cfg(feature = "azure_policy")]
let parameters = {
// Create a new engine from the compiled modules to extract parameters
let temp_engine = crate::engine::Engine::new_from_compiled_policy(self.inner.clone());
temp_engine.get_policy_parameters()?
};
Ok(crate::policy_info::PolicyInfo {
module_ids,
target_name,
applicable_resource_types,
entrypoint_rule: self.inner.rule_to_evaluate.clone(),
effect_rule,
#[cfg(feature = "azure_policy")]
parameters,
})
}
}
#[cfg(feature = "azure_policy")]
#[derive(Debug, Clone)]
pub(crate) struct TargetInfo {
pub(crate) target: Rc<Target>,
pub(crate) package: String,
pub(crate) effect_schema: Rc<Schema>,
pub(crate) effect_name: Rc<str>,
pub(crate) effect_path: Rc<str>,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct CompiledPolicyData {
pub(crate) modules: Rc<Vec<Ref<Module>>>,
pub(crate) schedule: Option<Schedule>,
pub(crate) rules: Map<String, Vec<Ref<Rule>>>,
pub(crate) default_rules: Map<String, Vec<DefaultRuleInfo>>,
pub(crate) imports: BTreeMap<String, Ref<Expr>>,
pub(crate) functions: FunctionTable,
pub(crate) rule_paths: Set<String>,
#[cfg(feature = "azure_policy")]
pub(crate) target_info: Option<TargetInfo>,
#[cfg(feature = "azure_policy")]
pub(crate) inferred_resource_types: Option<InferredResourceTypes>,
// User-defined rule to evaluate
pub(crate) rule_to_evaluate: Rc<str>,
// User-defined data
pub(crate) data: Option<Value>,
// Evaluation settings
pub(crate) strict_builtin_errors: bool,
// The semantics of extensions ought to be changes to be more Clone friendly.
pub(crate) extensions: Map<String, (u8, Rc<Box<dyn Extension>>)>,
}

View File

@@ -2,6 +2,7 @@
// Licensed under the MIT License.
use crate::ast::*;
use crate::compiled_policy::CompiledPolicy;
use crate::interpreter::*;
use crate::lexer::*;
use crate::parser::*;
@@ -346,7 +347,7 @@ impl Engine {
/// Get the data document.
///
/// The returned value is the data document that has been constructed using
/// one or more calls to [`Engine::add_data`]. The values of policy rules are
/// one or more calls to [`Engine::pre`]. The values of policy rules are
/// not included in the returned document.
///
///
@@ -397,6 +398,259 @@ impl Engine {
&self.modules
}
/// Compiles a target-aware policy from the current engine state.
///
/// This method creates a compiled policy that can work with Azure Policy targets,
/// enabling resource type inference and target-specific evaluation. The compiled
/// policy will automatically detect and handle `__target__` declarations in the
/// loaded modules.
///
/// The engine must have been prepared with:
/// - Policy modules added via [`Engine::add_policy`]
/// - Data added via [`Engine::add_data`] (optional)
///
/// # Returns
///
/// Returns a [`CompiledPolicy`] that can be used for efficient policy evaluation
/// with target support, including resource type inference capabilities.
///
/// # Examples
///
/// ## Basic Target-Aware Compilation
///
/// ```no_run
/// use regorus::*;
///
/// # fn main() -> anyhow::Result<()> {
/// let mut engine = Engine::new();
/// engine.add_data(Value::from_json_str(r#"{"allowed_sizes": ["small", "medium"]}"#)?)?;
/// engine.add_policy("policy.rego".to_string(), r#"
/// package policy.test
/// import rego.v1
/// __target__ := "target.tests.sample_test_target"
///
/// default allow := false
/// allow if {
/// input.type == "vm"
/// input.size in data.allowed_sizes
/// }
/// "#.to_string())?;
///
/// let compiled = engine.compile_for_target()?;
/// let result = compiled.eval_with_input(Value::from_json_str(r#"{"type": "vm", "size": "small"}"#)?)?;
/// # Ok(())
/// # }
/// ```
///
/// ## Target Registration and Usage
///
/// ```no_run
/// use regorus::*;
/// use regorus::registry::targets;
/// use regorus::target::Target;
/// use std::sync::Arc;
///
/// # fn main() -> anyhow::Result<()> {
/// // Register a target first
/// let target_json = r#"
/// {
/// "name": "target.example.vm_policy",
/// "description": "Simple VM validation target",
/// "version": "1.0.0",
/// "resource_schema_selector": "type",
/// "resource_schemas": [
/// {
/// "type": "object",
/// "properties": {
/// "name": { "type": "string" },
/// "type": { "const": "vm" },
/// "size": { "enum": ["small", "medium", "large"] }
/// },
/// "required": ["name", "type", "size"]
/// }
/// ],
/// "effects": {
/// "allow": { "type": "boolean" },
/// "deny": { "type": "boolean" }
/// }
/// }
/// "#;
///
/// let target = Target::from_json_str(target_json)?;
/// targets::register(Arc::new(target))?;
///
/// // Use the target in a policy
/// let mut engine = Engine::new();
/// engine.add_data(Value::from_json_str(r#"{"allowed_locations": ["us-east"]}"#)?)?;
/// engine.add_policy("vm_policy.rego".to_string(), r#"
/// package vm.validation
/// import rego.v1
/// __target__ := "target.example.vm_policy"
///
/// default allow := false
/// allow if {
/// input.type == "vm"
/// input.size in ["small", "medium"]
/// }
/// "#.to_string())?;
///
/// let compiled = engine.compile_for_target()?;
/// let result = compiled.eval_with_input(Value::from_json_str(r#"
/// {
/// "name": "test-vm",
/// "type": "vm",
/// "size": "small"
/// }"#)?)?;
/// assert_eq!(result, Value::from(true));
/// # Ok(())
/// # }
/// ```
///
/// # Notes
///
/// - This method is only available when the `azure_policy` feature is enabled
/// - Automatically enables print gathering for debugging purposes
/// - Requires that at least one module contains a `__target__` declaration
/// - The target referenced must be registered in the target registry
///
/// # See Also
///
/// - [`Engine::compile_with_entrypoint`] for explicit rule-based compilation
/// - [`crate::compile_policy_for_target`] for a higher-level convenience function
#[cfg(feature = "azure_policy")]
#[cfg_attr(docsrs, doc(cfg(feature = "azure_policy")))]
pub fn compile_for_target(&mut self) -> Result<CompiledPolicy> {
self.prepare_for_eval(false, true)?;
self.interpreter.clean_internal_evaluation_state();
self.interpreter.compile(None).map(CompiledPolicy::new)
}
/// Compiles a policy with a specific entry point rule.
///
/// This method creates a compiled policy that evaluates a specific rule as the entry point.
/// Unlike [`Engine::compile_for_target`], this method requires you to explicitly specify which
/// rule should be evaluated and does not automatically handle target-specific features.
///
/// The engine must have been prepared with:
/// - Policy modules added via [`Engine::add_policy`]
/// - Data added via [`Engine::add_data`] (optional)
///
/// # Arguments
///
/// * `rule` - The specific rule path to evaluate (e.g., "data.policy.allow")
///
/// # Returns
///
/// Returns a [`CompiledPolicy`] that can be used for efficient policy evaluation
/// focused on the specified entry point rule.
///
/// # Examples
///
/// ## Basic Usage
///
/// ```no_run
/// use regorus::*;
/// use std::rc::Rc;
///
/// # fn main() -> anyhow::Result<()> {
/// let mut engine = Engine::new();
/// engine.add_data(Value::from_json_str(r#"{"allowed_users": ["alice", "bob"]}"#)?)?;
/// engine.add_policy("authz.rego".to_string(), r#"
/// package authz
/// import rego.v1
///
/// default allow := false
/// allow if {
/// input.user in data.allowed_users
/// input.action == "read"
/// }
///
/// deny if {
/// input.user == "guest"
/// }
/// "#.to_string())?;
///
/// let compiled = engine.compile_with_entrypoint(&"data.authz.allow".into())?;
/// let result = compiled.eval_with_input(Value::from_json_str(r#"{"user": "alice", "action": "read"}"#)?)?;
/// assert_eq!(result, Value::from(true));
/// # Ok(())
/// # }
/// ```
///
/// ## Multi-Module Policy
///
/// ```no_run
/// use regorus::*;
/// use std::rc::Rc;
///
/// # fn main() -> anyhow::Result<()> {
/// let mut engine = Engine::new();
/// engine.add_data(Value::from_json_str(r#"{"departments": {"engineering": ["alice"], "hr": ["bob"]}}"#)?)?;
///
/// engine.add_policy("users.rego".to_string(), r#"
/// package users
/// import rego.v1
///
/// user_department(user) := dept if {
/// dept := [d | data.departments[d][_] == user][0]
/// }
/// "#.to_string())?;
///
/// engine.add_policy("permissions.rego".to_string(), r#"
/// package permissions
/// import rego.v1
/// import data.users
///
/// default allow := false
/// allow if {
/// users.user_department(input.user) == "engineering"
/// input.resource.type == "code"
/// }
///
/// allow if {
/// users.user_department(input.user) == "hr"
/// input.resource.type == "personnel_data"
/// }
/// "#.to_string())?;
///
/// let compiled = engine.compile_with_entrypoint(&"data.permissions.allow".into())?;
///
/// // Test engineering access to code
/// let result = compiled.eval_with_input(Value::from_json_str(r#"
/// {
/// "user": "alice",
/// "resource": {"type": "code", "name": "main.rs"}
/// }"#)?)?;
/// assert_eq!(result, Value::from(true));
/// # Ok(())
/// # }
/// ```
///
/// # Entry Point Rule Format
///
/// The `rule` parameter should follow the Rego rule path format:
/// - `"data.package.rule"` - For rules in a specific package
/// - `"data.package.subpackage.rule"` - For nested packages
/// - `"allow"` - For rules in the default package (though this is not recommended)
///
/// # Notes
///
/// - Automatically enables print gathering for debugging purposes
/// - If you need target-aware compilation with automatic `__target__` handling,
/// consider using [`Engine::compile_for_target`] instead (requires `azure_policy` feature)
///
/// # See Also
///
/// - [`Engine::compile_for_target`] for target-aware compilation
/// - [`crate::compile_policy_with_entrypoint`] for a higher-level convenience function
pub fn compile_with_entrypoint(&mut self, rule: &Rc<str>) -> Result<CompiledPolicy> {
self.prepare_for_eval(false, false)?;
self.interpreter.clean_internal_evaluation_state();
self.interpreter
.compile(Some(rule.clone()))
.map(CompiledPolicy::new)
}
/// Evaluate specified rule(s).
///
/// [`Engine::eval_rule`] is often faster than [`Engine::eval_query`] and should be preferred if
@@ -438,7 +692,7 @@ impl Engine {
/// # }
/// ```
pub fn eval_rule(&mut self, rule: String) -> Result<Value> {
self.prepare_for_eval(false)?;
self.prepare_for_eval(false, false)?;
self.interpreter.clean_internal_evaluation_state();
self.interpreter.eval_rule_in_path(rule)
}
@@ -479,7 +733,7 @@ impl Engine {
/// # }
/// ```
pub fn eval_query(&mut self, query: String, enable_tracing: bool) -> Result<QueryResults> {
self.prepare_for_eval(enable_tracing)?;
self.prepare_for_eval(enable_tracing, false)?;
self.interpreter.clean_internal_evaluation_state();
self.interpreter.create_rule_prefixes()?;
@@ -622,7 +876,7 @@ impl Engine {
}
#[doc(hidden)]
fn prepare_for_eval(&mut self, enable_tracing: bool) -> Result<()> {
fn prepare_for_eval(&mut self, enable_tracing: bool, for_target: bool) -> Result<()> {
self.interpreter.set_traces(enable_tracing);
// if the data/policies have changed or the interpreter has never been prepared
@@ -644,6 +898,23 @@ impl Engine {
.set_functions(gather_functions(&self.modules)?);
self.interpreter.gather_rules()?;
self.interpreter.process_imports()?;
#[cfg(feature = "azure_policy")]
if for_target {
// Resolve and validate target specifications across all modules
crate::interpreter::target::resolve::resolve_and_apply_target(
&mut self.interpreter,
)?;
// Infer resource types
crate::interpreter::target::infer::infer_resource_type(&mut self.interpreter)?;
}
if !for_target {
// Check if any module specifies a target and warn if so
#[cfg(feature = "azure_policy")]
self.warn_if_targets_present();
}
self.prepared = true;
}
@@ -657,7 +928,7 @@ impl Engine {
rule: &Ref<Rule>,
enable_tracing: bool,
) -> Result<Value> {
self.prepare_for_eval(enable_tracing)?;
self.prepare_for_eval(enable_tracing, false)?;
self.interpreter.clean_internal_evaluation_state();
self.interpreter.eval_rule(module, rule)?;
@@ -667,7 +938,7 @@ impl Engine {
#[doc(hidden)]
pub fn eval_modules(&mut self, enable_tracing: bool) -> Result<Value> {
self.prepare_for_eval(enable_tracing)?;
self.prepare_for_eval(enable_tracing, false)?;
self.interpreter.clean_internal_evaluation_state();
// Ensure that empty modules are created.
@@ -1043,6 +1314,29 @@ impl Engine {
Ok(policy_parameter_definitions)
}
/// Emit a warning if any modules contain target specifications but we're not using target-aware compilation.
#[cfg(feature = "azure_policy")]
fn warn_if_targets_present(&self) {
let mut has_target = false;
let mut target_files = Vec::new();
for module in self.modules.iter() {
if module.target.is_some() {
has_target = true;
target_files.push(module.package.span.source.get_path());
}
}
if has_target {
std::eprintln!("Warning: Target specifications found in policy modules but not using target-aware compilation.");
std::eprintln!(" The following files contain __target__ declarations:");
for file in target_files {
std::eprintln!(" - {}", file);
}
std::eprintln!(" Consider using compile_for_target() instead of compile_with_entrypoint() for target-aware evaluation.");
}
}
fn make_parser<'a>(&self, source: &'a Source) -> Result<Parser<'a>> {
let mut parser = Parser::new(source)?;
if self.rego_v1 {
@@ -1050,4 +1344,18 @@ impl Engine {
}
Ok(parser)
}
/// Create a new Engine from a compiled policy.
#[doc(hidden)]
pub(crate) fn new_from_compiled_policy(
compiled_policy: Rc<crate::compiled_policy::CompiledPolicyData>,
) -> Self {
let modules = compiled_policy.modules.clone();
Self {
modules,
interpreter: Interpreter::new_from_compiled_policy(compiled_policy),
rego_v1: true, // Value doesn't matter since this is used only for policy parsing
prepared: true,
}
}
}

View File

@@ -3,6 +3,9 @@
use crate::ast::*;
use crate::builtins::{self, BuiltinFcn};
use crate::compiled_policy::CompiledPolicyData;
#[cfg(feature = "azure_policy")]
use crate::compiled_policy::TargetInfo;
use crate::lexer::*;
use crate::parser::Parser;
use crate::scheduler::*;
@@ -18,7 +21,14 @@ use core::ops::Bound::*;
type Scope = BTreeMap<SourceStr, Value>;
type DefaultRuleInfo = (Ref<Rule>, Option<String>);
#[cfg(feature = "azure_policy")]
pub mod error;
#[cfg(feature = "azure_policy")]
pub mod target {
pub mod infer;
pub mod resolve;
}
type ContextExprs = (Option<Ref<Expr>>, Option<Ref<Expr>>);
type State = (
Value,
@@ -36,22 +46,11 @@ enum FunctionModifier {
Value(Value),
}
#[derive(Debug, Clone, Default)]
pub struct CompiledPolicy {
modules: Rc<Vec<Ref<Module>>>,
schedule: Option<Schedule>,
rules: Map<String, Vec<Ref<Rule>>>,
default_rules: Map<String, Vec<DefaultRuleInfo>>,
imports: BTreeMap<String, Ref<Expr>>,
functions: FunctionTable,
rule_paths: Set<String>,
}
type RuleValues = BTreeMap<Vec<Value>, (Value, Ref<Expr>)>;
#[derive(Debug)]
pub struct Interpreter {
compiled_policy: Rc<CompiledPolicy>,
compiled_policy: Rc<CompiledPolicyData>,
data: Value,
@@ -61,7 +60,6 @@ pub struct Interpreter {
enable_coverage: bool,
traces: Option<Vec<Rc<str>>>,
strict_builtin_errors: bool,
gather_prints: bool,
prints: Vec<String>,
@@ -107,7 +105,6 @@ impl Clone for Interpreter {
gather_prints: self.gather_prints,
prints: self.prints.clone(),
strict_builtin_errors: self.strict_builtin_errors,
traces: self.traces.clone(),
extensions: self.extensions.clone(),
@@ -216,8 +213,12 @@ impl LoopExpr {
impl Interpreter {
pub fn new() -> Interpreter {
let compiled_policy = compiled_policy::CompiledPolicyData {
strict_builtin_errors: true, // Preserve current behavior
..Default::default()
};
Interpreter {
compiled_policy: Rc::new(CompiledPolicy::default()),
compiled_policy: Rc::new(compiled_policy),
data: Value::new_object(),
module: None,
@@ -239,7 +240,6 @@ impl Interpreter {
builtins_cache: BTreeMap::new(),
no_rules_lookup: false,
traces: None,
strict_builtin_errors: true,
extensions: Map::new(),
#[cfg(feature = "coverage")]
@@ -252,7 +252,21 @@ impl Interpreter {
}
}
fn compiled_policy_mut(&mut self) -> &mut CompiledPolicy {
/// Create a new Interpreter from a compiled policy.
pub fn new_from_compiled_policy(compiled_policy: Rc<CompiledPolicyData>) -> Self {
let mut interpreter = Self::new();
interpreter.extensions = compiled_policy.extensions.clone();
interpreter.compiled_policy = compiled_policy;
// Set initial data if available
if let Some(data) = &interpreter.compiled_policy.data {
interpreter.init_data = data.clone();
}
interpreter
}
fn compiled_policy_mut(&mut self) -> &mut CompiledPolicyData {
Rc::make_mut(&mut self.compiled_policy)
}
@@ -284,6 +298,12 @@ impl Interpreter {
&mut self.init_data
}
// Used by tests.
#[allow(dead_code)]
pub fn get_compiled_policy(&self) -> &Rc<CompiledPolicyData> {
&self.compiled_policy
}
pub fn set_traces(&mut self, enable_tracing: bool) {
self.traces = match enable_tracing {
true => Some(vec![]),
@@ -292,7 +312,7 @@ impl Interpreter {
}
pub fn set_strict_builtin_errors(&mut self, b: bool) {
self.strict_builtin_errors = b;
self.compiled_policy_mut().strict_builtin_errors = b;
}
pub fn set_input(&mut self, input: Value) {
@@ -649,7 +669,7 @@ impl Interpreter {
rhs,
lhs_value,
rhs_value,
self.strict_builtin_errors,
self.compiled_policy.strict_builtin_errors,
),
}
}
@@ -2233,10 +2253,15 @@ impl Interpreter {
}
}
let v = match builtin.0(span, params, &args[..], self.strict_builtin_errors) {
let v = match builtin.0(
span,
params,
&args[..],
self.compiled_policy.strict_builtin_errors,
) {
Ok(v) => v,
// Ignore errors if we are not evaluating in strict mode.
Err(_) if !self.strict_builtin_errors => return Ok(Value::Undefined),
Err(_) if !self.compiled_policy.strict_builtin_errors => return Ok(Value::Undefined),
Err(e) => Err(e)?,
};
@@ -2550,7 +2575,7 @@ impl Interpreter {
}
}
if self.strict_builtin_errors && !errors.is_empty() {
if self.compiled_policy.strict_builtin_errors && !errors.is_empty() {
return Err(anyhow!(errors[0].to_string()));
}
@@ -2947,7 +2972,7 @@ impl Interpreter {
uexpr,
Value::from(0),
self.eval_expr(uexpr)?,
self.strict_builtin_errors,
self.compiled_policy.strict_builtin_errors,
)
}
_ => bail!(expr
@@ -3658,9 +3683,7 @@ impl Interpreter {
for c in 0..comps.len() {
let path = self.current_module_path.clone() + "." + &comps[0..c + 1].join(".");
if c + 1 == comps.len() {
Rc::make_mut(&mut self.compiled_policy)
.rule_paths
.insert(path.clone());
self.compiled_policy_mut().rule_paths.insert(path.clone());
}
match self.compiled_policy_mut().rules.entry(path) {
@@ -3687,9 +3710,7 @@ impl Interpreter {
for (idx, c) in (0..comps.len()).enumerate() {
let path = self.current_module_path.clone() + "." + &comps[0..c + 1].join(".");
if c + 1 == comps.len() {
Rc::make_mut(&mut self.compiled_policy)
.rule_paths
.insert(path.clone());
self.compiled_policy_mut().rule_paths.insert(path.clone());
}
match self.compiled_policy_mut().default_rules.entry(path) {
@@ -3957,6 +3978,35 @@ impl Interpreter {
self.ensure_rule_evaluated(path.clone())?;
let parts: Vec<&str> = path.split('.').collect();
Ok(Self::get_value_chained(self.data.clone(), &parts[1..]))
let value = Self::get_value_chained(self.data.clone(), &parts[1..]);
#[cfg(feature = "azure_policy")]
{
if let Some(target_info) = &self.compiled_policy.target_info {
// Allow undefined values to pass through without schema validation
if value != Value::Undefined {
target_info.effect_schema.validate(&value)?;
}
}
}
Ok(value)
}
pub fn compile(&mut self, rule: Option<Rc<str>>) -> Result<Rc<CompiledPolicyData>> {
let data = Some(self.init_data.clone());
let extensions = self.extensions.clone();
let compiled_policy = self.compiled_policy_mut();
compiled_policy.data = data;
compiled_policy.extensions = extensions;
if let Some(rule) = rule {
if !compiled_policy.rule_paths.contains(rule.as_ref()) {
bail!("not a valid rule path");
}
compiled_policy.rule_to_evaluate = rule;
} else {
compiled_policy.rule_to_evaluate = "".into();
}
Ok(self.compiled_policy.clone())
}
}

58
src/interpreter/error.rs Normal file
View File

@@ -0,0 +1,58 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use crate::Rc;
use thiserror::Error;
type String = Rc<str>;
/// Error type for interpreter target resolution operations.
#[derive(Debug, Clone, Error)]
pub enum TargetCompileError {
/// Multiple different targets specified across modules
#[error("Multiple different targets specified: '{existing}' and '{conflicting}'")]
ConflictingTargets {
existing: String,
conflicting: String,
},
/// Target not found in registry
#[error("Target '{0}' not found in registry")]
TargetNotFound(String),
/// No target specified when one is required
#[error("No target specified. When using compile_for_target, at least one module must specify a target using the __target__ annotation")]
NoTargetSpecified,
/// Modules with targets have different packages
#[error("Modules with target '{target}' have different packages: '{existing_package}' and '{conflicting_package}'")]
ConflictingPackages {
target: String,
existing_package: String,
conflicting_package: String,
},
/// No effects have rules defined for the target
#[error(
"Target '{target_name}' requires a rule with name {effect_names} in package '{package}'"
)]
NoEffectRules {
target_name: String,
package: String,
effect_names: String,
},
/// Multiple effect rules found for the same effect
#[error("Multiple effects have rules defined for target '{target_name}': {effect_names}. Only one effect should have rules defined in package '{path}'")]
MultipleEffectRules {
target_name: String,
effect_names: String,
path: String,
},
/// Missing default resource schema error
#[error("Missing default resource schema: {0}")]
MissingDefaultResourceSchema(String),
/// Incompatible default schema error
#[error("Incompatible default schema: {0}")]
IncompatibleDefaultSchema(String),
/// Invalid default schema type error
#[error("Invalid default schema type: {0}")]
InvalidDefaultSchemaType(String),
}

View File

@@ -0,0 +1,278 @@
use super::super::error::TargetCompileError;
use super::super::*;
use crate::ast::{BoolOp, Expr, Literal, Query, Rule};
use crate::compiled_policy::InferredResourceTypes;
use crate::value::Value;
use crate::{Rc, Schema};
type String = Rc<str>;
/// Analyzes policy rules to infer resource types from equality expressions.
///
/// This function examines the compiled policy rules corresponding to the effect path
/// and searches for equality statements that compare the resource selector field with
/// string literals. It identifies patterns like:
/// - `input.<resource_selector> == "resource_type_name"`
/// - `input["resource_selector"] == "resource_type_name"`
/// - `"resource_type_name" == input.<resource_selector>`
/// - `"resource_type_name" == input["resource_selector"]`
///
/// The `resource_selector` is determined by the target's resource schema selector
/// configuration (e.g., "type", "@odata.type").
///
/// # Schema Resolution
/// For each inferred resource type, the function attempts to find the corresponding
/// schema from the target's resource_schema_lookup table. If no specific schema is
/// found, it falls back to the default_resource_schema after validating compatibility.
///
/// # Returns
/// An InferredResourceTypes map mapping Query references to ResourceTypeInfo tuples
/// containing (resource_type_name, schema).
/// The results are also stored in the compiled policy's inferred_resource_types field
/// for later use during policy evaluation.
///
/// # Errors
/// Returns `TargetCompileError` if:
/// - Default resource schema is missing when needed
/// - Default schema is incompatible with the resource selector
/// - Default schema is not an object type
///
/// # Examples
/// For a policy with rules like:
/// ```rego
/// effect := "allow" { input.type == "Microsoft.Storage/storageAccounts" }
/// effect := "deny" { input["@odata.type"] == "microsoft.graph.user" }
/// ```
/// This function returns a map with entries for each query containing the resource type
/// conditions, mapping queries to their respective type names and schemas.
pub fn infer_resource_type(
interpreter: &mut Interpreter,
) -> Result<InferredResourceTypes, TargetCompileError> {
// Check if we have target info
if let Some(ref target_info) = interpreter.compiled_policy.target_info {
let target = &target_info.target;
let effect_path = &target_info.effect_path;
let resource_selector = &target.resource_schema_selector;
let mut result = InferredResourceTypes::new();
// Get rules for the effect path
if let Some(rules) = interpreter.compiled_policy.rules.get(effect_path.as_ref()) {
for rule in rules {
analyze_rule_for_resource_types(rule, resource_selector, target, &mut result)?;
}
}
// Note: We don't check default_rules because default rules cannot access input
// Store the result in the compiled policy for later use
let compiled_policy = Rc::make_mut(&mut interpreter.compiled_policy);
compiled_policy.inferred_resource_types = Some(result.clone());
Ok(result)
} else {
// No target info available
Ok(InferredResourceTypes::new())
}
}
fn analyze_rule_for_resource_types(
rule: &Rule,
resource_selector: &str,
target: &crate::target::Target,
result: &mut InferredResourceTypes,
) -> Result<(), TargetCompileError> {
if let Rule::Spec { bodies, .. } = rule {
for body in bodies {
analyze_query_for_resource_types(&body.query, resource_selector, target, result)?;
}
}
// Default rules typically don't contain resource type conditions
Ok(())
}
fn analyze_query_for_resource_types(
query: &Ref<Query>,
resource_selector: &str,
target: &crate::target::Target,
result: &mut InferredResourceTypes,
) -> Result<(), TargetCompileError> {
let mut found_resource_type: Option<String> = None;
for stmt in &query.stmts {
if let Literal::Expr { expr, .. } = &stmt.literal {
if let Some(resource_type) = analyze_expr_for_resource_types(expr, resource_selector) {
found_resource_type = Some(resource_type);
break; // Found resource type, no need to continue searching
}
}
// Note: We don't analyze NotExpr because it contains the opposite of type equality
// (e.g., not input.type == "value" means the type is NOT that value)
// Other literal statement (SomeVars, SomeIn, Every) don't typically contain
// direct resource type comparisons
}
// Now handle the insertion outside the loop
if let Some(resource_type) = found_resource_type {
// Look up the schema for this resource type
let resource_type_value = Value::String(resource_type.clone());
if let Some(schema) = target.resource_schema_lookup.get(&resource_type_value) {
result.insert(query.clone(), (resource_type, schema.clone()));
return Ok(());
}
// If not found in lookup, use default schema
let default_schema = get_validated_default_schema(target, resource_selector)?;
result.insert(query.clone(), (resource_type, default_schema));
} else {
// If no resource type was found for this query, use default schema
let default_schema = get_validated_default_schema(target, resource_selector)?;
result.insert(query.clone(), ("<default>".into(), default_schema));
}
Ok(())
}
fn analyze_expr_for_resource_types(expr: &Expr, resource_selector: &str) -> Option<String> {
// Only look for direct equality expressions: input.<resource_selector> == "string"
if let Expr::BoolExpr {
op: BoolOp::Eq,
lhs,
rhs,
..
} = expr
{
// Check if this is input.<resource_selector> == "string"
if let (Some(input_field), Some(string_value)) = (
extract_input_field_access(lhs, resource_selector),
extract_string_literal(rhs),
) {
if input_field.as_ref() == resource_selector {
return Some(string_value);
}
}
// Also check the reverse: "string" == input.<resource_selector>
else if let (Some(string_value), Some(input_field)) = (
extract_string_literal(lhs),
extract_input_field_access(rhs, resource_selector),
) {
if input_field.as_ref() == resource_selector {
return Some(string_value);
}
}
}
// We only look for direct equality expressions, no nested analysis
None
}
/// Extract input field access like `input.type` or `input["@odata.type"]`
fn extract_input_field_access(expr: &Expr, _expected_field: &str) -> Option<String> {
use crate::value::Value;
match expr {
// Handle input.field
Expr::RefDot { refr, field, .. } => {
if let (
Expr::Var {
value: Value::String(var_name),
..
},
Value::String(field_name),
) = (refr.as_ref(), &field.1)
{
if var_name.as_ref() == "input" {
return Some(field_name.clone());
}
}
}
// Handle input["field"] - the field is always a string literal
Expr::RefBrack { refr, index, .. } => {
if let (
Expr::Var {
value: Value::String(var_name),
..
},
Some(field_name),
) = (refr.as_ref(), extract_string_literal(index))
{
if var_name.as_ref() == "input" {
return Some(field_name);
}
}
}
_ => {}
}
None
}
/// Extract string literal from expression
fn extract_string_literal(expr: &Expr) -> Option<String> {
use crate::value::Value;
if let Expr::String {
value: Value::String(s),
..
} = expr
{
Some(s.clone())
} else {
None
}
}
/// Get and validate the default resource schema.
/// Returns the default schema if it exists and is compatible with the resource selector.
fn get_validated_default_schema(
target: &crate::target::Target,
resource_selector: &str,
) -> Result<Rc<Schema>, TargetCompileError> {
if let Some(default_schema) = &target.default_resource_schema {
// Validate that default schema can handle the resource selector field
validate_default_schema_compatibility(default_schema, resource_selector)?;
Ok(default_schema.clone())
} else {
Err(TargetCompileError::MissingDefaultResourceSchema(
format!("Target '{}' has no default resource schema", target.name).into(),
))
}
}
/// Validate that the default schema is compatible with the resource selector field.
/// The schema must either allow additional properties or have a property matching the resource selector.
fn validate_default_schema_compatibility(
schema: &Rc<Schema>,
resource_selector: &str,
) -> Result<(), TargetCompileError> {
use crate::schema::Type;
match schema.as_type() {
Type::Object {
properties,
additional_properties,
..
} => {
// Check if the schema has a property matching the resource selector
if properties.contains_key(resource_selector) {
return Ok(());
}
// Check if additional properties are allowed
if additional_properties.is_some() {
return Ok(());
}
// Neither condition is met
Err(TargetCompileError::IncompatibleDefaultSchema(
format!(
"Default resource schema must either have additional properties enabled or contain a '{}' property",
resource_selector
).into()
))
}
_ => {
// Default schema is not an object type
Err(TargetCompileError::InvalidDefaultSchemaType(
"Default resource schema must be an object type".into(),
))
}
}
}

View File

@@ -0,0 +1,248 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use super::super::error::TargetCompileError;
#[cfg(feature = "azure_policy")]
use super::super::TargetInfo;
use super::super::*;
fn format_effect_names(names: &[String]) -> String {
match names.len() {
0 => String::new(),
1 => names[0].clone(),
2 => format!("{} or {}", names[0], names[1]),
_ => {
if let Some((last, rest)) = names.split_last() {
format!("{} or {}", rest.join(", "), last)
} else {
String::new()
}
}
}
}
pub fn resolve_target(interpreter: &mut Interpreter) -> Result<(), TargetCompileError> {
use crate::registry::targets;
let mut target_name: Option<String> = None;
let mut target_package: Option<String> = None;
// Check all modules for target specifications
for module in interpreter.compiled_policy.modules.iter() {
if let Some(ref module_target) = module.target {
// Get the package path for this module
let module_package = Interpreter::get_path_string(&module.package.refr, None)
.map_err(|_| TargetCompileError::TargetNotFound(module_target.clone().into()))?;
match &target_name {
None => {
// First target found
target_name = Some(module_target.clone());
target_package = Some(module_package);
}
Some(existing_target) => {
// Ensure all modules specify the same target
if existing_target != module_target {
return Err(TargetCompileError::ConflictingTargets {
existing: existing_target.as_str().into(),
conflicting: module_target.as_str().into(),
});
}
// Ensure all modules with targets have the same package
if let Some(ref existing_package) = target_package {
if existing_package != &module_package {
return Err(TargetCompileError::ConflictingPackages {
target: module_target.as_str().into(),
existing_package: existing_package.as_str().into(),
conflicting_package: module_package.as_str().into(),
});
}
}
}
}
}
}
// If a target is specified, retrieve it from the registry
if let Some(target_name) = target_name {
match targets::get(&target_name) {
Some(target) => {
// Target found in registry - store it in the compiled policy
// We'll set a default effect schema here, but it will be updated in resolve_effect
// once we determine which effect actually has rules defined
let default_effect_schema = match target.effects.values().next() {
Some(schema) => schema.clone(),
None => {
return Err(TargetCompileError::TargetNotFound(
format!("Target '{}' has no effects defined", target_name)
.as_str()
.into(),
));
}
};
let target_info = TargetInfo {
target,
package: match target_package {
Some(pkg) => pkg.as_str().into(),
None => {
return Err(TargetCompileError::TargetNotFound(
format!("No package found for target '{}'", target_name)
.as_str()
.into(),
));
}
},
effect_schema: default_effect_schema,
effect_name: "".into(), // Will be updated in resolve_effect
effect_path: "".into(), // Will be updated in resolve_effect
};
interpreter.compiled_policy_mut().target_info = Some(target_info);
}
None => {
return Err(TargetCompileError::TargetNotFound(
target_name.as_str().into(),
));
}
}
} else {
// No target specified - this is an error when using compile_for_target
return Err(TargetCompileError::NoTargetSpecified);
}
Ok(())
}
pub fn resolve_effect(interpreter: &mut Interpreter) -> Result<(), TargetCompileError> {
// Check if we have target info from resolve_target
if let Some(ref target_info) = interpreter.compiled_policy.target_info {
let target = &target_info.target;
let package = &target_info.package;
let mut effects_with_rules = Vec::new();
// For each effect defined in the target, check if rules exist
for effect_name in target.effects.keys() {
// Rule keys are stored with "data." prefix in CompiledPolicy
let expected_path = format!("data.{}.{}", package, effect_name);
// Disallow sub-paths for effects in rules.
for rule_path in interpreter.compiled_policy.rules.keys() {
if rule_path.starts_with(&expected_path) && rule_path.len() > expected_path.len() {
// Sub-paths are not allowed for effects - they must be exact matches only
// This prevents effect rules from being defined at deeper nested paths
let all_effect_names: Vec<String> =
target.effects.keys().map(|k| k.to_string()).collect();
let formatted_names = format_effect_names(&all_effect_names);
return Err(TargetCompileError::NoEffectRules {
target_name: target.name.to_string().into(),
package: package.to_string().into(),
effect_names: formatted_names.as_str().into(),
});
}
}
// Disallow sub-paths for effects in default_rules.
for rule_path in interpreter.compiled_policy.default_rules.keys() {
if rule_path.starts_with(&expected_path) && rule_path.len() > expected_path.len() {
// Sub-paths are not allowed for effects - they must be exact matches only
let all_effect_names: Vec<String> =
target.effects.keys().map(|k| k.to_string()).collect();
let formatted_names = format_effect_names(&all_effect_names);
return Err(TargetCompileError::NoEffectRules {
target_name: target.name.to_string().into(),
package: package.to_string().into(),
effect_names: formatted_names.as_str().into(),
});
}
}
// Check if rules exist at the expected path or any sub-path
let mut has_rules = false;
// Check for exact match in rules
if let Some(rules) = interpreter.compiled_policy.rules.get(&expected_path) {
if !rules.is_empty() {
has_rules = true;
}
}
// Check for exact match in default_rules
if !has_rules {
if let Some(default_rules) = interpreter
.compiled_policy
.default_rules
.get(&expected_path)
{
if !default_rules.is_empty() {
has_rules = true;
}
}
}
if has_rules {
effects_with_rules.push(effect_name.clone());
}
}
// Ensure exactly one effect has rules defined
match effects_with_rules.len() {
0 => {
let all_effect_names: Vec<String> =
target.effects.keys().map(|k| k.to_string()).collect();
let formatted_names = format_effect_names(&all_effect_names);
return Err(TargetCompileError::NoEffectRules {
target_name: target.name.to_string().into(),
package: package.to_string().into(),
effect_names: formatted_names.as_str().into(),
});
}
1 => {
// Exactly one effect has rules - this is correct
// Update the target info with the correct effect schema
let effect_name = &effects_with_rules[0];
let effect_schema = match target.effects.get(effect_name) {
Some(schema) => schema.clone(),
None => {
// This should not happen since we got the effect_name from target.effects.keys()
return Err(TargetCompileError::TargetNotFound(
format!(
"Effect '{}' not found in target '{}'",
effect_name, target.name
)
.as_str()
.into(),
));
}
};
// Update the target info with the correct effect schema, name, and path
let expected_path = format!("data.{}.{}", package, effect_name);
if let Some(ref mut target_info) = interpreter.compiled_policy_mut().target_info {
target_info.effect_schema = effect_schema;
target_info.effect_name = effect_name.as_ref().into();
target_info.effect_path = expected_path.as_str().into();
}
}
_ => {
return Err(TargetCompileError::MultipleEffectRules {
target_name: target.name.to_string().into(),
effect_names: effects_with_rules.join(", ").as_str().into(),
path: package.to_string().into(),
});
}
}
}
Ok(())
}
pub fn resolve_and_apply_target(interpreter: &mut Interpreter) -> Result<(), TargetCompileError> {
// Resolve the target first
resolve_target(interpreter)?;
// Then resolve the effect
resolve_effect(interpreter)?;
Ok(())
}

View File

@@ -21,31 +21,44 @@ extern crate std;
mod ast;
mod builtins;
mod compile;
mod compiled_policy;
mod engine;
mod indexchecker;
mod interpreter;
mod lexer;
mod number;
mod parser;
mod policy_info;
#[cfg(feature = "azure_policy")]
mod registry;
pub mod registry;
mod scheduler;
#[cfg(feature = "azure_policy")]
mod schema;
#[cfg(feature = "azure_policy")]
pub mod target;
mod utils;
mod value;
#[cfg(feature = "azure_policy")]
pub use {
compile::compile_policy_for_target,
schema::{error::ValidationError, validate::SchemaValidator, Schema},
target::Target,
};
pub use compile::{compile_policy_with_entrypoint, PolicyModule};
pub use compiled_policy::CompiledPolicy;
pub use engine::Engine;
pub use lexer::Source;
#[cfg(feature = "azure_policy")]
pub use schema::{error::ValidationError, validate::SchemaValidator, Schema};
pub use policy_info::PolicyInfo;
pub use value::Value;
#[cfg(feature = "arc")]
use alloc::sync::Arc as Rc;
pub use alloc::sync::Arc as Rc;
#[cfg(not(feature = "arc"))]
use alloc::rc::Rc;
pub use alloc::rc::Rc;
#[cfg(feature = "std")]
use std::collections::{hash_map::Entry as MapEntry, HashMap as Map, HashSet as Set};

View File

@@ -1881,18 +1881,75 @@ impl<'source> Parser<'source> {
Ok(imports)
}
fn parse_string_literal(&mut self) -> Result<String> {
if self.tok.0 != TokenKind::String {
bail!(self.tok.1.error("expected string literal"));
}
let string_span = self.tok.1.clone();
let target_value =
match serde_json::from_str::<Value>(format!("\"{}\"", string_span.text()).as_str()) {
Ok(v) => v,
Err(e) => {
bail!(string_span.error(&format!("invalid string literal: {}", e)));
}
};
self.next_token()?;
match target_value.as_string() {
Ok(s) => Ok(s.as_ref().to_string()),
Err(_) => {
bail!(string_span.error("invalid string value"));
}
}
}
fn parse_target_rule(&mut self) -> Result<Option<String>> {
// Check if the current token starts a target rule: __target__
if self.tok.0 == TokenKind::Ident && self.token_text() == "__target__" {
// Parse __target__
self.next_token()?;
// Expect := operator
if self.token_text() != ":=" {
bail!(self.tok.1.error("expected ':=' after __target__"));
}
self.next_token()?;
// Parse the target name string using the helper function
let target_string = self.parse_string_literal()?;
Ok(Some(target_string))
} else {
Ok(None)
}
}
pub fn parse(&mut self) -> Result<Module> {
let package = self.parse_package()?;
let imports = self.parse_imports()?;
let target = self.parse_target_rule()?;
if target.is_some() {
self.rego_v1 = true;
}
let mut policy = vec![];
while self.tok.0 != TokenKind::Eof {
policy.push(Ref::new(self.parse_rule()?));
if self.token_text() == "__target__" {
bail!(self
.tok
.1
.error("__target__ must be defined before any rules"));
}
}
let m = Module {
package,
imports,
target,
policy,
rego_v1: self.rego_v1,
num_expressions: self.eidx,

42
src/policy_info.rs Normal file
View File

@@ -0,0 +1,42 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
#[cfg(feature = "azure_policy")]
use crate::engine::PolicyParameters;
use crate::*;
type String = Rc<str>;
/// Information about a compiled policy, including metadata about modules,
/// target configuration, and resource types that the policy can evaluate.
#[derive(serde::Serialize)]
pub struct PolicyInfo {
/// List of module identifiers that were compiled into this policy.
/// Each module ID represents a unique policy module that contributes
/// rules, functions, or data to the compiled policy.
pub module_ids: Vec<String>,
/// Name of the target configuration used during compilation, if any.
/// This indicates which target schema and validation rules were applied.
pub target_name: Option<String>,
/// List of resource types that this policy can evaluate.
/// For target-aware policies, this contains the inferred or configured
/// resource types. For general policies, this may be empty.
pub applicable_resource_types: Vec<String>,
/// The primary rule or entrypoint that this policy evaluates.
/// This is the rule path that will be executed when the policy runs.
pub entrypoint_rule: String,
/// The effect rule name for target-aware policies, if applicable.
/// This is the specific effect rule (e.g., "effect", "allow", "deny")
/// that determines the policy decision for target evaluation.
pub effect_rule: Option<String>,
/// Parameters that can be configured for this policy.
/// Contains parameter names and their expected types or default values.
/// Used for parameterized policies that accept configuration at evaluation time.
/// Each element represents parameters from a different module.
#[cfg(feature = "azure_policy")]
pub parameters: Vec<PolicyParameters>,
}

View File

@@ -13,6 +13,7 @@ mod tests {
mod core;
mod effect;
mod resource;
mod target;
}
/// Errors that can occur when interacting with a Registry.
@@ -166,6 +167,9 @@ impl<T> Registry<T> {
/// Type alias for Schema registry
pub type SchemaRegistry = Registry<crate::Schema>;
/// Type alias for Target registry
pub type TargetRegistry = Registry<crate::target::Target>;
/// Global registry instances
pub mod instances {
use super::*;
@@ -179,6 +183,11 @@ pub mod instances {
/// Global singleton instance of effect schemas registry.
pub static ref EFFECT_SCHEMA_REGISTRY: Registry<crate::Schema> = Registry::new("EFFECT_SCHEMA_REGISTRY");
}
lazy_static::lazy_static! {
/// Global singleton instance of targets registry.
pub static ref TARGET_REGISTRY: Registry<crate::target::Target> = Registry::new("TARGET_REGISTRY");
}
}
/// Macro to generate helper functions for registry operations.
@@ -287,3 +296,50 @@ pub mod schemas {
"effect schemas"
);
}
/// Helper functions for target registry operations.
pub mod targets {
use super::*;
use instances::*;
/// Register a target using its name property.
pub fn register(item: Rc<crate::target::Target>) -> Result<(), RegistryError> {
let name = item.name.as_ref().to_string();
TARGET_REGISTRY.register(name, item)
}
/// Retrieve a target by name.
pub fn get(name: &str) -> Option<Rc<crate::target::Target>> {
TARGET_REGISTRY.get(name)
}
/// Remove a target by name.
pub fn remove(name: &str) -> Option<Rc<crate::target::Target>> {
TARGET_REGISTRY.remove(name)
}
/// List all registered target names.
pub fn list_names() -> Vec<String> {
TARGET_REGISTRY.list_names()
}
/// Check if a target with the given name exists.
pub fn contains(name: &str) -> bool {
TARGET_REGISTRY.contains(name)
}
/// Get the number of registered targets.
pub fn len() -> usize {
TARGET_REGISTRY.len()
}
/// Check if the target registry is empty.
pub fn is_empty() -> bool {
TARGET_REGISTRY.is_empty()
}
/// Clear all targets from the registry.
pub fn clear() {
TARGET_REGISTRY.clear();
}
}

1254
src/registry/tests/target.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -301,7 +301,7 @@ impl Schema {
}
/// Returns a reference to the underlying type definition.
fn as_type(&self) -> &Type {
pub fn as_type(&self) -> &Type {
&self.t
}
@@ -311,12 +311,13 @@ impl Schema {
schema: serde_json::Value,
) -> Result<Self, Box<dyn core::error::Error + Send + Sync>> {
let meta_schema_validation_result = meta::validate_schema_detailed(&schema);
let result = serde_json::from_value::<Schema>(schema)
let schema = serde_json::from_value::<Schema>(schema)
.map_err(|e| format!("Failed to parse schema: {e}"))?;
if let Err(errors) = meta_schema_validation_result {
return Err(format!("Schema validation failed: {}", errors.join("\n")).into());
}
Ok(result)
Ok(schema)
}
/// Parse a JSON Schema document from a string into a `Schema` instance.
@@ -326,6 +327,31 @@ impl Schema {
serde_json::from_str(s).map_err(|e| format!("Failed to parse schema: {e}"))?;
Self::from_serde_json_value(value)
}
/// Validates a `Value` against this schema.
///
/// Returns `Ok(())` if the value conforms to the schema, or a `ValidationError`
/// with detailed error information if validation fails.
///
/// # Example
/// ```rust
/// use regorus::schema::Schema;
/// use regorus::Value;
/// use serde_json::json;
///
/// let schema_json = json!({
/// "type": "string",
/// "minLength": 1,
/// "maxLength": 10
/// });
/// let schema = Schema::from_serde_json_value(schema_json).unwrap();
/// let value = Value::from("hello");
///
/// assert!(schema.validate(&value).is_ok());
/// ```
pub fn validate(&self, value: &Value) -> Result<(), error::ValidationError> {
validate::SchemaValidator::validate(value, self)
}
}
impl<'de> Deserialize<'de> for Schema {

79
src/target.rs Normal file
View File

@@ -0,0 +1,79 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
#![allow(dead_code)]
use crate::{Rc, Schema, Value, Vec};
use alloc::collections::BTreeMap;
use serde::Deserialize;
mod deserialize;
mod error;
mod resource_schema_selector;
type String = Rc<str>;
use deserialize::{deserialize_effects, deserialize_resource_schemas};
pub use error::TargetError;
/// A target defines the domain for which a set of policies are written.
/// It specifies the types of input resources, possible policy effects,
/// and configuration for policy evaluation.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Target {
/// Name of the target domain
/// A Rego module can specify a target by defining a rule named `__target__`:
/// __target__ = "my_target"
pub name: String,
/// Description of what this target is for
pub description: Option<String>,
/// Version of the target
pub version: String,
/// Types of input resources that policies can evaluate
#[serde(deserialize_with = "deserialize_resource_schemas")]
pub resource_schemas: Vec<Rc<Schema>>,
/// The discriminator property that can be used to select
/// a specific resource schema
pub resource_schema_selector: String,
/// Set of effects that policies can produce
#[serde(deserialize_with = "deserialize_effects")]
pub effects: BTreeMap<String, Rc<Schema>>,
/// Lookup table for resource schemas by discrimiator values.
#[serde(skip)]
pub resource_schema_lookup: BTreeMap<Value, Rc<Schema>>,
/// Resource chemas that cannot be distinguished by the discriminator
#[serde(skip)]
pub default_resource_schema: Option<Rc<Schema>>,
}
impl Target {
pub fn from_json_str(json: &str) -> Result<Self, TargetError> {
let mut target: Target = serde_json::from_str(json).map_err(TargetError::from)?;
// Validate that resource schemas is not empty
if target.resource_schemas.is_empty() {
return Err(TargetError::EmptyResourceSchemas(
"Target must have at least one resource schema defined".into(),
));
}
if target.effects.is_empty() {
return Err(TargetError::EmptyEffectSchemas(
"Target must have at least one effect defined".into(),
));
}
resource_schema_selector::populate_target_lookup_fields(&mut target)?;
Ok(target)
}
}
#[cfg(test)]
mod tests {
mod deserialize;
}

76
src/target/deserialize.rs Normal file
View File

@@ -0,0 +1,76 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use crate::registry::instances::{EFFECT_SCHEMA_REGISTRY, RESOURCE_SCHEMA_REGISTRY};
use crate::{format, Rc, Schema, Vec};
use alloc::collections::BTreeMap;
use serde::de::{Deserializer, Error};
use serde::Deserialize;
type String = Rc<str>;
/// Deserialize resource schemas from either an array of schemas or schema names.
/// If specified as schema names, look them up from RESOURCE_SCHEMA_REGISTRY.
pub fn deserialize_resource_schemas<'de, D>(deserializer: D) -> Result<Vec<Rc<Schema>>, D::Error>
where
D: Deserializer<'de>,
{
let array: Vec<serde_json::Value> = Vec::deserialize(deserializer)
.map_err(|e| D::Error::custom(format!("Failed to deserialize resource_schemas: {}", e)))?;
let mut schemas = Vec::new();
for item in array.into_iter() {
let schema =
if let Some(name) = item.as_str() {
// Look up schema by name in the registry
RESOURCE_SCHEMA_REGISTRY.get(name).ok_or_else(|| {
D::Error::custom(format!("Resource schema '{}' not found in registry", name))
})?
} else {
// Treat as a direct schema definition
Rc::new(Schema::deserialize(item.clone()).map_err(|e| {
D::Error::custom(format!("Failed to deserialize schema: {}", e))
})?)
};
// Assert that the schema represents an object type
if !matches!(schema.as_type(), crate::schema::Type::Object { .. }) {
return Err(D::Error::custom("Resource schema must be an object type"));
}
schemas.push(schema);
}
Ok(schemas)
}
/// Deserialize effects from either an object of schemas or schema names.
/// If specified as schema names, look them up from EFFECT_SCHEMA_REGISTRY.
pub fn deserialize_effects<'de, D>(
deserializer: D,
) -> Result<BTreeMap<String, Rc<Schema>>, D::Error>
where
D: Deserializer<'de>,
{
let object: BTreeMap<String, serde_json::Value> = BTreeMap::deserialize(deserializer)
.map_err(|e| D::Error::custom(format!("Failed to deserialize effects: {}", e)))?;
let mut effects = BTreeMap::new();
for (key, item) in object.into_iter() {
if let Some(name) = item.as_str() {
// Look up schema by name in the registry
let schema = EFFECT_SCHEMA_REGISTRY.get(name).ok_or_else(|| {
D::Error::custom(format!("Effect schema '{}' not found in registry", name))
})?;
effects.insert(key, schema);
} else {
// Treat as a direct schema definition
let schema = Schema::deserialize(item.clone())
.map_err(|e| D::Error::custom(format!("Failed to deserialize schema: {}", e)))?;
effects.insert(key, Rc::new(schema));
}
}
Ok(effects)
}

35
src/target/error.rs Normal file
View File

@@ -0,0 +1,35 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use crate::{format, Rc};
type String = Rc<str>;
/// Error type for target parsing operations.
#[derive(Debug, Clone, thiserror::Error)]
pub enum TargetError {
/// JSON parsing error
#[error("JSON parse error: {0}")]
JsonParseError(String),
/// Target deserialization error
#[error("Deserialization error: {0}")]
DeserializationError(String),
/// Duplicate constant value error
#[error("Duplicate constant value: {0}")]
DuplicateConstantValue(String),
/// Multiple default resource schemas error
#[error("Multiple default schemas: {0}")]
MultipleDefaultSchemas(String),
/// Empty resource schemas error
#[error("Empty resource schemas: {0}")]
EmptyResourceSchemas(String),
/// Empty effect schemas error
#[error("Empty effect schemas: {0}")]
EmptyEffectSchemas(String),
}
impl From<serde_json::Error> for TargetError {
fn from(error: serde_json::Error) -> Self {
TargetError::JsonParseError(format!("{}", error).into())
}
}

View File

@@ -0,0 +1,90 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use crate::schema::Type;
use crate::{format, Rc, Schema, Value};
use alloc::collections::BTreeMap;
type String = Rc<str>;
use super::{Target, TargetError};
/// Populates the resource_schema_lookup and default_resource_schema fields
/// in a Target based on its resource_schema_selector field and resource_schemas.
///
/// This function analyzes each resource schema to:
/// - Find constant properties that match the selector field name
/// - Build a lookup table mapping constant values to schemas
/// - Collect schemas that don't have the constant property
/// - Raise an error if duplicate constant values are found
pub fn populate_target_lookup_fields(target: &mut Target) -> Result<(), TargetError> {
target.resource_schema_lookup.clear();
target.default_resource_schema = None;
// Track which schema index corresponds to each constant value
let mut value_to_index = BTreeMap::new();
// Analyze each schema for constant properties
for (index, schema) in target.resource_schemas.iter().enumerate() {
if let Some(constant_value) =
find_constant_property(schema, &target.resource_schema_selector)
{
// Check if this constant value already exists
if let Some(existing_index) = value_to_index.get(&constant_value) {
return Err(TargetError::DuplicateConstantValue(format!(
"Duplicate constant value '{}' found for resource schema selector field '{}' in schemas at indexes {} and {}",
constant_value,
target.resource_schema_selector,
existing_index,
index
).into()));
}
// Record the mapping and add to lookup table
value_to_index.insert(constant_value.clone(), index);
target
.resource_schema_lookup
.insert(constant_value, schema.clone());
} else {
// Schema doesn't have the constant property
if target.default_resource_schema.is_some() {
return Err(TargetError::MultipleDefaultSchemas(format!(
"Multiple schemas found without discriminator property '{}'. Only one default resource schema is allowed.",
target.resource_schema_selector
).into()));
}
target.default_resource_schema = Some(schema.clone());
}
}
Ok(())
}
/// Finds a constant property value in a schema for the specified field name.
/// Returns the constant value if found, or None if the schema doesn't have
/// a constant property for the given field.
fn find_constant_property(schema: &Rc<Schema>, field_name: &str) -> Option<Value> {
match schema.as_type() {
Type::Object { properties, .. } => {
// Look for the field in the schema's properties
if let Some(property_schema) = properties.get(field_name) {
match property_schema.as_type() {
Type::Const { value, .. } => {
// Found a constant property - return its value
Some(value.clone())
}
_ => {
// Property exists but is not a constant
None
}
}
} else {
// Field doesn't exist in this schema
None
}
}
_ => {
// Schema is not an object type
None
}
}
}

View File

@@ -0,0 +1,358 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use super::super::*;
use crate::Value;
use alloc::string::ToString;
use serde_json::json;
#[test]
fn test_target_deserialization_with_direct_schemas() {
let target_json = json!({
"name": "test_target",
"description": "A test target for validation",
"version": "1.0.0",
"resource_schema_selector": "type",
"resource_schemas": [
{
"type": "object",
"properties": {
"name": { "type": "string" },
"type": { "const": "user" }
},
"required": ["name", "type"]
},
{
"type": "object",
"properties": {
"name": { "type": "string" },
"type": { "const": "group" }
},
"required": ["name", "type"]
}
],
"effects": {
"allow": { "type": "boolean" },
"deny": { "type": "boolean" }
}
});
let target = Target::from_json_str(&target_json.to_string()).unwrap();
assert_eq!(target.name.as_ref(), "test_target");
assert_eq!(
target.description.as_ref().unwrap().as_ref(),
"A test target for validation"
);
assert_eq!(target.version.as_ref(), "1.0.0");
assert_eq!(target.resource_schema_selector.as_ref(), "type");
assert_eq!(target.resource_schemas.len(), 2);
assert_eq!(target.effects.len(), 2);
// Check that lookup table was populated
assert_eq!(target.resource_schema_lookup.len(), 2);
assert!(target
.resource_schema_lookup
.contains_key(&Value::String("user".into())));
assert!(target
.resource_schema_lookup
.contains_key(&Value::String("group".into())));
// Check that default_resource_schema is None since all schemas have the discriminator
assert!(target.default_resource_schema.is_none());
}
#[test]
fn test_target_deserialization_with_mixed_schemas() {
let target_json = json!({
"name": "mixed_target",
"version": "1.0.0",
"resource_schema_selector": "resourceType",
"resource_schemas": [
{
"type": "object",
"properties": {
"id": { "type": "string" },
"resourceType": { "const": "storage" }
},
"required": ["id", "resourceType"]
},
{
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string" }
},
"required": ["id"]
}
],
"effects": {
"permit": { "type": "string" }
}
});
let target = Target::from_json_str(&target_json.to_string()).unwrap();
assert_eq!(target.name.as_ref(), "mixed_target");
assert!(target.description.is_none());
assert_eq!(target.resource_schema_selector.as_ref(), "resourceType");
// One schema has discriminator, one doesn't
assert_eq!(target.resource_schema_lookup.len(), 1);
assert!(target
.resource_schema_lookup
.contains_key(&Value::String("storage".into())));
assert!(target.default_resource_schema.is_some());
}
#[test]
fn test_target_deserialization_multiple_default_schemas_error() {
let target_json = json!({
"name": "multiple_default_target",
"version": "1.0.0",
"resource_schema_selector": "kind",
"resource_schemas": [
{
"type": "object",
"properties": {
"id": { "type": "string" },
"data": { "type": "object" }
},
"required": ["id"]
},
{
"type": "object",
"properties": {
"name": { "type": "string" },
"value": { "type": "number" }
},
"required": ["name"]
}
],
"effects": {
"allow": { "type": "boolean" },
"deny": { "type": "boolean" }
}
});
// No schemas have the discriminator field - this should fail with MultipleDefaultSchemas error
let result = Target::from_json_str(&target_json.to_string());
assert!(result.is_err());
let error = result.unwrap_err();
assert!(matches!(error, TargetError::MultipleDefaultSchemas(_)));
}
#[test]
fn test_target_deserialization_single_default_schema() {
let target_json = json!({
"name": "single_default_target",
"version": "1.0.0",
"resource_schema_selector": "kind",
"resource_schemas": [
{
"type": "object",
"properties": {
"id": { "type": "string" },
"data": { "type": "object" }
},
"required": ["id"]
}
],
"effects": {
"allow": { "type": "boolean" }
}
});
let target = Target::from_json_str(&target_json.to_string()).unwrap();
// Single schema without discriminator should work fine
assert_eq!(target.resource_schema_lookup.len(), 0);
assert!(target.default_resource_schema.is_some());
}
#[test]
fn test_target_deserialization_duplicate_discriminator_error() {
let target_json = json!({
"name": "duplicate_target",
"version": "1.0.0",
"resource_schema_selector": "type",
"resource_schemas": [
{
"type": "object",
"properties": {
"id": { "type": "string" },
"type": { "const": "duplicate" }
},
"required": ["id", "type"]
},
{
"type": "object",
"properties": {
"name": { "type": "string" },
"type": { "const": "duplicate" }
},
"required": ["name", "type"]
}
],
"effects": {
"allow": { "type": "boolean" }
}
});
let result = Target::from_json_str(&target_json.to_string());
assert!(result.is_err());
let error = result.unwrap_err();
assert!(matches!(error, TargetError::DuplicateConstantValue(_)));
}
#[test]
fn test_target_deserialization_missing_required_field() {
let target_json = json!({
"name": "incomplete_target",
"version": "1.0.0",
// Missing resource_schema_selector
"resource_schemas": [
{
"type": "object",
"properties": {
"id": { "type": "string" }
}
}
],
"effects": {}
});
let result = Target::from_json_str(&target_json.to_string());
assert!(result.is_err());
let error = result.unwrap_err();
assert!(matches!(
error,
TargetError::JsonParseError(_) | TargetError::DeserializationError(_)
));
}
#[test]
fn test_target_deserialization_invalid_json() {
let invalid_json = "{ invalid json }";
let result = Target::from_json_str(invalid_json);
assert!(result.is_err());
let error = result.unwrap_err();
assert!(matches!(error, TargetError::JsonParseError(_)));
}
#[test]
fn test_target_deserialization_with_registry_schemas() {
// This test assumes that there are some schemas in the registries
// If the registries are empty, this test will fail with appropriate errors
let target_json = json!({
"name": "registry_target",
"version": "1.0.0",
"resource_schema_selector": "type",
"resource_schemas": [
"some_registry_schema_name" // This will be looked up from RESOURCE_SCHEMA_REGISTRY
],
"effects": {
"allow": "some_effect_schema_name" // This will be looked up from EFFECT_SCHEMA_REGISTRY
}
});
// This test will likely fail if the registries are empty, but it demonstrates
// the structure for testing registry-based schema resolution
let result = Target::from_json_str(&target_json.to_string());
// We expect this to fail with a "not found in registry" error since we haven't
// populated the registries with test data
if result.is_err() {
let error = result.unwrap_err();
assert!(matches!(
error,
TargetError::JsonParseError(_) | TargetError::DeserializationError(_)
));
}
}
#[test]
fn test_target_deserialization_numeric_discriminator() {
let target_json = json!({
"name": "numeric_target",
"version": "1.0.0",
"resource_schema_selector": "level",
"resource_schemas": [
{
"type": "object",
"properties": {
"name": { "type": "string" },
"level": { "const": 1 }
},
"required": ["name", "level"]
},
{
"type": "object",
"properties": {
"name": { "type": "string" },
"level": { "const": 2 }
},
"required": ["name", "level"]
}
],
"effects": {
"grant": { "type": "string" }
}
});
let target = Target::from_json_str(&target_json.to_string()).unwrap();
// Check that numeric discriminator values work
assert_eq!(target.resource_schema_lookup.len(), 2);
assert!(target.resource_schema_lookup.contains_key(&Value::from(1)));
assert!(target.resource_schema_lookup.contains_key(&Value::from(2)));
assert!(target.default_resource_schema.is_none());
}
#[test]
fn test_target_deserialization_boolean_discriminator() {
let target_json = json!({
"name": "boolean_target",
"version": "1.0.0",
"resource_schema_selector": "enabled",
"resource_schemas": [
{
"type": "object",
"properties": {
"name": { "type": "string" },
"enabled": { "const": true }
},
"required": ["name", "enabled"]
},
{
"type": "object",
"properties": {
"name": { "type": "string" },
"enabled": { "const": false }
},
"required": ["name", "enabled"]
}
],
"effects": {
"activate": { "type": "boolean" }
}
});
let target = Target::from_json_str(&target_json.to_string()).unwrap();
// Check that boolean discriminator values work
assert_eq!(target.resource_schema_lookup.len(), 2);
assert!(target
.resource_schema_lookup
.contains_key(&Value::from(true)));
assert!(target
.resource_schema_lookup
.contains_key(&Value::from(false)));
assert!(target.default_resource_schema.is_none());
}

View File

@@ -9,6 +9,119 @@ use anyhow::{bail, Result};
use serde::{ser::SerializeMap, Deserialize, Deserializer, Serialize, Serializer};
use test_generator::test_resources;
#[cfg(feature = "azure_policy")]
mod load_target_definitions {
use super::*;
use std::{eprintln, sync::Once};
static INIT: Once = Once::new();
/// Load and register all target definitions from tests/interpreter/target/definitions
/// This function is called once and loads all JSON target definition files.
pub fn load() -> Result<()> {
INIT.call_once(|| {
if let Err(e) = load_target_definitions_impl() {
eprintln!("Failed to load target definitions: {}", e);
}
});
Ok(())
}
fn load_target_definitions_impl() -> Result<()> {
use crate::registry::targets;
use crate::target::Target;
use std::fs;
use std::path::Path;
let definitions_path = Path::new("tests/interpreter/cases/target/definitions");
if !definitions_path.exists() {
eprintln!("Target definitions directory does not exist");
return Ok(());
}
let entries = fs::read_dir(definitions_path)?;
let mut found = false;
for entry in entries {
let entry = entry?;
let path = entry.path();
// Only process JSON files
if path.extension().and_then(|s| s.to_str()) == Some("json") {
let contents = fs::read_to_string(&path)?;
match Target::from_json_str(&contents) {
Ok(target) => {
let target_name = target.name.clone();
let target_rc = Rc::new(target);
found = true;
if let Err(e) = targets::register(target_rc.clone()) {
eprintln!("Failed to register target '{}': {}", target_name, e);
}
}
Err(e) => {
eprintln!(
"Failed to parse target definition from {}: {}",
path.display(),
e
);
}
}
}
}
if !found {
eprintln!("No target definitions were found");
}
Ok(())
}
#[test]
fn test_load_target_definitions() -> Result<()> {
use crate::registry::targets;
// Load target definitions
let _ = load()?;
// Check that the sample targets were loaded
assert!(
targets::contains("target.tests.sample_test_target"),
"Sample target should be loaded"
);
assert!(
targets::contains("target.tests.azure_compute"),
"Azure compute target should be loaded"
);
// Verify we can retrieve the targets
let sample_target = targets::get("target.tests.sample_test_target");
assert!(
sample_target.is_some(),
"Should be able to retrieve sample target"
);
let azure_target = targets::get("target.tests.azure_compute");
assert!(
azure_target.is_some(),
"Should be able to retrieve azure target"
);
// Verify target properties
if let Some(target) = sample_target {
assert_eq!(target.name.as_ref(), "target.tests.sample_test_target");
assert_eq!(target.version.as_ref(), "1.0.0");
}
if let Some(target) = azure_target {
assert_eq!(target.name.as_ref(), "target.tests.azure_compute");
assert_eq!(target.version.as_ref(), "1.0.0");
}
Ok(())
}
}
// Process test value specified in json/yaml to interpret special encodings.
pub fn process_value(v: &Value) -> Result<Value> {
match v {
@@ -211,6 +324,64 @@ pub fn eval_file(
Ok((results, engine.take_prints()?))
}
#[cfg(feature = "azure_policy")]
pub fn eval_file_with_rule_evaluation(
regos: &[String],
data_opt: Option<Value>,
input_opt: Option<ValueOrVec>,
query: &str,
_enable_tracing: bool,
strict: bool,
) -> Result<(Vec<Value>, Vec<String>)> {
let mut engine: Engine = Engine::new();
engine.set_rego_v0(true);
engine.set_strict_builtin_errors(strict);
engine.set_gather_prints(true);
#[cfg(feature = "coverage")]
engine.set_enable_coverage(true);
let mut results = vec![];
let mut files = vec![];
for (idx, _) in regos.iter().enumerate() {
files.push(format!("rego_{idx}"));
}
for (idx, file) in files.iter().enumerate() {
let contents = regos[idx].as_str();
engine.add_policy(file.to_string(), contents.to_string())?;
}
if let Some(data) = data_opt {
engine.add_data(data)?;
}
// Also test using the newer CompilerPolicy API.
let compiled_policy = engine.clone().compile_for_target()?;
let mut inputs = vec![];
match input_opt {
Some(ValueOrVec::Single(single_input)) => inputs.push(single_input),
Some(ValueOrVec::Many(mut many_input)) => inputs.append(&mut many_input),
_ => {
// For target tests without input, use an empty object as default
inputs.push(Value::new_object());
}
}
for input in inputs {
engine.set_input(input.clone());
// Use eval_rule instead of eval_query for target tests
let r_engine = engine.eval_rule(query.to_string())?;
let r_compiled_policy = compiled_policy.eval_with_input(input)?;
assert_eq!(r_engine, r_compiled_policy);
results.push(r_engine);
}
Ok((results, engine.take_prints()?))
}
#[derive(PartialEq, Debug)]
pub enum ValueOrVec {
Single(Value),
@@ -280,6 +451,9 @@ fn yaml_test_impl(file: &str) -> Result<()> {
let yaml_str = std::fs::read_to_string(file)?;
let test: YamlTest = serde_yaml::from_str(&yaml_str)?;
#[cfg(feature = "azure_policy")]
load_target_definitions::load().expect("Failed to load target definitions");
#[cfg(not(feature = "std"))]
{
// Skip tests that depend on bultins that need std feature.
@@ -329,14 +503,36 @@ fn yaml_test_impl(file: &str) -> Result<()> {
let enable_tracing = case.traces.is_some() && case.traces.unwrap();
match eval_file(
&case.modules,
case.data,
case.input,
case.query.as_str(),
enable_tracing,
case.strict,
) {
let is_target_test = file.contains("target");
let result = if is_target_test {
#[cfg(feature = "azure_policy")]
{
eval_file_with_rule_evaluation(
&case.modules,
case.data,
case.input,
case.query.as_str(),
enable_tracing,
case.strict,
)
}
#[cfg(not(feature = "azure_policy"))]
{
panic!("Target tests require azure_policy feature")
}
} else {
eval_file(
&case.modules,
case.data,
case.input,
case.query.as_str(),
enable_tracing,
case.strict,
)
};
match result {
Ok((results, prints)) => match case.want_result {
Some(want_result) => {
let mut expected_results = vec![];
@@ -392,6 +588,12 @@ fn yaml_test(file: &str) -> Result<()> {
return Ok(());
}
// Targets are supported only with azure_policy feature.
#[cfg(not(feature = "azure_policy"))]
if file.contains("target") {
return Ok(());
}
match yaml_test_impl(file) {
Ok(_) => Ok(()),
Err(e) => {