1#[cfg(feature = "openapi-code-mode")]
7use crate::config::OperationRegistry;
8use crate::graphql::GraphQLQueryInfo;
9use std::collections::HashSet;
10
11#[derive(Debug, Clone)]
17pub struct ServerConfigEntity {
18 pub server_id: String,
20
21 pub server_type: String,
23
24 pub allow_write: bool,
26
27 pub allow_delete: bool,
29
30 pub allow_admin: bool,
32
33 pub allowed_operations: HashSet<String>,
35
36 pub blocked_operations: HashSet<String>,
38
39 pub max_depth: u32,
41
42 pub max_field_count: u32,
44
45 pub max_cost: u32,
47
48 pub max_api_calls: u32,
50
51 pub blocked_fields: HashSet<String>,
53
54 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#[derive(Debug, Clone)]
80pub struct OperationEntity {
81 pub id: String,
83
84 pub operation_type: String,
86
87 pub operation_name: String,
89
90 pub root_fields: HashSet<String>,
92
93 pub accessed_types: HashSet<String>,
95
96 pub accessed_fields: HashSet<String>,
98
99 pub depth: u32,
101
102 pub field_count: u32,
104
105 pub estimated_cost: u32,
107
108 pub has_introspection: bool,
110
111 pub accesses_sensitive_data: bool,
113
114 pub sensitive_categories: HashSet<String>,
116}
117
118impl OperationEntity {
119 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#[derive(Debug, Clone)]
151pub struct AuthorizationDecision {
152 pub allowed: bool,
154
155 pub determining_policies: Vec<String>,
157
158 pub errors: Vec<String>,
160}
161
162#[cfg(feature = "openapi-code-mode")]
167#[derive(Debug, Clone)]
168pub struct ScriptEntity {
169 pub id: String,
171
172 pub script_type: String,
174
175 pub has_writes: bool,
177
178 pub has_deletes: bool,
180
181 pub total_api_calls: u32,
183
184 pub read_calls: u32,
186
187 pub write_calls: u32,
189
190 pub delete_calls: u32,
192
193 pub accessed_paths: HashSet<String>,
195
196 pub accessed_methods: HashSet<String>,
198
199 pub path_patterns: HashSet<String>,
201
202 pub called_operations: HashSet<String>,
204
205 pub loop_iterations: u32,
207
208 pub nesting_depth: u32,
210
211 pub script_length: u32,
213
214 pub accesses_sensitive_path: bool,
216
217 pub has_unbounded_loop: bool,
219
220 pub has_dynamic_path: bool,
222
223 pub has_output_declaration: bool,
225
226 pub output_fields: HashSet<String>,
228
229 pub has_spread_in_output: bool,
231}
232
233#[cfg(feature = "openapi-code-mode")]
234impl ScriptEntity {
235 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 let pattern = normalize_path_to_pattern(&api_call.path);
260 path_patterns.insert(pattern.clone());
261
262 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 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 if api_call.is_dynamic_path {
289 has_dynamic_path = true;
290 }
291
292 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 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 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#[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#[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#[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#[cfg(feature = "openapi-code-mode")]
432#[derive(Debug, Clone)]
433pub struct OpenAPIServerEntity {
434 pub server_id: String,
435 pub server_type: String,
436
437 pub allow_write: bool,
439 pub allow_delete: bool,
440 pub allow_admin: bool,
441
442 pub write_mode: String,
444
445 pub max_depth: u32,
447 pub max_cost: u32,
448 pub max_api_calls: u32,
449
450 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 pub allowed_operations: HashSet<String>,
458 pub blocked_operations: HashSet<String>,
459
460 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 pub auto_approve_read_only: bool,
469 pub max_api_calls_for_auto_approve: u32,
470
471 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#[cfg(feature = "sql-code-mode")]
517#[derive(Debug, Clone)]
518pub struct StatementEntity {
519 pub id: String,
521
522 pub statement_type: String,
524
525 pub tables: HashSet<String>,
527
528 pub columns: HashSet<String>,
530
531 pub has_where: bool,
533
534 pub has_limit: bool,
536
537 pub has_order_by: bool,
539
540 pub estimated_rows: u64,
542
543 pub join_count: u32,
545
546 pub subquery_count: u32,
548}
549
550#[cfg(feature = "sql-code-mode")]
551impl StatementEntity {
552 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 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#[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#[cfg(feature = "sql-code-mode")]
601#[derive(Debug, Clone)]
602pub struct SqlServerEntity {
603 pub server_id: String,
604 pub server_type: String,
605
606 pub allow_write: bool,
608 pub allow_delete: bool,
609 pub allow_admin: bool,
610
611 pub max_rows: u64,
613 pub max_joins: u32,
614
615 pub allowed_operations: HashSet<String>,
617 pub blocked_operations: HashSet<String>,
618
619 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
645pub 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#[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#[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
862pub 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#[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#[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(®istry));
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(®istry));
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(®istry));
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(®istry));
1118
1119 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(®istry));
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 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(®istry));
1151
1152 assert_eq!(entity.write_calls, 1);
1154 assert_eq!(entity.script_type, "write_only");
1155 }
1156}