Skip to main content

fraiseql_core/validation/
input_processor.rs

1//! Input processor for GraphQL variables with ID policy validation
2//!
3//! This module provides utilities to validate GraphQL input variables,
4//! particularly ID fields, according to the configured ID policy.
5//!
6//! **SECURITY CRITICAL**: Input validation is a critical security layer that
7//! prevents invalid data from propagating through the GraphQL pipeline.
8
9use std::collections::HashSet;
10
11use serde_json::{Map, Value};
12
13use super::id_policy::{IDPolicy, validate_id};
14
15/// Configuration for input processing
16#[derive(Debug, Clone)]
17pub struct InputProcessingConfig {
18    /// ID policy to enforce for ID fields
19    pub id_policy: IDPolicy,
20
21    /// Enable ID validation on all inputs (recommended)
22    pub validate_ids: bool,
23
24    /// List of field names known to be ID types
25    /// (in a real implementation, this would come from the schema)
26    pub id_field_names: HashSet<String>,
27}
28
29impl Default for InputProcessingConfig {
30    fn default() -> Self {
31        Self {
32            id_policy:      IDPolicy::default(),
33            validate_ids:   true,
34            id_field_names: Self::default_id_field_names(),
35        }
36    }
37}
38
39impl InputProcessingConfig {
40    /// Get default set of common ID field names
41    fn default_id_field_names() -> HashSet<String> {
42        [
43            "id",
44            "userId",
45            "user_id",
46            "postId",
47            "post_id",
48            "commentId",
49            "comment_id",
50            "authorId",
51            "author_id",
52            "ownerId",
53            "owner_id",
54            "creatorId",
55            "creator_id",
56            "tenantId",
57            "tenant_id",
58        ]
59        .iter()
60        .map(|s| (*s).to_string())
61        .collect()
62    }
63
64    /// Add a custom ID field name to validation
65    pub fn add_id_field(&mut self, field_name: String) {
66        self.id_field_names.insert(field_name);
67    }
68
69    /// Create a configuration for strict UUID validation
70    #[must_use]
71    pub fn strict_uuid() -> Self {
72        Self {
73            id_policy:      IDPolicy::UUID,
74            validate_ids:   true,
75            id_field_names: Self::default_id_field_names(),
76        }
77    }
78
79    /// Create a configuration for opaque IDs (GraphQL spec compliant)
80    #[must_use]
81    pub fn opaque() -> Self {
82        Self {
83            id_policy:      IDPolicy::OPAQUE,
84            validate_ids:   false, // No validation needed for opaque
85            id_field_names: Self::default_id_field_names(),
86        }
87    }
88}
89
90/// Process and validate GraphQL input variables
91///
92/// **SECURITY CRITICAL**: This validates all ID fields in input variables
93/// according to the configured ID policy.
94///
95/// # Arguments
96///
97/// * `variables` - GraphQL operation variables (JSON object)
98/// * `config` - Input processing configuration
99///
100/// # Returns
101///
102/// `Ok(processed_variables)` with validated data, or
103/// `Err(ProcessingError)` if validation fails
104///
105/// # Errors
106///
107/// Returns `ProcessingError` if any ID field fails validation according to the configured policy.
108///
109/// # Examples
110///
111/// ```
112/// use fraiseql_core::validation::{InputProcessingConfig, process_variables};
113/// use serde_json::json;
114///
115/// let config = InputProcessingConfig::strict_uuid();
116/// let variables = json!({"userId": "550e8400-e29b-41d4-a716-446655440000"});
117///
118/// match process_variables(&variables, &config) {
119///     Ok(processed) => { /* Use processed variables */ },
120///     Err(_e) => { /* Handle validation error */ },
121/// }
122/// ```
123pub fn process_variables(
124    variables: &Value,
125    config: &InputProcessingConfig,
126) -> Result<Value, ProcessingError> {
127    if !config.validate_ids {
128        return Ok(variables.clone());
129    }
130
131    match variables {
132        Value::Object(obj) => {
133            let mut result = Map::new();
134
135            for (key, value) in obj {
136                let processed_value = process_value(value, config, key)?;
137                result.insert(key.clone(), processed_value);
138            }
139
140            Ok(Value::Object(result))
141        },
142        Value::Null => Ok(Value::Null),
143        other => Ok(other.clone()),
144    }
145}
146
147/// Process a single JSON value, validating ID fields
148fn process_value(
149    value: &Value,
150    config: &InputProcessingConfig,
151    field_name: &str,
152) -> Result<Value, ProcessingError> {
153    match value {
154        // Validate ID string fields
155        // Extract base field name (before array indices like [0])
156        Value::String(s)
157            if {
158                let base_field = field_name.split('[').next().unwrap_or(field_name);
159                config.id_field_names.contains(base_field)
160            } =>
161        {
162            validate_id(s, config.id_policy).map_err(|e| ProcessingError {
163                field_path: field_name.to_string(),
164                reason:     format!("Invalid ID value: {e}"),
165            })?;
166            Ok(Value::String(s.clone()))
167        },
168
169        // Recursively process nested objects
170        Value::Object(obj) => {
171            let mut result = Map::new();
172
173            for (key, nested_value) in obj {
174                let processed = process_value(nested_value, config, key)?;
175                result.insert(key.clone(), processed);
176            }
177
178            Ok(Value::Object(result))
179        },
180
181        // Process array items
182        Value::Array(arr) => {
183            let processed_items: Result<Vec<_>, _> = arr
184                .iter()
185                .enumerate()
186                .map(|(idx, item)| {
187                    let array_field = format!("{field_name}[{idx}]");
188                    process_value(item, config, &array_field)
189                })
190                .collect();
191
192            Ok(Value::Array(processed_items?))
193        },
194
195        // Pass through other values unchanged
196        other => Ok(other.clone()),
197    }
198}
199
200/// Error type for input processing failures
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct ProcessingError {
203    /// The field path where the error occurred
204    pub field_path: String,
205    /// The reason for the error
206    pub reason:     String,
207}
208
209impl std::fmt::Display for ProcessingError {
210    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211        write!(f, "Error in field '{}': {}", self.field_path, self.reason)
212    }
213}
214
215impl std::error::Error for ProcessingError {}
216
217#[cfg(test)]
218mod tests {
219    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
220
221    use serde_json::json;
222
223    use super::*;
224
225    #[test]
226    fn test_process_valid_uuid_id() {
227        let config = InputProcessingConfig::strict_uuid();
228        let variables = json!({
229            "userId": "550e8400-e29b-41d4-a716-446655440000"
230        });
231
232        let result = process_variables(&variables, &config);
233        result.unwrap_or_else(|e| panic!("valid UUID should pass: {e}"));
234    }
235
236    #[test]
237    fn test_process_invalid_uuid_id() {
238        let config = InputProcessingConfig::strict_uuid();
239        let variables = json!({
240            "userId": "invalid-id"
241        });
242
243        let result = process_variables(&variables, &config);
244        let err = result.expect_err("invalid UUID should fail validation");
245        assert!(
246            err.field_path.contains("userId"),
247            "expected field_path to contain 'userId', got: {}",
248            err.field_path
249        );
250    }
251
252    #[test]
253    fn test_process_multiple_ids() {
254        let config = InputProcessingConfig::strict_uuid();
255        let variables = json!({
256            "userId": "550e8400-e29b-41d4-a716-446655440000",
257            "postId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
258            "name": "John"
259        });
260
261        let result = process_variables(&variables, &config);
262        result.unwrap_or_else(|e| panic!("multiple valid UUIDs should pass: {e}"));
263    }
264
265    #[test]
266    fn test_process_nested_ids() {
267        let config = InputProcessingConfig::strict_uuid();
268        let variables = json!({
269            "input": {
270                "userId": "550e8400-e29b-41d4-a716-446655440000",
271                "profile": {
272                    "authorId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
273                }
274            }
275        });
276
277        let result = process_variables(&variables, &config);
278        result.unwrap_or_else(|e| panic!("nested valid UUIDs should pass: {e}"));
279    }
280
281    #[test]
282    fn test_process_nested_invalid_id() {
283        let config = InputProcessingConfig::strict_uuid();
284        let variables = json!({
285            "input": {
286                "userId": "550e8400-e29b-41d4-a716-446655440000",
287                "profile": {
288                    "authorId": "invalid"
289                }
290            }
291        });
292
293        let result = process_variables(&variables, &config);
294        let err = result.expect_err("nested invalid UUID should fail");
295        assert!(
296            err.field_path.contains("authorId"),
297            "expected field_path to contain 'authorId', got: {}",
298            err.field_path
299        );
300    }
301
302    #[test]
303    fn test_process_array_of_ids() {
304        let config = InputProcessingConfig::strict_uuid();
305        let variables = json!({
306            "userIds": [
307                "550e8400-e29b-41d4-a716-446655440000",
308                "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
309            ]
310        });
311
312        let result = process_variables(&variables, &config);
313        result.unwrap_or_else(|e| panic!("array of valid UUIDs should pass: {e}"));
314    }
315
316    #[test]
317    fn test_process_array_with_invalid_id() {
318        let mut config = InputProcessingConfig::strict_uuid();
319        // Add "userIds" as a recognized ID field
320        config.add_id_field("userIds".to_string());
321        let variables = json!({
322            "userIds": [
323                "550e8400-e29b-41d4-a716-446655440000",
324                "invalid-id"
325            ]
326        });
327
328        let result = process_variables(&variables, &config);
329        let err = result.expect_err("array with invalid UUID should fail");
330        assert!(
331            err.field_path.contains("userIds"),
332            "expected field_path to contain 'userIds', got: {}",
333            err.field_path
334        );
335    }
336
337    #[test]
338    fn test_opaque_policy_accepts_any_id() {
339        let config = InputProcessingConfig::opaque();
340        let variables = json!({
341            "userId": "any-string-here"
342        });
343
344        let result = process_variables(&variables, &config);
345        result.unwrap_or_else(|e| panic!("opaque policy should accept any ID: {e}"));
346    }
347
348    #[test]
349    fn test_disabled_validation_skips_checks() {
350        let mut config = InputProcessingConfig::strict_uuid();
351        config.validate_ids = false;
352
353        let variables = json!({
354            "userId": "invalid-id"
355        });
356
357        let result = process_variables(&variables, &config);
358        result.unwrap_or_else(|e| panic!("disabled validation should skip checks: {e}"));
359    }
360
361    #[test]
362    fn test_custom_id_field_names() {
363        let mut config = InputProcessingConfig::strict_uuid();
364        config.add_id_field("customId".to_string());
365
366        let variables = json!({
367            "customId": "550e8400-e29b-41d4-a716-446655440000"
368        });
369
370        let result = process_variables(&variables, &config);
371        result.unwrap_or_else(|e| panic!("custom ID field with valid UUID should pass: {e}"));
372    }
373
374    #[test]
375    fn test_process_null_variables() {
376        let config = InputProcessingConfig::strict_uuid();
377        let result = process_variables(&Value::Null, &config);
378        let value = result.unwrap_or_else(|e| panic!("null variables should pass: {e}"));
379        assert!(value.is_null(), "expected null output, got: {value:?}");
380    }
381
382    #[test]
383    fn test_non_id_fields_pass_through() {
384        let config = InputProcessingConfig::strict_uuid();
385        let variables = json!({
386            "name": "not-a-uuid",
387            "email": "invalid-format@email",
388            "age": 25
389        });
390
391        let result = process_variables(&variables, &config);
392        result.unwrap_or_else(|e| {
393            panic!("non-ID fields should pass through without validation: {e}")
394        });
395    }
396}