Skip to main content

pmcp_code_mode/
validation.rs

1//! Validation pipeline for Code Mode.
2//!
3//! The pipeline validates code through multiple stages:
4//! 1. Parse (syntax check)
5//! 2. Policy evaluation (PolicyEvaluator trait or basic config checks)
6//! 3. Security analysis
7//! 4. Explanation generation
8//! 5. Token generation
9
10use 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
28/// Static flag to ensure the "no policy evaluator" warning is only logged once per process.
29static NO_POLICY_WARNING_LOGGED: AtomicBool = AtomicBool::new(false);
30
31/// Build the `Vec<PolicyViolation>` for a denied authorization decision.
32///
33/// Flows all available diagnostic information from the decision into violations
34/// so the client sees *why* a request was denied:
35///
36/// - Each `determining_policies` entry becomes a `policy` violation naming that policy
37/// - Each `decision.errors` entry becomes a `policy_error` violation surfacing the
38///   underlying Cedar/AVP error (e.g., "entity does not conform to schema")
39/// - If both lists are empty (the canonical "default-deny: no Permit matched" case)
40///   a synthetic `default_deny` violation is injected with context about the
41///   server_id and action, so the client has *something* to debug against
42///
43/// Without this, default-deny would surface as `valid:false, violations:[]` —
44/// impossible to debug from the client.
45fn 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
85/// Log a warning when Code Mode is enabled without a policy evaluator.
86fn 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/// Context for validation (user, session, schema).
99#[derive(Debug, Clone)]
100pub struct ValidationContext {
101    /// User ID from access token
102    pub user_id: String,
103
104    /// MCP session ID
105    pub session_id: String,
106
107    /// Schema hash for context binding
108    pub schema_hash: String,
109
110    /// Permissions hash for context binding
111    pub permissions_hash: String,
112}
113
114impl ValidationContext {
115    /// Create a new validation context.
116    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    /// Compute the combined context hash.
131    pub fn context_hash(&self) -> String {
132        compute_context_hash(&self.schema_hash, &self.permissions_hash)
133    }
134}
135
136/// The validation pipeline that orchestrates all validation stages.
137pub 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    /// The static OpenAPI class policy: derived from `config`, or installed
148    /// with [`Self::with_openapi_class_policy`].
149    #[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    /// Create a new validation pipeline with default generators.
158    ///
159    /// **Warning**: This constructor does not configure a policy evaluator.
160    /// Only basic config checks will be performed.
161    ///
162    /// # Errors
163    ///
164    /// Returns [`TokenError::SecretTooShort`] if the token secret is shorter
165    /// than [`HmacTokenGenerator::MIN_SECRET_LEN`] (16 bytes).
166    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    /// Create a new validation pipeline from a [`TokenSecret`].
196    ///
197    /// Convenience constructor for production callers and derive macro generated
198    /// code. Callers never need to call `expose_secret()` directly.
199    ///
200    /// **Security note**: Internally this creates an intermediate `Vec<u8>` copy
201    /// of the secret bytes that is **not** zeroized on drop. For maximum security,
202    /// prefer [`TokenSecret::from_env`] which minimizes secret copies. This
203    /// limitation will be addressed in a future version by adding a
204    /// `HmacTokenGenerator::from_secret_ref` constructor.
205    ///
206    /// **Warning**: This constructor does not configure a policy evaluator.
207    /// Only basic config checks will be performed.
208    ///
209    /// # Errors
210    ///
211    /// Returns [`TokenError::SecretTooShort`] if the token secret is shorter
212    /// than [`HmacTokenGenerator::MIN_SECRET_LEN`] (16 bytes).
213    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    /// Create a new validation pipeline with a policy evaluator.
221    ///
222    /// # Errors
223    ///
224    /// Returns [`TokenError::SecretTooShort`] if the token secret is shorter
225    /// than [`HmacTokenGenerator::MIN_SECRET_LEN`] (16 bytes).
226    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    /// Create a pipeline from a [`TokenSecret`] with an `Arc` policy evaluator.
261    ///
262    /// Used by derive macro generated code where the policy evaluator is
263    /// stored as `Arc<dyn PolicyEvaluator>` on the parent struct.
264    ///
265    /// # Errors
266    ///
267    /// Returns [`TokenError::SecretTooShort`] if the token secret is shorter
268    /// than [`HmacTokenGenerator::MIN_SECRET_LEN`] (16 bytes).
269    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    /// Create a pipeline with custom generators.
280    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    /// Set the policy evaluator for this pipeline.
307    pub fn set_policy_evaluator(&mut self, evaluator: Arc<dyn PolicyEvaluator>) {
308        self.policy_evaluator = Some(evaluator);
309    }
310
311    /// Check if a policy evaluator is configured.
312    pub fn has_policy_evaluator(&self) -> bool {
313        self.policy_evaluator.is_some()
314    }
315
316    /// Replace the static OpenAPI class policy derived from the config's
317    /// `openapi_*` keys, for a caller whose config can say more (a read
318    /// allowlist, an `admin` mode). It is enforced exactly as the derived one:
319    /// before any policy evaluator, which can only narrow it.
320    #[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    /// The static OpenAPI class policy this pipeline enforces.
331    #[cfg(feature = "openapi-code-mode")]
332    pub fn openapi_class_policy(&self) -> &crate::openapi_policy::OpenApiClassPolicy {
333        &self.openapi_policy
334    }
335
336    /// Check mutation and query authorization against config (blocklists, allowlists).
337    ///
338    /// This is the authorization logic shared between the sync and async validation paths.
339    /// It uses the already-parsed `query_info` to avoid re-parsing.
340    fn check_config_authorization(
341        &self,
342        query_info: &GraphQLQueryInfo,
343        start: Instant,
344    ) -> Option<ValidationResult> {
345        // Mutation authorization checks
346        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        // Query (read) authorization checks -- mirrors mutation enforcement above
397        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    /// Validate a GraphQL query using basic config checks only.
441    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        // Config-based authorization checks (mutation blocklist/allowlist, query blocklist/allowlist)
468        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    /// Validate a GraphQL query using a policy evaluator (async).
476    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        // Policy evaluation via trait
503        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            // Reuse already-parsed query_info instead of re-parsing via validate_graphql_query
537            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    /// Complete validation after policy check passes.
546    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    /// Build metadata from query info.
627    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    /// Validate JavaScript code for OpenAPI Code Mode (sync, no policy evaluation).
654    ///
655    /// Runs config-level checks only. For policy evaluation (Cedar/AVP), use
656    /// [`validate_javascript_code_async`] instead. Retained for backward
657    /// compatibility with callers that don't need policy enforcement.
658    #[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    /// Validate JavaScript code with async policy evaluation.
673    ///
674    /// Mirrors [`validate_graphql_query_async`] but for JavaScript/OpenAPI:
675    /// 1. Parse JS via SWC + config-level checks (shared with sync version)
676    /// 2. Policy evaluation via [`PolicyEvaluator::evaluate_script`] (async, fail-closed)
677    /// 3. Security analysis + token generation
678    ///
679    /// When no policy evaluator is configured, falls back to config-only checks.
680    #[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        // Policy evaluation via evaluate_script (mirrors GraphQL's evaluate_operation)
695        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    /// Shared JavaScript preamble: enabled check, length check, parse.
733    #[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    /// Config-level authorization checks for JavaScript code.
756    ///
757    /// Returns `Some(failure)` if a config check denied the code,
758    /// `None` if all checks pass. Shared between sync and async paths
759    /// (mirrors `check_config_authorization` for GraphQL).
760    #[cfg(feature = "openapi-code-mode")]
761    fn check_js_config_authorization(
762        &self,
763        code_info: &JavaScriptCodeInfo,
764        start: Instant,
765    ) -> Option<ValidationResult> {
766        // The whole static OpenAPI policy, complete without an evaluator. An
767        // evaluator, when configured, runs after this and can only narrow it.
768        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    /// Complete JavaScript validation after policy checks pass.
781    #[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    /// Build metadata from JavaScript code info.
843    #[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    /// Generate a human-readable explanation for JavaScript code.
883    #[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    /// Validate a SQL statement using basic config checks only (no policy evaluator).
936    ///
937    /// For policy evaluation (Cedar/AVP), use [`validate_sql_query_async`] instead.
938    #[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    /// Validate a SQL statement with async policy evaluation.
953    ///
954    /// Mirrors [`validate_graphql_query_async`] and [`validate_javascript_code_async`]:
955    /// 1. Parse SQL via sqlparser + config-level checks (shared with sync version)
956    /// 2. Policy evaluation via [`PolicyEvaluator::evaluate_statement`] (async, fail-closed)
957    /// 3. Security analysis + token generation
958    ///
959    /// When no policy evaluator is configured, falls back to config-only checks.
960    #[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    /// Shared SQL preamble: enabled check, length check, parse.
1006    #[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    /// Config-level authorization checks for SQL.
1033    ///
1034    /// Returns `Some(failure)` if a config check denied the statement,
1035    /// `None` if all checks pass.
1036    #[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        // Statement-type blocklist
1047        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        // Statement-type allowlist
1059        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        // Global action flags
1073        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                // Require a LIMIT on read-only statements when configured. This is
1086                // an opt-in guard independent of the sql_max_rows row-estimate
1087                // heuristic — a bare SELECT cannot be approved when set.
1088                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                // WHERE requirement applies to UPDATE only — INSERTs never have WHERE.
1113                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        // Table-level blocklist
1174        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        // Table-level allowlist
1190        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        // Column-level blocklist
1206        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        // Structural limits
1222        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    /// Complete SQL validation after config/policy checks pass.
1254    #[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    /// Build metadata from SQL statement info.
1316    #[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    /// Generate a human-readable explanation for a SQL statement.
1342    #[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    /// Check if a validation result should be auto-approved.
1392    pub fn should_auto_approve(&self, result: &ValidationResult) -> bool {
1393        result.is_valid && self.config.should_auto_approve(result.risk_level)
1394    }
1395
1396    /// Get the config.
1397    pub fn config(&self) -> &CodeModeConfig {
1398        &self.config
1399    }
1400
1401    /// Get the token generator.
1402    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        // "users" is not in the allowlist -- should be rejected
1514        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    // ============================================================================
1526    // SQL Code Mode tests
1527    // ============================================================================
1528
1529    #[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            // "orders" is not in the allowlist
1619            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        // ====================================================================
1669        // sql_require_limit (VERIFICATION Gap 1) tests
1670        // ====================================================================
1671
1672        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            // Bare SELECT with no LIMIT — rejected even though the row estimate
1684            // does not exceed sql_max_rows (the Gap-1 condition).
1685            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            // require_limit defaults to false — no regression for configs that omit it.
1709            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            // require_limit is a read-only-query guard; a write must not be
1723            // rejected for a missing LIMIT.
1724            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            // Omitted -> defaults to false; present -> parses to true.
1741            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    /// The static OpenAPI policy enforced at the pipeline boundary, under the
1868    /// Noop evaluator a config-driven server runs with. Each test fails when
1869    /// the static gate defers the key to an evaluator.
1870    #[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        /// D1 (T-191-40): a write allowlist widened to allow-all under Noop.
1911        #[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        /// D2: reads could not be denied.
1923        #[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        /// UMLS probe 1: a declared read-only server refuses a write.
1935        #[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        /// UMLS probe 4 / platform probe: reclassifying an operation changes
1980        /// the verdict with no evaluator.
1981        #[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}