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
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user