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