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/// SQL statement entity for policy evaluation (SQL Code Mode).
511///
512/// Mirrors the `Statement` entity in `SQL_CEDAR_SCHEMA` —
513/// see `cedar_validation.rs` for the schema definition.
514#[cfg(feature = "sql-code-mode")]
515#[derive(Debug, Clone)]
516pub struct StatementEntity {
517    /// Unique ID for this statement validation.
518    pub id: String,
519
520    /// Statement type: "SELECT", "INSERT", "UPDATE", "DELETE", "DDL", "OTHER".
521    pub statement_type: String,
522
523    /// Tables referenced by the statement.
524    pub tables: HashSet<String>,
525
526    /// Columns referenced by the statement. `*` for wildcards.
527    pub columns: HashSet<String>,
528
529    /// Whether the statement has a WHERE clause.
530    pub has_where: bool,
531
532    /// Whether the statement has a LIMIT clause.
533    pub has_limit: bool,
534
535    /// Whether the statement has an ORDER BY clause.
536    pub has_order_by: bool,
537
538    /// Estimated rows affected.
539    pub estimated_rows: u64,
540
541    /// Number of JOIN clauses.
542    pub join_count: u32,
543
544    /// Number of nested subqueries.
545    pub subquery_count: u32,
546}
547
548#[cfg(feature = "sql-code-mode")]
549impl StatementEntity {
550    /// Build from [`SqlStatementInfo`](crate::sql::SqlStatementInfo).
551    pub fn from_sql_info(info: &crate::sql::SqlStatementInfo) -> Self {
552        Self {
553            id: format!(
554                "{}:{}",
555                info.statement_type.as_str(),
556                first_or_default(&info.tables)
557            ),
558            statement_type: info.statement_type.as_str().to_string(),
559            tables: info.tables.clone(),
560            columns: info.columns.clone(),
561            has_where: info.has_where,
562            has_limit: info.has_limit,
563            has_order_by: info.has_order_by,
564            estimated_rows: info.estimated_rows,
565            join_count: info.join_count,
566            subquery_count: info.subquery_count,
567        }
568    }
569
570    /// Get the Cedar action for this statement using unified action model.
571    pub fn action(&self) -> &'static str {
572        match self.statement_type.as_str() {
573            "SELECT" => "Read",
574            "INSERT" | "UPDATE" => "Write",
575            "DELETE" => "Delete",
576            "DDL" => "Admin",
577            _ => "Read",
578        }
579    }
580}
581
582/// Helper for building a deterministic statement ID.
583#[cfg(feature = "sql-code-mode")]
584fn first_or_default(set: &HashSet<String>) -> String {
585    let mut names: Vec<&String> = set.iter().collect();
586    names.sort();
587    names
588        .first()
589        .map(|s| s.to_string())
590        .unwrap_or_else(|| "statement".to_string())
591}
592
593/// Server configuration for SQL Code Mode.
594///
595/// Fields use `sql_*` config prefixes externally so DBA administrators
596/// can set "this is a SQL server's config" vocabulary in `config.toml`.
597/// Field names here drop the prefix for concision in policy code.
598#[cfg(feature = "sql-code-mode")]
599#[derive(Debug, Clone)]
600pub struct SqlServerEntity {
601    pub server_id: String,
602    pub server_type: String,
603
604    // Unified action flags
605    pub allow_write: bool,
606    pub allow_delete: bool,
607    pub allow_admin: bool,
608
609    // SQL-specific limits
610    pub max_rows: u64,
611    pub max_joins: u32,
612
613    // Unified operation lists (statement-type level, e.g., "SELECT"/"INSERT")
614    pub allowed_operations: HashSet<String>,
615    pub blocked_operations: HashSet<String>,
616
617    // SQL-specific table/column controls
618    pub blocked_tables: HashSet<String>,
619    pub blocked_columns: HashSet<String>,
620    pub allowed_tables: HashSet<String>,
621}
622
623#[cfg(feature = "sql-code-mode")]
624impl Default for SqlServerEntity {
625    fn default() -> Self {
626        Self {
627            server_id: "unknown".to_string(),
628            server_type: "sql".to_string(),
629            allow_write: false,
630            allow_delete: false,
631            allow_admin: false,
632            max_rows: 10_000,
633            max_joins: 5,
634            allowed_operations: HashSet::new(),
635            blocked_operations: HashSet::new(),
636            blocked_tables: HashSet::new(),
637            blocked_columns: HashSet::new(),
638            allowed_tables: HashSet::new(),
639        }
640    }
641}
642
643/// Get the Cedar schema in JSON format.
644///
645/// Uses unified action model with Read/Write/Delete/Admin actions.
646pub fn get_code_mode_schema_json() -> serde_json::Value {
647    let applies_to = serde_json::json!({
648        "principalTypes": ["Operation"],
649        "resourceTypes": ["Server"],
650        "context": {
651            "type": "Record",
652            "attributes": {
653                "serverId": { "type": "String", "required": true },
654                "serverType": { "type": "String", "required": true },
655                "userId": { "type": "String", "required": false },
656                "sessionId": { "type": "String", "required": false }
657            }
658        }
659    });
660
661    serde_json::json!({
662        "CodeMode": {
663            "entityTypes": {
664                "Operation": {
665                    "shape": {
666                        "type": "Record",
667                        "attributes": {
668                            "operationType": { "type": "String", "required": true },
669                            "operationName": { "type": "String", "required": true },
670                            "rootFields": { "type": "Set", "element": { "type": "String" } },
671                            "accessedTypes": { "type": "Set", "element": { "type": "String" } },
672                            "accessedFields": { "type": "Set", "element": { "type": "String" } },
673                            "depth": { "type": "Long", "required": true },
674                            "fieldCount": { "type": "Long", "required": true },
675                            "estimatedCost": { "type": "Long", "required": true },
676                            "hasIntrospection": { "type": "Boolean", "required": true },
677                            "accessesSensitiveData": { "type": "Boolean", "required": true },
678                            "sensitiveCategories": { "type": "Set", "element": { "type": "String" } }
679                        }
680                    }
681                },
682                "Server": {
683                    "shape": {
684                        "type": "Record",
685                        "attributes": {
686                            "serverId": { "type": "String", "required": true },
687                            "serverType": { "type": "String", "required": true },
688                            "maxDepth": { "type": "Long", "required": true },
689                            "maxFieldCount": { "type": "Long", "required": true },
690                            "maxCost": { "type": "Long", "required": true },
691                            "maxApiCalls": { "type": "Long", "required": true },
692                            "allowWrite": { "type": "Boolean", "required": true },
693                            "allowDelete": { "type": "Boolean", "required": true },
694                            "allowAdmin": { "type": "Boolean", "required": true },
695                            "blockedOperations": { "type": "Set", "element": { "type": "String" } },
696                            "allowedOperations": { "type": "Set", "element": { "type": "String" } },
697                            "blockedFields": { "type": "Set", "element": { "type": "String" } }
698                        }
699                    }
700                }
701            },
702            "actions": {
703                "Read": { "appliesTo": applies_to },
704                "Write": { "appliesTo": applies_to },
705                "Delete": { "appliesTo": applies_to },
706                "Admin": { "appliesTo": applies_to }
707            }
708        }
709    })
710}
711
712/// Get the Cedar schema for OpenAPI Code Mode in JSON format.
713#[cfg(feature = "openapi-code-mode")]
714pub fn get_openapi_code_mode_schema_json() -> serde_json::Value {
715    let applies_to = serde_json::json!({
716        "principalTypes": ["Script"],
717        "resourceTypes": ["Server"],
718        "context": {
719            "type": "Record",
720            "attributes": {
721                "serverId": { "type": "String", "required": true },
722                "serverType": { "type": "String", "required": true },
723                "userId": { "type": "String", "required": false },
724                "sessionId": { "type": "String", "required": false }
725            }
726        }
727    });
728
729    serde_json::json!({
730        "CodeMode": {
731            "entityTypes": {
732                "Script": {
733                    "shape": {
734                        "type": "Record",
735                        "attributes": {
736                            "scriptType": { "type": "String", "required": true },
737                            "hasWrites": { "type": "Boolean", "required": true },
738                            "hasDeletes": { "type": "Boolean", "required": true },
739                            "totalApiCalls": { "type": "Long", "required": true },
740                            "readCalls": { "type": "Long", "required": true },
741                            "writeCalls": { "type": "Long", "required": true },
742                            "deleteCalls": { "type": "Long", "required": true },
743                            "accessedPaths": { "type": "Set", "element": { "type": "String" } },
744                            "accessedMethods": { "type": "Set", "element": { "type": "String" } },
745                            "pathPatterns": { "type": "Set", "element": { "type": "String" } },
746                            "calledOperations": { "type": "Set", "element": { "type": "String" } },
747                            "loopIterations": { "type": "Long", "required": true },
748                            "nestingDepth": { "type": "Long", "required": true },
749                            "scriptLength": { "type": "Long", "required": true },
750                            "accessesSensitivePath": { "type": "Boolean", "required": true },
751                            "hasUnboundedLoop": { "type": "Boolean", "required": true },
752                            "hasDynamicPath": { "type": "Boolean", "required": true },
753                            "outputFields": { "type": "Set", "element": { "type": "String" } },
754                            "hasOutputDeclaration": { "type": "Boolean", "required": true },
755                            "hasSpreadInOutput": { "type": "Boolean", "required": true }
756                        }
757                    }
758                },
759                "Server": {
760                    "shape": {
761                        "type": "Record",
762                        "attributes": {
763                            "serverId": { "type": "String", "required": true },
764                            "serverType": { "type": "String", "required": true },
765                            "writeMode": { "type": "String", "required": true },
766                            "maxDepth": { "type": "Long", "required": true },
767                            "maxCost": { "type": "Long", "required": true },
768                            "maxApiCalls": { "type": "Long", "required": true },
769                            "allowWrite": { "type": "Boolean", "required": true },
770                            "allowDelete": { "type": "Boolean", "required": true },
771                            "allowAdmin": { "type": "Boolean", "required": true },
772                            "blockedOperations": { "type": "Set", "element": { "type": "String" } },
773                            "allowedOperations": { "type": "Set", "element": { "type": "String" } },
774                            "blockedFields": { "type": "Set", "element": { "type": "String" } },
775                            "maxLoopIterations": { "type": "Long", "required": true },
776                            "maxScriptLength": { "type": "Long", "required": true },
777                            "maxNestingDepth": { "type": "Long", "required": true },
778                            "executionTimeoutSeconds": { "type": "Long", "required": true },
779                            "allowedMethods": { "type": "Set", "element": { "type": "String" } },
780                            "blockedMethods": { "type": "Set", "element": { "type": "String" } },
781                            "allowedPathPatterns": { "type": "Set", "element": { "type": "String" } },
782                            "blockedPathPatterns": { "type": "Set", "element": { "type": "String" } },
783                            "sensitivePathPatterns": { "type": "Set", "element": { "type": "String" } },
784                            "autoApproveReadOnly": { "type": "Boolean", "required": true },
785                            "maxApiCallsForAutoApprove": { "type": "Long", "required": true },
786                            "internalBlockedFields": { "type": "Set", "element": { "type": "String" } },
787                            "outputBlockedFields": { "type": "Set", "element": { "type": "String" } },
788                            "requireOutputDeclaration": { "type": "Boolean", "required": true }
789                        }
790                    }
791                }
792            },
793            "actions": {
794                "Read": { "appliesTo": applies_to },
795                "Write": { "appliesTo": applies_to },
796                "Delete": { "appliesTo": applies_to },
797                "Admin": { "appliesTo": applies_to }
798            }
799        }
800    })
801}
802
803/// Get baseline Cedar policies for OpenAPI Code Mode.
804#[cfg(feature = "openapi-code-mode")]
805pub fn get_openapi_baseline_policies() -> Vec<(&'static str, &'static str, &'static str)> {
806    vec![
807        (
808            "permit_reads",
809            "Permit all read operations (GET scripts)",
810            r#"permit(principal, action == CodeMode::Action::"Read", resource);"#,
811        ),
812        (
813            "permit_writes",
814            "Permit write operations (when enabled)",
815            r#"permit(principal, action == CodeMode::Action::"Write", resource) when { resource.allowWrite == true };"#,
816        ),
817        (
818            "permit_deletes",
819            "Permit delete operations (when enabled)",
820            r#"permit(principal, action == CodeMode::Action::"Delete", resource) when { resource.allowDelete == true };"#,
821        ),
822        (
823            "forbid_sensitive_paths",
824            "Block scripts accessing sensitive paths",
825            r#"forbid(principal, action, resource) when { principal.accessesSensitivePath == true };"#,
826        ),
827        (
828            "forbid_unbounded_loops",
829            "Block scripts with unbounded loops",
830            r#"forbid(principal, action, resource) when { principal.hasUnboundedLoop == true };"#,
831        ),
832        (
833            "forbid_excessive_api_calls",
834            "Enforce API call limit",
835            r#"forbid(principal, action, resource) when { principal.totalApiCalls > resource.maxApiCalls };"#,
836        ),
837        (
838            "forbid_excessive_nesting",
839            "Enforce nesting depth limit",
840            r#"forbid(principal, action, resource) when { principal.nestingDepth > resource.maxNestingDepth };"#,
841        ),
842        (
843            "forbid_output_blocked_fields",
844            "Block scripts that return output-blocked fields",
845            r#"forbid(principal, action, resource) when { principal.outputFields.containsAny(resource.outputBlockedFields) };"#,
846        ),
847        (
848            "forbid_spread_without_declaration",
849            "Block scripts with spread in output when output declaration is required",
850            r#"forbid(principal, action, resource) when { principal.hasSpreadInOutput == true && resource.requireOutputDeclaration == true };"#,
851        ),
852        (
853            "forbid_missing_output_declaration",
854            "Block scripts without output declaration when required",
855            r#"forbid(principal, action, resource) when { principal.hasOutputDeclaration == false && resource.requireOutputDeclaration == true };"#,
856        ),
857    ]
858}
859
860/// Get the baseline Cedar policies.
861pub fn get_baseline_policies() -> Vec<(&'static str, &'static str, &'static str)> {
862    vec![
863        (
864            "permit_reads",
865            "Permit all read operations (queries)",
866            r#"permit(principal, action == CodeMode::Action::"Read", resource);"#,
867        ),
868        (
869            "permit_writes",
870            "Permit write operations (when enabled)",
871            r#"permit(principal, action == CodeMode::Action::"Write", resource) when { resource.allowWrite == true };"#,
872        ),
873        (
874            "permit_deletes",
875            "Permit delete operations (when enabled)",
876            r#"permit(principal, action == CodeMode::Action::"Delete", resource) when { resource.allowDelete == true };"#,
877        ),
878        (
879            "permit_admin",
880            "Permit admin operations (when enabled)",
881            r#"permit(principal, action == CodeMode::Action::"Admin", resource) when { resource.allowAdmin == true };"#,
882        ),
883        (
884            "forbid_blocked_operations",
885            "Block operations in blocklist",
886            r#"forbid(principal, action, resource) when { resource.blockedOperations.contains(principal.operationName) };"#,
887        ),
888        (
889            "forbid_blocked_fields",
890            "Block access to blocked fields",
891            r#"forbid(principal, action, resource) when { resource.blockedFields.containsAny(principal.accessedFields) };"#,
892        ),
893        (
894            "forbid_excessive_depth",
895            "Enforce maximum query depth",
896            r#"forbid(principal, action, resource) when { principal.depth > resource.maxDepth };"#,
897        ),
898        (
899            "forbid_excessive_cost",
900            "Enforce maximum query cost",
901            r#"forbid(principal, action, resource) when { principal.estimatedCost > resource.maxCost };"#,
902        ),
903    ]
904}
905
906/// Get the Cedar schema for SQL Code Mode in JSON format.
907///
908/// Matches `SQL_CEDAR_SCHEMA` in `cedar_validation.rs`. A schema-sync test
909/// in `cedar_validation.rs` enforces this stays aligned.
910#[cfg(feature = "sql-code-mode")]
911pub fn get_sql_code_mode_schema_json() -> serde_json::Value {
912    let applies_to = serde_json::json!({
913        "principalTypes": ["Statement"],
914        "resourceTypes": ["Server"],
915        "context": {
916            "type": "Record",
917            "attributes": {
918                "serverId": { "type": "String", "required": true },
919                "serverType": { "type": "String", "required": true },
920                "userId": { "type": "String", "required": false },
921                "sessionId": { "type": "String", "required": false }
922            }
923        }
924    });
925
926    serde_json::json!({
927        "CodeMode": {
928            "entityTypes": {
929                "Statement": {
930                    "shape": {
931                        "type": "Record",
932                        "attributes": {
933                            "statementType": { "type": "String", "required": true },
934                            "tables": { "type": "Set", "element": { "type": "String" } },
935                            "columns": { "type": "Set", "element": { "type": "String" } },
936                            "hasWhere": { "type": "Boolean", "required": true },
937                            "hasLimit": { "type": "Boolean", "required": true },
938                            "hasOrderBy": { "type": "Boolean", "required": true },
939                            "estimatedRows": { "type": "Long", "required": true },
940                            "joinCount": { "type": "Long", "required": true },
941                            "subqueryCount": { "type": "Long", "required": true }
942                        }
943                    }
944                },
945                "Server": {
946                    "shape": {
947                        "type": "Record",
948                        "attributes": {
949                            "serverId": { "type": "String", "required": true },
950                            "serverType": { "type": "String", "required": true },
951                            "maxRows": { "type": "Long", "required": true },
952                            "maxJoins": { "type": "Long", "required": true },
953                            "allowWrite": { "type": "Boolean", "required": true },
954                            "allowDelete": { "type": "Boolean", "required": true },
955                            "allowAdmin": { "type": "Boolean", "required": true },
956                            "blockedOperations": { "type": "Set", "element": { "type": "String" } },
957                            "allowedOperations": { "type": "Set", "element": { "type": "String" } },
958                            "blockedTables": { "type": "Set", "element": { "type": "String" } },
959                            "blockedColumns": { "type": "Set", "element": { "type": "String" } }
960                        }
961                    }
962                }
963            },
964            "actions": {
965                "Read": { "appliesTo": applies_to },
966                "Write": { "appliesTo": applies_to },
967                "Delete": { "appliesTo": applies_to },
968                "Admin": { "appliesTo": applies_to }
969            }
970        }
971    })
972}
973
974/// Get baseline Cedar policies for SQL Code Mode.
975#[cfg(feature = "sql-code-mode")]
976pub fn get_sql_baseline_policies() -> Vec<(&'static str, &'static str, &'static str)> {
977    vec![
978        (
979            "permit_reads",
980            "Permit all SELECT statements",
981            r#"permit(principal, action == CodeMode::Action::"Read", resource);"#,
982        ),
983        (
984            "permit_writes",
985            "Permit INSERT/UPDATE when enabled",
986            r#"permit(principal, action == CodeMode::Action::"Write", resource) when { resource.allowWrite == true };"#,
987        ),
988        (
989            "permit_deletes",
990            "Permit DELETE when enabled",
991            r#"permit(principal, action == CodeMode::Action::"Delete", resource) when { resource.allowDelete == true };"#,
992        ),
993        (
994            "permit_admin",
995            "Permit DDL when enabled",
996            r#"permit(principal, action == CodeMode::Action::"Admin", resource) when { resource.allowAdmin == true };"#,
997        ),
998        (
999            "forbid_blocked_tables",
1000            "Block statements touching blocked tables",
1001            r#"forbid(principal, action, resource) when { principal.tables.containsAny(resource.blockedTables) };"#,
1002        ),
1003        (
1004            "forbid_blocked_columns",
1005            "Block statements touching blocked columns",
1006            r#"forbid(principal, action, resource) when { principal.columns.containsAny(resource.blockedColumns) };"#,
1007        ),
1008        (
1009            "forbid_excessive_rows",
1010            "Enforce row-count limit",
1011            r#"forbid(principal, action, resource) when { principal.estimatedRows > resource.maxRows };"#,
1012        ),
1013        (
1014            "forbid_excessive_joins",
1015            "Enforce JOIN-count limit",
1016            r#"forbid(principal, action, resource) when { principal.joinCount > resource.maxJoins };"#,
1017        ),
1018    ]
1019}
1020
1021#[cfg(all(test, feature = "openapi-code-mode"))]
1022mod tests {
1023    use super::*;
1024    use crate::config::{OperationEntry, OperationRegistry};
1025    use crate::javascript::{ApiCall, HttpMethod, JavaScriptCodeInfo};
1026
1027    fn make_api_call(method: HttpMethod, path: &str) -> ApiCall {
1028        ApiCall {
1029            method,
1030            path: path.to_string(),
1031            is_dynamic_path: false,
1032            line: 1,
1033            column: 0,
1034        }
1035    }
1036
1037    fn make_info(calls: Vec<ApiCall>) -> JavaScriptCodeInfo {
1038        JavaScriptCodeInfo {
1039            api_calls: calls,
1040            ..Default::default()
1041        }
1042    }
1043
1044    fn make_registry(entries: &[(&str, &str, &str)]) -> OperationRegistry {
1045        let entries: Vec<OperationEntry> = entries
1046            .iter()
1047            .map(|(id, category, path)| OperationEntry {
1048                id: id.to_string(),
1049                category: category.to_string(),
1050                description: String::new(),
1051                path: Some(path.to_string()),
1052            })
1053            .collect();
1054        OperationRegistry::from_entries(&entries)
1055    }
1056
1057    #[test]
1058    fn test_category_read_overrides_post_method() {
1059        let registry = make_registry(&[("getCostAnomalies", "read", "/getCostAnomalies")]);
1060        let info = make_info(vec![make_api_call(HttpMethod::Post, "/getCostAnomalies")]);
1061
1062        let entity = ScriptEntity::from_javascript_info(&info, &[], Some(&registry));
1063
1064        assert_eq!(entity.read_calls, 1);
1065        assert_eq!(entity.write_calls, 0);
1066        assert_eq!(entity.script_type, "read_only");
1067        assert_eq!(entity.action(), "Read");
1068    }
1069
1070    #[test]
1071    fn test_category_write_overrides_get_method() {
1072        let registry = make_registry(&[("triggerExport", "write", "/triggerExport")]);
1073        let info = make_info(vec![make_api_call(HttpMethod::Get, "/triggerExport")]);
1074
1075        let entity = ScriptEntity::from_javascript_info(&info, &[], Some(&registry));
1076
1077        assert_eq!(entity.write_calls, 1);
1078        assert_eq!(entity.read_calls, 0);
1079        assert_eq!(entity.script_type, "write_only");
1080        assert_eq!(entity.action(), "Write");
1081    }
1082
1083    #[test]
1084    fn test_category_delete_routes_correctly() {
1085        let registry = make_registry(&[("deleteReservation", "delete", "/deleteReservation")]);
1086        let info = make_info(vec![make_api_call(HttpMethod::Post, "/deleteReservation")]);
1087
1088        let entity = ScriptEntity::from_javascript_info(&info, &[], Some(&registry));
1089
1090        assert_eq!(entity.delete_calls, 1);
1091        assert_eq!(entity.has_deletes, true);
1092        assert_eq!(entity.action(), "Delete");
1093    }
1094
1095    #[test]
1096    fn test_no_registry_falls_back_to_http_method() {
1097        let info = make_info(vec![
1098            make_api_call(HttpMethod::Get, "/getCostAnomalies"),
1099            make_api_call(HttpMethod::Post, "/updateBudget"),
1100        ]);
1101
1102        let entity = ScriptEntity::from_javascript_info(&info, &[], None);
1103
1104        assert_eq!(entity.read_calls, 1);
1105        assert_eq!(entity.write_calls, 1);
1106        assert_eq!(entity.script_type, "mixed");
1107        assert_eq!(entity.action(), "Write");
1108    }
1109
1110    #[test]
1111    fn test_unregistered_path_falls_back_to_http_method() {
1112        let registry = make_registry(&[("getCostAnomalies", "read", "/getCostAnomalies")]);
1113        let info = make_info(vec![make_api_call(HttpMethod::Post, "/unknownEndpoint")]);
1114
1115        let entity = ScriptEntity::from_javascript_info(&info, &[], Some(&registry));
1116
1117        // POST with no category → write (HTTP method fallback)
1118        assert_eq!(entity.write_calls, 1);
1119        assert_eq!(entity.read_calls, 0);
1120        assert_eq!(entity.script_type, "write_only");
1121    }
1122
1123    #[test]
1124    fn test_mixed_categories_produce_mixed_script() {
1125        let registry = make_registry(&[
1126            ("getCostAnomalies", "read", "/getCostAnomalies"),
1127            ("updateBudget", "write", "/updateBudget"),
1128        ]);
1129        let info = make_info(vec![
1130            make_api_call(HttpMethod::Post, "/getCostAnomalies"),
1131            make_api_call(HttpMethod::Post, "/updateBudget"),
1132        ]);
1133
1134        let entity = ScriptEntity::from_javascript_info(&info, &[], Some(&registry));
1135
1136        assert_eq!(entity.read_calls, 1);
1137        assert_eq!(entity.write_calls, 1);
1138        assert_eq!(entity.script_type, "mixed");
1139        assert_eq!(entity.action(), "Write");
1140    }
1141
1142    #[test]
1143    fn test_empty_category_falls_back_to_http_method() {
1144        // category = "" (from #[serde(default)]) → no category → HTTP method fallback
1145        let registry = make_registry(&[("legacyOp", "", "/legacyOp")]);
1146        let info = make_info(vec![make_api_call(HttpMethod::Post, "/legacyOp")]);
1147
1148        let entity = ScriptEntity::from_javascript_info(&info, &[], Some(&registry));
1149
1150        // POST with empty category → write (HTTP method fallback)
1151        assert_eq!(entity.write_calls, 1);
1152        assert_eq!(entity.script_type, "write_only");
1153    }
1154}