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

* feat: Add Schema Registry and Validation Framework

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

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

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

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

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

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

Benchmarks:

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

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

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

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

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

---------

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>
This commit is contained in:
Anand Krishnamoorthi
2025-08-19 20:23:43 -05:00
committed by GitHub
parent 3c33d31d08
commit cc917ea75d
71 changed files with 10278 additions and 1000 deletions
+76
View File
@@ -0,0 +1,76 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use crate::registry::instances::{EFFECT_SCHEMA_REGISTRY, RESOURCE_SCHEMA_REGISTRY};
use crate::{format, Rc, Schema, Vec};
use alloc::collections::BTreeMap;
use serde::de::{Deserializer, Error};
use serde::Deserialize;
type String = Rc<str>;
/// Deserialize resource schemas from either an array of schemas or schema names.
/// If specified as schema names, look them up from RESOURCE_SCHEMA_REGISTRY.
pub fn deserialize_resource_schemas<'de, D>(deserializer: D) -> Result<Vec<Rc<Schema>>, D::Error>
where
D: Deserializer<'de>,
{
let array: Vec<serde_json::Value> = Vec::deserialize(deserializer)
.map_err(|e| D::Error::custom(format!("Failed to deserialize resource_schemas: {}", e)))?;
let mut schemas = Vec::new();
for item in array.into_iter() {
let schema =
if let Some(name) = item.as_str() {
// Look up schema by name in the registry
RESOURCE_SCHEMA_REGISTRY.get(name).ok_or_else(|| {
D::Error::custom(format!("Resource schema '{}' not found in registry", name))
})?
} else {
// Treat as a direct schema definition
Rc::new(Schema::deserialize(item.clone()).map_err(|e| {
D::Error::custom(format!("Failed to deserialize schema: {}", e))
})?)
};
// Assert that the schema represents an object type
if !matches!(schema.as_type(), crate::schema::Type::Object { .. }) {
return Err(D::Error::custom("Resource schema must be an object type"));
}
schemas.push(schema);
}
Ok(schemas)
}
/// Deserialize effects from either an object of schemas or schema names.
/// If specified as schema names, look them up from EFFECT_SCHEMA_REGISTRY.
pub fn deserialize_effects<'de, D>(
deserializer: D,
) -> Result<BTreeMap<String, Rc<Schema>>, D::Error>
where
D: Deserializer<'de>,
{
let object: BTreeMap<String, serde_json::Value> = BTreeMap::deserialize(deserializer)
.map_err(|e| D::Error::custom(format!("Failed to deserialize effects: {}", e)))?;
let mut effects = BTreeMap::new();
for (key, item) in object.into_iter() {
if let Some(name) = item.as_str() {
// Look up schema by name in the registry
let schema = EFFECT_SCHEMA_REGISTRY.get(name).ok_or_else(|| {
D::Error::custom(format!("Effect schema '{}' not found in registry", name))
})?;
effects.insert(key, schema);
} else {
// Treat as a direct schema definition
let schema = Schema::deserialize(item.clone())
.map_err(|e| D::Error::custom(format!("Failed to deserialize schema: {}", e)))?;
effects.insert(key, Rc::new(schema));
}
}
Ok(effects)
}
+35
View File
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use crate::{format, Rc};
type String = Rc<str>;
/// Error type for target parsing operations.
#[derive(Debug, Clone, thiserror::Error)]
pub enum TargetError {
/// JSON parsing error
#[error("JSON parse error: {0}")]
JsonParseError(String),
/// Target deserialization error
#[error("Deserialization error: {0}")]
DeserializationError(String),
/// Duplicate constant value error
#[error("Duplicate constant value: {0}")]
DuplicateConstantValue(String),
/// Multiple default resource schemas error
#[error("Multiple default schemas: {0}")]
MultipleDefaultSchemas(String),
/// Empty resource schemas error
#[error("Empty resource schemas: {0}")]
EmptyResourceSchemas(String),
/// Empty effect schemas error
#[error("Empty effect schemas: {0}")]
EmptyEffectSchemas(String),
}
impl From<serde_json::Error> for TargetError {
fn from(error: serde_json::Error) -> Self {
TargetError::JsonParseError(format!("{}", error).into())
}
}
+90
View File
@@ -0,0 +1,90 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use crate::schema::Type;
use crate::{format, Rc, Schema, Value};
use alloc::collections::BTreeMap;
type String = Rc<str>;
use super::{Target, TargetError};
/// Populates the resource_schema_lookup and default_resource_schema fields
/// in a Target based on its resource_schema_selector field and resource_schemas.
///
/// This function analyzes each resource schema to:
/// - Find constant properties that match the selector field name
/// - Build a lookup table mapping constant values to schemas
/// - Collect schemas that don't have the constant property
/// - Raise an error if duplicate constant values are found
pub fn populate_target_lookup_fields(target: &mut Target) -> Result<(), TargetError> {
target.resource_schema_lookup.clear();
target.default_resource_schema = None;
// Track which schema index corresponds to each constant value
let mut value_to_index = BTreeMap::new();
// Analyze each schema for constant properties
for (index, schema) in target.resource_schemas.iter().enumerate() {
if let Some(constant_value) =
find_constant_property(schema, &target.resource_schema_selector)
{
// Check if this constant value already exists
if let Some(existing_index) = value_to_index.get(&constant_value) {
return Err(TargetError::DuplicateConstantValue(format!(
"Duplicate constant value '{}' found for resource schema selector field '{}' in schemas at indexes {} and {}",
constant_value,
target.resource_schema_selector,
existing_index,
index
).into()));
}
// Record the mapping and add to lookup table
value_to_index.insert(constant_value.clone(), index);
target
.resource_schema_lookup
.insert(constant_value, schema.clone());
} else {
// Schema doesn't have the constant property
if target.default_resource_schema.is_some() {
return Err(TargetError::MultipleDefaultSchemas(format!(
"Multiple schemas found without discriminator property '{}'. Only one default resource schema is allowed.",
target.resource_schema_selector
).into()));
}
target.default_resource_schema = Some(schema.clone());
}
}
Ok(())
}
/// Finds a constant property value in a schema for the specified field name.
/// Returns the constant value if found, or None if the schema doesn't have
/// a constant property for the given field.
fn find_constant_property(schema: &Rc<Schema>, field_name: &str) -> Option<Value> {
match schema.as_type() {
Type::Object { properties, .. } => {
// Look for the field in the schema's properties
if let Some(property_schema) = properties.get(field_name) {
match property_schema.as_type() {
Type::Const { value, .. } => {
// Found a constant property - return its value
Some(value.clone())
}
_ => {
// Property exists but is not a constant
None
}
}
} else {
// Field doesn't exist in this schema
None
}
}
_ => {
// Schema is not an object type
None
}
}
}
+358
View File
@@ -0,0 +1,358 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use super::super::*;
use crate::Value;
use alloc::string::ToString;
use serde_json::json;
#[test]
fn test_target_deserialization_with_direct_schemas() {
let target_json = json!({
"name": "test_target",
"description": "A test target for validation",
"version": "1.0.0",
"resource_schema_selector": "type",
"resource_schemas": [
{
"type": "object",
"properties": {
"name": { "type": "string" },
"type": { "const": "user" }
},
"required": ["name", "type"]
},
{
"type": "object",
"properties": {
"name": { "type": "string" },
"type": { "const": "group" }
},
"required": ["name", "type"]
}
],
"effects": {
"allow": { "type": "boolean" },
"deny": { "type": "boolean" }
}
});
let target = Target::from_json_str(&target_json.to_string()).unwrap();
assert_eq!(target.name.as_ref(), "test_target");
assert_eq!(
target.description.as_ref().unwrap().as_ref(),
"A test target for validation"
);
assert_eq!(target.version.as_ref(), "1.0.0");
assert_eq!(target.resource_schema_selector.as_ref(), "type");
assert_eq!(target.resource_schemas.len(), 2);
assert_eq!(target.effects.len(), 2);
// Check that lookup table was populated
assert_eq!(target.resource_schema_lookup.len(), 2);
assert!(target
.resource_schema_lookup
.contains_key(&Value::String("user".into())));
assert!(target
.resource_schema_lookup
.contains_key(&Value::String("group".into())));
// Check that default_resource_schema is None since all schemas have the discriminator
assert!(target.default_resource_schema.is_none());
}
#[test]
fn test_target_deserialization_with_mixed_schemas() {
let target_json = json!({
"name": "mixed_target",
"version": "1.0.0",
"resource_schema_selector": "resourceType",
"resource_schemas": [
{
"type": "object",
"properties": {
"id": { "type": "string" },
"resourceType": { "const": "storage" }
},
"required": ["id", "resourceType"]
},
{
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string" }
},
"required": ["id"]
}
],
"effects": {
"permit": { "type": "string" }
}
});
let target = Target::from_json_str(&target_json.to_string()).unwrap();
assert_eq!(target.name.as_ref(), "mixed_target");
assert!(target.description.is_none());
assert_eq!(target.resource_schema_selector.as_ref(), "resourceType");
// One schema has discriminator, one doesn't
assert_eq!(target.resource_schema_lookup.len(), 1);
assert!(target
.resource_schema_lookup
.contains_key(&Value::String("storage".into())));
assert!(target.default_resource_schema.is_some());
}
#[test]
fn test_target_deserialization_multiple_default_schemas_error() {
let target_json = json!({
"name": "multiple_default_target",
"version": "1.0.0",
"resource_schema_selector": "kind",
"resource_schemas": [
{
"type": "object",
"properties": {
"id": { "type": "string" },
"data": { "type": "object" }
},
"required": ["id"]
},
{
"type": "object",
"properties": {
"name": { "type": "string" },
"value": { "type": "number" }
},
"required": ["name"]
}
],
"effects": {
"allow": { "type": "boolean" },
"deny": { "type": "boolean" }
}
});
// No schemas have the discriminator field - this should fail with MultipleDefaultSchemas error
let result = Target::from_json_str(&target_json.to_string());
assert!(result.is_err());
let error = result.unwrap_err();
assert!(matches!(error, TargetError::MultipleDefaultSchemas(_)));
}
#[test]
fn test_target_deserialization_single_default_schema() {
let target_json = json!({
"name": "single_default_target",
"version": "1.0.0",
"resource_schema_selector": "kind",
"resource_schemas": [
{
"type": "object",
"properties": {
"id": { "type": "string" },
"data": { "type": "object" }
},
"required": ["id"]
}
],
"effects": {
"allow": { "type": "boolean" }
}
});
let target = Target::from_json_str(&target_json.to_string()).unwrap();
// Single schema without discriminator should work fine
assert_eq!(target.resource_schema_lookup.len(), 0);
assert!(target.default_resource_schema.is_some());
}
#[test]
fn test_target_deserialization_duplicate_discriminator_error() {
let target_json = json!({
"name": "duplicate_target",
"version": "1.0.0",
"resource_schema_selector": "type",
"resource_schemas": [
{
"type": "object",
"properties": {
"id": { "type": "string" },
"type": { "const": "duplicate" }
},
"required": ["id", "type"]
},
{
"type": "object",
"properties": {
"name": { "type": "string" },
"type": { "const": "duplicate" }
},
"required": ["name", "type"]
}
],
"effects": {
"allow": { "type": "boolean" }
}
});
let result = Target::from_json_str(&target_json.to_string());
assert!(result.is_err());
let error = result.unwrap_err();
assert!(matches!(error, TargetError::DuplicateConstantValue(_)));
}
#[test]
fn test_target_deserialization_missing_required_field() {
let target_json = json!({
"name": "incomplete_target",
"version": "1.0.0",
// Missing resource_schema_selector
"resource_schemas": [
{
"type": "object",
"properties": {
"id": { "type": "string" }
}
}
],
"effects": {}
});
let result = Target::from_json_str(&target_json.to_string());
assert!(result.is_err());
let error = result.unwrap_err();
assert!(matches!(
error,
TargetError::JsonParseError(_) | TargetError::DeserializationError(_)
));
}
#[test]
fn test_target_deserialization_invalid_json() {
let invalid_json = "{ invalid json }";
let result = Target::from_json_str(invalid_json);
assert!(result.is_err());
let error = result.unwrap_err();
assert!(matches!(error, TargetError::JsonParseError(_)));
}
#[test]
fn test_target_deserialization_with_registry_schemas() {
// This test assumes that there are some schemas in the registries
// If the registries are empty, this test will fail with appropriate errors
let target_json = json!({
"name": "registry_target",
"version": "1.0.0",
"resource_schema_selector": "type",
"resource_schemas": [
"some_registry_schema_name" // This will be looked up from RESOURCE_SCHEMA_REGISTRY
],
"effects": {
"allow": "some_effect_schema_name" // This will be looked up from EFFECT_SCHEMA_REGISTRY
}
});
// This test will likely fail if the registries are empty, but it demonstrates
// the structure for testing registry-based schema resolution
let result = Target::from_json_str(&target_json.to_string());
// We expect this to fail with a "not found in registry" error since we haven't
// populated the registries with test data
if result.is_err() {
let error = result.unwrap_err();
assert!(matches!(
error,
TargetError::JsonParseError(_) | TargetError::DeserializationError(_)
));
}
}
#[test]
fn test_target_deserialization_numeric_discriminator() {
let target_json = json!({
"name": "numeric_target",
"version": "1.0.0",
"resource_schema_selector": "level",
"resource_schemas": [
{
"type": "object",
"properties": {
"name": { "type": "string" },
"level": { "const": 1 }
},
"required": ["name", "level"]
},
{
"type": "object",
"properties": {
"name": { "type": "string" },
"level": { "const": 2 }
},
"required": ["name", "level"]
}
],
"effects": {
"grant": { "type": "string" }
}
});
let target = Target::from_json_str(&target_json.to_string()).unwrap();
// Check that numeric discriminator values work
assert_eq!(target.resource_schema_lookup.len(), 2);
assert!(target.resource_schema_lookup.contains_key(&Value::from(1)));
assert!(target.resource_schema_lookup.contains_key(&Value::from(2)));
assert!(target.default_resource_schema.is_none());
}
#[test]
fn test_target_deserialization_boolean_discriminator() {
let target_json = json!({
"name": "boolean_target",
"version": "1.0.0",
"resource_schema_selector": "enabled",
"resource_schemas": [
{
"type": "object",
"properties": {
"name": { "type": "string" },
"enabled": { "const": true }
},
"required": ["name", "enabled"]
},
{
"type": "object",
"properties": {
"name": { "type": "string" },
"enabled": { "const": false }
},
"required": ["name", "enabled"]
}
],
"effects": {
"activate": { "type": "boolean" }
}
});
let target = Target::from_json_str(&target_json.to_string()).unwrap();
// Check that boolean discriminator values work
assert_eq!(target.resource_schema_lookup.len(), 2);
assert!(target
.resource_schema_lookup
.contains_key(&Value::from(true)));
assert!(target
.resource_schema_lookup
.contains_key(&Value::from(false)));
assert!(target.default_resource_schema.is_none());
}