1use crate::config::CodeModeConfig;
11#[cfg(feature = "openapi-code-mode")]
12use crate::config::OperationRegistry;
13use crate::explanation::{ExplanationGenerator, TemplateExplanationGenerator};
14use crate::graphql::{GraphQLQueryInfo, GraphQLValidator};
15use crate::policy::{OperationEntity, PolicyEvaluator};
16use crate::token::{compute_context_hash, HmacTokenGenerator, TokenGenerator, TokenSecret};
17use crate::types::{
18 PolicyViolation, TokenError, UnifiedAction, ValidationError, ValidationMetadata,
19 ValidationResult,
20};
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::Arc;
23use std::time::Instant;
24
25#[cfg(feature = "openapi-code-mode")]
26use crate::javascript::{JavaScriptCodeInfo, JavaScriptValidator};
27
28static NO_POLICY_WARNING_LOGGED: AtomicBool = AtomicBool::new(false);
30
31fn build_policy_violations(
46 decision: &crate::policy::AuthorizationDecision,
47 server_id: &str,
48 action: impl std::fmt::Display,
49 denied_subject: &str,
50) -> Vec<PolicyViolation> {
51 let capacity = decision.determining_policies.len() + decision.errors.len() + 1;
52 let mut violations: Vec<PolicyViolation> = Vec::with_capacity(capacity);
53
54 for policy_id in &decision.determining_policies {
55 violations.push(PolicyViolation::new(
56 "policy",
57 policy_id.clone(),
58 format!("Policy denied the {}", denied_subject),
59 ));
60 }
61
62 for err in &decision.errors {
63 violations.push(PolicyViolation::new(
64 "policy_error",
65 "evaluation_error",
66 err.clone(),
67 ));
68 }
69
70 if violations.is_empty() {
71 violations.push(PolicyViolation::new(
72 "policy",
73 "default_deny",
74 format!(
75 "Authorization default-deny: no Permit policy matched for \
76 server_id={server_id} action={action}. Check that Cedar \
77 policies exist for this server and that server_id is set correctly."
78 ),
79 ));
80 }
81
82 violations
83}
84
85fn warn_no_policy_configured() {
87 if !NO_POLICY_WARNING_LOGGED.swap(true, Ordering::SeqCst) {
88 tracing::warn!(
89 target: "code_mode",
90 "CODE MODE SECURITY WARNING: Code Mode is enabled but no policy evaluator \
91 is configured. Only basic config checks (allow_mutations, max_depth, etc.) will be \
92 performed. This provides NO real authorization policy evaluation. \
93 For production deployments, configure a policy evaluator (AVP or local Cedar)."
94 );
95 }
96}
97
98#[derive(Debug, Clone)]
100pub struct ValidationContext {
101 pub user_id: String,
103
104 pub session_id: String,
106
107 pub schema_hash: String,
109
110 pub permissions_hash: String,
112}
113
114impl ValidationContext {
115 pub fn new(
117 user_id: impl Into<String>,
118 session_id: impl Into<String>,
119 schema_hash: impl Into<String>,
120 permissions_hash: impl Into<String>,
121 ) -> Self {
122 Self {
123 user_id: user_id.into(),
124 session_id: session_id.into(),
125 schema_hash: schema_hash.into(),
126 permissions_hash: permissions_hash.into(),
127 }
128 }
129
130 pub fn context_hash(&self) -> String {
132 compute_context_hash(&self.schema_hash, &self.permissions_hash)
133 }
134}
135
136pub struct ValidationPipeline<
138 T: TokenGenerator = HmacTokenGenerator,
139 E: ExplanationGenerator = TemplateExplanationGenerator,
140> {
141 config: CodeModeConfig,
142 graphql_validator: GraphQLValidator,
143 #[cfg(feature = "openapi-code-mode")]
144 javascript_validator: JavaScriptValidator,
145 #[cfg(feature = "openapi-code-mode")]
146 operation_registry: OperationRegistry,
147 #[cfg(feature = "openapi-code-mode")]
150 openapi_policy: crate::openapi_policy::OpenApiClassPolicy,
151 token_generator: T,
152 explanation_generator: E,
153 policy_evaluator: Option<Arc<dyn PolicyEvaluator>>,
154}
155
156impl ValidationPipeline<HmacTokenGenerator, TemplateExplanationGenerator> {
157 pub fn new(
167 mut config: CodeModeConfig,
168 token_secret: impl Into<Vec<u8>>,
169 ) -> Result<Self, TokenError> {
170 if config.enabled {
171 warn_no_policy_configured();
172 }
173
174 config.resolve_server_id();
175
176 #[cfg(feature = "openapi-code-mode")]
177 let operation_registry = OperationRegistry::from_entries(&config.operations);
178
179 Ok(Self {
180 graphql_validator: GraphQLValidator::default(),
181 #[cfg(feature = "openapi-code-mode")]
182 javascript_validator: JavaScriptValidator::default()
183 .with_sdk_operations(config.sdk_operations.clone()),
184 #[cfg(feature = "openapi-code-mode")]
185 operation_registry,
186 #[cfg(feature = "openapi-code-mode")]
187 openapi_policy: crate::openapi_policy::OpenApiClassPolicy::from_config(&config),
188 token_generator: HmacTokenGenerator::new_from_bytes(token_secret)?,
189 explanation_generator: TemplateExplanationGenerator::new(),
190 policy_evaluator: None,
191 config,
192 })
193 }
194
195 pub fn from_token_secret(
214 config: CodeModeConfig,
215 secret: &TokenSecret,
216 ) -> Result<Self, TokenError> {
217 Self::new(config, secret.expose_secret().to_vec())
218 }
219
220 pub fn with_policy_evaluator(
227 mut config: CodeModeConfig,
228 token_secret: impl Into<Vec<u8>>,
229 evaluator: Arc<dyn PolicyEvaluator>,
230 ) -> Result<Self, TokenError> {
231 config.resolve_server_id();
232 if config.server_id.is_none() {
233 tracing::warn!(
234 target: "code_mode",
235 "CodeModeConfig.server_id is not set — AVP/Cedar authorization will use 'unknown' \
236 as the resource entity ID and will likely default-deny silently. \
237 Set server_id in config.toml, or the PMCP_SERVER_ID or AWS_LAMBDA_FUNCTION_NAME env var."
238 );
239 }
240
241 #[cfg(feature = "openapi-code-mode")]
242 let operation_registry = OperationRegistry::from_entries(&config.operations);
243
244 Ok(Self {
245 graphql_validator: GraphQLValidator::default(),
246 #[cfg(feature = "openapi-code-mode")]
247 javascript_validator: JavaScriptValidator::default()
248 .with_sdk_operations(config.sdk_operations.clone()),
249 #[cfg(feature = "openapi-code-mode")]
250 operation_registry,
251 #[cfg(feature = "openapi-code-mode")]
252 openapi_policy: crate::openapi_policy::OpenApiClassPolicy::from_config(&config),
253 token_generator: HmacTokenGenerator::new_from_bytes(token_secret)?,
254 explanation_generator: TemplateExplanationGenerator::new(),
255 policy_evaluator: Some(evaluator),
256 config,
257 })
258 }
259
260 pub fn from_token_secret_with_policy(
270 config: CodeModeConfig,
271 secret: &TokenSecret,
272 evaluator: Arc<dyn PolicyEvaluator>,
273 ) -> Result<Self, TokenError> {
274 Self::with_policy_evaluator(config, secret.expose_secret().to_vec(), evaluator)
275 }
276}
277
278impl<T: TokenGenerator, E: ExplanationGenerator> ValidationPipeline<T, E> {
279 pub fn with_generators(
281 mut config: CodeModeConfig,
282 token_generator: T,
283 explanation_generator: E,
284 ) -> Self {
285 config.resolve_server_id();
286
287 #[cfg(feature = "openapi-code-mode")]
288 let operation_registry = OperationRegistry::from_entries(&config.operations);
289
290 Self {
291 graphql_validator: GraphQLValidator::default(),
292 #[cfg(feature = "openapi-code-mode")]
293 javascript_validator: JavaScriptValidator::default()
294 .with_sdk_operations(config.sdk_operations.clone()),
295 #[cfg(feature = "openapi-code-mode")]
296 operation_registry,
297 #[cfg(feature = "openapi-code-mode")]
298 openapi_policy: crate::openapi_policy::OpenApiClassPolicy::from_config(&config),
299 token_generator,
300 explanation_generator,
301 policy_evaluator: None,
302 config,
303 }
304 }
305
306 pub fn set_policy_evaluator(&mut self, evaluator: Arc<dyn PolicyEvaluator>) {
308 self.policy_evaluator = Some(evaluator);
309 }
310
311 pub fn has_policy_evaluator(&self) -> bool {
313 self.policy_evaluator.is_some()
314 }
315
316 #[cfg(feature = "openapi-code-mode")]
321 #[must_use]
322 pub fn with_openapi_class_policy(
323 mut self,
324 policy: crate::openapi_policy::OpenApiClassPolicy,
325 ) -> Self {
326 self.openapi_policy = policy;
327 self
328 }
329
330 #[cfg(feature = "openapi-code-mode")]
332 pub fn openapi_class_policy(&self) -> &crate::openapi_policy::OpenApiClassPolicy {
333 &self.openapi_policy
334 }
335
336 fn check_config_authorization(
341 &self,
342 query_info: &GraphQLQueryInfo,
343 start: Instant,
344 ) -> Option<ValidationResult> {
345 if !query_info.operation_type.is_read_only() {
347 let mutation_name = query_info.root_fields.first().cloned().unwrap_or_default();
348
349 if !self.config.blocked_mutations.is_empty()
350 && self.config.blocked_mutations.contains(&mutation_name)
351 {
352 return Some(ValidationResult::failure(
353 vec![PolicyViolation::new(
354 "code_mode",
355 "blocked_mutation",
356 &format!("Mutation '{}' is blocked for this server", mutation_name),
357 )
358 .with_suggestion("This mutation is in the blocklist and cannot be executed")],
359 self.build_metadata(query_info, start.elapsed().as_millis() as u64),
360 ));
361 }
362
363 if !self.config.allowed_mutations.is_empty() {
364 if !self.config.allowed_mutations.contains(&mutation_name) {
365 return Some(ValidationResult::failure(
366 vec![PolicyViolation::new(
367 "code_mode",
368 "mutation_not_allowed",
369 &format!("Mutation '{}' is not in the allowlist", mutation_name),
370 )
371 .with_suggestion(&format!(
372 "Only these mutations are allowed: {}",
373 self.config
374 .allowed_mutations
375 .iter()
376 .cloned()
377 .collect::<Vec<_>>()
378 .join(", ")
379 ))],
380 self.build_metadata(query_info, start.elapsed().as_millis() as u64),
381 ));
382 }
383 } else if !self.config.allow_mutations {
384 return Some(ValidationResult::failure(
385 vec![PolicyViolation::new(
386 "code_mode",
387 "allow_mutations",
388 "Mutations are not enabled for this server",
389 )
390 .with_suggestion("Only read-only queries are allowed")],
391 self.build_metadata(query_info, start.elapsed().as_millis() as u64),
392 ));
393 }
394 }
395
396 if query_info.operation_type.is_read_only() {
398 let query_name = query_info.root_fields.first().cloned().unwrap_or_default();
399
400 if !self.config.blocked_queries.is_empty()
401 && self.config.blocked_queries.contains(&query_name)
402 {
403 return Some(ValidationResult::failure(
404 vec![PolicyViolation::new(
405 "code_mode",
406 "blocked_query",
407 &format!("Query '{}' is blocked for this server", query_name),
408 )
409 .with_suggestion("This query is in the blocklist and cannot be executed")],
410 self.build_metadata(query_info, start.elapsed().as_millis() as u64),
411 ));
412 }
413
414 if !self.config.allowed_queries.is_empty()
415 && !self.config.allowed_queries.contains(&query_name)
416 {
417 return Some(ValidationResult::failure(
418 vec![PolicyViolation::new(
419 "code_mode",
420 "query_not_allowed",
421 &format!("Query '{}' is not in the allowlist", query_name),
422 )
423 .with_suggestion(&format!(
424 "Only these queries are allowed: {}",
425 self.config
426 .allowed_queries
427 .iter()
428 .cloned()
429 .collect::<Vec<_>>()
430 .join(", ")
431 ))],
432 self.build_metadata(query_info, start.elapsed().as_millis() as u64),
433 ));
434 }
435 }
436
437 None
438 }
439
440 pub fn validate_graphql_query(
442 &self,
443 query: &str,
444 context: &ValidationContext,
445 ) -> Result<ValidationResult, ValidationError> {
446 let start = Instant::now();
447
448 if !self.config.enabled {
449 return Err(ValidationError::ConfigError(
450 "Code Mode is not enabled for this server".into(),
451 ));
452 }
453
454 if query.len() > self.config.max_query_length {
455 return Err(ValidationError::SecurityError {
456 message: format!(
457 "Query length {} exceeds maximum {}",
458 query.len(),
459 self.config.max_query_length
460 ),
461 issue: crate::types::SecurityIssueType::HighComplexity,
462 });
463 }
464
465 let query_info = self.graphql_validator.validate(query)?;
466
467 if let Some(failure) = self.check_config_authorization(&query_info, start) {
469 return Ok(failure);
470 }
471
472 self.complete_validation(query, &query_info, context, start)
473 }
474
475 pub async fn validate_graphql_query_async(
477 &self,
478 query: &str,
479 context: &ValidationContext,
480 ) -> Result<ValidationResult, ValidationError> {
481 let start = Instant::now();
482
483 if !self.config.enabled {
484 return Err(ValidationError::ConfigError(
485 "Code Mode is not enabled for this server".into(),
486 ));
487 }
488
489 if query.len() > self.config.max_query_length {
490 return Err(ValidationError::SecurityError {
491 message: format!(
492 "Query length {} exceeds maximum {}",
493 query.len(),
494 self.config.max_query_length
495 ),
496 issue: crate::types::SecurityIssueType::HighComplexity,
497 });
498 }
499
500 let query_info = self.graphql_validator.validate(query)?;
501
502 if let Some(ref evaluator) = self.policy_evaluator {
504 let operation_entity = OperationEntity::from_query_info(&query_info);
505 let server_config = self.config.to_server_config_entity();
506
507 let decision = evaluator
508 .evaluate_operation(&operation_entity, &server_config)
509 .await
510 .map_err(|e| {
511 ValidationError::InternalError(format!("Policy evaluation error: {}", e))
512 })?;
513
514 if !decision.allowed {
515 let op_type_str = format!("{:?}", query_info.operation_type);
516 let action =
517 UnifiedAction::from_graphql(&op_type_str, query_info.operation_name.as_deref());
518 let violations = build_policy_violations(
519 &decision,
520 self.config.server_id(),
521 action,
522 "operation",
523 );
524
525 return Ok(ValidationResult::failure(
526 violations,
527 self.build_metadata(&query_info, start.elapsed().as_millis() as u64),
528 ));
529 }
530 } else {
531 warn_no_policy_configured();
532 tracing::debug!(
533 target: "code_mode",
534 "Falling back to basic config checks (no policy evaluator configured)"
535 );
536 if let Some(failure) = self.check_config_authorization(&query_info, start) {
538 return Ok(failure);
539 }
540 }
541
542 self.complete_validation(query, &query_info, context, start)
543 }
544
545 fn complete_validation(
547 &self,
548 query: &str,
549 query_info: &GraphQLQueryInfo,
550 context: &ValidationContext,
551 start: Instant,
552 ) -> Result<ValidationResult, ValidationError> {
553 let security_analysis = self.graphql_validator.analyze_security(query_info);
554 let risk_level = security_analysis.assess_risk();
555
556 if security_analysis
557 .potential_issues
558 .iter()
559 .any(|i| i.is_critical())
560 {
561 let violations: Vec<PolicyViolation> = security_analysis
562 .potential_issues
563 .iter()
564 .filter(|i| i.is_critical())
565 .map(|i| {
566 PolicyViolation::new("security", format!("{:?}", i.issue_type), &i.message)
567 })
568 .collect();
569
570 return Ok(ValidationResult::failure(
571 violations,
572 self.build_metadata(query_info, start.elapsed().as_millis() as u64),
573 ));
574 }
575
576 let explanation = self
577 .explanation_generator
578 .explain_graphql(query_info, &security_analysis);
579
580 let context_hash = context.context_hash();
581 let token = self.token_generator.generate(
582 query,
583 &context.user_id,
584 &context.session_id,
585 self.config.server_id(),
586 &context_hash,
587 risk_level,
588 self.config.token_ttl_seconds,
589 );
590
591 let token_string = token.encode().map_err(|e| {
592 ValidationError::InternalError(format!("Failed to encode token: {}", e))
593 })?;
594
595 let operation_type_str = format!("{:?}", query_info.operation_type).to_lowercase();
596 let mutation_name = query_info.operation_name.as_deref();
597 let inferred_action = UnifiedAction::from_graphql(&operation_type_str, mutation_name);
598 let action = UnifiedAction::resolve(
599 inferred_action,
600 &self.config.action_tags,
601 query_info.operation_name.as_deref().unwrap_or(""),
602 );
603
604 let metadata = ValidationMetadata {
605 is_read_only: query_info.operation_type.is_read_only(),
606 estimated_rows: security_analysis.estimated_rows,
607 accessed_types: security_analysis.tables_accessed.iter().cloned().collect(),
608 accessed_fields: security_analysis.fields_accessed.iter().cloned().collect(),
609 has_aggregation: security_analysis.has_aggregation,
610 code_type: Some(self.graphql_validator.to_code_type(query_info)),
611 action: Some(action),
612 validation_time_ms: start.elapsed().as_millis() as u64,
613 };
614
615 let mut result = ValidationResult::success(explanation, risk_level, token_string, metadata);
616
617 for issue in &security_analysis.potential_issues {
618 if !issue.is_critical() {
619 result.warnings.push(issue.message.clone());
620 }
621 }
622
623 Ok(result)
624 }
625
626 fn build_metadata(
628 &self,
629 query_info: &GraphQLQueryInfo,
630 validation_time_ms: u64,
631 ) -> ValidationMetadata {
632 let operation_type_str = format!("{:?}", query_info.operation_type).to_lowercase();
633 let mutation_name = query_info.operation_name.as_deref();
634 let inferred_action = UnifiedAction::from_graphql(&operation_type_str, mutation_name);
635 let action = UnifiedAction::resolve(
636 inferred_action,
637 &self.config.action_tags,
638 query_info.operation_name.as_deref().unwrap_or(""),
639 );
640
641 ValidationMetadata {
642 is_read_only: query_info.operation_type.is_read_only(),
643 estimated_rows: None,
644 accessed_types: query_info.types_accessed.iter().cloned().collect(),
645 accessed_fields: query_info.fields_accessed.iter().cloned().collect(),
646 has_aggregation: false,
647 code_type: Some(self.graphql_validator.to_code_type(query_info)),
648 action: Some(action),
649 validation_time_ms,
650 }
651 }
652
653 #[cfg(feature = "openapi-code-mode")]
659 pub fn validate_javascript_code(
660 &self,
661 code: &str,
662 context: &ValidationContext,
663 ) -> Result<ValidationResult, ValidationError> {
664 let start = Instant::now();
665 let code_info = self.validate_js_preamble(code)?;
666 if let Some(failure) = self.check_js_config_authorization(&code_info, start) {
667 return Ok(failure);
668 }
669 self.complete_js_validation(code, &code_info, context, start)
670 }
671
672 #[cfg(feature = "openapi-code-mode")]
681 pub async fn validate_javascript_code_async(
682 &self,
683 code: &str,
684 context: &ValidationContext,
685 ) -> Result<ValidationResult, ValidationError> {
686 use crate::policy::types::ScriptEntity;
687
688 let start = Instant::now();
689 let code_info = self.validate_js_preamble(code)?;
690 if let Some(failure) = self.check_js_config_authorization(&code_info, start) {
691 return Ok(failure);
692 }
693
694 if let Some(ref evaluator) = self.policy_evaluator {
696 let sensitive_patterns: Vec<String> =
697 self.config.openapi_blocked_paths.iter().cloned().collect();
698 let registry_ref = if self.operation_registry.is_empty() {
699 None
700 } else {
701 Some(&self.operation_registry)
702 };
703 let script_entity =
704 ScriptEntity::from_javascript_info(&code_info, &sensitive_patterns, registry_ref);
705 let server_entity = self.config.to_openapi_server_entity();
706
707 let decision = evaluator
708 .evaluate_script(&script_entity, &server_entity)
709 .await
710 .map_err(|e| {
711 ValidationError::InternalError(format!("Policy evaluation error: {}", e))
712 })?;
713
714 if !decision.allowed {
715 let violations = build_policy_violations(
716 &decision,
717 self.config.server_id(),
718 script_entity.action(),
719 "script",
720 );
721
722 return Ok(ValidationResult::failure(
723 violations,
724 self.build_js_metadata(&code_info, start.elapsed().as_millis() as u64),
725 ));
726 }
727 }
728
729 self.complete_js_validation(code, &code_info, context, start)
730 }
731
732 #[cfg(feature = "openapi-code-mode")]
734 fn validate_js_preamble(&self, code: &str) -> Result<JavaScriptCodeInfo, ValidationError> {
735 if !self.config.enabled {
736 return Err(ValidationError::ConfigError(
737 "Code Mode is not enabled for this server".into(),
738 ));
739 }
740
741 if code.len() > self.config.max_query_length {
742 return Err(ValidationError::SecurityError {
743 message: format!(
744 "Code length {} exceeds maximum {}",
745 code.len(),
746 self.config.max_query_length
747 ),
748 issue: crate::types::SecurityIssueType::HighComplexity,
749 });
750 }
751
752 self.javascript_validator.validate(code)
753 }
754
755 #[cfg(feature = "openapi-code-mode")]
761 fn check_js_config_authorization(
762 &self,
763 code_info: &JavaScriptCodeInfo,
764 start: Instant,
765 ) -> Option<ValidationResult> {
766 let violations = self
769 .openapi_policy
770 .check_script(code_info, &self.operation_registry);
771 if violations.is_empty() {
772 return None;
773 }
774 Some(ValidationResult::failure(
775 violations,
776 self.build_js_metadata(code_info, start.elapsed().as_millis() as u64),
777 ))
778 }
779
780 #[cfg(feature = "openapi-code-mode")]
782 fn complete_js_validation(
783 &self,
784 code: &str,
785 code_info: &JavaScriptCodeInfo,
786 context: &ValidationContext,
787 start: Instant,
788 ) -> Result<ValidationResult, ValidationError> {
789 let security_analysis = self.javascript_validator.analyze_security(code_info);
790 let risk_level = security_analysis.assess_risk();
791
792 if security_analysis
793 .potential_issues
794 .iter()
795 .any(|i| i.is_critical())
796 {
797 let violations: Vec<PolicyViolation> = security_analysis
798 .potential_issues
799 .iter()
800 .filter(|i| i.is_critical())
801 .map(|i| {
802 PolicyViolation::new("security", format!("{:?}", i.issue_type), &i.message)
803 })
804 .collect();
805
806 return Ok(ValidationResult::failure(
807 violations,
808 self.build_js_metadata(code_info, start.elapsed().as_millis() as u64),
809 ));
810 }
811
812 let explanation = self.generate_js_explanation(code_info, &security_analysis);
813
814 let context_hash = context.context_hash();
815 let token = self.token_generator.generate(
816 code,
817 &context.user_id,
818 &context.session_id,
819 self.config.server_id(),
820 &context_hash,
821 risk_level,
822 self.config.token_ttl_seconds,
823 );
824
825 let token_string = token.encode().map_err(|e| {
826 ValidationError::InternalError(format!("Failed to encode token: {}", e))
827 })?;
828
829 let metadata = self.build_js_metadata(code_info, start.elapsed().as_millis() as u64);
830
831 let mut result = ValidationResult::success(explanation, risk_level, token_string, metadata);
832
833 for issue in &security_analysis.potential_issues {
834 if !issue.is_critical() {
835 result.warnings.push(issue.message.clone());
836 }
837 }
838
839 Ok(result)
840 }
841
842 #[cfg(feature = "openapi-code-mode")]
844 fn build_js_metadata(
845 &self,
846 code_info: &JavaScriptCodeInfo,
847 validation_time_ms: u64,
848 ) -> ValidationMetadata {
849 let action = if !code_info.api_calls.is_empty() {
850 let mut max_action = UnifiedAction::Read;
851 for call in &code_info.api_calls {
852 let method_str = format!("{:?}", call.method);
853 let inferred = UnifiedAction::from_http_method(&method_str);
854 match (&max_action, &inferred) {
855 (UnifiedAction::Read, _) => max_action = inferred,
856 (UnifiedAction::Write, UnifiedAction::Delete | UnifiedAction::Admin) => {
857 max_action = inferred
858 },
859 (UnifiedAction::Delete, UnifiedAction::Admin) => max_action = inferred,
860 _ => {},
861 }
862 }
863 Some(max_action)
864 } else if code_info.is_read_only {
865 Some(UnifiedAction::Read)
866 } else {
867 Some(UnifiedAction::Write)
868 };
869
870 ValidationMetadata {
871 is_read_only: code_info.is_read_only,
872 estimated_rows: None,
873 accessed_types: code_info.endpoints_accessed.iter().cloned().collect(),
874 accessed_fields: code_info.methods_used.iter().cloned().collect(),
875 has_aggregation: false,
876 code_type: Some(self.javascript_validator.to_code_type(code_info)),
877 action,
878 validation_time_ms,
879 }
880 }
881
882 #[cfg(feature = "openapi-code-mode")]
884 fn generate_js_explanation(
885 &self,
886 code_info: &JavaScriptCodeInfo,
887 security_analysis: &crate::types::SecurityAnalysis,
888 ) -> String {
889 let mut parts = Vec::new();
890
891 if code_info.is_read_only {
892 parts.push("This code will perform read-only API requests.".to_string());
893 } else {
894 parts.push("This code will perform API requests that may modify data.".to_string());
895 }
896
897 if !code_info.api_calls.is_empty() {
898 let call_descriptions: Vec<String> = code_info
899 .api_calls
900 .iter()
901 .map(|call| format!("{:?} {}", call.method, call.path))
902 .collect();
903
904 if call_descriptions.len() <= 3 {
905 parts.push(format!("API calls: {}", call_descriptions.join(", ")));
906 } else {
907 parts.push(format!(
908 "API calls: {} and {} more",
909 call_descriptions[..2].join(", "),
910 call_descriptions.len() - 2
911 ));
912 }
913 }
914
915 if code_info.loop_count > 0 {
916 if code_info.all_loops_bounded {
917 parts.push(format!(
918 "Contains {} bounded loop(s).",
919 code_info.loop_count
920 ));
921 } else {
922 parts.push(format!(
923 "Contains {} loop(s) - ensure they are properly bounded.",
924 code_info.loop_count
925 ));
926 }
927 }
928
929 let risk = security_analysis.assess_risk();
930 parts.push(format!("Risk: {}", risk));
931
932 parts.join(" ")
933 }
934
935 #[cfg(feature = "sql-code-mode")]
939 pub fn validate_sql_query(
940 &self,
941 sql: &str,
942 context: &ValidationContext,
943 ) -> Result<ValidationResult, ValidationError> {
944 let start = Instant::now();
945 let info = self.validate_sql_preamble(sql)?;
946 if let Some(failure) = self.check_sql_config_authorization(&info, start) {
947 return Ok(failure);
948 }
949 self.complete_sql_validation(sql, &info, context, start)
950 }
951
952 #[cfg(feature = "sql-code-mode")]
961 pub async fn validate_sql_query_async(
962 &self,
963 sql: &str,
964 context: &ValidationContext,
965 ) -> Result<ValidationResult, ValidationError> {
966 use crate::policy::StatementEntity;
967
968 let start = Instant::now();
969 let info = self.validate_sql_preamble(sql)?;
970 if let Some(failure) = self.check_sql_config_authorization(&info, start) {
971 return Ok(failure);
972 }
973
974 if let Some(ref evaluator) = self.policy_evaluator {
975 let statement_entity = StatementEntity::from_sql_info(&info);
976 let server_entity = self.config.to_sql_server_entity();
977
978 let decision = evaluator
979 .evaluate_statement(&statement_entity, &server_entity)
980 .await
981 .map_err(|e| {
982 ValidationError::InternalError(format!("Policy evaluation error: {}", e))
983 })?;
984
985 if !decision.allowed {
986 let violations = build_policy_violations(
987 &decision,
988 self.config.server_id(),
989 statement_entity.action(),
990 "SQL statement",
991 );
992
993 return Ok(ValidationResult::failure(
994 violations,
995 self.build_sql_metadata(&info, start.elapsed().as_millis() as u64),
996 ));
997 }
998 } else {
999 warn_no_policy_configured();
1000 }
1001
1002 self.complete_sql_validation(sql, &info, context, start)
1003 }
1004
1005 #[cfg(feature = "sql-code-mode")]
1007 fn validate_sql_preamble(
1008 &self,
1009 sql: &str,
1010 ) -> Result<crate::sql::SqlStatementInfo, ValidationError> {
1011 if !self.config.enabled {
1012 return Err(ValidationError::ConfigError(
1013 "Code Mode is not enabled for this server".into(),
1014 ));
1015 }
1016
1017 if sql.len() > self.config.max_query_length {
1018 return Err(ValidationError::SecurityError {
1019 message: format!(
1020 "SQL length {} exceeds maximum {}",
1021 sql.len(),
1022 self.config.max_query_length
1023 ),
1024 issue: crate::types::SecurityIssueType::HighComplexity,
1025 });
1026 }
1027
1028 let validator = crate::sql::SqlValidator::new();
1029 validator.validate(sql)
1030 }
1031
1032 #[cfg(feature = "sql-code-mode")]
1037 fn check_sql_config_authorization(
1038 &self,
1039 info: &crate::sql::SqlStatementInfo,
1040 start: Instant,
1041 ) -> Option<ValidationResult> {
1042 use crate::sql::SqlStatementType;
1043
1044 let stype = info.statement_type.as_str();
1045
1046 if self.config.sql_blocked_statements.contains(stype) {
1048 return Some(ValidationResult::failure(
1049 vec![PolicyViolation::new(
1050 "code_mode",
1051 "blocked_statement",
1052 format!("Statement type '{}' is blocked for this server", stype),
1053 )],
1054 self.build_sql_metadata(info, start.elapsed().as_millis() as u64),
1055 ));
1056 }
1057
1058 if !self.config.sql_allowed_statements.is_empty()
1060 && !self.config.sql_allowed_statements.contains(stype)
1061 {
1062 return Some(ValidationResult::failure(
1063 vec![PolicyViolation::new(
1064 "code_mode",
1065 "statement_not_allowed",
1066 format!("Statement type '{}' is not in the allowlist", stype),
1067 )],
1068 self.build_sql_metadata(info, start.elapsed().as_millis() as u64),
1069 ));
1070 }
1071
1072 match info.statement_type {
1074 SqlStatementType::Select => {
1075 if !self.config.sql_reads_enabled {
1076 return Some(ValidationResult::failure(
1077 vec![PolicyViolation::new(
1078 "code_mode",
1079 "reads_disabled",
1080 "SELECT statements are not enabled for this server",
1081 )],
1082 self.build_sql_metadata(info, start.elapsed().as_millis() as u64),
1083 ));
1084 }
1085 if self.config.sql_require_limit && !info.has_limit {
1089 return Some(ValidationResult::failure(
1090 vec![PolicyViolation::new(
1091 "code_mode",
1092 "missing_limit",
1093 "SELECT statements must declare a LIMIT for this server",
1094 )
1095 .with_suggestion("Add a LIMIT clause (e.g. `LIMIT 100`).")],
1096 self.build_sql_metadata(info, start.elapsed().as_millis() as u64),
1097 ));
1098 }
1099 },
1100 SqlStatementType::Insert | SqlStatementType::Update => {
1101 if !self.config.sql_allow_writes {
1102 return Some(ValidationResult::failure(
1103 vec![PolicyViolation::new(
1104 "code_mode",
1105 "writes_disabled",
1106 "INSERT/UPDATE statements are not enabled for this server",
1107 )
1108 .with_suggestion("Contact your administrator to enable sql_allow_writes.")],
1109 self.build_sql_metadata(info, start.elapsed().as_millis() as u64),
1110 ));
1111 }
1112 if matches!(info.statement_type, SqlStatementType::Update)
1114 && self.config.sql_require_where_on_writes
1115 && !info.has_where
1116 {
1117 return Some(ValidationResult::failure(
1118 vec![PolicyViolation::new(
1119 "code_mode",
1120 "missing_where",
1121 format!("{} without WHERE clause is not allowed", info.verb),
1122 )],
1123 self.build_sql_metadata(info, start.elapsed().as_millis() as u64),
1124 ));
1125 }
1126 },
1127 SqlStatementType::Delete => {
1128 if !self.config.sql_allow_deletes {
1129 return Some(ValidationResult::failure(
1130 vec![PolicyViolation::new(
1131 "code_mode",
1132 "deletes_disabled",
1133 "DELETE statements are not enabled for this server",
1134 )],
1135 self.build_sql_metadata(info, start.elapsed().as_millis() as u64),
1136 ));
1137 }
1138 if self.config.sql_require_where_on_writes && !info.has_where {
1139 return Some(ValidationResult::failure(
1140 vec![PolicyViolation::new(
1141 "code_mode",
1142 "missing_where",
1143 "DELETE without WHERE clause is not allowed",
1144 )],
1145 self.build_sql_metadata(info, start.elapsed().as_millis() as u64),
1146 ));
1147 }
1148 },
1149 SqlStatementType::Ddl => {
1150 if !self.config.sql_allow_ddl {
1151 return Some(ValidationResult::failure(
1152 vec![PolicyViolation::new(
1153 "code_mode",
1154 "ddl_disabled",
1155 "DDL (CREATE/ALTER/DROP/GRANT/REVOKE) is not enabled for this server",
1156 )],
1157 self.build_sql_metadata(info, start.elapsed().as_millis() as u64),
1158 ));
1159 }
1160 },
1161 SqlStatementType::Other => {
1162 return Some(ValidationResult::failure(
1163 vec![PolicyViolation::new(
1164 "code_mode",
1165 "unsupported_statement",
1166 format!("Statement type '{}' is not supported", info.verb),
1167 )],
1168 self.build_sql_metadata(info, start.elapsed().as_millis() as u64),
1169 ));
1170 },
1171 }
1172
1173 if !self.config.sql_blocked_tables.is_empty() {
1175 for table in &info.tables {
1176 if self.config.sql_blocked_tables.contains(table) {
1177 return Some(ValidationResult::failure(
1178 vec![PolicyViolation::new(
1179 "code_mode",
1180 "blocked_table",
1181 format!("Table '{}' is blocked for this server", table),
1182 )],
1183 self.build_sql_metadata(info, start.elapsed().as_millis() as u64),
1184 ));
1185 }
1186 }
1187 }
1188
1189 if !self.config.sql_allowed_tables.is_empty() {
1191 for table in &info.tables {
1192 if !self.config.sql_allowed_tables.contains(table) {
1193 return Some(ValidationResult::failure(
1194 vec![PolicyViolation::new(
1195 "code_mode",
1196 "table_not_allowed",
1197 format!("Table '{}' is not in the allowlist", table),
1198 )],
1199 self.build_sql_metadata(info, start.elapsed().as_millis() as u64),
1200 ));
1201 }
1202 }
1203 }
1204
1205 if !self.config.sql_blocked_columns.is_empty() {
1207 for col in &info.columns {
1208 if self.config.sql_blocked_columns.contains(col) {
1209 return Some(ValidationResult::failure(
1210 vec![PolicyViolation::new(
1211 "code_mode",
1212 "blocked_column",
1213 format!("Column '{}' is blocked for this server", col),
1214 )],
1215 self.build_sql_metadata(info, start.elapsed().as_millis() as u64),
1216 ));
1217 }
1218 }
1219 }
1220
1221 if info.join_count > self.config.sql_max_joins {
1223 return Some(ValidationResult::failure(
1224 vec![PolicyViolation::new(
1225 "code_mode",
1226 "excessive_joins",
1227 format!(
1228 "Query has {} JOINs, exceeds limit of {}",
1229 info.join_count, self.config.sql_max_joins
1230 ),
1231 )],
1232 self.build_sql_metadata(info, start.elapsed().as_millis() as u64),
1233 ));
1234 }
1235
1236 if info.estimated_rows > self.config.sql_max_rows {
1237 return Some(ValidationResult::failure(
1238 vec![PolicyViolation::new(
1239 "code_mode",
1240 "excessive_rows",
1241 format!(
1242 "Estimated rows {} exceeds limit of {}",
1243 info.estimated_rows, self.config.sql_max_rows
1244 ),
1245 )],
1246 self.build_sql_metadata(info, start.elapsed().as_millis() as u64),
1247 ));
1248 }
1249
1250 None
1251 }
1252
1253 #[cfg(feature = "sql-code-mode")]
1255 fn complete_sql_validation(
1256 &self,
1257 sql: &str,
1258 info: &crate::sql::SqlStatementInfo,
1259 context: &ValidationContext,
1260 start: Instant,
1261 ) -> Result<ValidationResult, ValidationError> {
1262 let validator = crate::sql::SqlValidator::new();
1263 let security_analysis = validator.analyze_security(info);
1264 let risk_level = security_analysis.assess_risk();
1265
1266 if security_analysis
1267 .potential_issues
1268 .iter()
1269 .any(|i| i.is_critical())
1270 {
1271 let violations: Vec<PolicyViolation> = security_analysis
1272 .potential_issues
1273 .iter()
1274 .filter(|i| i.is_critical())
1275 .map(|i| {
1276 PolicyViolation::new("security", format!("{:?}", i.issue_type), &i.message)
1277 })
1278 .collect();
1279
1280 return Ok(ValidationResult::failure(
1281 violations,
1282 self.build_sql_metadata(info, start.elapsed().as_millis() as u64),
1283 ));
1284 }
1285
1286 let context_hash = context.context_hash();
1287 let token = self.token_generator.generate(
1288 sql,
1289 &context.user_id,
1290 &context.session_id,
1291 self.config.server_id(),
1292 &context_hash,
1293 risk_level,
1294 self.config.token_ttl_seconds,
1295 );
1296
1297 let token_string = token.encode().map_err(|e| {
1298 ValidationError::InternalError(format!("Failed to encode token: {}", e))
1299 })?;
1300
1301 let explanation = self.generate_sql_explanation(info, &security_analysis);
1302 let metadata = self.build_sql_metadata(info, start.elapsed().as_millis() as u64);
1303
1304 let mut result = ValidationResult::success(explanation, risk_level, token_string, metadata);
1305
1306 for issue in &security_analysis.potential_issues {
1307 if !issue.is_critical() {
1308 result.warnings.push(issue.message.clone());
1309 }
1310 }
1311
1312 Ok(result)
1313 }
1314
1315 #[cfg(feature = "sql-code-mode")]
1317 fn build_sql_metadata(
1318 &self,
1319 info: &crate::sql::SqlStatementInfo,
1320 validation_time_ms: u64,
1321 ) -> ValidationMetadata {
1322 let inferred = UnifiedAction::from_sql(info.statement_type.as_str());
1323 let action = UnifiedAction::resolve(inferred, &self.config.action_tags, &info.verb);
1324
1325 ValidationMetadata {
1326 is_read_only: info.statement_type.is_read_only(),
1327 estimated_rows: Some(info.estimated_rows),
1328 accessed_types: info.tables.iter().cloned().collect(),
1329 accessed_fields: info.columns.iter().cloned().collect(),
1330 has_aggregation: info.has_aggregation,
1331 code_type: Some(if info.statement_type.is_read_only() {
1332 crate::types::CodeType::SqlQuery
1333 } else {
1334 crate::types::CodeType::SqlMutation
1335 }),
1336 action: Some(action),
1337 validation_time_ms,
1338 }
1339 }
1340
1341 #[cfg(feature = "sql-code-mode")]
1343 fn generate_sql_explanation(
1344 &self,
1345 info: &crate::sql::SqlStatementInfo,
1346 security_analysis: &crate::types::SecurityAnalysis,
1347 ) -> String {
1348 let mut parts = Vec::new();
1349
1350 let verb_phrase = match info.statement_type.as_str() {
1351 "SELECT" => "This query reads data",
1352 "INSERT" => "This statement inserts rows",
1353 "UPDATE" => "This statement updates rows",
1354 "DELETE" => "This statement deletes rows",
1355 "DDL" => "This statement changes schema or permissions",
1356 _ => "This statement",
1357 };
1358
1359 let tables_phrase = if info.tables.is_empty() {
1360 String::new()
1361 } else {
1362 let mut ts: Vec<&String> = info.tables.iter().collect();
1363 ts.sort();
1364 format!(
1365 " in table(s): {}",
1366 ts.into_iter().cloned().collect::<Vec<_>>().join(", ")
1367 )
1368 };
1369
1370 parts.push(format!("{}{}.", verb_phrase, tables_phrase));
1371
1372 if info.has_where {
1373 parts.push("Filtered with WHERE clause.".to_string());
1374 }
1375 if info.has_limit {
1376 parts.push(format!("Limited to {} rows.", info.estimated_rows));
1377 }
1378 if info.join_count > 0 {
1379 parts.push(format!("Uses {} JOIN(s).", info.join_count));
1380 }
1381 if info.subquery_count > 0 {
1382 parts.push(format!("Contains {} subquer(ies).", info.subquery_count));
1383 }
1384
1385 let risk = security_analysis.assess_risk();
1386 parts.push(format!("Risk: {}", risk));
1387
1388 parts.join(" ")
1389 }
1390
1391 pub fn should_auto_approve(&self, result: &ValidationResult) -> bool {
1393 result.is_valid && self.config.should_auto_approve(result.risk_level)
1394 }
1395
1396 pub fn config(&self) -> &CodeModeConfig {
1398 &self.config
1399 }
1400
1401 pub fn token_generator(&self) -> &T {
1403 &self.token_generator
1404 }
1405}
1406
1407#[cfg(test)]
1408mod tests {
1409 use super::*;
1410 use crate::types::RiskLevel;
1411
1412 fn test_pipeline() -> ValidationPipeline {
1413 ValidationPipeline::new(CodeModeConfig::enabled(), b"test-secret-key!".to_vec()).unwrap()
1414 }
1415
1416 fn test_context() -> ValidationContext {
1417 ValidationContext::new("user-123", "session-456", "schema-hash", "perms-hash")
1418 }
1419
1420 #[test]
1421 fn test_simple_query_validation() {
1422 let pipeline = test_pipeline();
1423 let ctx = test_context();
1424
1425 let result = pipeline
1426 .validate_graphql_query("query { users { id name } }", &ctx)
1427 .unwrap();
1428
1429 assert!(result.is_valid);
1430 assert!(result.approval_token.is_some());
1431 assert_eq!(result.risk_level, RiskLevel::Low);
1432 assert!(result.explanation.contains("read"));
1433 }
1434
1435 #[test]
1436 fn test_mutation_blocked() {
1437 let mut config = CodeModeConfig::enabled();
1438 config.allow_mutations = false;
1439
1440 let pipeline = ValidationPipeline::new(config, b"test-secret-key!".to_vec()).unwrap();
1441 let ctx = test_context();
1442
1443 let result = pipeline
1444 .validate_graphql_query("mutation { createUser(name: \"test\") { id } }", &ctx)
1445 .unwrap();
1446
1447 assert!(!result.is_valid);
1448 assert!(result
1449 .violations
1450 .iter()
1451 .any(|v| v.rule == "allow_mutations"));
1452 }
1453
1454 #[test]
1455 fn test_disabled_code_mode() {
1456 let config = CodeModeConfig::default();
1457 let pipeline = ValidationPipeline::new(config, b"test-secret-key!".to_vec()).unwrap();
1458 let ctx = test_context();
1459
1460 let result = pipeline.validate_graphql_query("query { users { id } }", &ctx);
1461
1462 assert!(matches!(result, Err(ValidationError::ConfigError(_))));
1463 }
1464
1465 #[test]
1466 fn test_auto_approve_low_risk() {
1467 let pipeline = test_pipeline();
1468 let ctx = test_context();
1469
1470 let result = pipeline
1471 .validate_graphql_query("query { users { id } }", &ctx)
1472 .unwrap();
1473
1474 assert!(pipeline.should_auto_approve(&result));
1475 }
1476
1477 #[test]
1478 fn test_context_hash() {
1479 let ctx = test_context();
1480 let hash1 = ctx.context_hash();
1481
1482 let ctx2 =
1483 ValidationContext::new("user-123", "session-456", "different-schema", "perms-hash");
1484 let hash2 = ctx2.context_hash();
1485
1486 assert_ne!(hash1, hash2);
1487 }
1488
1489 #[test]
1490 fn test_blocked_query_rejected() {
1491 let mut config = CodeModeConfig::enabled();
1492 config.blocked_queries.insert("users".to_string());
1493
1494 let pipeline = ValidationPipeline::new(config, b"test-secret-key!".to_vec()).unwrap();
1495 let ctx = test_context();
1496
1497 let result = pipeline
1498 .validate_graphql_query("query { users { id } }", &ctx)
1499 .unwrap();
1500
1501 assert!(!result.is_valid);
1502 assert!(result.violations.iter().any(|v| v.rule == "blocked_query"));
1503 }
1504
1505 #[test]
1506 fn test_allowed_queries_enforced() {
1507 let mut config = CodeModeConfig::enabled();
1508 config.allowed_queries.insert("orders".to_string());
1509
1510 let pipeline = ValidationPipeline::new(config, b"test-secret-key!".to_vec()).unwrap();
1511 let ctx = test_context();
1512
1513 let result = pipeline
1515 .validate_graphql_query("query { users { id } }", &ctx)
1516 .unwrap();
1517
1518 assert!(!result.is_valid);
1519 assert!(result
1520 .violations
1521 .iter()
1522 .any(|v| v.rule == "query_not_allowed"));
1523 }
1524
1525 #[cfg(feature = "sql-code-mode")]
1530 mod sql_tests {
1531 use super::*;
1532
1533 fn sql_pipeline() -> ValidationPipeline {
1534 ValidationPipeline::new(CodeModeConfig::enabled(), b"test-secret-key!".to_vec())
1535 .unwrap()
1536 }
1537
1538 #[test]
1539 fn validates_select() {
1540 let pipeline = sql_pipeline();
1541 let ctx = test_context();
1542
1543 let result = pipeline
1544 .validate_sql_query("SELECT id, name FROM users LIMIT 10", &ctx)
1545 .unwrap();
1546
1547 assert!(result.is_valid);
1548 assert!(result.approval_token.is_some());
1549 }
1550
1551 #[test]
1552 fn rejects_insert_when_writes_disabled() {
1553 let pipeline = sql_pipeline();
1554 let ctx = test_context();
1555
1556 let result = pipeline
1557 .validate_sql_query("INSERT INTO users (id, name) VALUES (1, 'Alice')", &ctx)
1558 .unwrap();
1559
1560 assert!(!result.is_valid);
1561 assert!(result
1562 .violations
1563 .iter()
1564 .any(|v| v.rule == "writes_disabled"));
1565 }
1566
1567 #[test]
1568 fn permits_insert_when_writes_enabled() {
1569 let mut config = CodeModeConfig::enabled();
1570 config.sql_allow_writes = true;
1571 let pipeline = ValidationPipeline::new(config, b"test-secret-key!".to_vec()).unwrap();
1572 let ctx = test_context();
1573
1574 let result = pipeline
1575 .validate_sql_query("INSERT INTO users (id, name) VALUES (1, 'Alice')", &ctx)
1576 .unwrap();
1577
1578 assert!(result.is_valid);
1579 }
1580
1581 #[test]
1582 fn rejects_update_without_where_by_default() {
1583 let mut config = CodeModeConfig::enabled();
1584 config.sql_allow_writes = true;
1585 let pipeline = ValidationPipeline::new(config, b"test-secret-key!".to_vec()).unwrap();
1586 let ctx = test_context();
1587
1588 let result = pipeline
1589 .validate_sql_query("UPDATE users SET active = 0", &ctx)
1590 .unwrap();
1591
1592 assert!(!result.is_valid);
1593 assert!(result.violations.iter().any(|v| v.rule == "missing_where"));
1594 }
1595
1596 #[test]
1597 fn rejects_blocked_table() {
1598 let mut config = CodeModeConfig::enabled();
1599 config.sql_blocked_tables.insert("secrets".to_string());
1600 let pipeline = ValidationPipeline::new(config, b"test-secret-key!".to_vec()).unwrap();
1601 let ctx = test_context();
1602
1603 let result = pipeline
1604 .validate_sql_query("SELECT * FROM secrets LIMIT 10", &ctx)
1605 .unwrap();
1606
1607 assert!(!result.is_valid);
1608 assert!(result.violations.iter().any(|v| v.rule == "blocked_table"));
1609 }
1610
1611 #[test]
1612 fn rejects_non_allowlisted_table() {
1613 let mut config = CodeModeConfig::enabled();
1614 config.sql_allowed_tables.insert("users".to_string());
1615 let pipeline = ValidationPipeline::new(config, b"test-secret-key!".to_vec()).unwrap();
1616 let ctx = test_context();
1617
1618 let result = pipeline
1620 .validate_sql_query("SELECT id FROM orders LIMIT 10", &ctx)
1621 .unwrap();
1622
1623 assert!(!result.is_valid);
1624 assert!(result
1625 .violations
1626 .iter()
1627 .any(|v| v.rule == "table_not_allowed"));
1628 }
1629
1630 #[test]
1631 fn rejects_blocked_column() {
1632 let mut config = CodeModeConfig::enabled();
1633 config.sql_blocked_columns.insert("password".to_string());
1634 let pipeline = ValidationPipeline::new(config, b"test-secret-key!".to_vec()).unwrap();
1635 let ctx = test_context();
1636
1637 let result = pipeline
1638 .validate_sql_query("SELECT id, password FROM users LIMIT 10", &ctx)
1639 .unwrap();
1640
1641 assert!(!result.is_valid);
1642 assert!(result.violations.iter().any(|v| v.rule == "blocked_column"));
1643 }
1644
1645 #[test]
1646 fn rejects_ddl_by_default() {
1647 let pipeline = sql_pipeline();
1648 let ctx = test_context();
1649
1650 let result = pipeline
1651 .validate_sql_query("CREATE TABLE foo (id INT)", &ctx)
1652 .unwrap();
1653
1654 assert!(!result.is_valid);
1655 assert!(result.violations.iter().any(|v| v.rule == "ddl_disabled"));
1656 }
1657
1658 #[test]
1659 fn rejects_syntax_error() {
1660 let pipeline = sql_pipeline();
1661 let ctx = test_context();
1662
1663 let result = pipeline.validate_sql_query("SELEC id FRM users", &ctx);
1664
1665 assert!(matches!(result, Err(ValidationError::ParseError { .. })));
1666 }
1667
1668 fn require_limit_pipeline() -> ValidationPipeline {
1673 let mut config = CodeModeConfig::enabled();
1674 config.sql_require_limit = true;
1675 ValidationPipeline::new(config, b"test-secret-key!".to_vec()).unwrap()
1676 }
1677
1678 #[test]
1679 fn require_limit_rejects_select_without_limit() {
1680 let pipeline = require_limit_pipeline();
1681 let ctx = test_context();
1682
1683 let result = pipeline
1686 .validate_sql_query("SELECT * FROM Artist", &ctx)
1687 .unwrap();
1688
1689 assert!(!result.is_valid);
1690 assert!(result.violations.iter().any(|v| v.rule == "missing_limit"));
1691 }
1692
1693 #[test]
1694 fn require_limit_accepts_select_with_limit() {
1695 let pipeline = require_limit_pipeline();
1696 let ctx = test_context();
1697
1698 let result = pipeline
1699 .validate_sql_query("SELECT * FROM Artist LIMIT 25", &ctx)
1700 .unwrap();
1701
1702 assert!(result.is_valid);
1703 assert!(!result.violations.iter().any(|v| v.rule == "missing_limit"));
1704 }
1705
1706 #[test]
1707 fn require_limit_default_accepts_bare_select() {
1708 let pipeline = sql_pipeline();
1710 let ctx = test_context();
1711
1712 let result = pipeline
1713 .validate_sql_query("SELECT * FROM Artist", &ctx)
1714 .unwrap();
1715
1716 assert!(result.is_valid);
1717 assert!(!result.violations.iter().any(|v| v.rule == "missing_limit"));
1718 }
1719
1720 #[test]
1721 fn require_limit_does_not_affect_writes() {
1722 let mut config = CodeModeConfig::enabled();
1725 config.sql_require_limit = true;
1726 config.sql_allow_writes = true;
1727 let pipeline = ValidationPipeline::new(config, b"test-secret-key!".to_vec()).unwrap();
1728 let ctx = test_context();
1729
1730 let result = pipeline
1731 .validate_sql_query("INSERT INTO Artist (Name) VALUES ('AC/DC')", &ctx)
1732 .unwrap();
1733
1734 assert!(result.is_valid);
1735 assert!(!result.violations.iter().any(|v| v.rule == "missing_limit"));
1736 }
1737
1738 #[test]
1739 fn require_limit_serde_round_trip() {
1740 let without: CodeModeConfig =
1742 toml::from_str("enabled = true\n").expect("parse without require_limit");
1743 assert!(!without.sql_require_limit);
1744
1745 let with: CodeModeConfig = toml::from_str("enabled = true\nrequire_limit = true\n")
1746 .expect("parse with require_limit");
1747 assert!(with.sql_require_limit);
1748 }
1749
1750 struct FixedDenyEvaluator {
1751 errors: Vec<String>,
1752 }
1753
1754 #[async_trait::async_trait]
1755 impl PolicyEvaluator for FixedDenyEvaluator {
1756 async fn evaluate_operation(
1757 &self,
1758 _op: &crate::policy::OperationEntity,
1759 _cfg: &crate::policy::ServerConfigEntity,
1760 ) -> Result<crate::policy::AuthorizationDecision, crate::policy::PolicyEvaluationError>
1761 {
1762 Ok(crate::policy::AuthorizationDecision {
1763 allowed: false,
1764 determining_policies: vec![],
1765 errors: self.errors.clone(),
1766 })
1767 }
1768
1769 #[cfg(feature = "sql-code-mode")]
1770 async fn evaluate_statement(
1771 &self,
1772 _stmt: &crate::policy::StatementEntity,
1773 _server: &crate::policy::SqlServerEntity,
1774 ) -> Result<crate::policy::AuthorizationDecision, crate::policy::PolicyEvaluationError>
1775 {
1776 Ok(crate::policy::AuthorizationDecision {
1777 allowed: false,
1778 determining_policies: vec![],
1779 errors: self.errors.clone(),
1780 })
1781 }
1782
1783 fn name(&self) -> &str {
1784 "fixed-deny-test"
1785 }
1786 }
1787
1788 fn sql_pipeline_with_evaluator(evaluator: Arc<dyn PolicyEvaluator>) -> ValidationPipeline {
1789 let mut config = CodeModeConfig::enabled();
1790 config.server_id = Some("test-server".to_string());
1791 ValidationPipeline::with_policy_evaluator(
1792 config,
1793 b"test-secret-key!".to_vec(),
1794 evaluator,
1795 )
1796 .unwrap()
1797 }
1798
1799 #[tokio::test]
1800 async fn default_deny_produces_synthetic_violation() {
1801 let evaluator =
1802 Arc::new(FixedDenyEvaluator { errors: vec![] }) as Arc<dyn PolicyEvaluator>;
1803 let pipeline = sql_pipeline_with_evaluator(evaluator);
1804 let ctx = test_context();
1805
1806 let result = pipeline
1807 .validate_sql_query_async("SELECT id FROM users LIMIT 10", &ctx)
1808 .await
1809 .unwrap();
1810
1811 assert!(!result.is_valid);
1812 let default_deny = result
1813 .violations
1814 .iter()
1815 .find(|v| v.rule == "default_deny")
1816 .expect("expected a synthetic default_deny violation");
1817 assert!(default_deny.message.contains("test-server"));
1818 assert!(default_deny.message.contains("Read"));
1819 }
1820
1821 #[tokio::test]
1822 async fn policy_errors_flow_to_violations() {
1823 let evaluator = Arc::new(FixedDenyEvaluator {
1824 errors: vec!["schema validation: missing required attribute X".to_string()],
1825 }) as Arc<dyn PolicyEvaluator>;
1826 let pipeline = sql_pipeline_with_evaluator(evaluator);
1827 let ctx = test_context();
1828
1829 let result = pipeline
1830 .validate_sql_query_async("SELECT id FROM users LIMIT 10", &ctx)
1831 .await
1832 .unwrap();
1833
1834 assert!(!result.is_valid);
1835 let policy_error = result
1836 .violations
1837 .iter()
1838 .find(|v| v.rule == "evaluation_error")
1839 .expect("expected a policy_error violation");
1840 assert!(policy_error.message.contains("schema validation"));
1841 }
1842
1843 #[test]
1844 fn rejects_excessive_joins() {
1845 let mut config = CodeModeConfig::enabled();
1846 config.sql_max_joins = 1;
1847 let pipeline = ValidationPipeline::new(config, b"test-secret-key!".to_vec()).unwrap();
1848 let ctx = test_context();
1849
1850 let result = pipeline
1851 .validate_sql_query(
1852 "SELECT u.id FROM users u \
1853 JOIN orders o ON u.id = o.user_id \
1854 JOIN items i ON o.id = i.order_id LIMIT 10",
1855 &ctx,
1856 )
1857 .unwrap();
1858
1859 assert!(!result.is_valid);
1860 assert!(result
1861 .violations
1862 .iter()
1863 .any(|v| v.rule == "excessive_joins"));
1864 }
1865 }
1866
1867 #[cfg(feature = "openapi-code-mode")]
1871 mod openapi_static_policy_tests {
1872 use super::*;
1873 use crate::config::OperationEntry;
1874 use crate::policy::NoopPolicyEvaluator;
1875
1876 fn noop_pipeline(config: CodeModeConfig) -> ValidationPipeline {
1877 ValidationPipeline::with_policy_evaluator(
1878 config,
1879 b"test-secret-key!".to_vec(),
1880 Arc::new(NoopPolicyEvaluator::new()),
1881 )
1882 .unwrap()
1883 }
1884
1885 async fn verdict(config: CodeModeConfig, code: &str) -> ValidationResult {
1886 let pipeline = noop_pipeline(config.clone());
1887 let sync = pipeline
1888 .validate_javascript_code(code, &test_context())
1889 .unwrap();
1890 let async_result = pipeline
1891 .validate_javascript_code_async(code, &test_context())
1892 .await
1893 .unwrap();
1894 assert_eq!(sync.is_valid, async_result.is_valid, "sync and async agree");
1895 async_result
1896 }
1897
1898 fn rules(result: &ValidationResult) -> Vec<&str> {
1899 result.violations.iter().map(|v| v.rule.as_str()).collect()
1900 }
1901
1902 fn enabled() -> CodeModeConfig {
1903 let mut config = CodeModeConfig::enabled();
1904 config.server_id = Some("umls".into());
1905 config
1906 }
1907
1908 const WRITE: &str = "await api.post('/items', {}); return 1;";
1909
1910 #[tokio::test]
1912 async fn write_allowlist_does_not_widen_under_noop() {
1913 let mut config = enabled();
1914 config.openapi_allow_writes = true;
1915 config.openapi_allowed_writes = ["POST /items".to_string()].into();
1916 assert!(verdict(config.clone(), WRITE).await.is_valid);
1917 let refused = verdict(config, "await api.post('/admin/reset', {}); return 1;").await;
1918 assert!(!refused.is_valid);
1919 assert_eq!(rules(&refused), ["not_in_allowlist"]);
1920 }
1921
1922 #[tokio::test]
1924 async fn read_deny_refuses_a_get_only_script() {
1925 let mut config = enabled();
1926 config.openapi_reads_enabled = false;
1927 let refused = verdict(config, "const r = await api.get('/items'); return r;").await;
1928 assert!(!refused.is_valid);
1929 assert!(refused.violations[0]
1930 .message
1931 .contains("read operations are deny_all"));
1932 }
1933
1934 #[tokio::test]
1936 async fn read_only_server_refuses_a_write() {
1937 let refused = verdict(enabled(), WRITE).await;
1938 assert!(!refused.is_valid);
1939 assert_eq!(rules(&refused), ["class_denied"]);
1940 }
1941
1942 #[tokio::test]
1943 async fn deletes_need_allow_deletes() {
1944 let mut config = enabled();
1945 config.openapi_allow_writes = true;
1946 let refused = verdict(config, "await api.delete('/items/1'); return 1;").await;
1947 assert!(!refused.is_valid);
1948 assert!(refused.violations[0].message.contains("delete operations"));
1949 }
1950
1951 #[tokio::test]
1952 async fn blocked_paths_are_enforced_under_noop() {
1953 let mut config = enabled();
1954 config.openapi_blocked_paths = ["/internal".to_string()].into();
1955 let refused = verdict(config, "await api.get('/internal/keys'); return 1;").await;
1956 assert!(!refused.is_valid);
1957 assert_eq!(rules(&refused), ["blocked_path"]);
1958 }
1959
1960 #[tokio::test]
1961 async fn an_installed_class_policy_replaces_the_derived_one() {
1962 use crate::openapi_policy::{ClassMode, OpenApiClassPolicy};
1963 let get = "const r = await api.get('/items'); return r;";
1964 let config = enabled();
1965 let policy = OpenApiClassPolicy::from_config(&config)
1966 .with_mode(crate::types::UnifiedAction::Read, ClassMode::DenyAll);
1967 let pipeline = noop_pipeline(config).with_openapi_class_policy(policy);
1968 let refused = pipeline
1969 .validate_javascript_code_async(get, &test_context())
1970 .await
1971 .unwrap();
1972 assert!(!refused.is_valid);
1973 assert!(pipeline
1974 .openapi_class_policy()
1975 .to_string()
1976 .starts_with("read=deny_all"));
1977 }
1978
1979 #[tokio::test]
1982 async fn reclassifying_an_operation_changes_the_verdict() {
1983 let search = "await api.post('/search', {q: 'aspirin'}); return 1;";
1984 assert!(!verdict(enabled(), search).await.is_valid);
1985
1986 let mut config = enabled();
1987 config.operations = vec![OperationEntry {
1988 id: "search".into(),
1989 category: "read".into(),
1990 description: String::new(),
1991 path: Some("POST /search".into()),
1992 }];
1993 assert!(verdict(config.clone(), search).await.is_valid);
1994
1995 config.operations[0].category = "admin".into();
1996 let refused = verdict(config, search).await;
1997 assert!(refused.violations[0]
1998 .message
1999 .contains("admin operations are deny_all"));
2000 }
2001 }
2002}