Skip to main content

pmcp_code_mode/
policy_annotations.rs

1//! Policy annotation parser for Cedar policies.
2//!
3//! This module parses rustdoc-style annotations from Cedar policy comments
4//! to extract metadata for UI display and policy management.
5//!
6//! ## Annotation Format
7//!
8//! ```cedar
9//! /// @title Allow Write Operations
10//! /// @description Permits adding and updating states in the policy database.
11//! /// These operations are considered safe for automated execution.
12//! /// @category write
13//! /// @risk medium
14//! /// @editable true
15//! permit(
16//!   principal,
17//!   action == Action::"executeMutation",
18//!   resource
19//! ) when {
20//!   resource.mutation in ["addState", "updateState"]
21//! };
22//! ```
23//!
24//! ## Supported Annotations
25//!
26//! | Annotation | Required | Description |
27//! |------------|----------|-------------|
28//! | `@title` | Yes | Short display name for the policy |
29//! | `@description` | Yes | Multi-line description (continuation lines without @) |
30//! | `@category` | Yes | One of: read, write, delete, fields, admin |
31//! | `@risk` | Yes | One of: low, medium, high, critical |
32//! | `@editable` | No | Whether admins can modify (default: true) |
33//! | `@reason` | No | Why the policy exists or is non-editable |
34//! | `@author` | No | Who created or last modified |
35//! | `@modified` | No | ISO date of last modification |
36//!
37//! ## Category Mapping
38//!
39//! The unified categories work across all server types:
40//! - `read`: Queries (GraphQL), GET (OpenAPI), SELECT (SQL)
41//! - `write`: Create/update mutations, POST/PUT/PATCH, INSERT/UPDATE
42//! - `delete`: Delete mutations, DELETE, DELETE/TRUNCATE
43//! - `admin`: Introspection, schema access, DDL
44//! - `fields`: Field-level access control
45//!
46//! Legacy category names are still supported for parsing: `queries` → `read`,
47//! `mutations` → `write`, `introspection` → `admin`.
48
49use serde::{Deserialize, Serialize};
50use std::fmt;
51use std::str::FromStr;
52
53/// Unified policy category for grouping in the UI.
54/// Works consistently across GraphQL, OpenAPI, and SQL servers.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
56#[serde(rename_all = "lowercase")]
57pub enum PolicyCategory {
58    /// Read operations (Query, GET, SELECT)
59    #[default]
60    Read,
61    /// Write operations (create/update mutations, POST/PUT/PATCH, INSERT/UPDATE)
62    Write,
63    /// Delete operations (delete mutations, DELETE, DELETE/TRUNCATE)
64    Delete,
65    /// Field-level access policies
66    Fields,
67    /// Administrative operations (introspection, DDL, schema changes)
68    Admin,
69}
70
71impl fmt::Display for PolicyCategory {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match self {
74            PolicyCategory::Read => write!(f, "read"),
75            PolicyCategory::Write => write!(f, "write"),
76            PolicyCategory::Delete => write!(f, "delete"),
77            PolicyCategory::Fields => write!(f, "fields"),
78            PolicyCategory::Admin => write!(f, "admin"),
79        }
80    }
81}
82
83impl FromStr for PolicyCategory {
84    type Err = ();
85
86    fn from_str(s: &str) -> Result<Self, Self::Err> {
87        match s.to_lowercase().as_str() {
88            // Unified names
89            "read" | "reads" => Ok(PolicyCategory::Read),
90            "write" | "writes" => Ok(PolicyCategory::Write),
91            "delete" | "deletes" => Ok(PolicyCategory::Delete),
92            "fields" | "field" | "paths" => Ok(PolicyCategory::Fields),
93            "admin" | "safety" | "limits" => Ok(PolicyCategory::Admin),
94            // Legacy GraphQL names (backward compatibility)
95            "queries" | "query" => Ok(PolicyCategory::Read),
96            "mutations" | "mutation" => Ok(PolicyCategory::Write),
97            "introspection" => Ok(PolicyCategory::Admin),
98            _ => Err(()),
99        }
100    }
101}
102
103/// Risk level for visual indication in the UI.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
105#[serde(rename_all = "lowercase")]
106pub enum PolicyRiskLevel {
107    /// Low risk - typically read-only operations
108    #[default]
109    Low,
110    /// Medium risk - safe mutations
111    Medium,
112    /// High risk - sensitive operations
113    High,
114    /// Critical risk - destructive or admin operations
115    Critical,
116}
117
118impl fmt::Display for PolicyRiskLevel {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        match self {
121            PolicyRiskLevel::Low => write!(f, "low"),
122            PolicyRiskLevel::Medium => write!(f, "medium"),
123            PolicyRiskLevel::High => write!(f, "high"),
124            PolicyRiskLevel::Critical => write!(f, "critical"),
125        }
126    }
127}
128
129impl FromStr for PolicyRiskLevel {
130    type Err = ();
131
132    fn from_str(s: &str) -> Result<Self, Self::Err> {
133        match s.to_lowercase().as_str() {
134            "low" => Ok(PolicyRiskLevel::Low),
135            "medium" => Ok(PolicyRiskLevel::Medium),
136            "high" => Ok(PolicyRiskLevel::High),
137            "critical" => Ok(PolicyRiskLevel::Critical),
138            _ => Err(()),
139        }
140    }
141}
142
143/// Parsed policy metadata from Cedar doc comments.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct PolicyMetadata {
146    /// AVP policy ID
147    pub id: String,
148
149    /// Short display name (@title)
150    pub title: String,
151
152    /// Longer description (@description), may be multi-line
153    pub description: String,
154
155    /// Policy category for grouping (@category)
156    pub category: PolicyCategory,
157
158    /// Risk level for visual indication (@risk)
159    pub risk: PolicyRiskLevel,
160
161    /// Whether administrators can modify this policy (@editable)
162    pub editable: bool,
163
164    /// Reason for the policy or why it's non-editable (@reason)
165    pub reason: Option<String>,
166
167    /// Who created or last modified (@author)
168    pub author: Option<String>,
169
170    /// ISO date of last modification (@modified)
171    pub modified: Option<String>,
172
173    /// The full Cedar policy text
174    pub raw_cedar: String,
175
176    /// Whether this is a baseline policy (cannot be deleted)
177    pub is_baseline: bool,
178
179    /// Policy template ID (for template-linked policies)
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub template_id: Option<String>,
182}
183
184/// Infer category and risk from Cedar policy content when annotations are missing.
185///
186/// This analyzes the Cedar action clause to determine:
187/// - Category: based on `CodeMode::Action::"Read"/"Write"/"Delete"`
188/// - Risk: based on policy effect (permit vs forbid) and action type
189pub fn infer_category_and_risk_from_cedar(cedar: &str) -> (PolicyCategory, PolicyRiskLevel) {
190    let cedar_lower = cedar.to_lowercase();
191
192    // Determine category from action clause
193    let category = if cedar.contains("Action::\"Delete\"") || cedar.contains("Action::\"delete\"") {
194        PolicyCategory::Delete
195    } else if cedar.contains("Action::\"Write\"") || cedar.contains("Action::\"write\"") {
196        PolicyCategory::Write
197    } else if cedar.contains("Action::\"Read\"") || cedar.contains("Action::\"read\"") {
198        PolicyCategory::Read
199    } else if cedar.contains("Action::\"Admin\"")
200        || cedar.contains("Action::\"admin\"")
201        || cedar.contains("Action::\"Introspection\"")
202    {
203        PolicyCategory::Admin
204    } else {
205        // Check for generic patterns
206        if cedar_lower.contains("delete") {
207            PolicyCategory::Delete
208        } else if cedar_lower.contains("write") || cedar_lower.contains("mutation") {
209            PolicyCategory::Write
210        } else {
211            PolicyCategory::Read
212        }
213    };
214
215    // Determine risk based on effect and category
216    let _is_forbid = cedar_lower.trim_start().starts_with("forbid");
217    let is_permit = cedar_lower.trim_start().starts_with("permit");
218
219    let risk = match category {
220        PolicyCategory::Delete => {
221            if is_permit {
222                PolicyRiskLevel::High // Permitting deletes is high risk
223            } else {
224                PolicyRiskLevel::Low // Forbidding deletes is protective
225            }
226        }
227        PolicyCategory::Write => {
228            if is_permit {
229                PolicyRiskLevel::Medium // Permitting writes is medium risk
230            } else {
231                PolicyRiskLevel::Low // Forbidding writes is protective
232            }
233        }
234        PolicyCategory::Admin => {
235            if is_permit {
236                PolicyRiskLevel::High // Permitting admin is high risk
237            } else {
238                PolicyRiskLevel::Medium // Forbidding admin is protective but important
239            }
240        }
241        PolicyCategory::Read => PolicyRiskLevel::Low, // Reads are generally low risk
242        PolicyCategory::Fields => PolicyRiskLevel::Medium, // Field restrictions are medium
243    };
244
245    (category, risk)
246}
247
248impl Default for PolicyMetadata {
249    fn default() -> Self {
250        Self {
251            id: String::new(),
252            title: String::new(),
253            description: String::new(),
254            category: PolicyCategory::default(),
255            risk: PolicyRiskLevel::default(),
256            editable: true,
257            reason: None,
258            author: None,
259            modified: None,
260            raw_cedar: String::new(),
261            is_baseline: false,
262            template_id: None,
263        }
264    }
265}
266
267impl PolicyMetadata {
268    /// Create a new PolicyMetadata with the given ID and Cedar content.
269    pub fn new(id: impl Into<String>, cedar: impl Into<String>) -> Self {
270        let cedar = cedar.into();
271        let mut metadata = parse_policy_annotations(&cedar, &id.into());
272        metadata.raw_cedar = cedar;
273        metadata
274    }
275
276    /// Check if this policy has all required annotations.
277    pub fn validate(&self) -> Result<(), Vec<PolicyValidationError>> {
278        let mut errors = Vec::new();
279
280        if self.title.is_empty() {
281            errors.push(PolicyValidationError::MissingAnnotation(
282                "@title".to_string(),
283            ));
284        }
285
286        if self.description.is_empty() {
287            errors.push(PolicyValidationError::MissingAnnotation(
288                "@description".to_string(),
289            ));
290        }
291
292        // Category and risk have defaults, so we check if they were explicitly set
293        // by checking if the raw Cedar contains the annotations
294        if !self.raw_cedar.contains("@category") {
295            errors.push(PolicyValidationError::MissingAnnotation(
296                "@category".to_string(),
297            ));
298        }
299
300        if !self.raw_cedar.contains("@risk") {
301            errors.push(PolicyValidationError::MissingAnnotation(
302                "@risk".to_string(),
303            ));
304        }
305
306        if errors.is_empty() {
307            Ok(())
308        } else {
309            Err(errors)
310        }
311    }
312}
313
314/// Validation error for policy annotations.
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub enum PolicyValidationError {
317    /// A required annotation is missing
318    MissingAnnotation(String),
319    /// An annotation has an invalid value
320    InvalidAnnotation { annotation: String, message: String },
321    /// Cedar syntax error
322    CedarSyntaxError { line: Option<u32>, message: String },
323}
324
325impl fmt::Display for PolicyValidationError {
326    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
327        match self {
328            PolicyValidationError::MissingAnnotation(ann) => {
329                write!(f, "Missing required annotation: {}", ann)
330            }
331            PolicyValidationError::InvalidAnnotation {
332                annotation,
333                message,
334            } => {
335                write!(f, "Invalid {}: {}", annotation, message)
336            }
337            PolicyValidationError::CedarSyntaxError { line, message } => {
338                if let Some(line) = line {
339                    write!(f, "Cedar syntax error at line {}: {}", line, message)
340                } else {
341                    write!(f, "Cedar syntax error: {}", message)
342                }
343            }
344        }
345    }
346}
347
348/// Parse Cedar policy annotations from doc comments.
349///
350/// # Example
351///
352/// ```ignore
353/// use pmcp_code_mode::policy_annotations::parse_policy_annotations;
354///
355/// let cedar = r#"
356/// /// @title Allow Queries
357/// /// @description Permits all read-only queries.
358/// /// @category queries
359/// /// @risk low
360/// permit(principal, action, resource);
361/// "#;
362///
363/// let metadata = parse_policy_annotations(cedar, "policy-123");
364/// assert_eq!(metadata.title, "Allow Queries");
365/// assert_eq!(metadata.description, "Permits all read-only queries.");
366/// ```
367pub fn parse_policy_annotations(cedar: &str, policy_id: &str) -> PolicyMetadata {
368    let mut metadata = PolicyMetadata {
369        id: policy_id.to_string(),
370        raw_cedar: cedar.to_string(),
371        ..Default::default()
372    };
373
374    let mut in_description = false;
375    let mut found_category = false;
376    let mut found_risk = false;
377
378    for line in cedar.lines() {
379        let line = line.trim();
380
381        if let Some(content) = line.strip_prefix("/// @") {
382            in_description = false;
383
384            // Split on first space to get key and value
385            if let Some((key, value)) = content.split_once(' ') {
386                let value = value.trim();
387                match key.to_lowercase().as_str() {
388                    "title" => {
389                        metadata.title = value.to_string();
390                        // Check if it's a baseline policy
391                        if value.starts_with("Baseline:") {
392                            metadata.is_baseline = true;
393                            metadata.editable = false;
394                        }
395                    }
396                    "description" => {
397                        metadata.description = value.to_string();
398                        in_description = true;
399                    }
400                    "category" => {
401                        metadata.category = value.parse().unwrap_or_default();
402                        found_category = true;
403                    }
404                    "risk" => {
405                        metadata.risk = value.parse().unwrap_or_default();
406                        found_risk = true;
407                    }
408                    "editable" => {
409                        metadata.editable = value.eq_ignore_ascii_case("true");
410                    }
411                    "reason" => {
412                        metadata.reason = Some(value.to_string());
413                    }
414                    "author" => {
415                        metadata.author = Some(value.to_string());
416                    }
417                    "modified" => {
418                        metadata.modified = Some(value.to_string());
419                    }
420                    _ => {
421                        // Unknown annotation, ignore
422                    }
423                }
424            }
425        } else if let Some(content) = line.strip_prefix("/// ") {
426            // Continuation of description (line starting with /// but no @)
427            if in_description {
428                if !metadata.description.is_empty() {
429                    metadata.description.push('\n');
430                }
431                metadata.description.push_str(content);
432            }
433        } else if line == "///" {
434            // Empty doc comment line (paragraph break in description)
435            if in_description {
436                metadata.description.push_str("\n\n");
437            }
438        } else {
439            // Non-comment line, stop parsing description
440            in_description = false;
441        }
442    }
443
444    // Trim trailing whitespace from description
445    metadata.description = metadata.description.trim().to_string();
446
447    // If category or risk annotations are missing, infer from Cedar content
448    if !found_category || !found_risk {
449        let (inferred_category, inferred_risk) = infer_category_and_risk_from_cedar(cedar);
450        if !found_category {
451            metadata.category = inferred_category;
452        }
453        if !found_risk {
454            metadata.risk = inferred_risk;
455        }
456    }
457
458    metadata
459}
460
461/// Generate Cedar policy text with annotations from metadata.
462///
463/// This creates a properly formatted Cedar policy with doc comments.
464pub fn generate_policy_cedar(metadata: &PolicyMetadata, policy_body: &str) -> String {
465    let mut lines = Vec::new();
466
467    // Title
468    lines.push(format!("/// @title {}", metadata.title));
469
470    // Description (handle multi-line)
471    for (i, desc_line) in metadata.description.lines().enumerate() {
472        if i == 0 {
473            lines.push(format!("/// @description {}", desc_line));
474        } else if desc_line.is_empty() {
475            lines.push("///".to_string());
476        } else {
477            lines.push(format!("/// {}", desc_line));
478        }
479    }
480
481    // Category and risk
482    lines.push(format!("/// @category {}", metadata.category));
483    lines.push(format!("/// @risk {}", metadata.risk));
484
485    // Optional annotations
486    if !metadata.editable {
487        lines.push("/// @editable false".to_string());
488    }
489
490    if let Some(ref reason) = metadata.reason {
491        lines.push(format!("/// @reason {}", reason));
492    }
493
494    if let Some(ref author) = metadata.author {
495        lines.push(format!("/// @author {}", author));
496    }
497
498    if let Some(ref modified) = metadata.modified {
499        lines.push(format!("/// @modified {}", modified));
500    }
501
502    // Add the policy body
503    lines.push(policy_body.to_string());
504
505    lines.join("\n")
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    #[test]
513    fn test_parse_simple_policy() {
514        let cedar = r#"/// @title Allow Queries
515/// @description Permits all read-only queries.
516/// @category read
517/// @risk low
518permit(principal, action, resource);"#;
519
520        let metadata = parse_policy_annotations(cedar, "policy-123");
521
522        assert_eq!(metadata.id, "policy-123");
523        assert_eq!(metadata.title, "Allow Queries");
524        assert_eq!(metadata.description, "Permits all read-only queries.");
525        assert_eq!(metadata.category, PolicyCategory::Read);
526        assert_eq!(metadata.risk, PolicyRiskLevel::Low);
527        assert!(metadata.editable);
528        assert!(!metadata.is_baseline);
529    }
530
531    #[test]
532    fn test_parse_legacy_category_names() {
533        // Test that legacy category names are parsed correctly
534        let cedar = r#"/// @title Allow Queries
535/// @description Permits all read-only queries.
536/// @category queries
537/// @risk low
538permit(principal, action, resource);"#;
539
540        let metadata = parse_policy_annotations(cedar, "policy-legacy");
541        assert_eq!(metadata.category, PolicyCategory::Read);
542
543        let cedar2 = r#"/// @title Block Mutations
544/// @description Blocks write operations.
545/// @category mutations
546/// @risk high
547forbid(principal, action, resource);"#;
548
549        let metadata2 = parse_policy_annotations(cedar2, "policy-legacy2");
550        assert_eq!(metadata2.category, PolicyCategory::Write);
551    }
552
553    #[test]
554    fn test_parse_multiline_description() {
555        let cedar = r#"/// @title Block Mutations
556/// @description Prevents execution of dangerous mutations.
557/// This is a critical security policy.
558///
559/// Do not modify without approval.
560/// @category write
561/// @risk critical
562/// @editable false
563/// @reason Security compliance
564forbid(principal, action, resource);"#;
565
566        let metadata = parse_policy_annotations(cedar, "policy-456");
567
568        assert_eq!(metadata.title, "Block Mutations");
569        assert!(metadata.description.contains("Prevents execution"));
570        assert!(metadata.description.contains("Do not modify"));
571        assert_eq!(metadata.category, PolicyCategory::Write);
572        assert_eq!(metadata.risk, PolicyRiskLevel::Critical);
573        assert!(!metadata.editable);
574        assert_eq!(metadata.reason, Some("Security compliance".to_string()));
575    }
576
577    #[test]
578    fn test_parse_baseline_policy() {
579        let cedar = r#"/// @title Baseline: Allow Read-Only Queries
580/// @description Core functionality for Code Mode.
581/// @category read
582/// @risk low
583permit(principal, action, resource);"#;
584
585        let metadata = parse_policy_annotations(cedar, "baseline-1");
586
587        assert!(metadata.is_baseline);
588        assert!(!metadata.editable);
589    }
590
591    #[test]
592    fn test_validate_missing_annotations() {
593        let metadata = PolicyMetadata {
594            id: "test".to_string(),
595            title: "".to_string(), // Missing
596            description: "Has description".to_string(),
597            raw_cedar: "permit(principal, action, resource);".to_string(),
598            ..Default::default()
599        };
600
601        let result = metadata.validate();
602        assert!(result.is_err());
603
604        let errors = result.unwrap_err();
605        assert!(errors.iter().any(|e| matches!(e,
606            PolicyValidationError::MissingAnnotation(s) if s == "@title"
607        )));
608    }
609
610    #[test]
611    fn test_generate_policy_cedar() {
612        let metadata = PolicyMetadata {
613            id: "test".to_string(),
614            title: "Allow Writes".to_string(),
615            description: "Permits safe write operations.\nAdd operations to the list.".to_string(),
616            category: PolicyCategory::Write,
617            risk: PolicyRiskLevel::Medium,
618            editable: true,
619            reason: None,
620            author: Some("admin".to_string()),
621            modified: Some("2024-01-15".to_string()),
622            raw_cedar: String::new(),
623            is_baseline: false,
624            template_id: None,
625        };
626
627        let body = r#"permit(
628  principal,
629  action == Action::"executeMutation",
630  resource
631);"#;
632
633        let cedar = generate_policy_cedar(&metadata, body);
634
635        assert!(cedar.contains("/// @title Allow Writes"));
636        assert!(cedar.contains("/// @description Permits safe write operations."));
637        assert!(cedar.contains("/// Add operations to the list."));
638        assert!(cedar.contains("/// @category write"));
639        assert!(cedar.contains("/// @risk medium"));
640        assert!(cedar.contains("/// @author admin"));
641        assert!(cedar.contains("/// @modified 2024-01-15"));
642        assert!(cedar.contains("permit("));
643    }
644
645    #[test]
646    fn test_policy_category_parsing() {
647        // Unified names (singular)
648        assert_eq!(
649            "read".parse::<PolicyCategory>().unwrap(),
650            PolicyCategory::Read
651        );
652        assert_eq!(
653            "write".parse::<PolicyCategory>().unwrap(),
654            PolicyCategory::Write
655        );
656        assert_eq!(
657            "delete".parse::<PolicyCategory>().unwrap(),
658            PolicyCategory::Delete
659        );
660        assert_eq!(
661            "FIELDS".parse::<PolicyCategory>().unwrap(),
662            PolicyCategory::Fields
663        );
664        assert_eq!(
665            "admin".parse::<PolicyCategory>().unwrap(),
666            PolicyCategory::Admin
667        );
668        // Unified names (plural - used by OpenAPI policies)
669        assert_eq!(
670            "reads".parse::<PolicyCategory>().unwrap(),
671            PolicyCategory::Read
672        );
673        assert_eq!(
674            "writes".parse::<PolicyCategory>().unwrap(),
675            PolicyCategory::Write
676        );
677        assert_eq!(
678            "deletes".parse::<PolicyCategory>().unwrap(),
679            PolicyCategory::Delete
680        );
681        // OpenAPI-specific categories
682        assert_eq!(
683            "paths".parse::<PolicyCategory>().unwrap(),
684            PolicyCategory::Fields
685        );
686        assert_eq!(
687            "safety".parse::<PolicyCategory>().unwrap(),
688            PolicyCategory::Admin
689        );
690        assert_eq!(
691            "limits".parse::<PolicyCategory>().unwrap(),
692            PolicyCategory::Admin
693        );
694        // Legacy GraphQL names map to unified
695        assert_eq!(
696            "queries".parse::<PolicyCategory>().unwrap(),
697            PolicyCategory::Read
698        );
699        assert_eq!(
700            "mutation".parse::<PolicyCategory>().unwrap(),
701            PolicyCategory::Write
702        );
703        assert_eq!(
704            "introspection".parse::<PolicyCategory>().unwrap(),
705            PolicyCategory::Admin
706        );
707        assert!("unknown".parse::<PolicyCategory>().is_err());
708    }
709
710    #[test]
711    fn test_policy_risk_parsing() {
712        assert_eq!(
713            "low".parse::<PolicyRiskLevel>().unwrap(),
714            PolicyRiskLevel::Low
715        );
716        assert_eq!(
717            "CRITICAL".parse::<PolicyRiskLevel>().unwrap(),
718            PolicyRiskLevel::Critical
719        );
720        assert!("unknown".parse::<PolicyRiskLevel>().is_err());
721    }
722}