1use serde::{Deserialize, Serialize};
50use std::fmt;
51use std::str::FromStr;
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
56#[serde(rename_all = "lowercase")]
57pub enum PolicyCategory {
58 #[default]
60 Read,
61 Write,
63 Delete,
65 Fields,
67 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 "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 "queries" | "query" => Ok(PolicyCategory::Read),
96 "mutations" | "mutation" => Ok(PolicyCategory::Write),
97 "introspection" => Ok(PolicyCategory::Admin),
98 _ => Err(()),
99 }
100 }
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
105#[serde(rename_all = "lowercase")]
106pub enum PolicyRiskLevel {
107 #[default]
109 Low,
110 Medium,
112 High,
114 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#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct PolicyMetadata {
146 pub id: String,
148
149 pub title: String,
151
152 pub description: String,
154
155 pub category: PolicyCategory,
157
158 pub risk: PolicyRiskLevel,
160
161 pub editable: bool,
163
164 pub reason: Option<String>,
166
167 pub author: Option<String>,
169
170 pub modified: Option<String>,
172
173 pub raw_cedar: String,
175
176 pub is_baseline: bool,
178
179 #[serde(skip_serializing_if = "Option::is_none")]
181 pub template_id: Option<String>,
182}
183
184pub fn infer_category_and_risk_from_cedar(cedar: &str) -> (PolicyCategory, PolicyRiskLevel) {
190 let cedar_lower = cedar.to_lowercase();
191
192 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 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 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 } else {
224 PolicyRiskLevel::Low }
226 },
227 PolicyCategory::Write => {
228 if is_permit {
229 PolicyRiskLevel::Medium } else {
231 PolicyRiskLevel::Low }
233 },
234 PolicyCategory::Admin => {
235 if is_permit {
236 PolicyRiskLevel::High } else {
238 PolicyRiskLevel::Medium }
240 },
241 PolicyCategory::Read => PolicyRiskLevel::Low, PolicyCategory::Fields => PolicyRiskLevel::Medium, };
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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
316pub enum PolicyValidationError {
317 MissingAnnotation(String),
319 InvalidAnnotation { annotation: String, message: String },
321 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
348pub 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 in_description = process_annotation_line(
381 line,
382 &mut metadata,
383 in_description,
384 &mut found_category,
385 &mut found_risk,
386 );
387 }
388
389 metadata.description = metadata.description.trim().to_string();
390 apply_inferred_category_and_risk(&mut metadata, cedar, found_category, found_risk);
391 metadata
392}
393
394fn process_annotation_line(
397 line: &str,
398 metadata: &mut PolicyMetadata,
399 in_description: bool,
400 found_category: &mut bool,
401 found_risk: &mut bool,
402) -> bool {
403 if let Some(content) = line.strip_prefix("/// @") {
404 return apply_at_annotation(content, metadata, found_category, found_risk);
405 }
406 if let Some(content) = line.strip_prefix("/// ") {
407 if in_description {
408 append_description_line(metadata, content);
409 }
410 return in_description;
411 }
412 if line == "///" {
413 if in_description {
414 metadata.description.push_str("\n\n");
415 }
416 return in_description;
417 }
418 false
420}
421
422fn apply_at_annotation(
425 content: &str,
426 metadata: &mut PolicyMetadata,
427 found_category: &mut bool,
428 found_risk: &mut bool,
429) -> bool {
430 let Some((key, value)) = content.split_once(' ') else {
431 return false;
432 };
433 let value = value.trim();
434 match key.to_lowercase().as_str() {
435 "title" => {
436 apply_title(metadata, value);
437 false
438 },
439 "description" => {
440 metadata.description = value.to_string();
441 true
442 },
443 "category" => {
444 metadata.category = value.parse().unwrap_or_default();
445 *found_category = true;
446 false
447 },
448 "risk" => {
449 metadata.risk = value.parse().unwrap_or_default();
450 *found_risk = true;
451 false
452 },
453 "editable" => {
454 metadata.editable = value.eq_ignore_ascii_case("true");
455 false
456 },
457 "reason" => {
458 metadata.reason = Some(value.to_string());
459 false
460 },
461 "author" => {
462 metadata.author = Some(value.to_string());
463 false
464 },
465 "modified" => {
466 metadata.modified = Some(value.to_string());
467 false
468 },
469 _ => false, }
471}
472
473fn apply_title(metadata: &mut PolicyMetadata, value: &str) {
475 metadata.title = value.to_string();
476 if value.starts_with("Baseline:") {
477 metadata.is_baseline = true;
478 metadata.editable = false;
479 }
480}
481
482fn append_description_line(metadata: &mut PolicyMetadata, content: &str) {
484 if !metadata.description.is_empty() {
485 metadata.description.push('\n');
486 }
487 metadata.description.push_str(content);
488}
489
490fn apply_inferred_category_and_risk(
492 metadata: &mut PolicyMetadata,
493 cedar: &str,
494 found_category: bool,
495 found_risk: bool,
496) {
497 if found_category && found_risk {
498 return;
499 }
500 let (inferred_category, inferred_risk) = infer_category_and_risk_from_cedar(cedar);
501 if !found_category {
502 metadata.category = inferred_category;
503 }
504 if !found_risk {
505 metadata.risk = inferred_risk;
506 }
507}
508
509pub fn generate_policy_cedar(metadata: &PolicyMetadata, policy_body: &str) -> String {
513 let mut lines = Vec::new();
514
515 lines.push(format!("/// @title {}", metadata.title));
517
518 for (i, desc_line) in metadata.description.lines().enumerate() {
520 if i == 0 {
521 lines.push(format!("/// @description {}", desc_line));
522 } else if desc_line.is_empty() {
523 lines.push("///".to_string());
524 } else {
525 lines.push(format!("/// {}", desc_line));
526 }
527 }
528
529 lines.push(format!("/// @category {}", metadata.category));
531 lines.push(format!("/// @risk {}", metadata.risk));
532
533 if !metadata.editable {
535 lines.push("/// @editable false".to_string());
536 }
537
538 if let Some(ref reason) = metadata.reason {
539 lines.push(format!("/// @reason {}", reason));
540 }
541
542 if let Some(ref author) = metadata.author {
543 lines.push(format!("/// @author {}", author));
544 }
545
546 if let Some(ref modified) = metadata.modified {
547 lines.push(format!("/// @modified {}", modified));
548 }
549
550 lines.push(policy_body.to_string());
552
553 lines.join("\n")
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559
560 #[test]
561 fn test_parse_simple_policy() {
562 let cedar = r#"/// @title Allow Queries
563/// @description Permits all read-only queries.
564/// @category read
565/// @risk low
566permit(principal, action, resource);"#;
567
568 let metadata = parse_policy_annotations(cedar, "policy-123");
569
570 assert_eq!(metadata.id, "policy-123");
571 assert_eq!(metadata.title, "Allow Queries");
572 assert_eq!(metadata.description, "Permits all read-only queries.");
573 assert_eq!(metadata.category, PolicyCategory::Read);
574 assert_eq!(metadata.risk, PolicyRiskLevel::Low);
575 assert!(metadata.editable);
576 assert!(!metadata.is_baseline);
577 }
578
579 #[test]
580 fn test_parse_legacy_category_names() {
581 let cedar = r#"/// @title Allow Queries
583/// @description Permits all read-only queries.
584/// @category queries
585/// @risk low
586permit(principal, action, resource);"#;
587
588 let metadata = parse_policy_annotations(cedar, "policy-legacy");
589 assert_eq!(metadata.category, PolicyCategory::Read);
590
591 let cedar2 = r#"/// @title Block Mutations
592/// @description Blocks write operations.
593/// @category mutations
594/// @risk high
595forbid(principal, action, resource);"#;
596
597 let metadata2 = parse_policy_annotations(cedar2, "policy-legacy2");
598 assert_eq!(metadata2.category, PolicyCategory::Write);
599 }
600
601 #[test]
602 fn test_parse_multiline_description() {
603 let cedar = r#"/// @title Block Mutations
604/// @description Prevents execution of dangerous mutations.
605/// This is a critical security policy.
606///
607/// Do not modify without approval.
608/// @category write
609/// @risk critical
610/// @editable false
611/// @reason Security compliance
612forbid(principal, action, resource);"#;
613
614 let metadata = parse_policy_annotations(cedar, "policy-456");
615
616 assert_eq!(metadata.title, "Block Mutations");
617 assert!(metadata.description.contains("Prevents execution"));
618 assert!(metadata.description.contains("Do not modify"));
619 assert_eq!(metadata.category, PolicyCategory::Write);
620 assert_eq!(metadata.risk, PolicyRiskLevel::Critical);
621 assert!(!metadata.editable);
622 assert_eq!(metadata.reason, Some("Security compliance".to_string()));
623 }
624
625 #[test]
626 fn test_parse_baseline_policy() {
627 let cedar = r#"/// @title Baseline: Allow Read-Only Queries
628/// @description Core functionality for Code Mode.
629/// @category read
630/// @risk low
631permit(principal, action, resource);"#;
632
633 let metadata = parse_policy_annotations(cedar, "baseline-1");
634
635 assert!(metadata.is_baseline);
636 assert!(!metadata.editable);
637 }
638
639 #[test]
640 fn test_validate_missing_annotations() {
641 let metadata = PolicyMetadata {
642 id: "test".to_string(),
643 title: "".to_string(), description: "Has description".to_string(),
645 raw_cedar: "permit(principal, action, resource);".to_string(),
646 ..Default::default()
647 };
648
649 let result = metadata.validate();
650 assert!(result.is_err());
651
652 let errors = result.unwrap_err();
653 assert!(errors.iter().any(|e| matches!(e,
654 PolicyValidationError::MissingAnnotation(s) if s == "@title"
655 )));
656 }
657
658 #[test]
659 fn test_generate_policy_cedar() {
660 let metadata = PolicyMetadata {
661 id: "test".to_string(),
662 title: "Allow Writes".to_string(),
663 description: "Permits safe write operations.\nAdd operations to the list.".to_string(),
664 category: PolicyCategory::Write,
665 risk: PolicyRiskLevel::Medium,
666 editable: true,
667 reason: None,
668 author: Some("admin".to_string()),
669 modified: Some("2024-01-15".to_string()),
670 raw_cedar: String::new(),
671 is_baseline: false,
672 template_id: None,
673 };
674
675 let body = r#"permit(
676 principal,
677 action == Action::"executeMutation",
678 resource
679);"#;
680
681 let cedar = generate_policy_cedar(&metadata, body);
682
683 assert!(cedar.contains("/// @title Allow Writes"));
684 assert!(cedar.contains("/// @description Permits safe write operations."));
685 assert!(cedar.contains("/// Add operations to the list."));
686 assert!(cedar.contains("/// @category write"));
687 assert!(cedar.contains("/// @risk medium"));
688 assert!(cedar.contains("/// @author admin"));
689 assert!(cedar.contains("/// @modified 2024-01-15"));
690 assert!(cedar.contains("permit("));
691 }
692
693 #[test]
694 fn test_policy_category_parsing() {
695 assert_eq!(
697 "read".parse::<PolicyCategory>().unwrap(),
698 PolicyCategory::Read
699 );
700 assert_eq!(
701 "write".parse::<PolicyCategory>().unwrap(),
702 PolicyCategory::Write
703 );
704 assert_eq!(
705 "delete".parse::<PolicyCategory>().unwrap(),
706 PolicyCategory::Delete
707 );
708 assert_eq!(
709 "FIELDS".parse::<PolicyCategory>().unwrap(),
710 PolicyCategory::Fields
711 );
712 assert_eq!(
713 "admin".parse::<PolicyCategory>().unwrap(),
714 PolicyCategory::Admin
715 );
716 assert_eq!(
718 "reads".parse::<PolicyCategory>().unwrap(),
719 PolicyCategory::Read
720 );
721 assert_eq!(
722 "writes".parse::<PolicyCategory>().unwrap(),
723 PolicyCategory::Write
724 );
725 assert_eq!(
726 "deletes".parse::<PolicyCategory>().unwrap(),
727 PolicyCategory::Delete
728 );
729 assert_eq!(
731 "paths".parse::<PolicyCategory>().unwrap(),
732 PolicyCategory::Fields
733 );
734 assert_eq!(
735 "safety".parse::<PolicyCategory>().unwrap(),
736 PolicyCategory::Admin
737 );
738 assert_eq!(
739 "limits".parse::<PolicyCategory>().unwrap(),
740 PolicyCategory::Admin
741 );
742 assert_eq!(
744 "queries".parse::<PolicyCategory>().unwrap(),
745 PolicyCategory::Read
746 );
747 assert_eq!(
748 "mutation".parse::<PolicyCategory>().unwrap(),
749 PolicyCategory::Write
750 );
751 assert_eq!(
752 "introspection".parse::<PolicyCategory>().unwrap(),
753 PolicyCategory::Admin
754 );
755 assert!("unknown".parse::<PolicyCategory>().is_err());
756 }
757
758 #[test]
759 fn test_policy_risk_parsing() {
760 assert_eq!(
761 "low".parse::<PolicyRiskLevel>().unwrap(),
762 PolicyRiskLevel::Low
763 );
764 assert_eq!(
765 "CRITICAL".parse::<PolicyRiskLevel>().unwrap(),
766 PolicyRiskLevel::Critical
767 );
768 assert!("unknown".parse::<PolicyRiskLevel>().is_err());
769 }
770}