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 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 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 if api_call.is_dynamic_path {
287 has_dynamic_path = true;
288 }
289
290 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 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 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#[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#[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#[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#[cfg(feature = "openapi-code-mode")]
430#[derive(Debug, Clone)]
431pub struct OpenAPIServerEntity {
432 pub server_id: String,
433 pub server_type: String,
434
435 pub allow_write: bool,
437 pub allow_delete: bool,
438 pub allow_admin: bool,
439
440 pub write_mode: String,
442
443 pub max_depth: u32,
445 pub max_cost: u32,
446 pub max_api_calls: u32,
447
448 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 pub allowed_operations: HashSet<String>,
456 pub blocked_operations: HashSet<String>,
457
458 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 pub auto_approve_read_only: bool,
467 pub max_api_calls_for_auto_approve: u32,
468
469 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#[cfg(feature = "sql-code-mode")]
515#[derive(Debug, Clone)]
516pub struct StatementEntity {
517 pub id: String,
519
520 pub statement_type: String,
522
523 pub tables: HashSet<String>,
525
526 pub columns: HashSet<String>,
528
529 pub has_where: bool,
531
532 pub has_limit: bool,
534
535 pub has_order_by: bool,
537
538 pub estimated_rows: u64,
540
541 pub join_count: u32,
543
544 pub subquery_count: u32,
546}
547
548#[cfg(feature = "sql-code-mode")]
549impl StatementEntity {
550 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 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#[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#[cfg(feature = "sql-code-mode")]
599#[derive(Debug, Clone)]
600pub struct SqlServerEntity {
601 pub server_id: String,
602 pub server_type: String,
603
604 pub allow_write: bool,
606 pub allow_delete: bool,
607 pub allow_admin: bool,
608
609 pub max_rows: u64,
611 pub max_joins: u32,
612
613 pub allowed_operations: HashSet<String>,
615 pub blocked_operations: HashSet<String>,
616
617 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
643pub 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#[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#[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
860pub 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#[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#[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(®istry));
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(®istry));
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(®istry));
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(®istry));
1116
1117 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(®istry));
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 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(®istry));
1149
1150 assert_eq!(entity.write_calls, 1);
1152 assert_eq!(entity.script_type, "write_only");
1153 }
1154}