Skip to main content

pmcp_code_mode/policy/
types.rs

1//! Domain types for policy evaluation.
2//!
3//! These types represent the entities used in Cedar policy evaluation.
4//! They are pure domain types with no AWS SDK dependency.
5
6#[cfg(feature = "openapi-code-mode")]
7use crate::config::OperationRegistry;
8use crate::graphql::GraphQLQueryInfo;
9use std::collections::HashSet;
10
11/// Server configuration for policy evaluation.
12///
13/// Uses unified attribute names that match the Cedar schema:
14/// - `allow_write`, `allow_delete`, `allow_admin` (unified action flags)
15/// - `blocked_operations`, `allowed_operations` (unified operation lists)
16#[derive(Debug, Clone)]
17pub struct ServerConfigEntity {
18    /// Server ID
19    pub server_id: String,
20
21    /// Server type (e.g., "graphql")
22    pub server_type: String,
23
24    /// Whether write operations (mutations) are allowed
25    pub allow_write: bool,
26
27    /// Whether delete operations are allowed
28    pub allow_delete: bool,
29
30    /// Whether admin operations (introspection) are allowed
31    pub allow_admin: bool,
32
33    /// Allowed operation names (allowlist mode)
34    pub allowed_operations: HashSet<String>,
35
36    /// Blocked operation names (blocklist mode)
37    pub blocked_operations: HashSet<String>,
38
39    /// Maximum query depth
40    pub max_depth: u32,
41
42    /// Maximum field count
43    pub max_field_count: u32,
44
45    /// Maximum estimated cost
46    pub max_cost: u32,
47
48    /// Maximum API calls (for compatibility with unified schema)
49    pub max_api_calls: u32,
50
51    /// Fields that should be blocked
52    pub blocked_fields: HashSet<String>,
53
54    /// Allowed sensitive data categories
55    pub allowed_sensitive_categories: HashSet<String>,
56}
57
58impl Default for ServerConfigEntity {
59    fn default() -> Self {
60        Self {
61            server_id: "unknown".to_string(),
62            server_type: "graphql".to_string(),
63            allow_write: false,
64            allow_delete: false,
65            allow_admin: false,
66            allowed_operations: HashSet::new(),
67            blocked_operations: HashSet::new(),
68            max_depth: 10,
69            max_field_count: 100,
70            max_cost: 1000,
71            max_api_calls: 50,
72            blocked_fields: HashSet::new(),
73            allowed_sensitive_categories: HashSet::new(),
74        }
75    }
76}
77
78/// Operation entity for policy evaluation.
79#[derive(Debug, Clone)]
80pub struct OperationEntity {
81    /// Unique ID for this operation
82    pub id: String,
83
84    /// Operation type: "query", "mutation", or "subscription"
85    pub operation_type: String,
86
87    /// Operation name (if provided)
88    pub operation_name: String,
89
90    /// Root fields accessed
91    pub root_fields: HashSet<String>,
92
93    /// Types accessed
94    pub accessed_types: HashSet<String>,
95
96    /// Fields accessed (Type.field format)
97    pub accessed_fields: HashSet<String>,
98
99    /// Query nesting depth
100    pub depth: u32,
101
102    /// Total field count
103    pub field_count: u32,
104
105    /// Estimated query cost
106    pub estimated_cost: u32,
107
108    /// Whether introspection is used
109    pub has_introspection: bool,
110
111    /// Whether sensitive data is accessed
112    pub accesses_sensitive_data: bool,
113
114    /// Sensitive data categories accessed
115    pub sensitive_categories: HashSet<String>,
116}
117
118impl OperationEntity {
119    /// Create from GraphQL query info.
120    pub fn from_query_info(query_info: &GraphQLQueryInfo) -> Self {
121        use crate::graphql::GraphQLOperationType;
122
123        let operation_type = match query_info.operation_type {
124            GraphQLOperationType::Query => "query",
125            GraphQLOperationType::Mutation => "mutation",
126            GraphQLOperationType::Subscription => "subscription",
127        };
128
129        Self {
130            id: query_info
131                .operation_name
132                .clone()
133                .unwrap_or_else(|| "anonymous".to_string()),
134            operation_type: operation_type.to_string(),
135            operation_name: query_info.operation_name.clone().unwrap_or_default(),
136            root_fields: query_info.root_fields.iter().cloned().collect(),
137            accessed_types: query_info.types_accessed.iter().cloned().collect(),
138            accessed_fields: query_info.fields_accessed.iter().cloned().collect(),
139            depth: query_info.max_depth as u32,
140            field_count: query_info.fields_accessed.len() as u32,
141            estimated_cost: query_info.fields_accessed.len() as u32,
142            has_introspection: query_info.has_introspection,
143            accesses_sensitive_data: false,
144            sensitive_categories: HashSet::new(),
145        }
146    }
147}
148
149/// Authorization decision from policy evaluation.
150#[derive(Debug, Clone)]
151pub struct AuthorizationDecision {
152    /// Whether the operation is allowed
153    pub allowed: bool,
154
155    /// Policy IDs that determined the decision
156    pub determining_policies: Vec<String>,
157
158    /// Error messages (if any)
159    pub errors: Vec<String>,
160}
161
162/// Script entity for policy evaluation (OpenAPI Code Mode).
163///
164/// Unlike GraphQL's single Operation entity, OpenAPI Code Mode validates
165/// JavaScript scripts that can contain multiple API calls with loops and logic.
166#[cfg(feature = "openapi-code-mode")]
167#[derive(Debug, Clone)]
168pub struct ScriptEntity {
169    /// Unique ID for this script validation
170    pub id: String,
171
172    /// Script type: "read_only", "mixed", or "write_only"
173    pub script_type: String,
174
175    /// Whether script contains any write operations (POST/PUT/PATCH/DELETE)
176    pub has_writes: bool,
177
178    /// Whether script contains DELETE operations
179    pub has_deletes: bool,
180
181    /// Total number of API calls in the script
182    pub total_api_calls: u32,
183
184    /// Number of GET calls
185    pub read_calls: u32,
186
187    /// Number of POST/PUT/PATCH calls
188    pub write_calls: u32,
189
190    /// Number of DELETE calls
191    pub delete_calls: u32,
192
193    /// Set of all paths accessed
194    pub accessed_paths: HashSet<String>,
195
196    /// Set of all HTTP methods used
197    pub accessed_methods: HashSet<String>,
198
199    /// Normalized path patterns (IDs replaced with *)
200    pub path_patterns: HashSet<String>,
201
202    /// Called operations in "METHOD:pathPattern" format for allowlist/blocklist matching
203    pub called_operations: HashSet<String>,
204
205    /// Maximum loop iterations (from .slice() bounds)
206    pub loop_iterations: u32,
207
208    /// Maximum nesting depth in the AST
209    pub nesting_depth: u32,
210
211    /// Script length in characters
212    pub script_length: u32,
213
214    /// Whether script accesses sensitive paths (/admin, /internal, etc.)
215    pub accesses_sensitive_path: bool,
216
217    /// Whether script has an unbounded loop
218    pub has_unbounded_loop: bool,
219
220    /// Whether script uses dynamic path interpolation
221    pub has_dynamic_path: bool,
222
223    /// Whether script has a @returns output declaration
224    pub has_output_declaration: bool,
225
226    /// Fields declared in the @returns annotation
227    pub output_fields: HashSet<String>,
228
229    /// Whether script uses spread operators in output (potential field leakage)
230    pub has_spread_in_output: bool,
231}
232
233#[cfg(feature = "openapi-code-mode")]
234impl ScriptEntity {
235    /// Build from JavaScript code analysis.
236    pub fn from_javascript_info(
237        info: &crate::javascript::JavaScriptCodeInfo,
238        sensitive_patterns: &[String],
239        registry: Option<&OperationRegistry>,
240    ) -> Self {
241        use crate::javascript::HttpMethod;
242
243        let mut accessed_paths = HashSet::new();
244        let mut accessed_methods = HashSet::new();
245        let mut path_patterns = HashSet::new();
246        let mut called_operations = HashSet::new();
247        let mut read_calls = 0u32;
248        let mut write_calls = 0u32;
249        let mut delete_calls = 0u32;
250        let mut has_dynamic_path = false;
251        let mut accesses_sensitive_path = false;
252
253        for api_call in &info.api_calls {
254            accessed_paths.insert(api_call.path.clone());
255            let method_str = format!("{:?}", api_call.method).to_uppercase();
256            accessed_methods.insert(method_str.clone());
257
258            // Normalize path to pattern
259            let pattern = normalize_path_to_pattern(&api_call.path);
260            path_patterns.insert(pattern.clone());
261
262            // Build called operation string: use canonical ID from registry if available,
263            // fall back to METHOD:/path format when no registry entry matches.
264            let op_id = registry
265                .and_then(|r| r.lookup(&api_call.path))
266                .map(|id| id.to_string())
267                .unwrap_or_else(|| format!("{}:{}", method_str, pattern));
268            called_operations.insert(op_id);
269
270            // Count by declared category (from [[code_mode.operations]]) when available,
271            // fall back to HTTP method when no registry entry or no category declared.
272            let call_category = registry.and_then(|r| r.lookup_category(&api_call.path));
273            match call_category {
274                Some("read") => read_calls += 1,
275                Some("delete") => delete_calls += 1,
276                Some("write" | "admin") => write_calls += 1,
277                Some(_) => write_calls += 1,
278                None => match api_call.method {
279                    HttpMethod::Get | HttpMethod::Head | HttpMethod::Options => read_calls += 1,
280                    HttpMethod::Delete => delete_calls += 1,
281                    _ => write_calls += 1,
282                },
283            }
284
285            // Track dynamic paths
286            if api_call.is_dynamic_path {
287                has_dynamic_path = true;
288            }
289
290            // Check for sensitive path access
291            let path_lower = api_call.path.to_lowercase();
292            for pattern in sensitive_patterns {
293                if path_lower.contains(&pattern.to_lowercase()) {
294                    accesses_sensitive_path = true;
295                    break;
296                }
297            }
298        }
299
300        // Determine script type
301        let has_writes = write_calls > 0 || delete_calls > 0;
302        let has_reads = read_calls > 0;
303        let script_type = match (has_reads, has_writes) {
304            (true, false) => "read_only",
305            (false, true) => "write_only",
306            (true, true) => "mixed",
307            (false, false) => "empty",
308        };
309
310        Self {
311            id: info
312                .api_calls
313                .first()
314                .map(|c| format!("{}:{}", format!("{:?}", c.method).to_uppercase(), c.path))
315                .unwrap_or_else(|| "script".to_string()),
316            script_type: script_type.to_string(),
317            has_writes,
318            has_deletes: delete_calls > 0,
319            total_api_calls: info.api_calls.len() as u32,
320            read_calls,
321            write_calls,
322            delete_calls,
323            accessed_paths,
324            accessed_methods,
325            path_patterns,
326            called_operations,
327            loop_iterations: 0,
328            nesting_depth: info.max_depth as u32,
329            script_length: 0,
330            accesses_sensitive_path,
331            has_unbounded_loop: !info.all_loops_bounded && info.loop_count > 0,
332            has_dynamic_path,
333            has_output_declaration: info.output_declaration.has_declaration,
334            output_fields: info.output_declaration.declared_fields.clone(),
335            has_spread_in_output: info.output_declaration.has_spread_risk
336                || info.has_output_spread_risk,
337        }
338    }
339
340    /// Get the policy action for this script using unified action model.
341    pub fn action(&self) -> &'static str {
342        match self.script_type.as_str() {
343            "read_only" | "empty" => "Read",
344            "write_only" | "mixed" => {
345                if self.has_deletes {
346                    "Delete"
347                } else {
348                    "Write"
349                }
350            },
351            _ => "Read",
352        }
353    }
354}
355
356/// Check whether a path segment looks like a UUID (8-4-4-4-12 hex pattern).
357#[cfg(feature = "openapi-code-mode")]
358fn is_uuid_like(segment: &str) -> bool {
359    if segment.len() != 36 {
360        return false;
361    }
362    let parts: Vec<&str> = segment.split('-').collect();
363    matches!(parts.as_slice(), [a, b, c, d, e]
364        if a.len() == 8 && b.len() == 4 && c.len() == 4
365        && d.len() == 4 && e.len() == 12
366        && segment.chars().all(|ch| ch.is_ascii_hexdigit() || ch == '-'))
367}
368
369/// Normalize a path to a pattern by replacing numeric/UUID segments with *.
370#[cfg(feature = "openapi-code-mode")]
371pub fn normalize_path_to_pattern(path: &str) -> String {
372    path.split('/')
373        .map(|segment| {
374            if segment.chars().all(|c| c.is_ascii_digit()) || is_uuid_like(segment) {
375                "*"
376            } else {
377                segment
378            }
379        })
380        .collect::<Vec<_>>()
381        .join("/")
382}
383
384/// Normalize an operation string to the canonical "METHOD:/path" format.
385#[cfg(feature = "openapi-code-mode")]
386pub fn normalize_operation_format(op: &str) -> String {
387    let trimmed = op.trim();
388
389    let (method, path) = if let Some(idx) = trimmed.find(':') {
390        let potential_path = trimmed[idx + 1..].trim();
391        if potential_path.starts_with('/') {
392            let method = trimmed[..idx].trim();
393            (method, potential_path)
394        } else {
395            return trimmed.to_string();
396        }
397    } else if let Some(idx) = trimmed.find(' ') {
398        let method = trimmed[..idx].trim();
399        let path = trimmed[idx + 1..].trim();
400        (method, path)
401    } else {
402        return trimmed.to_string();
403    };
404
405    let method_upper = method.to_uppercase();
406
407    let normalized_path = path
408        .split('/')
409        .map(|segment| {
410            if segment.starts_with('{') && segment.ends_with('}') {
411                "*"
412            } else if segment.starts_with(':') {
413                "*"
414            } else if segment.chars().all(|c| c.is_ascii_digit()) {
415                "*"
416            } else if is_uuid_like(segment) {
417                "*"
418            } else {
419                segment
420            }
421        })
422        .collect::<Vec<_>>()
423        .join("/");
424
425    format!("{}:{}", method_upper, normalized_path)
426}
427
428/// Server configuration for OpenAPI Code Mode.
429#[cfg(feature = "openapi-code-mode")]
430#[derive(Debug, Clone)]
431pub struct OpenAPIServerEntity {
432    pub server_id: String,
433    pub server_type: String,
434
435    // Unified action flags
436    pub allow_write: bool,
437    pub allow_delete: bool,
438    pub allow_admin: bool,
439
440    // Write mode: "allow_all", "deny_all", "allowlist", "blocklist"
441    pub write_mode: String,
442
443    // Unified limits
444    pub max_depth: u32,
445    pub max_cost: u32,
446    pub max_api_calls: u32,
447
448    // OpenAPI-specific limits
449    pub max_loop_iterations: u32,
450    pub max_script_length: u32,
451    pub max_nesting_depth: u32,
452    pub execution_timeout_seconds: u32,
453
454    // Unified operation lists
455    pub allowed_operations: HashSet<String>,
456    pub blocked_operations: HashSet<String>,
457
458    // OpenAPI-specific method controls
459    pub allowed_methods: HashSet<String>,
460    pub blocked_methods: HashSet<String>,
461    pub allowed_path_patterns: HashSet<String>,
462    pub blocked_path_patterns: HashSet<String>,
463    pub sensitive_path_patterns: HashSet<String>,
464
465    // Auto-approval settings
466    pub auto_approve_read_only: bool,
467    pub max_api_calls_for_auto_approve: u32,
468
469    // Field control (two-tier blocklist)
470    pub internal_blocked_fields: HashSet<String>,
471    pub output_blocked_fields: HashSet<String>,
472    pub require_output_declaration: bool,
473}
474
475#[cfg(feature = "openapi-code-mode")]
476impl Default for OpenAPIServerEntity {
477    fn default() -> Self {
478        Self {
479            server_id: "unknown".to_string(),
480            server_type: "openapi".to_string(),
481            allow_write: false,
482            allow_delete: false,
483            allow_admin: false,
484            write_mode: "deny_all".to_string(),
485            max_depth: 10,
486            max_cost: 1000,
487            max_api_calls: 50,
488            max_loop_iterations: 100,
489            max_script_length: 10000,
490            max_nesting_depth: 10,
491            execution_timeout_seconds: 30,
492            allowed_operations: HashSet::new(),
493            blocked_operations: HashSet::new(),
494            allowed_methods: HashSet::new(),
495            blocked_methods: HashSet::new(),
496            allowed_path_patterns: HashSet::new(),
497            blocked_path_patterns: ["/admin".into(), "/internal".into()].into_iter().collect(),
498            sensitive_path_patterns: ["/admin".into(), "/internal".into(), "/debug".into()]
499                .into_iter()
500                .collect(),
501            auto_approve_read_only: true,
502            max_api_calls_for_auto_approve: 10,
503            internal_blocked_fields: HashSet::new(),
504            output_blocked_fields: HashSet::new(),
505            require_output_declaration: false,
506        }
507    }
508}
509
510/// Get the Cedar schema in JSON format.
511///
512/// Uses unified action model with Read/Write/Delete/Admin actions.
513pub fn get_code_mode_schema_json() -> serde_json::Value {
514    let applies_to = serde_json::json!({
515        "principalTypes": ["Operation"],
516        "resourceTypes": ["Server"],
517        "context": {
518            "type": "Record",
519            "attributes": {
520                "serverId": { "type": "String", "required": true },
521                "serverType": { "type": "String", "required": true },
522                "userId": { "type": "String", "required": false },
523                "sessionId": { "type": "String", "required": false }
524            }
525        }
526    });
527
528    serde_json::json!({
529        "CodeMode": {
530            "entityTypes": {
531                "Operation": {
532                    "shape": {
533                        "type": "Record",
534                        "attributes": {
535                            "operationType": { "type": "String", "required": true },
536                            "operationName": { "type": "String", "required": true },
537                            "rootFields": { "type": "Set", "element": { "type": "String" } },
538                            "accessedTypes": { "type": "Set", "element": { "type": "String" } },
539                            "accessedFields": { "type": "Set", "element": { "type": "String" } },
540                            "depth": { "type": "Long", "required": true },
541                            "fieldCount": { "type": "Long", "required": true },
542                            "estimatedCost": { "type": "Long", "required": true },
543                            "hasIntrospection": { "type": "Boolean", "required": true },
544                            "accessesSensitiveData": { "type": "Boolean", "required": true },
545                            "sensitiveCategories": { "type": "Set", "element": { "type": "String" } }
546                        }
547                    }
548                },
549                "Server": {
550                    "shape": {
551                        "type": "Record",
552                        "attributes": {
553                            "serverId": { "type": "String", "required": true },
554                            "serverType": { "type": "String", "required": true },
555                            "maxDepth": { "type": "Long", "required": true },
556                            "maxFieldCount": { "type": "Long", "required": true },
557                            "maxCost": { "type": "Long", "required": true },
558                            "maxApiCalls": { "type": "Long", "required": true },
559                            "allowWrite": { "type": "Boolean", "required": true },
560                            "allowDelete": { "type": "Boolean", "required": true },
561                            "allowAdmin": { "type": "Boolean", "required": true },
562                            "blockedOperations": { "type": "Set", "element": { "type": "String" } },
563                            "allowedOperations": { "type": "Set", "element": { "type": "String" } },
564                            "blockedFields": { "type": "Set", "element": { "type": "String" } }
565                        }
566                    }
567                }
568            },
569            "actions": {
570                "Read": { "appliesTo": applies_to },
571                "Write": { "appliesTo": applies_to },
572                "Delete": { "appliesTo": applies_to },
573                "Admin": { "appliesTo": applies_to }
574            }
575        }
576    })
577}
578
579/// Get the Cedar schema for OpenAPI Code Mode in JSON format.
580#[cfg(feature = "openapi-code-mode")]
581pub fn get_openapi_code_mode_schema_json() -> serde_json::Value {
582    let applies_to = serde_json::json!({
583        "principalTypes": ["Script"],
584        "resourceTypes": ["Server"],
585        "context": {
586            "type": "Record",
587            "attributes": {
588                "serverId": { "type": "String", "required": true },
589                "serverType": { "type": "String", "required": true },
590                "userId": { "type": "String", "required": false },
591                "sessionId": { "type": "String", "required": false }
592            }
593        }
594    });
595
596    serde_json::json!({
597        "CodeMode": {
598            "entityTypes": {
599                "Script": {
600                    "shape": {
601                        "type": "Record",
602                        "attributes": {
603                            "scriptType": { "type": "String", "required": true },
604                            "hasWrites": { "type": "Boolean", "required": true },
605                            "hasDeletes": { "type": "Boolean", "required": true },
606                            "totalApiCalls": { "type": "Long", "required": true },
607                            "readCalls": { "type": "Long", "required": true },
608                            "writeCalls": { "type": "Long", "required": true },
609                            "deleteCalls": { "type": "Long", "required": true },
610                            "accessedPaths": { "type": "Set", "element": { "type": "String" } },
611                            "accessedMethods": { "type": "Set", "element": { "type": "String" } },
612                            "pathPatterns": { "type": "Set", "element": { "type": "String" } },
613                            "calledOperations": { "type": "Set", "element": { "type": "String" } },
614                            "loopIterations": { "type": "Long", "required": true },
615                            "nestingDepth": { "type": "Long", "required": true },
616                            "scriptLength": { "type": "Long", "required": true },
617                            "accessesSensitivePath": { "type": "Boolean", "required": true },
618                            "hasUnboundedLoop": { "type": "Boolean", "required": true },
619                            "hasDynamicPath": { "type": "Boolean", "required": true },
620                            "outputFields": { "type": "Set", "element": { "type": "String" } },
621                            "hasOutputDeclaration": { "type": "Boolean", "required": true },
622                            "hasSpreadInOutput": { "type": "Boolean", "required": true }
623                        }
624                    }
625                },
626                "Server": {
627                    "shape": {
628                        "type": "Record",
629                        "attributes": {
630                            "serverId": { "type": "String", "required": true },
631                            "serverType": { "type": "String", "required": true },
632                            "writeMode": { "type": "String", "required": true },
633                            "maxDepth": { "type": "Long", "required": true },
634                            "maxCost": { "type": "Long", "required": true },
635                            "maxApiCalls": { "type": "Long", "required": true },
636                            "allowWrite": { "type": "Boolean", "required": true },
637                            "allowDelete": { "type": "Boolean", "required": true },
638                            "allowAdmin": { "type": "Boolean", "required": true },
639                            "blockedOperations": { "type": "Set", "element": { "type": "String" } },
640                            "allowedOperations": { "type": "Set", "element": { "type": "String" } },
641                            "blockedFields": { "type": "Set", "element": { "type": "String" } },
642                            "maxLoopIterations": { "type": "Long", "required": true },
643                            "maxScriptLength": { "type": "Long", "required": true },
644                            "maxNestingDepth": { "type": "Long", "required": true },
645                            "executionTimeoutSeconds": { "type": "Long", "required": true },
646                            "allowedMethods": { "type": "Set", "element": { "type": "String" } },
647                            "blockedMethods": { "type": "Set", "element": { "type": "String" } },
648                            "allowedPathPatterns": { "type": "Set", "element": { "type": "String" } },
649                            "blockedPathPatterns": { "type": "Set", "element": { "type": "String" } },
650                            "sensitivePathPatterns": { "type": "Set", "element": { "type": "String" } },
651                            "autoApproveReadOnly": { "type": "Boolean", "required": true },
652                            "maxApiCallsForAutoApprove": { "type": "Long", "required": true },
653                            "internalBlockedFields": { "type": "Set", "element": { "type": "String" } },
654                            "outputBlockedFields": { "type": "Set", "element": { "type": "String" } },
655                            "requireOutputDeclaration": { "type": "Boolean", "required": true }
656                        }
657                    }
658                }
659            },
660            "actions": {
661                "Read": { "appliesTo": applies_to },
662                "Write": { "appliesTo": applies_to },
663                "Delete": { "appliesTo": applies_to },
664                "Admin": { "appliesTo": applies_to }
665            }
666        }
667    })
668}
669
670/// Get baseline Cedar policies for OpenAPI Code Mode.
671#[cfg(feature = "openapi-code-mode")]
672pub fn get_openapi_baseline_policies() -> Vec<(&'static str, &'static str, &'static str)> {
673    vec![
674        (
675            "permit_reads",
676            "Permit all read operations (GET scripts)",
677            r#"permit(principal, action == CodeMode::Action::"Read", resource);"#,
678        ),
679        (
680            "permit_writes",
681            "Permit write operations (when enabled)",
682            r#"permit(principal, action == CodeMode::Action::"Write", resource) when { resource.allowWrite == true };"#,
683        ),
684        (
685            "permit_deletes",
686            "Permit delete operations (when enabled)",
687            r#"permit(principal, action == CodeMode::Action::"Delete", resource) when { resource.allowDelete == true };"#,
688        ),
689        (
690            "forbid_sensitive_paths",
691            "Block scripts accessing sensitive paths",
692            r#"forbid(principal, action, resource) when { principal.accessesSensitivePath == true };"#,
693        ),
694        (
695            "forbid_unbounded_loops",
696            "Block scripts with unbounded loops",
697            r#"forbid(principal, action, resource) when { principal.hasUnboundedLoop == true };"#,
698        ),
699        (
700            "forbid_excessive_api_calls",
701            "Enforce API call limit",
702            r#"forbid(principal, action, resource) when { principal.totalApiCalls > resource.maxApiCalls };"#,
703        ),
704        (
705            "forbid_excessive_nesting",
706            "Enforce nesting depth limit",
707            r#"forbid(principal, action, resource) when { principal.nestingDepth > resource.maxNestingDepth };"#,
708        ),
709        (
710            "forbid_output_blocked_fields",
711            "Block scripts that return output-blocked fields",
712            r#"forbid(principal, action, resource) when { principal.outputFields.containsAny(resource.outputBlockedFields) };"#,
713        ),
714        (
715            "forbid_spread_without_declaration",
716            "Block scripts with spread in output when output declaration is required",
717            r#"forbid(principal, action, resource) when { principal.hasSpreadInOutput == true && resource.requireOutputDeclaration == true };"#,
718        ),
719        (
720            "forbid_missing_output_declaration",
721            "Block scripts without output declaration when required",
722            r#"forbid(principal, action, resource) when { principal.hasOutputDeclaration == false && resource.requireOutputDeclaration == true };"#,
723        ),
724    ]
725}
726
727/// Get the baseline Cedar policies.
728pub fn get_baseline_policies() -> Vec<(&'static str, &'static str, &'static str)> {
729    vec![
730        (
731            "permit_reads",
732            "Permit all read operations (queries)",
733            r#"permit(principal, action == CodeMode::Action::"Read", resource);"#,
734        ),
735        (
736            "permit_writes",
737            "Permit write operations (when enabled)",
738            r#"permit(principal, action == CodeMode::Action::"Write", resource) when { resource.allowWrite == true };"#,
739        ),
740        (
741            "permit_deletes",
742            "Permit delete operations (when enabled)",
743            r#"permit(principal, action == CodeMode::Action::"Delete", resource) when { resource.allowDelete == true };"#,
744        ),
745        (
746            "permit_admin",
747            "Permit admin operations (when enabled)",
748            r#"permit(principal, action == CodeMode::Action::"Admin", resource) when { resource.allowAdmin == true };"#,
749        ),
750        (
751            "forbid_blocked_operations",
752            "Block operations in blocklist",
753            r#"forbid(principal, action, resource) when { resource.blockedOperations.contains(principal.operationName) };"#,
754        ),
755        (
756            "forbid_blocked_fields",
757            "Block access to blocked fields",
758            r#"forbid(principal, action, resource) when { resource.blockedFields.containsAny(principal.accessedFields) };"#,
759        ),
760        (
761            "forbid_excessive_depth",
762            "Enforce maximum query depth",
763            r#"forbid(principal, action, resource) when { principal.depth > resource.maxDepth };"#,
764        ),
765        (
766            "forbid_excessive_cost",
767            "Enforce maximum query cost",
768            r#"forbid(principal, action, resource) when { principal.estimatedCost > resource.maxCost };"#,
769        ),
770    ]
771}
772
773#[cfg(all(test, feature = "openapi-code-mode"))]
774mod tests {
775    use super::*;
776    use crate::config::{OperationEntry, OperationRegistry};
777    use crate::javascript::{ApiCall, HttpMethod, JavaScriptCodeInfo};
778
779    fn make_api_call(method: HttpMethod, path: &str) -> ApiCall {
780        ApiCall {
781            method,
782            path: path.to_string(),
783            is_dynamic_path: false,
784            line: 1,
785            column: 0,
786        }
787    }
788
789    fn make_info(calls: Vec<ApiCall>) -> JavaScriptCodeInfo {
790        JavaScriptCodeInfo {
791            api_calls: calls,
792            ..Default::default()
793        }
794    }
795
796    fn make_registry(entries: &[(&str, &str, &str)]) -> OperationRegistry {
797        let entries: Vec<OperationEntry> = entries
798            .iter()
799            .map(|(id, category, path)| OperationEntry {
800                id: id.to_string(),
801                category: category.to_string(),
802                description: String::new(),
803                path: Some(path.to_string()),
804            })
805            .collect();
806        OperationRegistry::from_entries(&entries)
807    }
808
809    #[test]
810    fn test_category_read_overrides_post_method() {
811        let registry = make_registry(&[("getCostAnomalies", "read", "/getCostAnomalies")]);
812        let info = make_info(vec![make_api_call(HttpMethod::Post, "/getCostAnomalies")]);
813
814        let entity = ScriptEntity::from_javascript_info(&info, &[], Some(&registry));
815
816        assert_eq!(entity.read_calls, 1);
817        assert_eq!(entity.write_calls, 0);
818        assert_eq!(entity.script_type, "read_only");
819        assert_eq!(entity.action(), "Read");
820    }
821
822    #[test]
823    fn test_category_write_overrides_get_method() {
824        let registry = make_registry(&[("triggerExport", "write", "/triggerExport")]);
825        let info = make_info(vec![make_api_call(HttpMethod::Get, "/triggerExport")]);
826
827        let entity = ScriptEntity::from_javascript_info(&info, &[], Some(&registry));
828
829        assert_eq!(entity.write_calls, 1);
830        assert_eq!(entity.read_calls, 0);
831        assert_eq!(entity.script_type, "write_only");
832        assert_eq!(entity.action(), "Write");
833    }
834
835    #[test]
836    fn test_category_delete_routes_correctly() {
837        let registry = make_registry(&[("deleteReservation", "delete", "/deleteReservation")]);
838        let info = make_info(vec![make_api_call(HttpMethod::Post, "/deleteReservation")]);
839
840        let entity = ScriptEntity::from_javascript_info(&info, &[], Some(&registry));
841
842        assert_eq!(entity.delete_calls, 1);
843        assert_eq!(entity.has_deletes, true);
844        assert_eq!(entity.action(), "Delete");
845    }
846
847    #[test]
848    fn test_no_registry_falls_back_to_http_method() {
849        let info = make_info(vec![
850            make_api_call(HttpMethod::Get, "/getCostAnomalies"),
851            make_api_call(HttpMethod::Post, "/updateBudget"),
852        ]);
853
854        let entity = ScriptEntity::from_javascript_info(&info, &[], None);
855
856        assert_eq!(entity.read_calls, 1);
857        assert_eq!(entity.write_calls, 1);
858        assert_eq!(entity.script_type, "mixed");
859        assert_eq!(entity.action(), "Write");
860    }
861
862    #[test]
863    fn test_unregistered_path_falls_back_to_http_method() {
864        let registry = make_registry(&[("getCostAnomalies", "read", "/getCostAnomalies")]);
865        let info = make_info(vec![make_api_call(HttpMethod::Post, "/unknownEndpoint")]);
866
867        let entity = ScriptEntity::from_javascript_info(&info, &[], Some(&registry));
868
869        // POST with no category → write (HTTP method fallback)
870        assert_eq!(entity.write_calls, 1);
871        assert_eq!(entity.read_calls, 0);
872        assert_eq!(entity.script_type, "write_only");
873    }
874
875    #[test]
876    fn test_mixed_categories_produce_mixed_script() {
877        let registry = make_registry(&[
878            ("getCostAnomalies", "read", "/getCostAnomalies"),
879            ("updateBudget", "write", "/updateBudget"),
880        ]);
881        let info = make_info(vec![
882            make_api_call(HttpMethod::Post, "/getCostAnomalies"),
883            make_api_call(HttpMethod::Post, "/updateBudget"),
884        ]);
885
886        let entity = ScriptEntity::from_javascript_info(&info, &[], Some(&registry));
887
888        assert_eq!(entity.read_calls, 1);
889        assert_eq!(entity.write_calls, 1);
890        assert_eq!(entity.script_type, "mixed");
891        assert_eq!(entity.action(), "Write");
892    }
893
894    #[test]
895    fn test_empty_category_falls_back_to_http_method() {
896        // category = "" (from #[serde(default)]) → no category → HTTP method fallback
897        let registry = make_registry(&[("legacyOp", "", "/legacyOp")]);
898        let info = make_info(vec![make_api_call(HttpMethod::Post, "/legacyOp")]);
899
900        let entity = ScriptEntity::from_javascript_info(&info, &[], Some(&registry));
901
902        // POST with empty category → write (HTTP method fallback)
903        assert_eq!(entity.write_calls, 1);
904        assert_eq!(entity.script_type, "write_only");
905    }
906}