use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum PolicyCategory {
#[default]
Read,
Write,
Delete,
Fields,
Admin,
}
impl fmt::Display for PolicyCategory {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PolicyCategory::Read => write!(f, "read"),
PolicyCategory::Write => write!(f, "write"),
PolicyCategory::Delete => write!(f, "delete"),
PolicyCategory::Fields => write!(f, "fields"),
PolicyCategory::Admin => write!(f, "admin"),
}
}
}
impl FromStr for PolicyCategory {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"read" | "reads" => Ok(PolicyCategory::Read),
"write" | "writes" => Ok(PolicyCategory::Write),
"delete" | "deletes" => Ok(PolicyCategory::Delete),
"fields" | "field" | "paths" => Ok(PolicyCategory::Fields),
"admin" | "safety" | "limits" => Ok(PolicyCategory::Admin),
"queries" | "query" => Ok(PolicyCategory::Read),
"mutations" | "mutation" => Ok(PolicyCategory::Write),
"introspection" => Ok(PolicyCategory::Admin),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum PolicyRiskLevel {
#[default]
Low,
Medium,
High,
Critical,
}
impl fmt::Display for PolicyRiskLevel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PolicyRiskLevel::Low => write!(f, "low"),
PolicyRiskLevel::Medium => write!(f, "medium"),
PolicyRiskLevel::High => write!(f, "high"),
PolicyRiskLevel::Critical => write!(f, "critical"),
}
}
}
impl FromStr for PolicyRiskLevel {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"low" => Ok(PolicyRiskLevel::Low),
"medium" => Ok(PolicyRiskLevel::Medium),
"high" => Ok(PolicyRiskLevel::High),
"critical" => Ok(PolicyRiskLevel::Critical),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyMetadata {
pub id: String,
pub title: String,
pub description: String,
pub category: PolicyCategory,
pub risk: PolicyRiskLevel,
pub editable: bool,
pub reason: Option<String>,
pub author: Option<String>,
pub modified: Option<String>,
pub raw_cedar: String,
pub is_baseline: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub template_id: Option<String>,
}
pub fn infer_category_and_risk_from_cedar(cedar: &str) -> (PolicyCategory, PolicyRiskLevel) {
let cedar_lower = cedar.to_lowercase();
let category = if cedar.contains("Action::\"Delete\"") || cedar.contains("Action::\"delete\"") {
PolicyCategory::Delete
} else if cedar.contains("Action::\"Write\"") || cedar.contains("Action::\"write\"") {
PolicyCategory::Write
} else if cedar.contains("Action::\"Read\"") || cedar.contains("Action::\"read\"") {
PolicyCategory::Read
} else if cedar.contains("Action::\"Admin\"")
|| cedar.contains("Action::\"admin\"")
|| cedar.contains("Action::\"Introspection\"")
{
PolicyCategory::Admin
} else {
if cedar_lower.contains("delete") {
PolicyCategory::Delete
} else if cedar_lower.contains("write") || cedar_lower.contains("mutation") {
PolicyCategory::Write
} else {
PolicyCategory::Read
}
};
let _is_forbid = cedar_lower.trim_start().starts_with("forbid");
let is_permit = cedar_lower.trim_start().starts_with("permit");
let risk = match category {
PolicyCategory::Delete => {
if is_permit {
PolicyRiskLevel::High } else {
PolicyRiskLevel::Low }
},
PolicyCategory::Write => {
if is_permit {
PolicyRiskLevel::Medium } else {
PolicyRiskLevel::Low }
},
PolicyCategory::Admin => {
if is_permit {
PolicyRiskLevel::High } else {
PolicyRiskLevel::Medium }
},
PolicyCategory::Read => PolicyRiskLevel::Low, PolicyCategory::Fields => PolicyRiskLevel::Medium, };
(category, risk)
}
impl Default for PolicyMetadata {
fn default() -> Self {
Self {
id: String::new(),
title: String::new(),
description: String::new(),
category: PolicyCategory::default(),
risk: PolicyRiskLevel::default(),
editable: true,
reason: None,
author: None,
modified: None,
raw_cedar: String::new(),
is_baseline: false,
template_id: None,
}
}
}
impl PolicyMetadata {
pub fn new(id: impl Into<String>, cedar: impl Into<String>) -> Self {
let cedar = cedar.into();
let mut metadata = parse_policy_annotations(&cedar, &id.into());
metadata.raw_cedar = cedar;
metadata
}
pub fn validate(&self) -> Result<(), Vec<PolicyValidationError>> {
let mut errors = Vec::new();
if self.title.is_empty() {
errors.push(PolicyValidationError::MissingAnnotation(
"@title".to_string(),
));
}
if self.description.is_empty() {
errors.push(PolicyValidationError::MissingAnnotation(
"@description".to_string(),
));
}
if !self.raw_cedar.contains("@category") {
errors.push(PolicyValidationError::MissingAnnotation(
"@category".to_string(),
));
}
if !self.raw_cedar.contains("@risk") {
errors.push(PolicyValidationError::MissingAnnotation(
"@risk".to_string(),
));
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PolicyValidationError {
MissingAnnotation(String),
InvalidAnnotation { annotation: String, message: String },
CedarSyntaxError { line: Option<u32>, message: String },
}
impl fmt::Display for PolicyValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PolicyValidationError::MissingAnnotation(ann) => {
write!(f, "Missing required annotation: {}", ann)
},
PolicyValidationError::InvalidAnnotation {
annotation,
message,
} => {
write!(f, "Invalid {}: {}", annotation, message)
},
PolicyValidationError::CedarSyntaxError { line, message } => {
if let Some(line) = line {
write!(f, "Cedar syntax error at line {}: {}", line, message)
} else {
write!(f, "Cedar syntax error: {}", message)
}
},
}
}
}
pub fn parse_policy_annotations(cedar: &str, policy_id: &str) -> PolicyMetadata {
let mut metadata = PolicyMetadata {
id: policy_id.to_string(),
raw_cedar: cedar.to_string(),
..Default::default()
};
let mut in_description = false;
let mut found_category = false;
let mut found_risk = false;
for line in cedar.lines() {
let line = line.trim();
if let Some(content) = line.strip_prefix("/// @") {
in_description = false;
if let Some((key, value)) = content.split_once(' ') {
let value = value.trim();
match key.to_lowercase().as_str() {
"title" => {
metadata.title = value.to_string();
if value.starts_with("Baseline:") {
metadata.is_baseline = true;
metadata.editable = false;
}
},
"description" => {
metadata.description = value.to_string();
in_description = true;
},
"category" => {
metadata.category = value.parse().unwrap_or_default();
found_category = true;
},
"risk" => {
metadata.risk = value.parse().unwrap_or_default();
found_risk = true;
},
"editable" => {
metadata.editable = value.eq_ignore_ascii_case("true");
},
"reason" => {
metadata.reason = Some(value.to_string());
},
"author" => {
metadata.author = Some(value.to_string());
},
"modified" => {
metadata.modified = Some(value.to_string());
},
_ => {
},
}
}
} else if let Some(content) = line.strip_prefix("/// ") {
if in_description {
if !metadata.description.is_empty() {
metadata.description.push('\n');
}
metadata.description.push_str(content);
}
} else if line == "///" {
if in_description {
metadata.description.push_str("\n\n");
}
} else {
in_description = false;
}
}
metadata.description = metadata.description.trim().to_string();
if !found_category || !found_risk {
let (inferred_category, inferred_risk) = infer_category_and_risk_from_cedar(cedar);
if !found_category {
metadata.category = inferred_category;
}
if !found_risk {
metadata.risk = inferred_risk;
}
}
metadata
}
pub fn generate_policy_cedar(metadata: &PolicyMetadata, policy_body: &str) -> String {
let mut lines = Vec::new();
lines.push(format!("/// @title {}", metadata.title));
for (i, desc_line) in metadata.description.lines().enumerate() {
if i == 0 {
lines.push(format!("/// @description {}", desc_line));
} else if desc_line.is_empty() {
lines.push("///".to_string());
} else {
lines.push(format!("/// {}", desc_line));
}
}
lines.push(format!("/// @category {}", metadata.category));
lines.push(format!("/// @risk {}", metadata.risk));
if !metadata.editable {
lines.push("/// @editable false".to_string());
}
if let Some(ref reason) = metadata.reason {
lines.push(format!("/// @reason {}", reason));
}
if let Some(ref author) = metadata.author {
lines.push(format!("/// @author {}", author));
}
if let Some(ref modified) = metadata.modified {
lines.push(format!("/// @modified {}", modified));
}
lines.push(policy_body.to_string());
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_policy() {
let cedar = r#"/// @title Allow Queries
/// @description Permits all read-only queries.
/// @category read
/// @risk low
permit(principal, action, resource);"#;
let metadata = parse_policy_annotations(cedar, "policy-123");
assert_eq!(metadata.id, "policy-123");
assert_eq!(metadata.title, "Allow Queries");
assert_eq!(metadata.description, "Permits all read-only queries.");
assert_eq!(metadata.category, PolicyCategory::Read);
assert_eq!(metadata.risk, PolicyRiskLevel::Low);
assert!(metadata.editable);
assert!(!metadata.is_baseline);
}
#[test]
fn test_parse_legacy_category_names() {
let cedar = r#"/// @title Allow Queries
/// @description Permits all read-only queries.
/// @category queries
/// @risk low
permit(principal, action, resource);"#;
let metadata = parse_policy_annotations(cedar, "policy-legacy");
assert_eq!(metadata.category, PolicyCategory::Read);
let cedar2 = r#"/// @title Block Mutations
/// @description Blocks write operations.
/// @category mutations
/// @risk high
forbid(principal, action, resource);"#;
let metadata2 = parse_policy_annotations(cedar2, "policy-legacy2");
assert_eq!(metadata2.category, PolicyCategory::Write);
}
#[test]
fn test_parse_multiline_description() {
let cedar = r#"/// @title Block Mutations
/// @description Prevents execution of dangerous mutations.
/// This is a critical security policy.
///
/// Do not modify without approval.
/// @category write
/// @risk critical
/// @editable false
/// @reason Security compliance
forbid(principal, action, resource);"#;
let metadata = parse_policy_annotations(cedar, "policy-456");
assert_eq!(metadata.title, "Block Mutations");
assert!(metadata.description.contains("Prevents execution"));
assert!(metadata.description.contains("Do not modify"));
assert_eq!(metadata.category, PolicyCategory::Write);
assert_eq!(metadata.risk, PolicyRiskLevel::Critical);
assert!(!metadata.editable);
assert_eq!(metadata.reason, Some("Security compliance".to_string()));
}
#[test]
fn test_parse_baseline_policy() {
let cedar = r#"/// @title Baseline: Allow Read-Only Queries
/// @description Core functionality for Code Mode.
/// @category read
/// @risk low
permit(principal, action, resource);"#;
let metadata = parse_policy_annotations(cedar, "baseline-1");
assert!(metadata.is_baseline);
assert!(!metadata.editable);
}
#[test]
fn test_validate_missing_annotations() {
let metadata = PolicyMetadata {
id: "test".to_string(),
title: "".to_string(), description: "Has description".to_string(),
raw_cedar: "permit(principal, action, resource);".to_string(),
..Default::default()
};
let result = metadata.validate();
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors.iter().any(|e| matches!(e,
PolicyValidationError::MissingAnnotation(s) if s == "@title"
)));
}
#[test]
fn test_generate_policy_cedar() {
let metadata = PolicyMetadata {
id: "test".to_string(),
title: "Allow Writes".to_string(),
description: "Permits safe write operations.\nAdd operations to the list.".to_string(),
category: PolicyCategory::Write,
risk: PolicyRiskLevel::Medium,
editable: true,
reason: None,
author: Some("admin".to_string()),
modified: Some("2024-01-15".to_string()),
raw_cedar: String::new(),
is_baseline: false,
template_id: None,
};
let body = r#"permit(
principal,
action == Action::"executeMutation",
resource
);"#;
let cedar = generate_policy_cedar(&metadata, body);
assert!(cedar.contains("/// @title Allow Writes"));
assert!(cedar.contains("/// @description Permits safe write operations."));
assert!(cedar.contains("/// Add operations to the list."));
assert!(cedar.contains("/// @category write"));
assert!(cedar.contains("/// @risk medium"));
assert!(cedar.contains("/// @author admin"));
assert!(cedar.contains("/// @modified 2024-01-15"));
assert!(cedar.contains("permit("));
}
#[test]
fn test_policy_category_parsing() {
assert_eq!(
"read".parse::<PolicyCategory>().unwrap(),
PolicyCategory::Read
);
assert_eq!(
"write".parse::<PolicyCategory>().unwrap(),
PolicyCategory::Write
);
assert_eq!(
"delete".parse::<PolicyCategory>().unwrap(),
PolicyCategory::Delete
);
assert_eq!(
"FIELDS".parse::<PolicyCategory>().unwrap(),
PolicyCategory::Fields
);
assert_eq!(
"admin".parse::<PolicyCategory>().unwrap(),
PolicyCategory::Admin
);
assert_eq!(
"reads".parse::<PolicyCategory>().unwrap(),
PolicyCategory::Read
);
assert_eq!(
"writes".parse::<PolicyCategory>().unwrap(),
PolicyCategory::Write
);
assert_eq!(
"deletes".parse::<PolicyCategory>().unwrap(),
PolicyCategory::Delete
);
assert_eq!(
"paths".parse::<PolicyCategory>().unwrap(),
PolicyCategory::Fields
);
assert_eq!(
"safety".parse::<PolicyCategory>().unwrap(),
PolicyCategory::Admin
);
assert_eq!(
"limits".parse::<PolicyCategory>().unwrap(),
PolicyCategory::Admin
);
assert_eq!(
"queries".parse::<PolicyCategory>().unwrap(),
PolicyCategory::Read
);
assert_eq!(
"mutation".parse::<PolicyCategory>().unwrap(),
PolicyCategory::Write
);
assert_eq!(
"introspection".parse::<PolicyCategory>().unwrap(),
PolicyCategory::Admin
);
assert!("unknown".parse::<PolicyCategory>().is_err());
}
#[test]
fn test_policy_risk_parsing() {
assert_eq!(
"low".parse::<PolicyRiskLevel>().unwrap(),
PolicyRiskLevel::Low
);
assert_eq!(
"CRITICAL".parse::<PolicyRiskLevel>().unwrap(),
PolicyRiskLevel::Critical
);
assert!("unknown".parse::<PolicyRiskLevel>().is_err());
}
}