Skip to main content

fraiseql_core/security/
validation_audit.rs

1//! Validation-specific audit logging with tenant isolation and PII redaction.
2//!
3//! Provides audit trail tracking for all validation decisions, including
4//! field name, validation rule applied, success/failure, and execution context.
5
6use std::sync::{Arc, Mutex};
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11/// Redaction policy for sensitive fields in audit logs
12#[derive(Debug, Clone, Copy, Default)]
13pub enum RedactionPolicy {
14    /// No redaction - log everything
15    None,
16    /// Conservative redaction - redact passwords, tokens, etc.
17    #[default]
18    Conservative,
19    /// Aggressive redaction - redact most user-related data
20    Aggressive,
21}
22
23/// Configuration for validation audit logging
24#[derive(Debug, Clone)]
25pub struct ValidationAuditLoggerConfig {
26    /// Enable validation audit logging
27    pub enabled: bool,
28    /// Capture successful validation entries (not just failures)
29    pub capture_successful_validations: bool,
30    /// Include the GraphQL query/mutation string in logs
31    pub capture_query_strings: bool,
32    /// Redaction policy for sensitive data
33    pub redaction_policy: RedactionPolicy,
34}
35
36impl Default for ValidationAuditLoggerConfig {
37    fn default() -> Self {
38        Self {
39            enabled: true,
40            capture_successful_validations: true,
41            capture_query_strings: true,
42            redaction_policy: RedactionPolicy::default(),
43        }
44    }
45}
46
47/// A single validation audit entry
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct ValidationAuditEntry {
50    /// Timestamp of the validation check
51    pub timestamp:         DateTime<Utc>,
52    /// User ID from authentication context
53    pub user_id:           Option<String>,
54    /// Tenant ID for multi-tenancy isolation
55    pub tenant_id:         Option<String>,
56    /// Client IP address
57    pub ip_address:        String,
58    /// GraphQL query or mutation string (may be redacted)
59    pub query_string:      String,
60    /// Name of the mutation (if applicable)
61    pub mutation_name:     Option<String>,
62    /// Field name that was validated
63    pub field:             String,
64    /// Validation rule that was applied
65    pub validation_rule:   String,
66    /// Whether the validation passed
67    pub valid:             bool,
68    /// Reason for failure (if applicable)
69    pub failure_reason:    Option<String>,
70    /// Duration of validation in microseconds
71    pub duration_us:       u64,
72    /// Type of validator executed (e.g., "pattern_validator", "async_validator")
73    pub execution_context: String,
74}
75
76/// Validation audit logger for recording validation decisions
77#[derive(Clone)]
78pub struct ValidationAuditLogger {
79    config:  Arc<ValidationAuditLoggerConfig>,
80    entries: Arc<Mutex<Vec<ValidationAuditEntry>>>,
81}
82
83impl ValidationAuditLogger {
84    /// Create a new validation audit logger with the given configuration
85    pub fn new(config: ValidationAuditLoggerConfig) -> Self {
86        Self {
87            config:  Arc::new(config),
88            entries: Arc::new(Mutex::new(Vec::new())),
89        }
90    }
91
92    /// Log a validation audit entry
93    pub fn log_entry(&self, entry: ValidationAuditEntry) {
94        if !self.config.enabled {
95            return;
96        }
97
98        // Only log failures or successful entries if configured to capture successes
99        if !entry.valid || self.config.capture_successful_validations {
100            match self.entries.lock() {
101                Ok(mut entries) => entries.push(entry),
102                Err(e) => {
103                    eprintln!("CRITICAL: Audit log mutex poisoned, entry lost. Error: {:?}", e);
104                    eprintln!("Lost entry: {:?}", entry);
105                    // In production, this should trigger an alert/metric
106                },
107            }
108        }
109    }
110
111    /// Check if audit logging is enabled
112    pub fn is_enabled(&self) -> bool {
113        self.config.enabled
114    }
115
116    /// Get all logged entries (for testing/compliance export)
117    pub fn get_entries(&self) -> Vec<ValidationAuditEntry> {
118        if let Ok(entries) = self.entries.lock() {
119            entries.clone()
120        } else {
121            Vec::new()
122        }
123    }
124
125    /// Clear all logged entries
126    pub fn clear(&self) {
127        if let Ok(mut entries) = self.entries.lock() {
128            entries.clear();
129        }
130    }
131
132    /// Get count of logged entries
133    pub fn entry_count(&self) -> usize {
134        if let Ok(entries) = self.entries.lock() {
135            entries.len()
136        } else {
137            0
138        }
139    }
140
141    /// Filter entries by user ID
142    pub fn entries_by_user(&self, user_id: &str) -> Vec<ValidationAuditEntry> {
143        if let Ok(entries) = self.entries.lock() {
144            entries
145                .iter()
146                .filter(|e| e.user_id.as_deref() == Some(user_id))
147                .cloned()
148                .collect()
149        } else {
150            Vec::new()
151        }
152    }
153
154    /// Filter entries by tenant ID
155    pub fn entries_by_tenant(&self, tenant_id: &str) -> Vec<ValidationAuditEntry> {
156        if let Ok(entries) = self.entries.lock() {
157            entries
158                .iter()
159                .filter(|e| e.tenant_id.as_deref() == Some(tenant_id))
160                .cloned()
161                .collect()
162        } else {
163            Vec::new()
164        }
165    }
166
167    /// Filter entries by field name
168    pub fn entries_by_field(&self, field: &str) -> Vec<ValidationAuditEntry> {
169        if let Ok(entries) = self.entries.lock() {
170            entries.iter().filter(|e| e.field == field).cloned().collect()
171        } else {
172            Vec::new()
173        }
174    }
175
176    /// Count validation failures
177    pub fn failure_count(&self) -> usize {
178        if let Ok(entries) = self.entries.lock() {
179            entries.iter().filter(|e| !e.valid).count()
180        } else {
181            0
182        }
183    }
184
185    /// Get configuration reference
186    pub fn config(&self) -> &ValidationAuditLoggerConfig {
187        &self.config
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn test_redaction_policy_default() {
197        let policy = RedactionPolicy::default();
198        match policy {
199            RedactionPolicy::Conservative => {},
200            _ => panic!("Default should be Conservative"),
201        }
202    }
203
204    #[test]
205    fn test_config_default() {
206        let config = ValidationAuditLoggerConfig::default();
207        assert!(config.enabled);
208        assert!(config.capture_successful_validations);
209        assert!(config.capture_query_strings);
210    }
211
212    #[test]
213    fn test_logger_enabled_disabled() {
214        let config = ValidationAuditLoggerConfig {
215            enabled: false,
216            ..Default::default()
217        };
218
219        let logger = ValidationAuditLogger::new(config);
220        assert!(!logger.is_enabled());
221
222        let config2 = ValidationAuditLoggerConfig::default();
223        let logger2 = ValidationAuditLogger::new(config2);
224        assert!(logger2.is_enabled());
225    }
226
227    #[test]
228    fn test_logger_entry_logging() {
229        let config = ValidationAuditLoggerConfig::default();
230        let logger = ValidationAuditLogger::new(config);
231
232        let entry = ValidationAuditEntry {
233            timestamp:         Utc::now(),
234            user_id:           Some("user:1".to_string()),
235            tenant_id:         Some("tenant:1".to_string()),
236            ip_address:        "192.168.1.1".to_string(),
237            query_string:      "{ user { id } }".to_string(),
238            mutation_name:     None,
239            field:             "email".to_string(),
240            validation_rule:   "pattern".to_string(),
241            valid:             false,
242            failure_reason:    Some("Invalid format".to_string()),
243            duration_us:       100,
244            execution_context: "pattern_validator".to_string(),
245        };
246
247        logger.log_entry(entry);
248        assert_eq!(logger.entry_count(), 1);
249    }
250
251    #[test]
252    fn test_logger_filter_by_user() {
253        let config = ValidationAuditLoggerConfig::default();
254        let logger = ValidationAuditLogger::new(config);
255
256        let entry1 = ValidationAuditEntry {
257            timestamp:         Utc::now(),
258            user_id:           Some("user:1".to_string()),
259            tenant_id:         None,
260            ip_address:        "192.168.1.1".to_string(),
261            query_string:      String::new(),
262            mutation_name:     None,
263            field:             "field1".to_string(),
264            validation_rule:   "required".to_string(),
265            valid:             false,
266            failure_reason:    None,
267            duration_us:       0,
268            execution_context: "validator".to_string(),
269        };
270
271        let mut entry2 = entry1.clone();
272        entry2.user_id = Some("user:2".to_string());
273
274        logger.log_entry(entry1);
275        logger.log_entry(entry2);
276
277        let user1_entries = logger.entries_by_user("user:1");
278        assert_eq!(user1_entries.len(), 1);
279    }
280
281    #[test]
282    fn test_logger_failure_count() {
283        let config = ValidationAuditLoggerConfig::default();
284        let logger = ValidationAuditLogger::new(config);
285
286        let entry = ValidationAuditEntry {
287            timestamp:         Utc::now(),
288            user_id:           None,
289            tenant_id:         None,
290            ip_address:        "192.168.1.1".to_string(),
291            query_string:      String::new(),
292            mutation_name:     None,
293            field:             "field".to_string(),
294            validation_rule:   "pattern".to_string(),
295            valid:             false,
296            failure_reason:    Some("error".to_string()),
297            duration_us:       0,
298            execution_context: "validator".to_string(),
299        };
300
301        logger.log_entry(entry.clone());
302
303        let mut entry_success = entry;
304        entry_success.valid = true;
305        logger.log_entry(entry_success);
306
307        assert_eq!(logger.failure_count(), 1);
308    }
309}