1use crate::graphql::GraphQLQueryInfo;
7use std::collections::HashSet;
8
9#[derive(Debug, Clone)]
15pub struct ServerConfigEntity {
16 pub server_id: String,
18
19 pub server_type: String,
21
22 pub allow_write: bool,
24
25 pub allow_delete: bool,
27
28 pub allow_admin: bool,
30
31 pub allowed_operations: HashSet<String>,
33
34 pub blocked_operations: HashSet<String>,
36
37 pub max_depth: u32,
39
40 pub max_field_count: u32,
42
43 pub max_cost: u32,
45
46 pub max_api_calls: u32,
48
49 pub blocked_fields: HashSet<String>,
51
52 pub allowed_sensitive_categories: HashSet<String>,
54}
55
56impl Default for ServerConfigEntity {
57 fn default() -> Self {
58 Self {
59 server_id: "unknown".to_string(),
60 server_type: "graphql".to_string(),
61 allow_write: false,
62 allow_delete: false,
63 allow_admin: false,
64 allowed_operations: HashSet::new(),
65 blocked_operations: HashSet::new(),
66 max_depth: 10,
67 max_field_count: 100,
68 max_cost: 1000,
69 max_api_calls: 50,
70 blocked_fields: HashSet::new(),
71 allowed_sensitive_categories: HashSet::new(),
72 }
73 }
74}
75
76#[derive(Debug, Clone)]
78pub struct OperationEntity {
79 pub id: String,
81
82 pub operation_type: String,
84
85 pub operation_name: String,
87
88 pub root_fields: HashSet<String>,
90
91 pub accessed_types: HashSet<String>,
93
94 pub accessed_fields: HashSet<String>,
96
97 pub depth: u32,
99
100 pub field_count: u32,
102
103 pub estimated_cost: u32,
105
106 pub has_introspection: bool,
108
109 pub accesses_sensitive_data: bool,
111
112 pub sensitive_categories: HashSet<String>,
114}
115
116impl OperationEntity {
117 pub fn from_query_info(query_info: &GraphQLQueryInfo) -> Self {
119 use crate::graphql::GraphQLOperationType;
120
121 let operation_type = match query_info.operation_type {
122 GraphQLOperationType::Query => "query",
123 GraphQLOperationType::Mutation => "mutation",
124 GraphQLOperationType::Subscription => "subscription",
125 };
126
127 Self {
128 id: query_info
129 .operation_name
130 .clone()
131 .unwrap_or_else(|| "anonymous".to_string()),
132 operation_type: operation_type.to_string(),
133 operation_name: query_info.operation_name.clone().unwrap_or_default(),
134 root_fields: query_info.root_fields.iter().cloned().collect(),
135 accessed_types: query_info.types_accessed.iter().cloned().collect(),
136 accessed_fields: query_info.fields_accessed.iter().cloned().collect(),
137 depth: query_info.max_depth as u32,
138 field_count: query_info.fields_accessed.len() as u32,
139 estimated_cost: query_info.fields_accessed.len() as u32,
140 has_introspection: query_info.has_introspection,
141 accesses_sensitive_data: false,
142 sensitive_categories: HashSet::new(),
143 }
144 }
145}
146
147#[derive(Debug, Clone)]
149pub struct AuthorizationDecision {
150 pub allowed: bool,
152
153 pub determining_policies: Vec<String>,
155
156 pub errors: Vec<String>,
158}
159
160#[cfg(feature = "openapi-code-mode")]
165#[derive(Debug, Clone)]
166pub struct ScriptEntity {
167 pub id: String,
169
170 pub script_type: String,
172
173 pub has_writes: bool,
175
176 pub has_deletes: bool,
178
179 pub total_api_calls: u32,
181
182 pub read_calls: u32,
184
185 pub write_calls: u32,
187
188 pub delete_calls: u32,
190
191 pub accessed_paths: HashSet<String>,
193
194 pub accessed_methods: HashSet<String>,
196
197 pub path_patterns: HashSet<String>,
199
200 pub called_operations: HashSet<String>,
202
203 pub loop_iterations: u32,
205
206 pub nesting_depth: u32,
208
209 pub script_length: u32,
211
212 pub accesses_sensitive_path: bool,
214
215 pub has_unbounded_loop: bool,
217
218 pub has_dynamic_path: bool,
220
221 pub has_output_declaration: bool,
223
224 pub output_fields: HashSet<String>,
226
227 pub has_spread_in_output: bool,
229}
230
231#[cfg(feature = "openapi-code-mode")]
232impl ScriptEntity {
233 pub fn from_javascript_info(
235 info: &crate::javascript::JavaScriptCodeInfo,
236 sensitive_patterns: &[String],
237 ) -> Self {
238 use crate::javascript::HttpMethod;
239
240 let mut accessed_paths = HashSet::new();
241 let mut accessed_methods = HashSet::new();
242 let mut path_patterns = HashSet::new();
243 let mut called_operations = HashSet::new();
244 let mut read_calls = 0u32;
245 let mut write_calls = 0u32;
246 let mut delete_calls = 0u32;
247 let mut has_dynamic_path = false;
248 let mut accesses_sensitive_path = false;
249
250 for api_call in &info.api_calls {
251 accessed_paths.insert(api_call.path.clone());
252 let method_str = format!("{:?}", api_call.method).to_uppercase();
253 accessed_methods.insert(method_str.clone());
254
255 let pattern = normalize_path_to_pattern(&api_call.path);
257 path_patterns.insert(pattern.clone());
258
259 called_operations.insert(format!("{}:{}", method_str, pattern));
261
262 match api_call.method {
264 HttpMethod::Get | HttpMethod::Head | HttpMethod::Options => read_calls += 1,
265 HttpMethod::Delete => delete_calls += 1,
266 _ => write_calls += 1,
267 }
268
269 if api_call.is_dynamic_path {
271 has_dynamic_path = true;
272 }
273
274 let path_lower = api_call.path.to_lowercase();
276 for pattern in sensitive_patterns {
277 if path_lower.contains(&pattern.to_lowercase()) {
278 accesses_sensitive_path = true;
279 break;
280 }
281 }
282 }
283
284 let has_writes = write_calls > 0 || delete_calls > 0;
286 let has_reads = read_calls > 0;
287 let script_type = match (has_reads, has_writes) {
288 (true, false) => "read_only",
289 (false, true) => "write_only",
290 (true, true) => "mixed",
291 (false, false) => "empty",
292 };
293
294 Self {
295 id: info
296 .api_calls
297 .first()
298 .map(|c| format!("{}:{}", format!("{:?}", c.method).to_uppercase(), c.path))
299 .unwrap_or_else(|| "script".to_string()),
300 script_type: script_type.to_string(),
301 has_writes,
302 has_deletes: delete_calls > 0,
303 total_api_calls: info.api_calls.len() as u32,
304 read_calls,
305 write_calls,
306 delete_calls,
307 accessed_paths,
308 accessed_methods,
309 path_patterns,
310 called_operations,
311 loop_iterations: 0,
312 nesting_depth: info.max_depth as u32,
313 script_length: 0,
314 accesses_sensitive_path,
315 has_unbounded_loop: !info.all_loops_bounded && info.loop_count > 0,
316 has_dynamic_path,
317 has_output_declaration: info.output_declaration.has_declaration,
318 output_fields: info.output_declaration.declared_fields.clone(),
319 has_spread_in_output: info.output_declaration.has_spread_risk
320 || info.has_output_spread_risk,
321 }
322 }
323
324 pub fn action(&self) -> &'static str {
326 match self.script_type.as_str() {
327 "read_only" | "empty" => "Read",
328 "write_only" | "mixed" => {
329 if self.has_deletes {
330 "Delete"
331 } else {
332 "Write"
333 }
334 }
335 _ => "Read",
336 }
337 }
338}
339
340#[cfg(feature = "openapi-code-mode")]
342pub fn normalize_path_to_pattern(path: &str) -> String {
343 path.split('/')
344 .map(|segment| {
345 if segment.chars().all(|c| c.is_ascii_digit())
346 || (segment.len() == 36 && segment.contains('-'))
347 {
348 "*"
349 } else {
350 segment
351 }
352 })
353 .collect::<Vec<_>>()
354 .join("/")
355}
356
357#[cfg(feature = "openapi-code-mode")]
359pub fn normalize_operation_format(op: &str) -> String {
360 let trimmed = op.trim();
361
362 let (method, path) = if let Some(idx) = trimmed.find(':') {
363 let potential_path = trimmed[idx + 1..].trim();
364 if potential_path.starts_with('/') {
365 let method = trimmed[..idx].trim();
366 (method, potential_path)
367 } else {
368 return trimmed.to_string();
369 }
370 } else if let Some(idx) = trimmed.find(' ') {
371 let method = trimmed[..idx].trim();
372 let path = trimmed[idx + 1..].trim();
373 (method, path)
374 } else {
375 return trimmed.to_string();
376 };
377
378 let method_upper = method.to_uppercase();
379
380 let normalized_path = path
381 .split('/')
382 .map(|segment| {
383 if segment.starts_with('{') && segment.ends_with('}') {
384 "*"
385 } else if segment.starts_with(':') {
386 "*"
387 } else if segment.chars().all(|c| c.is_ascii_digit()) {
388 "*"
389 } else if segment.len() == 36 && segment.contains('-') {
390 "*"
391 } else {
392 segment
393 }
394 })
395 .collect::<Vec<_>>()
396 .join("/");
397
398 format!("{}:{}", method_upper, normalized_path)
399}
400
401#[cfg(feature = "openapi-code-mode")]
403#[derive(Debug, Clone)]
404pub struct OpenAPIServerEntity {
405 pub server_id: String,
406 pub server_type: String,
407
408 pub allow_write: bool,
410 pub allow_delete: bool,
411 pub allow_admin: bool,
412
413 pub write_mode: String,
415
416 pub max_depth: u32,
418 pub max_cost: u32,
419 pub max_api_calls: u32,
420
421 pub max_loop_iterations: u32,
423 pub max_script_length: u32,
424 pub max_nesting_depth: u32,
425 pub execution_timeout_seconds: u32,
426
427 pub allowed_operations: HashSet<String>,
429 pub blocked_operations: HashSet<String>,
430
431 pub allowed_methods: HashSet<String>,
433 pub blocked_methods: HashSet<String>,
434 pub allowed_path_patterns: HashSet<String>,
435 pub blocked_path_patterns: HashSet<String>,
436 pub sensitive_path_patterns: HashSet<String>,
437
438 pub auto_approve_read_only: bool,
440 pub max_api_calls_for_auto_approve: u32,
441
442 pub internal_blocked_fields: HashSet<String>,
444 pub output_blocked_fields: HashSet<String>,
445 pub require_output_declaration: bool,
446}
447
448#[cfg(feature = "openapi-code-mode")]
449impl Default for OpenAPIServerEntity {
450 fn default() -> Self {
451 Self {
452 server_id: "unknown".to_string(),
453 server_type: "openapi".to_string(),
454 allow_write: false,
455 allow_delete: false,
456 allow_admin: false,
457 write_mode: "deny_all".to_string(),
458 max_depth: 10,
459 max_cost: 1000,
460 max_api_calls: 50,
461 max_loop_iterations: 100,
462 max_script_length: 10000,
463 max_nesting_depth: 10,
464 execution_timeout_seconds: 30,
465 allowed_operations: HashSet::new(),
466 blocked_operations: HashSet::new(),
467 allowed_methods: HashSet::new(),
468 blocked_methods: HashSet::new(),
469 allowed_path_patterns: HashSet::new(),
470 blocked_path_patterns: ["/admin".into(), "/internal".into()].into_iter().collect(),
471 sensitive_path_patterns: ["/admin".into(), "/internal".into(), "/debug".into()]
472 .into_iter()
473 .collect(),
474 auto_approve_read_only: true,
475 max_api_calls_for_auto_approve: 10,
476 internal_blocked_fields: HashSet::new(),
477 output_blocked_fields: HashSet::new(),
478 require_output_declaration: false,
479 }
480 }
481}
482
483pub fn get_code_mode_schema_json() -> serde_json::Value {
487 let applies_to = serde_json::json!({
488 "principalTypes": ["Operation"],
489 "resourceTypes": ["Server"],
490 "context": {
491 "type": "Record",
492 "attributes": {
493 "serverId": { "type": "String", "required": true },
494 "serverType": { "type": "String", "required": true },
495 "userId": { "type": "String", "required": false },
496 "sessionId": { "type": "String", "required": false }
497 }
498 }
499 });
500
501 serde_json::json!({
502 "CodeMode": {
503 "entityTypes": {
504 "Operation": {
505 "shape": {
506 "type": "Record",
507 "attributes": {
508 "operationType": { "type": "String", "required": true },
509 "operationName": { "type": "String", "required": true },
510 "rootFields": { "type": "Set", "element": { "type": "String" } },
511 "accessedTypes": { "type": "Set", "element": { "type": "String" } },
512 "accessedFields": { "type": "Set", "element": { "type": "String" } },
513 "depth": { "type": "Long", "required": true },
514 "fieldCount": { "type": "Long", "required": true },
515 "estimatedCost": { "type": "Long", "required": true },
516 "hasIntrospection": { "type": "Boolean", "required": true },
517 "accessesSensitiveData": { "type": "Boolean", "required": true },
518 "sensitiveCategories": { "type": "Set", "element": { "type": "String" } }
519 }
520 }
521 },
522 "Server": {
523 "shape": {
524 "type": "Record",
525 "attributes": {
526 "serverId": { "type": "String", "required": true },
527 "serverType": { "type": "String", "required": true },
528 "maxDepth": { "type": "Long", "required": true },
529 "maxCost": { "type": "Long", "required": true },
530 "maxApiCalls": { "type": "Long", "required": true },
531 "allowWrite": { "type": "Boolean", "required": true },
532 "allowDelete": { "type": "Boolean", "required": true },
533 "allowAdmin": { "type": "Boolean", "required": true },
534 "blockedOperations": { "type": "Set", "element": { "type": "String" } },
535 "allowedOperations": { "type": "Set", "element": { "type": "String" } },
536 "blockedFields": { "type": "Set", "element": { "type": "String" } }
537 }
538 }
539 }
540 },
541 "actions": {
542 "Read": { "appliesTo": applies_to },
543 "Write": { "appliesTo": applies_to },
544 "Delete": { "appliesTo": applies_to },
545 "Admin": { "appliesTo": applies_to }
546 }
547 }
548 })
549}
550
551#[cfg(feature = "openapi-code-mode")]
553pub fn get_openapi_code_mode_schema_json() -> serde_json::Value {
554 let applies_to = serde_json::json!({
555 "principalTypes": ["Script"],
556 "resourceTypes": ["Server"],
557 "context": {
558 "type": "Record",
559 "attributes": {
560 "serverId": { "type": "String", "required": true },
561 "serverType": { "type": "String", "required": true },
562 "userId": { "type": "String", "required": false },
563 "sessionId": { "type": "String", "required": false }
564 }
565 }
566 });
567
568 serde_json::json!({
569 "CodeMode": {
570 "entityTypes": {
571 "Script": {
572 "shape": {
573 "type": "Record",
574 "attributes": {
575 "scriptType": { "type": "String", "required": true },
576 "hasWrites": { "type": "Boolean", "required": true },
577 "hasDeletes": { "type": "Boolean", "required": true },
578 "totalApiCalls": { "type": "Long", "required": true },
579 "readCalls": { "type": "Long", "required": true },
580 "writeCalls": { "type": "Long", "required": true },
581 "deleteCalls": { "type": "Long", "required": true },
582 "accessedPaths": { "type": "Set", "element": { "type": "String" } },
583 "accessedMethods": { "type": "Set", "element": { "type": "String" } },
584 "pathPatterns": { "type": "Set", "element": { "type": "String" } },
585 "calledOperations": { "type": "Set", "element": { "type": "String" } },
586 "loopIterations": { "type": "Long", "required": true },
587 "nestingDepth": { "type": "Long", "required": true },
588 "scriptLength": { "type": "Long", "required": true },
589 "accessesSensitivePath": { "type": "Boolean", "required": true },
590 "hasUnboundedLoop": { "type": "Boolean", "required": true },
591 "hasDynamicPath": { "type": "Boolean", "required": true },
592 "outputFields": { "type": "Set", "element": { "type": "String" } },
593 "hasOutputDeclaration": { "type": "Boolean", "required": true },
594 "hasSpreadInOutput": { "type": "Boolean", "required": true }
595 }
596 }
597 },
598 "Server": {
599 "shape": {
600 "type": "Record",
601 "attributes": {
602 "serverId": { "type": "String", "required": true },
603 "serverType": { "type": "String", "required": true },
604 "maxDepth": { "type": "Long", "required": true },
605 "maxCost": { "type": "Long", "required": true },
606 "maxApiCalls": { "type": "Long", "required": true },
607 "allowWrite": { "type": "Boolean", "required": true },
608 "allowDelete": { "type": "Boolean", "required": true },
609 "allowAdmin": { "type": "Boolean", "required": true },
610 "blockedOperations": { "type": "Set", "element": { "type": "String" } },
611 "allowedOperations": { "type": "Set", "element": { "type": "String" } },
612 "blockedFields": { "type": "Set", "element": { "type": "String" } },
613 "maxLoopIterations": { "type": "Long", "required": true },
614 "maxScriptLength": { "type": "Long", "required": true },
615 "maxNestingDepth": { "type": "Long", "required": true },
616 "executionTimeoutSeconds": { "type": "Long", "required": true },
617 "allowedMethods": { "type": "Set", "element": { "type": "String" } },
618 "blockedMethods": { "type": "Set", "element": { "type": "String" } },
619 "allowedPathPatterns": { "type": "Set", "element": { "type": "String" } },
620 "blockedPathPatterns": { "type": "Set", "element": { "type": "String" } },
621 "sensitivePathPatterns": { "type": "Set", "element": { "type": "String" } },
622 "autoApproveReadOnly": { "type": "Boolean", "required": true },
623 "maxApiCallsForAutoApprove": { "type": "Long", "required": true },
624 "internalBlockedFields": { "type": "Set", "element": { "type": "String" } },
625 "outputBlockedFields": { "type": "Set", "element": { "type": "String" } },
626 "requireOutputDeclaration": { "type": "Boolean", "required": true }
627 }
628 }
629 }
630 },
631 "actions": {
632 "Read": { "appliesTo": applies_to },
633 "Write": { "appliesTo": applies_to },
634 "Delete": { "appliesTo": applies_to },
635 "Admin": { "appliesTo": applies_to }
636 }
637 }
638 })
639}
640
641#[cfg(feature = "openapi-code-mode")]
643pub fn get_openapi_baseline_policies() -> Vec<(&'static str, &'static str, &'static str)> {
644 vec![
645 (
646 "permit_reads",
647 "Permit all read operations (GET scripts)",
648 r#"permit(principal, action == CodeMode::Action::"Read", resource);"#,
649 ),
650 (
651 "permit_writes",
652 "Permit write operations (when enabled)",
653 r#"permit(principal, action == CodeMode::Action::"Write", resource) when { resource.allowWrite == true };"#,
654 ),
655 (
656 "permit_deletes",
657 "Permit delete operations (when enabled)",
658 r#"permit(principal, action == CodeMode::Action::"Delete", resource) when { resource.allowDelete == true };"#,
659 ),
660 (
661 "forbid_sensitive_paths",
662 "Block scripts accessing sensitive paths",
663 r#"forbid(principal, action, resource) when { principal.accessesSensitivePath == true };"#,
664 ),
665 (
666 "forbid_unbounded_loops",
667 "Block scripts with unbounded loops",
668 r#"forbid(principal, action, resource) when { principal.hasUnboundedLoop == true };"#,
669 ),
670 (
671 "forbid_excessive_api_calls",
672 "Enforce API call limit",
673 r#"forbid(principal, action, resource) when { principal.totalApiCalls > resource.maxApiCalls };"#,
674 ),
675 (
676 "forbid_excessive_nesting",
677 "Enforce nesting depth limit",
678 r#"forbid(principal, action, resource) when { principal.nestingDepth > resource.maxNestingDepth };"#,
679 ),
680 (
681 "forbid_output_blocked_fields",
682 "Block scripts that return output-blocked fields",
683 r#"forbid(principal, action, resource) when { principal.outputFields.containsAny(resource.outputBlockedFields) };"#,
684 ),
685 (
686 "forbid_spread_without_declaration",
687 "Block scripts with spread in output when output declaration is required",
688 r#"forbid(principal, action, resource) when { principal.hasSpreadInOutput == true && resource.requireOutputDeclaration == true };"#,
689 ),
690 (
691 "forbid_missing_output_declaration",
692 "Block scripts without output declaration when required",
693 r#"forbid(principal, action, resource) when { principal.hasOutputDeclaration == false && resource.requireOutputDeclaration == true };"#,
694 ),
695 ]
696}
697
698pub fn get_baseline_policies() -> Vec<(&'static str, &'static str, &'static str)> {
700 vec![
701 (
702 "permit_reads",
703 "Permit all read operations (queries)",
704 r#"permit(principal, action == CodeMode::Action::"Read", resource);"#,
705 ),
706 (
707 "permit_writes",
708 "Permit write operations (when enabled)",
709 r#"permit(principal, action == CodeMode::Action::"Write", resource) when { resource.allowWrite == true };"#,
710 ),
711 (
712 "permit_deletes",
713 "Permit delete operations (when enabled)",
714 r#"permit(principal, action == CodeMode::Action::"Delete", resource) when { resource.allowDelete == true };"#,
715 ),
716 (
717 "permit_admin",
718 "Permit admin operations (when enabled)",
719 r#"permit(principal, action == CodeMode::Action::"Admin", resource) when { resource.allowAdmin == true };"#,
720 ),
721 (
722 "forbid_blocked_operations",
723 "Block operations in blocklist",
724 r#"forbid(principal, action, resource) when { resource.blockedOperations.contains(principal.operationName) };"#,
725 ),
726 (
727 "forbid_blocked_fields",
728 "Block access to blocked fields",
729 r#"forbid(principal, action, resource) when { resource.blockedFields.containsAny(principal.accessedFields) };"#,
730 ),
731 (
732 "forbid_excessive_depth",
733 "Enforce maximum query depth",
734 r#"forbid(principal, action, resource) when { principal.depth > resource.maxDepth };"#,
735 ),
736 (
737 "forbid_excessive_cost",
738 "Enforce maximum query cost",
739 r#"forbid(principal, action, resource) when { principal.estimatedCost > resource.maxCost };"#,
740 ),
741 ]
742}