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
381 if let Some(content) = line.strip_prefix("/// @") {
382 in_description = false;
383
384 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 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 }
423 }
424 }
425 } else if let Some(content) = line.strip_prefix("/// ") {
426 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 if in_description {
436 metadata.description.push_str("\n\n");
437 }
438 } else {
439 in_description = false;
441 }
442 }
443
444 metadata.description = metadata.description.trim().to_string();
446
447 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
461pub fn generate_policy_cedar(metadata: &PolicyMetadata, policy_body: &str) -> String {
465 let mut lines = Vec::new();
466
467 lines.push(format!("/// @title {}", metadata.title));
469
470 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 lines.push(format!("/// @category {}", metadata.category));
483 lines.push(format!("/// @risk {}", metadata.risk));
484
485 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 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 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(), 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 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 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 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 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}