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")]
342fn is_uuid_like(segment: &str) -> bool {
343 if segment.len() != 36 {
344 return false;
345 }
346 let parts: Vec<&str> = segment.split('-').collect();
347 matches!(parts.as_slice(), [a, b, c, d, e]
348 if a.len() == 8 && b.len() == 4 && c.len() == 4
349 && d.len() == 4 && e.len() == 12
350 && segment.chars().all(|ch| ch.is_ascii_hexdigit() || ch == '-'))
351}
352
353#[cfg(feature = "openapi-code-mode")]
355pub fn normalize_path_to_pattern(path: &str) -> String {
356 path.split('/')
357 .map(|segment| {
358 if segment.chars().all(|c| c.is_ascii_digit()) || is_uuid_like(segment) {
359 "*"
360 } else {
361 segment
362 }
363 })
364 .collect::<Vec<_>>()
365 .join("/")
366}
367
368#[cfg(feature = "openapi-code-mode")]
370pub fn normalize_operation_format(op: &str) -> String {
371 let trimmed = op.trim();
372
373 let (method, path) = if let Some(idx) = trimmed.find(':') {
374 let potential_path = trimmed[idx + 1..].trim();
375 if potential_path.starts_with('/') {
376 let method = trimmed[..idx].trim();
377 (method, potential_path)
378 } else {
379 return trimmed.to_string();
380 }
381 } else if let Some(idx) = trimmed.find(' ') {
382 let method = trimmed[..idx].trim();
383 let path = trimmed[idx + 1..].trim();
384 (method, path)
385 } else {
386 return trimmed.to_string();
387 };
388
389 let method_upper = method.to_uppercase();
390
391 let normalized_path = path
392 .split('/')
393 .map(|segment| {
394 if segment.starts_with('{') && segment.ends_with('}') {
395 "*"
396 } else if segment.starts_with(':') {
397 "*"
398 } else if segment.chars().all(|c| c.is_ascii_digit()) {
399 "*"
400 } else if is_uuid_like(segment) {
401 "*"
402 } else {
403 segment
404 }
405 })
406 .collect::<Vec<_>>()
407 .join("/");
408
409 format!("{}:{}", method_upper, normalized_path)
410}
411
412#[cfg(feature = "openapi-code-mode")]
414#[derive(Debug, Clone)]
415pub struct OpenAPIServerEntity {
416 pub server_id: String,
417 pub server_type: String,
418
419 pub allow_write: bool,
421 pub allow_delete: bool,
422 pub allow_admin: bool,
423
424 pub write_mode: String,
426
427 pub max_depth: u32,
429 pub max_cost: u32,
430 pub max_api_calls: u32,
431
432 pub max_loop_iterations: u32,
434 pub max_script_length: u32,
435 pub max_nesting_depth: u32,
436 pub execution_timeout_seconds: u32,
437
438 pub allowed_operations: HashSet<String>,
440 pub blocked_operations: HashSet<String>,
441
442 pub allowed_methods: HashSet<String>,
444 pub blocked_methods: HashSet<String>,
445 pub allowed_path_patterns: HashSet<String>,
446 pub blocked_path_patterns: HashSet<String>,
447 pub sensitive_path_patterns: HashSet<String>,
448
449 pub auto_approve_read_only: bool,
451 pub max_api_calls_for_auto_approve: u32,
452
453 pub internal_blocked_fields: HashSet<String>,
455 pub output_blocked_fields: HashSet<String>,
456 pub require_output_declaration: bool,
457}
458
459#[cfg(feature = "openapi-code-mode")]
460impl Default for OpenAPIServerEntity {
461 fn default() -> Self {
462 Self {
463 server_id: "unknown".to_string(),
464 server_type: "openapi".to_string(),
465 allow_write: false,
466 allow_delete: false,
467 allow_admin: false,
468 write_mode: "deny_all".to_string(),
469 max_depth: 10,
470 max_cost: 1000,
471 max_api_calls: 50,
472 max_loop_iterations: 100,
473 max_script_length: 10000,
474 max_nesting_depth: 10,
475 execution_timeout_seconds: 30,
476 allowed_operations: HashSet::new(),
477 blocked_operations: HashSet::new(),
478 allowed_methods: HashSet::new(),
479 blocked_methods: HashSet::new(),
480 allowed_path_patterns: HashSet::new(),
481 blocked_path_patterns: ["/admin".into(), "/internal".into()].into_iter().collect(),
482 sensitive_path_patterns: ["/admin".into(), "/internal".into(), "/debug".into()]
483 .into_iter()
484 .collect(),
485 auto_approve_read_only: true,
486 max_api_calls_for_auto_approve: 10,
487 internal_blocked_fields: HashSet::new(),
488 output_blocked_fields: HashSet::new(),
489 require_output_declaration: false,
490 }
491 }
492}
493
494pub fn get_code_mode_schema_json() -> serde_json::Value {
498 let applies_to = serde_json::json!({
499 "principalTypes": ["Operation"],
500 "resourceTypes": ["Server"],
501 "context": {
502 "type": "Record",
503 "attributes": {
504 "serverId": { "type": "String", "required": true },
505 "serverType": { "type": "String", "required": true },
506 "userId": { "type": "String", "required": false },
507 "sessionId": { "type": "String", "required": false }
508 }
509 }
510 });
511
512 serde_json::json!({
513 "CodeMode": {
514 "entityTypes": {
515 "Operation": {
516 "shape": {
517 "type": "Record",
518 "attributes": {
519 "operationType": { "type": "String", "required": true },
520 "operationName": { "type": "String", "required": true },
521 "rootFields": { "type": "Set", "element": { "type": "String" } },
522 "accessedTypes": { "type": "Set", "element": { "type": "String" } },
523 "accessedFields": { "type": "Set", "element": { "type": "String" } },
524 "depth": { "type": "Long", "required": true },
525 "fieldCount": { "type": "Long", "required": true },
526 "estimatedCost": { "type": "Long", "required": true },
527 "hasIntrospection": { "type": "Boolean", "required": true },
528 "accessesSensitiveData": { "type": "Boolean", "required": true },
529 "sensitiveCategories": { "type": "Set", "element": { "type": "String" } }
530 }
531 }
532 },
533 "Server": {
534 "shape": {
535 "type": "Record",
536 "attributes": {
537 "serverId": { "type": "String", "required": true },
538 "serverType": { "type": "String", "required": true },
539 "maxDepth": { "type": "Long", "required": true },
540 "maxCost": { "type": "Long", "required": true },
541 "maxApiCalls": { "type": "Long", "required": true },
542 "allowWrite": { "type": "Boolean", "required": true },
543 "allowDelete": { "type": "Boolean", "required": true },
544 "allowAdmin": { "type": "Boolean", "required": true },
545 "blockedOperations": { "type": "Set", "element": { "type": "String" } },
546 "allowedOperations": { "type": "Set", "element": { "type": "String" } },
547 "blockedFields": { "type": "Set", "element": { "type": "String" } }
548 }
549 }
550 }
551 },
552 "actions": {
553 "Read": { "appliesTo": applies_to },
554 "Write": { "appliesTo": applies_to },
555 "Delete": { "appliesTo": applies_to },
556 "Admin": { "appliesTo": applies_to }
557 }
558 }
559 })
560}
561
562#[cfg(feature = "openapi-code-mode")]
564pub fn get_openapi_code_mode_schema_json() -> serde_json::Value {
565 let applies_to = serde_json::json!({
566 "principalTypes": ["Script"],
567 "resourceTypes": ["Server"],
568 "context": {
569 "type": "Record",
570 "attributes": {
571 "serverId": { "type": "String", "required": true },
572 "serverType": { "type": "String", "required": true },
573 "userId": { "type": "String", "required": false },
574 "sessionId": { "type": "String", "required": false }
575 }
576 }
577 });
578
579 serde_json::json!({
580 "CodeMode": {
581 "entityTypes": {
582 "Script": {
583 "shape": {
584 "type": "Record",
585 "attributes": {
586 "scriptType": { "type": "String", "required": true },
587 "hasWrites": { "type": "Boolean", "required": true },
588 "hasDeletes": { "type": "Boolean", "required": true },
589 "totalApiCalls": { "type": "Long", "required": true },
590 "readCalls": { "type": "Long", "required": true },
591 "writeCalls": { "type": "Long", "required": true },
592 "deleteCalls": { "type": "Long", "required": true },
593 "accessedPaths": { "type": "Set", "element": { "type": "String" } },
594 "accessedMethods": { "type": "Set", "element": { "type": "String" } },
595 "pathPatterns": { "type": "Set", "element": { "type": "String" } },
596 "calledOperations": { "type": "Set", "element": { "type": "String" } },
597 "loopIterations": { "type": "Long", "required": true },
598 "nestingDepth": { "type": "Long", "required": true },
599 "scriptLength": { "type": "Long", "required": true },
600 "accessesSensitivePath": { "type": "Boolean", "required": true },
601 "hasUnboundedLoop": { "type": "Boolean", "required": true },
602 "hasDynamicPath": { "type": "Boolean", "required": true },
603 "outputFields": { "type": "Set", "element": { "type": "String" } },
604 "hasOutputDeclaration": { "type": "Boolean", "required": true },
605 "hasSpreadInOutput": { "type": "Boolean", "required": true }
606 }
607 }
608 },
609 "Server": {
610 "shape": {
611 "type": "Record",
612 "attributes": {
613 "serverId": { "type": "String", "required": true },
614 "serverType": { "type": "String", "required": true },
615 "maxDepth": { "type": "Long", "required": true },
616 "maxCost": { "type": "Long", "required": true },
617 "maxApiCalls": { "type": "Long", "required": true },
618 "allowWrite": { "type": "Boolean", "required": true },
619 "allowDelete": { "type": "Boolean", "required": true },
620 "allowAdmin": { "type": "Boolean", "required": true },
621 "blockedOperations": { "type": "Set", "element": { "type": "String" } },
622 "allowedOperations": { "type": "Set", "element": { "type": "String" } },
623 "blockedFields": { "type": "Set", "element": { "type": "String" } },
624 "maxLoopIterations": { "type": "Long", "required": true },
625 "maxScriptLength": { "type": "Long", "required": true },
626 "maxNestingDepth": { "type": "Long", "required": true },
627 "executionTimeoutSeconds": { "type": "Long", "required": true },
628 "allowedMethods": { "type": "Set", "element": { "type": "String" } },
629 "blockedMethods": { "type": "Set", "element": { "type": "String" } },
630 "allowedPathPatterns": { "type": "Set", "element": { "type": "String" } },
631 "blockedPathPatterns": { "type": "Set", "element": { "type": "String" } },
632 "sensitivePathPatterns": { "type": "Set", "element": { "type": "String" } },
633 "autoApproveReadOnly": { "type": "Boolean", "required": true },
634 "maxApiCallsForAutoApprove": { "type": "Long", "required": true },
635 "internalBlockedFields": { "type": "Set", "element": { "type": "String" } },
636 "outputBlockedFields": { "type": "Set", "element": { "type": "String" } },
637 "requireOutputDeclaration": { "type": "Boolean", "required": true }
638 }
639 }
640 }
641 },
642 "actions": {
643 "Read": { "appliesTo": applies_to },
644 "Write": { "appliesTo": applies_to },
645 "Delete": { "appliesTo": applies_to },
646 "Admin": { "appliesTo": applies_to }
647 }
648 }
649 })
650}
651
652#[cfg(feature = "openapi-code-mode")]
654pub fn get_openapi_baseline_policies() -> Vec<(&'static str, &'static str, &'static str)> {
655 vec![
656 (
657 "permit_reads",
658 "Permit all read operations (GET scripts)",
659 r#"permit(principal, action == CodeMode::Action::"Read", resource);"#,
660 ),
661 (
662 "permit_writes",
663 "Permit write operations (when enabled)",
664 r#"permit(principal, action == CodeMode::Action::"Write", resource) when { resource.allowWrite == true };"#,
665 ),
666 (
667 "permit_deletes",
668 "Permit delete operations (when enabled)",
669 r#"permit(principal, action == CodeMode::Action::"Delete", resource) when { resource.allowDelete == true };"#,
670 ),
671 (
672 "forbid_sensitive_paths",
673 "Block scripts accessing sensitive paths",
674 r#"forbid(principal, action, resource) when { principal.accessesSensitivePath == true };"#,
675 ),
676 (
677 "forbid_unbounded_loops",
678 "Block scripts with unbounded loops",
679 r#"forbid(principal, action, resource) when { principal.hasUnboundedLoop == true };"#,
680 ),
681 (
682 "forbid_excessive_api_calls",
683 "Enforce API call limit",
684 r#"forbid(principal, action, resource) when { principal.totalApiCalls > resource.maxApiCalls };"#,
685 ),
686 (
687 "forbid_excessive_nesting",
688 "Enforce nesting depth limit",
689 r#"forbid(principal, action, resource) when { principal.nestingDepth > resource.maxNestingDepth };"#,
690 ),
691 (
692 "forbid_output_blocked_fields",
693 "Block scripts that return output-blocked fields",
694 r#"forbid(principal, action, resource) when { principal.outputFields.containsAny(resource.outputBlockedFields) };"#,
695 ),
696 (
697 "forbid_spread_without_declaration",
698 "Block scripts with spread in output when output declaration is required",
699 r#"forbid(principal, action, resource) when { principal.hasSpreadInOutput == true && resource.requireOutputDeclaration == true };"#,
700 ),
701 (
702 "forbid_missing_output_declaration",
703 "Block scripts without output declaration when required",
704 r#"forbid(principal, action, resource) when { principal.hasOutputDeclaration == false && resource.requireOutputDeclaration == true };"#,
705 ),
706 ]
707}
708
709pub fn get_baseline_policies() -> Vec<(&'static str, &'static str, &'static str)> {
711 vec![
712 (
713 "permit_reads",
714 "Permit all read operations (queries)",
715 r#"permit(principal, action == CodeMode::Action::"Read", resource);"#,
716 ),
717 (
718 "permit_writes",
719 "Permit write operations (when enabled)",
720 r#"permit(principal, action == CodeMode::Action::"Write", resource) when { resource.allowWrite == true };"#,
721 ),
722 (
723 "permit_deletes",
724 "Permit delete operations (when enabled)",
725 r#"permit(principal, action == CodeMode::Action::"Delete", resource) when { resource.allowDelete == true };"#,
726 ),
727 (
728 "permit_admin",
729 "Permit admin operations (when enabled)",
730 r#"permit(principal, action == CodeMode::Action::"Admin", resource) when { resource.allowAdmin == true };"#,
731 ),
732 (
733 "forbid_blocked_operations",
734 "Block operations in blocklist",
735 r#"forbid(principal, action, resource) when { resource.blockedOperations.contains(principal.operationName) };"#,
736 ),
737 (
738 "forbid_blocked_fields",
739 "Block access to blocked fields",
740 r#"forbid(principal, action, resource) when { resource.blockedFields.containsAny(principal.accessedFields) };"#,
741 ),
742 (
743 "forbid_excessive_depth",
744 "Enforce maximum query depth",
745 r#"forbid(principal, action, resource) when { principal.depth > resource.maxDepth };"#,
746 ),
747 (
748 "forbid_excessive_cost",
749 "Enforce maximum query cost",
750 r#"forbid(principal, action, resource) when { principal.estimatedCost > resource.maxCost };"#,
751 ),
752 ]
753}