mirror of
https://github.com/microsoft/regorus.git
synced 2026-08-05 02:16:11 +00:00
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:
committed by
GitHub
parent
3c33d31d08
commit
cc917ea75d
@@ -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.
|
||||
|
||||
@@ -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
91
src/compile.rs
Normal 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
215
src/compiled_policy.rs
Normal 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>>)>,
|
||||
}
|
||||
320
src/engine.rs
320
src/engine.rs
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
58
src/interpreter/error.rs
Normal 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),
|
||||
}
|
||||
278
src/interpreter/target/infer.rs
Normal file
278
src/interpreter/target/infer.rs
Normal 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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
248
src/interpreter/target/resolve.rs
Normal file
248
src/interpreter/target/resolve.rs
Normal 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(())
|
||||
}
|
||||
23
src/lib.rs
23
src/lib.rs
@@ -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};
|
||||
|
||||
@@ -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
42
src/policy_info.rs
Normal 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>,
|
||||
}
|
||||
@@ -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
1254
src/registry/tests/target.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
79
src/target.rs
Normal 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
76
src/target/deserialize.rs
Normal 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
35
src/target/error.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
90
src/target/resource_schema_selector.rs
Normal file
90
src/target/resource_schema_selector.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
358
src/target/tests/deserialize.rs
Normal file
358
src/target/tests/deserialize.rs
Normal 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());
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user