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            // The same lookup as the static class gate (openapi_policy), so both
265            // see one operation for one call.
266            let entry = registry.and_then(|r| r.lookup_entry(Some(&method_str), &api_call.path));
267            let op_id = entry
268                .map(|e| e.id.clone())
269                .unwrap_or_else(|| format!("{}:{}", method_str, pattern));
270            called_operations.insert(op_id);
271
272            // Count by declared category (from [[code_mode.operations]]) when available,
273            // fall back to HTTP method when no registry entry or no category declared.
274            let call_category = entry.map(|e| e.category.as_str()).filter(|c| !c.is_empty());
275            match call_category {
276                Some("read") => read_calls += 1,
277                Some("delete") => delete_calls += 1,
278                Some("write" | "admin") => write_calls += 1,
279                Some(_) => write_calls += 1,
280                None => match api_call.method {
281                    HttpMethod::Get | HttpMethod::Head | HttpMethod::Options => read_calls += 1,
282                    HttpMethod::Delete => delete_calls += 1,
283                    _ => write_calls += 1,
284                },
285            }
286
287            // Track dynamic paths
288            if api_call.is_dynamic_path {
289                has_dynamic_path = true;
290            }
291
292            // Check for sensitive path access
293            let path_lower = api_call.path.to_lowercase();
294            for pattern in sensitive_patterns {
295                if path_lower.contains(&pattern.to_lowercase()) {
296                    accesses_sensitive_path = true;
297                    break;
298                }
299            }
300        }
301
302        // Determine script type
303        let has_writes = write_calls > 0 || delete_calls > 0;
304        let has_reads = read_calls > 0;
305        let script_type = match (has_reads, has_writes) {
306            (true, false) => "read_only",
307            (false, true) => "write_only",
308            (true, true) => "mixed",
309            (false, false) => "empty",
310        };
311
312        Self {
313            id: info
314                .api_calls
315                .first()
316                .map(|c| format!("{}:{}", format!("{:?}", c.method).to_uppercase(), c.path))
317                .unwrap_or_else(|| "script".to_string()),
318            script_type: script_type.to_string(),
319            has_writes,
320            has_deletes: delete_calls > 0,
321            total_api_calls: info.api_calls.len() as u32,
322            read_calls,
323            write_calls,
324            delete_calls,
325            accessed_paths,
326            accessed_methods,
327            path_patterns,
328            called_operations,
329            loop_iterations: 0,
330            nesting_depth: info.max_depth as u32,
331            script_length: 0,
332            accesses_sensitive_path,
333            has_unbounded_loop: !info.all_loops_bounded && info.loop_count > 0,
334            has_dynamic_path,
335            has_output_declaration: info.output_declaration.has_declaration,
336            output_fields: info.output_declaration.declared_fields.clone(),
337            has_spread_in_output: info.output_declaration.has_spread_risk
338                || info.has_output_spread_risk,
339        }
340    }
341
342    /// Get the policy action for this script using unified action model.
343    pub fn action(&self) -> &'static str {
344        match self.script_type.as_str() {
345            "read_only" | "empty" => "Read",
346            "write_only" | "mixed" => {
347                if self.has_deletes {
348                    "Delete"
349                } else {
350                    "Write"
351                }
352            },
353            _ => "Read",
354        }
355    }
356}
357
358/// Check whether a path segment looks like a UUID (8-4-4-4-12 hex pattern).
359#[cfg(feature = "openapi-code-mode")]
360fn is_uuid_like(segment: &str) -> bool {
361    if segment.len() != 36 {
362        return false;
363    }
364    let parts: Vec<&str> = segment.split('-').collect();
365    matches!(parts.as_slice(), [a, b, c, d, e]
366        if a.len() == 8 && b.len() == 4 && c.len() == 4
367        && d.len() == 4 && e.len() == 12
368        && segment.chars().all(|ch| ch.is_ascii_hexdigit() || ch == '-'))
369}
370
371/// Normalize a path to a pattern by replacing numeric/UUID segments with *.
372#[cfg(feature = "openapi-code-mode")]
373pub fn normalize_path_to_pattern(path: &str) -> String {
374    path.split('/')
375        .map(|segment| {
376            if segment.chars().all(|c| c.is_ascii_digit()) || is_uuid_like(segment) {
377                "*"
378            } else {
379                segment
380            }
381        })
382        .collect::<Vec<_>>()
383        .join("/")
384}
385
386/// Normalize an operation string to the canonical "METHOD:/path" format.
387#[cfg(feature = "openapi-code-mode")]
388pub fn normalize_operation_format(op: &str) -> String {
389    let trimmed = op.trim();
390
391    let (method, path) = if let Some(idx) = trimmed.find(':') {
392        let potential_path = trimmed[idx + 1..].trim();
393        if potential_path.starts_with('/') {
394            let method = trimmed[..idx].trim();
395            (method, potential_path)
396        } else {
397            return trimmed.to_string();
398        }
399    } else if let Some(idx) = trimmed.find(' ') {
400        let method = trimmed[..idx].trim();
401        let path = trimmed[idx + 1..].trim();
402        (method, path)
403    } else {
404        return trimmed.to_string();
405    };
406
407    let method_upper = method.to_uppercase();
408
409    let normalized_path = path
410        .split('/')
411        .map(|segment| {
412            if segment.starts_with('{') && segment.ends_with('}') {
413                "*"
414            } else if segment.starts_with(':') {
415                "*"
416            } else if segment.chars().all(|c| c.is_ascii_digit()) {
417                "*"
418            } else if is_uuid_like(segment) {
419                "*"
420            } else {
421                segment
422            }
423        })
424        .collect::<Vec<_>>()
425        .join("/");
426
427    format!("{}:{}", method_upper, normalized_path)
428}
429
430/// Server configuration for OpenAPI Code Mode.
431#[cfg(feature = "openapi-code-mode")]
432#[derive(Debug, Clone)]
433pub struct OpenAPIServerEntity {
434    pub server_id: String,
435    pub server_type: String,
436
437    // Unified action flags
438    pub allow_write: bool,
439    pub allow_delete: bool,
440    pub allow_admin: bool,
441
442    // Write mode: "allow_all", "deny_all", "allowlist", "blocklist"
443    pub write_mode: String,
444
445    // Unified limits
446    pub max_depth: u32,
447    pub max_cost: u32,
448    pub max_api_calls: u32,
449
450    // OpenAPI-specific limits
451    pub max_loop_iterations: u32,
452    pub max_script_length: u32,
453    pub max_nesting_depth: u32,
454    pub execution_timeout_seconds: u32,
455
456    // Unified operation lists
457    pub allowed_operations: HashSet<String>,
458    pub blocked_operations: HashSet<String>,
459
460    // OpenAPI-specific method controls
461    pub allowed_methods: HashSet<String>,
462    pub blocked_methods: HashSet<String>,
463    pub allowed_path_patterns: HashSet<String>,
464    pub blocked_path_patterns: HashSet<String>,
465    pub sensitive_path_patterns: HashSet<String>,
466
467    // Auto-approval settings
468    pub auto_approve_read_only: bool,
469    pub max_api_calls_for_auto_approve: u32,
470
471    // Field control (two-tier blocklist)
472    pub internal_blocked_fields: HashSet<String>,
473    pub output_blocked_fields: HashSet<String>,
474    pub require_output_declaration: bool,
475}
476
477#[cfg(feature = "openapi-code-mode")]
478impl Default for OpenAPIServerEntity {
479    fn default() -> Self {
480        Self {
481            server_id: "unknown".to_string(),
482            server_type: "openapi".to_string(),
483            allow_write: false,
484            allow_delete: false,
485            allow_admin: false,
486            write_mode: "deny_all".to_string(),
487            max_depth: 10,
488            max_cost: 1000,
489            max_api_calls: 50,
490            max_loop_iterations: 100,
491            max_script_length: 10000,
492            max_nesting_depth: 10,
493            execution_timeout_seconds: 30,
494            allowed_operations: HashSet::new(),
495            blocked_operations: HashSet::new(),
496            allowed_methods: HashSet::new(),
497            blocked_methods: HashSet::new(),
498            allowed_path_patterns: HashSet::new(),
499            blocked_path_patterns: ["/admin".into(), "/internal".into()].into_iter().collect(),
500            sensitive_path_patterns: ["/admin".into(), "/internal".into(), "/debug".into()]
501                .into_iter()
502                .collect(),
503            auto_approve_read_only: true,
504            max_api_calls_for_auto_approve: 10,
505            internal_blocked_fields: HashSet::new(),
506            output_blocked_fields: HashSet::new(),
507            require_output_declaration: false,
508        }
509    }
510}
511
512/// SQL statement entity for policy evaluation (SQL Code Mode).
513///
514/// Mirrors the `Statement` entity in `SQL_CEDAR_SCHEMA` —
515/// see `cedar_validation.rs` for the schema definition.
516#[cfg(feature = "sql-code-mode")]
517#[derive(Debug, Clone)]
518pub struct StatementEntity {
519    /// Unique ID for this statement validation.
520    pub id: String,
521
522    /// Statement type: "SELECT", "INSERT", "UPDATE", "DELETE", "DDL", "OTHER".
523    pub statement_type: String,
524
525    /// Tables referenced by the statement.
526    pub tables: HashSet<String>,
527
528    /// Columns referenced by the statement. `*` for wildcards.
529    pub columns: HashSet<String>,
530
531    /// Whether the statement has a WHERE clause.
532    pub has_where: bool,
533
534    /// Whether the statement has a LIMIT clause.
535    pub has_limit: bool,
536
537    /// Whether the statement has an ORDER BY clause.
538    pub has_order_by: bool,
539
540    /// Estimated rows affected.
541    pub estimated_rows: u64,
542
543    /// Number of JOIN clauses.
544    pub join_count: u32,
545
546    /// Number of nested subqueries.
547    pub subquery_count: u32,
548}
549
550#[cfg(feature = "sql-code-mode")]
551impl StatementEntity {
552    /// Build from [`SqlStatementInfo`](crate::sql::SqlStatementInfo).
553    pub fn from_sql_info(info: &crate::sql::SqlStatementInfo) -> Self {
554        Self {
555            id: format!(
556                "{}:{}",
557                info.statement_type.as_str(),
558                first_or_default(&info.tables)
559            ),
560            statement_type: info.statement_type.as_str().to_string(),
561            tables: info.tables.clone(),
562            columns: info.columns.clone(),
563            has_where: info.has_where,
564            has_limit: info.has_limit,
565            has_order_by: info.has_order_by,
566            estimated_rows: info.estimated_rows,
567            join_count: info.join_count,
568            subquery_count: info.subquery_count,
569        }
570    }
571
572    /// Get the Cedar action for this statement using unified action model.
573    pub fn action(&self) -> &'static str {
574        match self.statement_type.as_str() {
575            "SELECT" => "Read",
576            "INSERT" | "UPDATE" => "Write",
577            "DELETE" => "Delete",
578            "DDL" => "Admin",
579            _ => "Read",
580        }
581    }
582}
583
584/// Helper for building a deterministic statement ID.
585#[cfg(feature = "sql-code-mode")]
586fn first_or_default(set: &HashSet<String>) -> String {
587    let mut names: Vec<&String> = set.iter().collect();
588    names.sort();
589    names
590        .first()
591        .map(|s| s.to_string())
592        .unwrap_or_else(|| "statement".to_string())
593}
594
595/// Server configuration for SQL Code Mode.
596///
597/// Fields use `sql_*` config prefixes externally so DBA administrators
598/// can set "this is a SQL server's config" vocabulary in `config.toml`.
599/// Field names here drop the prefix for concision in policy code.
600#[cfg(feature = "sql-code-mode")]
601#[derive(Debug, Clone)]
602pub struct SqlServerEntity {
603    pub server_id: String,
604    pub server_type: String,
605
606    // Unified action flags
607    pub allow_write: bool,
608    pub allow_delete: bool,
609    pub allow_admin: bool,
610
611    // SQL-specific limits
612    pub max_rows: u64,
613    pub max_joins: u32,
614
615    // Unified operation lists (statement-type level, e.g., "SELECT"/"INSERT")
616    pub allowed_operations: HashSet<String>,
617    pub blocked_operations: HashSet<String>,
618
619    // SQL-specific table/column controls
620    pub blocked_tables: HashSet<String>,
621    pub blocked_columns: HashSet<String>,
622    pub allowed_tables: HashSet<String>,
623}
624
625#[cfg(feature = "sql-code-mode")]
626impl Default for SqlServerEntity {
627    fn default() -> Self {
628        Self {
629            server_id: "unknown".to_string(),
630            server_type: "sql".to_string(),
631            allow_write: false,
632            allow_delete: false,
633            allow_admin: false,
634            max_rows: 10_000,
635            max_joins: 5,
636            allowed_operations: HashSet::new(),
637            blocked_operations: HashSet::new(),
638            blocked_tables: HashSet::new(),
639            blocked_columns: HashSet::new(),
640            allowed_tables: HashSet::new(),
641        }
642    }
643}
644
645/// Get the Cedar schema in JSON format.
646///
647/// Uses unified action model with Read/Write/Delete/Admin actions.
648pub fn get_code_mode_schema_json() -> serde_json::Value {
649    let applies_to = serde_json::json!({
650        "principalTypes": ["Operation"],
651        "resourceTypes": ["Server"],
652        "context": {
653            "type": "Record",
654            "attributes": {
655                "serverId": { "type": "String", "required": true },
656                "serverType": { "type": "String", "required": true },
657                "userId": { "type": "String", "required": false },
658                "sessionId": { "type": "String", "required": false }
659            }
660        }
661    });
662
663    serde_json::json!({
664        "CodeMode": {
665            "entityTypes": {
666                "Operation": {
667                    "shape": {
668                        "type": "Record",
669                        "attributes": {
670                            "operationType": { "type": "String", "required": true },
671                            "operationName": { "type": "String", "required": true },
672                            "rootFields": { "type": "Set", "element": { "type": "String" } },
673                            "accessedTypes": { "type": "Set", "element": { "type": "String" } },
674                            "accessedFields": { "type": "Set", "element": { "type": "String" } },
675                            "depth": { "type": "Long", "required": true },
676                            "fieldCount": { "type": "Long", "required": true },
677                            "estimatedCost": { "type": "Long", "required": true },
678                            "hasIntrospection": { "type": "Boolean", "required": true },
679                            "accessesSensitiveData": { "type": "Boolean", "required": true },
680                            "sensitiveCategories": { "type": "Set", "element": { "type": "String" } }
681                        }
682                    }
683                },
684                "Server": {
685                    "shape": {
686                        "type": "Record",
687                        "attributes": {
688                            "serverId": { "type": "String", "required": true },
689                            "serverType": { "type": "String", "required": true },
690                            "maxDepth": { "type": "Long", "required": true },
691                            "maxFieldCount": { "type": "Long", "required": true },
692                            "maxCost": { "type": "Long", "required": true },
693                            "maxApiCalls": { "type": "Long", "required": true },
694                            "allowWrite": { "type": "Boolean", "required": true },
695                            "allowDelete": { "type": "Boolean", "required": true },
696                            "allowAdmin": { "type": "Boolean", "required": true },
697                            "blockedOperations": { "type": "Set", "element": { "type": "String" } },
698                            "allowedOperations": { "type": "Set", "element": { "type": "String" } },
699                            "blockedFields": { "type": "Set", "element": { "type": "String" } }
700                        }
701                    }
702                }
703            },
704            "actions": {
705                "Read": { "appliesTo": applies_to },
706                "Write": { "appliesTo": applies_to },
707                "Delete": { "appliesTo": applies_to },
708                "Admin": { "appliesTo": applies_to }
709            }
710        }
711    })
712}
713
714/// Get the Cedar schema for OpenAPI Code Mode in JSON format.
715#[cfg(feature = "openapi-code-mode")]
716pub fn get_openapi_code_mode_schema_json() -> serde_json::Value {
717    let applies_to = serde_json::json!({
718        "principalTypes": ["Script"],
719        "resourceTypes": ["Server"],
720        "context": {
721            "type": "Record",
722            "attributes": {
723                "serverId": { "type": "String", "required": true },
724                "serverType": { "type": "String", "required": true },
725                "userId": { "type": "String", "required": false },
726                "sessionId": { "type": "String", "required": false }
727            }
728        }
729    });
730
731    serde_json::json!({
732        "CodeMode": {
733            "entityTypes": {
734                "Script": {
735                    "shape": {
736                        "type": "Record",
737                        "attributes": {
738                            "scriptType": { "type": "String", "required": true },
739                            "hasWrites": { "type": "Boolean", "required": true },
740                            "hasDeletes": { "type": "Boolean", "required": true },
741                            "totalApiCalls": { "type": "Long", "required": true },
742                            "readCalls": { "type": "Long", "required": true },
743                            "writeCalls": { "type": "Long", "required": true },
744                            "deleteCalls": { "type": "Long", "required": true },
745                            "accessedPaths": { "type": "Set", "element": { "type": "String" } },
746                            "accessedMethods": { "type": "Set", "element": { "type": "String" } },
747                            "pathPatterns": { "type": "Set", "element": { "type": "String" } },
748                            "calledOperations": { "type": "Set", "element": { "type": "String" } },
749                            "loopIterations": { "type": "Long", "required": true },
750                            "nestingDepth": { "type": "Long", "required": true },
751                            "scriptLength": { "type": "Long", "required": true },
752                            "accessesSensitivePath": { "type": "Boolean", "required": true },
753                            "hasUnboundedLoop": { "type": "Boolean", "required": true },
754                            "hasDynamicPath": { "type": "Boolean", "required": true },
755                            "outputFields": { "type": "Set", "element": { "type": "String" } },
756                            "hasOutputDeclaration": { "type": "Boolean", "required": true },
757                            "hasSpreadInOutput": { "type": "Boolean", "required": true }
758                        }
759                    }
760                },
761                "Server": {
762                    "shape": {
763                        "type": "Record",
764                        "attributes": {
765                            "serverId": { "type": "String", "required": true },
766                            "serverType": { "type": "String", "required": true },
767                            "writeMode": { "type": "String", "required": true },
768                            "maxDepth": { "type": "Long", "required": true },
769                            "maxCost": { "type": "Long", "required": true },
770                            "maxApiCalls": { "type": "Long", "required": true },
771                            "allowWrite": { "type": "Boolean", "required": true },
772                            "allowDelete": { "type": "Boolean", "required": true },
773                            "allowAdmin": { "type": "Boolean", "required": true },
774                            "blockedOperations": { "type": "Set", "element": { "type": "String" } },
775                            "allowedOperations": { "type": "Set", "element": { "type": "String" } },
776                            "blockedFields": { "type": "Set", "element": { "type": "String" } },
777                            "maxLoopIterations": { "type": "Long", "required": true },
778                            "maxScriptLength": { "type": "Long", "required": true },
779                            "maxNestingDepth": { "type": "Long", "required": true },
780                            "executionTimeoutSeconds": { "type": "Long", "required": true },
781                            "allowedMethods": { "type": "Set", "element": { "type": "String" } },
782                            "blockedMethods": { "type": "Set", "element": { "type": "String" } },
783                            "allowedPathPatterns": { "type": "Set", "element": { "type": "String" } },
784                            "blockedPathPatterns": { "type": "Set", "element": { "type": "String" } },
785                            "sensitivePathPatterns": { "type": "Set", "element": { "type": "String" } },
786                            "autoApproveReadOnly": { "type": "Boolean", "required": true },
787                            "maxApiCallsForAutoApprove": { "type": "Long", "required": true },
788                            "internalBlockedFields": { "type": "Set", "element": { "type": "String" } },
789                            "outputBlockedFields": { "type": "Set", "element": { "type": "String" } },
790                            "requireOutputDeclaration": { "type": "Boolean", "required": true }
791                        }
792                    }
793                }
794            },
795            "actions": {
796                "Read": { "appliesTo": applies_to },
797                "Write": { "appliesTo": applies_to },
798                "Delete": { "appliesTo": applies_to },
799                "Admin": { "appliesTo": applies_to }
800            }
801        }
802    })
803}
804
805/// Get baseline Cedar policies for OpenAPI Code Mode.
806#[cfg(feature = "openapi-code-mode")]
807pub fn get_openapi_baseline_policies() -> Vec<(&'static str, &'static str, &'static str)> {
808    vec![
809        (
810            "permit_reads",
811            "Permit all read operations (GET scripts)",
812            r#"permit(principal, action == CodeMode::Action::"Read", resource);"#,
813        ),
814        (
815            "permit_writes",
816            "Permit write operations (when enabled)",
817            r#"permit(principal, action == CodeMode::Action::"Write", resource) when { resource.allowWrite == true };"#,
818        ),
819        (
820            "permit_deletes",
821            "Permit delete operations (when enabled)",
822            r#"permit(principal, action == CodeMode::Action::"Delete", resource) when { resource.allowDelete == true };"#,
823        ),
824        (
825            "forbid_sensitive_paths",
826            "Block scripts accessing sensitive paths",
827            r#"forbid(principal, action, resource) when { principal.accessesSensitivePath == true };"#,
828        ),
829        (
830            "forbid_unbounded_loops",
831            "Block scripts with unbounded loops",
832            r#"forbid(principal, action, resource) when { principal.hasUnboundedLoop == true };"#,
833        ),
834        (
835            "forbid_excessive_api_calls",
836            "Enforce API call limit",
837            r#"forbid(principal, action, resource) when { principal.totalApiCalls > resource.maxApiCalls };"#,
838        ),
839        (
840            "forbid_excessive_nesting",
841            "Enforce nesting depth limit",
842            r#"forbid(principal, action, resource) when { principal.nestingDepth > resource.maxNestingDepth };"#,
843        ),
844        (
845            "forbid_output_blocked_fields",
846            "Block scripts that return output-blocked fields",
847            r#"forbid(principal, action, resource) when { principal.outputFields.containsAny(resource.outputBlockedFields) };"#,
848        ),
849        (
850            "forbid_spread_without_declaration",
851            "Block scripts with spread in output when output declaration is required",
852            r#"forbid(principal, action, resource) when { principal.hasSpreadInOutput == true && resource.requireOutputDeclaration == true };"#,
853        ),
854        (
855            "forbid_missing_output_declaration",
856            "Block scripts without output declaration when required",
857            r#"forbid(principal, action, resource) when { principal.hasOutputDeclaration == false && resource.requireOutputDeclaration == true };"#,
858        ),
859    ]
860}
861
862/// Get the baseline Cedar policies.
863pub fn get_baseline_policies() -> Vec<(&'static str, &'static str, &'static str)> {
864    vec![
865        (
866            "permit_reads",
867            "Permit all read operations (queries)",
868            r#"permit(principal, action == CodeMode::Action::"Read", resource);"#,
869        ),
870        (
871            "permit_writes",
872            "Permit write operations (when enabled)",
873            r#"permit(principal, action == CodeMode::Action::"Write", resource) when { resource.allowWrite == true };"#,
874        ),
875        (
876            "permit_deletes",
877            "Permit delete operations (when enabled)",
878            r#"permit(principal, action == CodeMode::Action::"Delete", resource) when { resource.allowDelete == true };"#,
879        ),
880        (
881            "permit_admin",
882            "Permit admin operations (when enabled)",
883            r#"permit(principal, action == CodeMode::Action::"Admin", resource) when { resource.allowAdmin == true };"#,
884        ),
885        (
886            "forbid_blocked_operations",
887            "Block operations in blocklist",
888            r#"forbid(principal, action, resource) when { resource.blockedOperations.contains(principal.operationName) };"#,
889        ),
890        (
891            "forbid_blocked_fields",
892            "Block access to blocked fields",
893            r#"forbid(principal, action, resource) when { resource.blockedFields.containsAny(principal.accessedFields) };"#,
894        ),
895        (
896            "forbid_excessive_depth",
897            "Enforce maximum query depth",
898            r#"forbid(principal, action, resource) when { principal.depth > resource.maxDepth };"#,
899        ),
900        (
901            "forbid_excessive_cost",
902            "Enforce maximum query cost",
903            r#"forbid(principal, action, resource) when { principal.estimatedCost > resource.maxCost };"#,
904        ),
905    ]
906}
907
908/// Get the Cedar schema for SQL Code Mode in JSON format.
909///
910/// Matches `SQL_CEDAR_SCHEMA` in `cedar_validation.rs`. A schema-sync test
911/// in `cedar_validation.rs` enforces this stays aligned.
912#[cfg(feature = "sql-code-mode")]
913pub fn get_sql_code_mode_schema_json() -> serde_json::Value {
914    let applies_to = serde_json::json!({
915        "principalTypes": ["Statement"],
916        "resourceTypes": ["Server"],
917        "context": {
918            "type": "Record",
919            "attributes": {
920                "serverId": { "type": "String", "required": true },
921                "serverType": { "type": "String", "required": true },
922                "userId": { "type": "String", "required": false },
923                "sessionId": { "type": "String", "required": false }
924            }
925        }
926    });
927
928    serde_json::json!({
929        "CodeMode": {
930            "entityTypes": {
931                "Statement": {
932                    "shape": {
933                        "type": "Record",
934                        "attributes": {
935                            "statementType": { "type": "String", "required": true },
936                            "tables": { "type": "Set", "element": { "type": "String" } },
937                            "columns": { "type": "Set", "element": { "type": "String" } },
938                            "hasWhere": { "type": "Boolean", "required": true },
939                            "hasLimit": { "type": "Boolean", "required": true },
940                            "hasOrderBy": { "type": "Boolean", "required": true },
941                            "estimatedRows": { "type": "Long", "required": true },
942                            "joinCount": { "type": "Long", "required": true },
943                            "subqueryCount": { "type": "Long", "required": true }
944                        }
945                    }
946                },
947                "Server": {
948                    "shape": {
949                        "type": "Record",
950                        "attributes": {
951                            "serverId": { "type": "String", "required": true },
952                            "serverType": { "type": "String", "required": true },
953                            "maxRows": { "type": "Long", "required": true },
954                            "maxJoins": { "type": "Long", "required": true },
955                            "allowWrite": { "type": "Boolean", "required": true },
956                            "allowDelete": { "type": "Boolean", "required": true },
957                            "allowAdmin": { "type": "Boolean", "required": true },
958                            "blockedOperations": { "type": "Set", "element": { "type": "String" } },
959                            "allowedOperations": { "type": "Set", "element": { "type": "String" } },
960                            "blockedTables": { "type": "Set", "element": { "type": "String" } },
961                            "blockedColumns": { "type": "Set", "element": { "type": "String" } }
962                        }
963                    }
964                }
965            },
966            "actions": {
967                "Read": { "appliesTo": applies_to },
968                "Write": { "appliesTo": applies_to },
969                "Delete": { "appliesTo": applies_to },
970                "Admin": { "appliesTo": applies_to }
971            }
972        }
973    })
974}
975
976/// Get baseline Cedar policies for SQL Code Mode.
977#[cfg(feature = "sql-code-mode")]
978pub fn get_sql_baseline_policies() -> Vec<(&'static str, &'static str, &'static str)> {
979    vec![
980        (
981            "permit_reads",
982            "Permit all SELECT statements",
983            r#"permit(principal, action == CodeMode::Action::"Read", resource);"#,
984        ),
985        (
986            "permit_writes",
987            "Permit INSERT/UPDATE when enabled",
988            r#"permit(principal, action == CodeMode::Action::"Write", resource) when { resource.allowWrite == true };"#,
989        ),
990        (
991            "permit_deletes",
992            "Permit DELETE when enabled",
993            r#"permit(principal, action == CodeMode::Action::"Delete", resource) when { resource.allowDelete == true };"#,
994        ),
995        (
996            "permit_admin",
997            "Permit DDL when enabled",
998            r#"permit(principal, action == CodeMode::Action::"Admin", resource) when { resource.allowAdmin == true };"#,
999        ),
1000        (
1001            "forbid_blocked_tables",
1002            "Block statements touching blocked tables",
1003            r#"forbid(principal, action, resource) when { principal.tables.containsAny(resource.blockedTables) };"#,
1004        ),
1005        (
1006            "forbid_blocked_columns",
1007            "Block statements touching blocked columns",
1008            r#"forbid(principal, action, resource) when { principal.columns.containsAny(resource.blockedColumns) };"#,
1009        ),
1010        (
1011            "forbid_excessive_rows",
1012            "Enforce row-count limit",
1013            r#"forbid(principal, action, resource) when { principal.estimatedRows > resource.maxRows };"#,
1014        ),
1015        (
1016            "forbid_excessive_joins",
1017            "Enforce JOIN-count limit",
1018            r#"forbid(principal, action, resource) when { principal.joinCount > resource.maxJoins };"#,
1019        ),
1020    ]
1021}
1022
1023#[cfg(all(test, feature = "openapi-code-mode"))]
1024mod tests {
1025    use super::*;
1026    use crate::config::{OperationEntry, OperationRegistry};
1027    use crate::javascript::{ApiCall, HttpMethod, JavaScriptCodeInfo};
1028
1029    fn make_api_call(method: HttpMethod, path: &str) -> ApiCall {
1030        ApiCall {
1031            method,
1032            path: path.to_string(),
1033            is_dynamic_path: false,
1034            line: 1,
1035            column: 0,
1036        }
1037    }
1038
1039    fn make_info(calls: Vec<ApiCall>) -> JavaScriptCodeInfo {
1040        JavaScriptCodeInfo {
1041            api_calls: calls,
1042            ..Default::default()
1043        }
1044    }
1045
1046    fn make_registry(entries: &[(&str, &str, &str)]) -> OperationRegistry {
1047        let entries: Vec<OperationEntry> = entries
1048            .iter()
1049            .map(|(id, category, path)| OperationEntry {
1050                id: id.to_string(),
1051                category: category.to_string(),
1052                description: String::new(),
1053                path: Some(path.to_string()),
1054            })
1055            .collect();
1056        OperationRegistry::from_entries(&entries)
1057    }
1058
1059    #[test]
1060    fn test_category_read_overrides_post_method() {
1061        let registry = make_registry(&[("getCostAnomalies", "read", "/getCostAnomalies")]);
1062        let info = make_info(vec![make_api_call(HttpMethod::Post, "/getCostAnomalies")]);
1063
1064        let entity = ScriptEntity::from_javascript_info(&info, &[], Some(&registry));
1065
1066        assert_eq!(entity.read_calls, 1);
1067        assert_eq!(entity.write_calls, 0);
1068        assert_eq!(entity.script_type, "read_only");
1069        assert_eq!(entity.action(), "Read");
1070    }
1071
1072    #[test]
1073    fn test_category_write_overrides_get_method() {
1074        let registry = make_registry(&[("triggerExport", "write", "/triggerExport")]);
1075        let info = make_info(vec![make_api_call(HttpMethod::Get, "/triggerExport")]);
1076
1077        let entity = ScriptEntity::from_javascript_info(&info, &[], Some(&registry));
1078
1079        assert_eq!(entity.write_calls, 1);
1080        assert_eq!(entity.read_calls, 0);
1081        assert_eq!(entity.script_type, "write_only");
1082        assert_eq!(entity.action(), "Write");
1083    }
1084
1085    #[test]
1086    fn test_category_delete_routes_correctly() {
1087        let registry = make_registry(&[("deleteReservation", "delete", "/deleteReservation")]);
1088        let info = make_info(vec![make_api_call(HttpMethod::Post, "/deleteReservation")]);
1089
1090        let entity = ScriptEntity::from_javascript_info(&info, &[], Some(&registry));
1091
1092        assert_eq!(entity.delete_calls, 1);
1093        assert!(entity.has_deletes);
1094        assert_eq!(entity.action(), "Delete");
1095    }
1096
1097    #[test]
1098    fn test_no_registry_falls_back_to_http_method() {
1099        let info = make_info(vec![
1100            make_api_call(HttpMethod::Get, "/getCostAnomalies"),
1101            make_api_call(HttpMethod::Post, "/updateBudget"),
1102        ]);
1103
1104        let entity = ScriptEntity::from_javascript_info(&info, &[], None);
1105
1106        assert_eq!(entity.read_calls, 1);
1107        assert_eq!(entity.write_calls, 1);
1108        assert_eq!(entity.script_type, "mixed");
1109        assert_eq!(entity.action(), "Write");
1110    }
1111
1112    #[test]
1113    fn test_unregistered_path_falls_back_to_http_method() {
1114        let registry = make_registry(&[("getCostAnomalies", "read", "/getCostAnomalies")]);
1115        let info = make_info(vec![make_api_call(HttpMethod::Post, "/unknownEndpoint")]);
1116
1117        let entity = ScriptEntity::from_javascript_info(&info, &[], Some(&registry));
1118
1119        // POST with no category → write (HTTP method fallback)
1120        assert_eq!(entity.write_calls, 1);
1121        assert_eq!(entity.read_calls, 0);
1122        assert_eq!(entity.script_type, "write_only");
1123    }
1124
1125    #[test]
1126    fn test_mixed_categories_produce_mixed_script() {
1127        let registry = make_registry(&[
1128            ("getCostAnomalies", "read", "/getCostAnomalies"),
1129            ("updateBudget", "write", "/updateBudget"),
1130        ]);
1131        let info = make_info(vec![
1132            make_api_call(HttpMethod::Post, "/getCostAnomalies"),
1133            make_api_call(HttpMethod::Post, "/updateBudget"),
1134        ]);
1135
1136        let entity = ScriptEntity::from_javascript_info(&info, &[], Some(&registry));
1137
1138        assert_eq!(entity.read_calls, 1);
1139        assert_eq!(entity.write_calls, 1);
1140        assert_eq!(entity.script_type, "mixed");
1141        assert_eq!(entity.action(), "Write");
1142    }
1143
1144    #[test]
1145    fn test_empty_category_falls_back_to_http_method() {
1146        // category = "" (from #[serde(default)]) → no category → HTTP method fallback
1147        let registry = make_registry(&[("legacyOp", "", "/legacyOp")]);
1148        let info = make_info(vec![make_api_call(HttpMethod::Post, "/legacyOp")]);
1149
1150        let entity = ScriptEntity::from_javascript_info(&info, &[], Some(&registry));
1151
1152        // POST with empty category → write (HTTP method fallback)
1153        assert_eq!(entity.write_calls, 1);
1154        assert_eq!(entity.script_type, "write_only");
1155    }
1156}