Skip to main content

fraiseql_auth/
security_config.rs

1//! Security configuration loading and initialization
2//!
3//! Loads security configuration from schema.compiled.json and initializes
4//! all security subsystems (audit logging, rate limiting, error sanitization, etc.)
5
6use std::env;
7
8use serde_json::Value as JsonValue;
9
10/// Security configuration loaded from schema.compiled.json
11#[derive(Debug, Clone)]
12pub struct SecurityConfigFromSchema {
13    /// Audit logging configuration
14    pub audit_logging:      AuditLoggingSettings,
15    /// Error sanitization configuration
16    pub error_sanitization: ErrorSanitizationSettings,
17    /// Rate limiting configuration
18    pub rate_limiting:      RateLimitingSettings,
19    /// State encryption configuration
20    pub state_encryption:   StateEncryptionSettings,
21}
22
23/// Audit logging subsystem settings loaded from the compiled schema.
24#[derive(Debug, Clone)]
25pub struct AuditLoggingSettings {
26    /// Whether audit logging is active.
27    pub enabled:                bool,
28    /// Minimum tracing level for audit records (e.g., `"info"`, `"debug"`).
29    pub log_level:              String,
30    /// When `true`, raw credential values may appear in log records.
31    /// Must be `false` in production deployments.
32    pub include_sensitive_data: bool,
33    /// When `true`, log records are written from a background task rather than
34    /// the request thread, reducing latency at the cost of some delivery guarantees.
35    pub async_logging:          bool,
36    /// Number of audit records to buffer before flushing (async mode only).
37    pub buffer_size:            u32,
38    /// How frequently (in seconds) the async buffer is flushed.
39    pub flush_interval_secs:    u32,
40}
41
42/// Error sanitization settings — controls how authentication errors are presented to clients.
43#[derive(Debug, Clone)]
44pub struct ErrorSanitizationSettings {
45    /// Whether error sanitization is active.
46    /// When `false`, internal error details may be forwarded to API clients.
47    pub enabled:                bool,
48    /// Replace specific internal error messages with generic user-safe strings.
49    pub generic_messages:       bool,
50    /// Log the full internal error message via `tracing` before sanitizing.
51    pub internal_logging:       bool,
52    /// When `true`, sensitive field values (tokens, keys, etc.) may appear in error messages.
53    /// **Must be `false` in production** — setting this to `true` fails
54    /// [`crate::security_init::validate_security_config`].
55    pub leak_sensitive_details: bool,
56    /// Format template for user-facing error messages (e.g., `"generic"`).
57    pub user_facing_format:     String,
58}
59
60/// Rate-limiting thresholds for each authentication endpoint.
61#[derive(Debug, Clone)]
62pub struct RateLimitingSettings {
63    /// Whether rate limiting is active across all auth endpoints.
64    pub enabled:                    bool,
65    /// Maximum requests to `/auth/start` per IP per window.
66    pub auth_start_max_requests:    u32,
67    /// Window duration (in seconds) for the `/auth/start` rate limit.
68    pub auth_start_window_secs:     u64,
69    /// Maximum requests to `/auth/callback` per IP per window.
70    pub auth_callback_max_requests: u32,
71    /// Window duration (in seconds) for the `/auth/callback` rate limit.
72    pub auth_callback_window_secs:  u64,
73    /// Maximum token refresh requests per user per window.
74    pub auth_refresh_max_requests:  u32,
75    /// Window duration (in seconds) for the `/auth/refresh` rate limit.
76    pub auth_refresh_window_secs:   u64,
77    /// Maximum logout requests per user per window.
78    pub auth_logout_max_requests:   u32,
79    /// Window duration (in seconds) for the `/auth/logout` rate limit.
80    pub auth_logout_window_secs:    u64,
81    /// Maximum failed login attempts per user per window (brute-force protection).
82    pub failed_login_max_requests:  u32,
83    /// Window duration (in seconds) for the failed login rate limit (typically 1 hour).
84    pub failed_login_window_secs:   u64,
85}
86
87/// OAuth state encryption settings loaded from the compiled schema.
88#[derive(Debug, Clone)]
89pub struct StateEncryptionSettings {
90    /// Whether OAuth PKCE state tokens are encrypted before being sent to the provider.
91    pub enabled:              bool,
92    /// AEAD algorithm to use (e.g., `"chacha20-poly1305"`, `"aes-256-gcm"`).
93    pub algorithm:            String,
94    /// When `true`, keys are rotated automatically.
95    pub key_rotation_enabled: bool,
96    /// Nonce size in bytes (must be 12 for both ChaCha20-Poly1305 and AES-256-GCM).
97    pub nonce_size:           u32,
98    /// Encryption key size in bytes (must be 32 for both supported algorithms).
99    pub key_size:             u32,
100}
101
102impl Default for SecurityConfigFromSchema {
103    fn default() -> Self {
104        Self {
105            audit_logging:      AuditLoggingSettings {
106                enabled:                true,
107                log_level:              "info".to_string(),
108                include_sensitive_data: false,
109                async_logging:          true,
110                buffer_size:            1000,
111                flush_interval_secs:    5,
112            },
113            error_sanitization: ErrorSanitizationSettings {
114                enabled:                true,
115                generic_messages:       true,
116                internal_logging:       true,
117                leak_sensitive_details: false,
118                user_facing_format:     "generic".to_string(),
119            },
120            rate_limiting:      RateLimitingSettings {
121                enabled:                    true,
122                auth_start_max_requests:    100,
123                auth_start_window_secs:     60,
124                auth_callback_max_requests: 50,
125                auth_callback_window_secs:  60,
126                auth_refresh_max_requests:  10,
127                auth_refresh_window_secs:   60,
128                auth_logout_max_requests:   20,
129                auth_logout_window_secs:    60,
130                failed_login_max_requests:  5,
131                failed_login_window_secs:   3600,
132            },
133            state_encryption:   StateEncryptionSettings {
134                enabled:              true,
135                algorithm:            "chacha20-poly1305".to_string(),
136                key_rotation_enabled: false,
137                nonce_size:           12,
138                key_size:             32,
139            },
140        }
141    }
142}
143
144impl SecurityConfigFromSchema {
145    /// Parse security configuration from JSON (from schema.compiled.json)
146    ///
147    /// # Errors
148    ///
149    /// Returns an error if the JSON structure contains invalid or unparseable fields.
150    pub fn from_json(value: &JsonValue) -> anyhow::Result<Self> {
151        let mut config = Self::default();
152
153        if let Some(audit) = value.get("auditLogging").and_then(|v| v.as_object()) {
154            config.audit_logging.enabled =
155                audit.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
156            config.audit_logging.log_level =
157                audit.get("logLevel").and_then(|v| v.as_str()).unwrap_or("info").to_string();
158            config.audit_logging.include_sensitive_data =
159                audit.get("includeSensitiveData").and_then(|v| v.as_bool()).unwrap_or(false);
160            config.audit_logging.async_logging =
161                audit.get("asyncLogging").and_then(|v| v.as_bool()).unwrap_or(true);
162            #[allow(clippy::cast_possible_truncation)]
163            // Reason: buffer_size is a config value bounded well within u32 range
164            {
165                config.audit_logging.buffer_size =
166                    audit.get("bufferSize").and_then(|v| v.as_u64()).unwrap_or(1000) as u32;
167                config.audit_logging.flush_interval_secs =
168                    audit.get("flushIntervalSecs").and_then(|v| v.as_u64()).unwrap_or(5) as u32;
169            }
170        }
171
172        if let Some(error_san) = value.get("errorSanitization").and_then(|v| v.as_object()) {
173            config.error_sanitization.enabled =
174                error_san.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
175            config.error_sanitization.generic_messages =
176                error_san.get("genericMessages").and_then(|v| v.as_bool()).unwrap_or(true);
177            config.error_sanitization.internal_logging =
178                error_san.get("internalLogging").and_then(|v| v.as_bool()).unwrap_or(true);
179            config.error_sanitization.leak_sensitive_details =
180                error_san.get("leakSensitiveDetails").and_then(|v| v.as_bool()).unwrap_or(false);
181            config.error_sanitization.user_facing_format = error_san
182                .get("userFacingFormat")
183                .and_then(|v| v.as_str())
184                .unwrap_or("generic")
185                .to_string();
186        }
187
188        if let Some(rate_limit) = value.get("rateLimiting").and_then(|v| v.as_object()) {
189            config.rate_limiting.enabled =
190                rate_limit.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
191
192            #[allow(clippy::cast_possible_truncation)]
193            // Reason: rate-limit maxRequests values are config-bounded within u32
194            if let Some(auth_start) = rate_limit.get("authStart").and_then(|v| v.as_object()) {
195                config.rate_limiting.auth_start_max_requests =
196                    auth_start.get("maxRequests").and_then(|v| v.as_u64()).unwrap_or(100) as u32;
197                config.rate_limiting.auth_start_window_secs =
198                    auth_start.get("windowSecs").and_then(|v| v.as_u64()).unwrap_or(60);
199            }
200
201            #[allow(clippy::cast_possible_truncation)]
202            // Reason: rate-limit maxRequests values are config-bounded within u32
203            if let Some(auth_callback) = rate_limit.get("authCallback").and_then(|v| v.as_object())
204            {
205                config.rate_limiting.auth_callback_max_requests =
206                    auth_callback.get("maxRequests").and_then(|v| v.as_u64()).unwrap_or(50) as u32;
207                config.rate_limiting.auth_callback_window_secs =
208                    auth_callback.get("windowSecs").and_then(|v| v.as_u64()).unwrap_or(60);
209            }
210
211            #[allow(clippy::cast_possible_truncation)]
212            // Reason: rate-limit maxRequests values are config-bounded within u32
213            if let Some(auth_refresh) = rate_limit.get("authRefresh").and_then(|v| v.as_object()) {
214                config.rate_limiting.auth_refresh_max_requests =
215                    auth_refresh.get("maxRequests").and_then(|v| v.as_u64()).unwrap_or(10) as u32;
216                config.rate_limiting.auth_refresh_window_secs =
217                    auth_refresh.get("windowSecs").and_then(|v| v.as_u64()).unwrap_or(60);
218            }
219
220            #[allow(clippy::cast_possible_truncation)]
221            // Reason: rate-limit maxRequests values are config-bounded within u32
222            if let Some(auth_logout) = rate_limit.get("authLogout").and_then(|v| v.as_object()) {
223                config.rate_limiting.auth_logout_max_requests =
224                    auth_logout.get("maxRequests").and_then(|v| v.as_u64()).unwrap_or(20) as u32;
225                config.rate_limiting.auth_logout_window_secs =
226                    auth_logout.get("windowSecs").and_then(|v| v.as_u64()).unwrap_or(60);
227            }
228
229            #[allow(clippy::cast_possible_truncation)]
230            // Reason: rate-limit maxRequests values are config-bounded within u32
231            if let Some(failed_login) = rate_limit.get("failedLogin").and_then(|v| v.as_object()) {
232                config.rate_limiting.failed_login_max_requests =
233                    failed_login.get("maxRequests").and_then(|v| v.as_u64()).unwrap_or(5) as u32;
234                config.rate_limiting.failed_login_window_secs =
235                    failed_login.get("windowSecs").and_then(|v| v.as_u64()).unwrap_or(3600);
236            }
237        }
238
239        if let Some(state_enc) = value.get("stateEncryption").and_then(|v| v.as_object()) {
240            config.state_encryption.enabled =
241                state_enc.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
242            config.state_encryption.algorithm = state_enc
243                .get("algorithm")
244                .and_then(|v| v.as_str())
245                .unwrap_or("chacha20-poly1305")
246                .to_string();
247            config.state_encryption.key_rotation_enabled =
248                state_enc.get("keyRotationEnabled").and_then(|v| v.as_bool()).unwrap_or(false);
249            #[allow(clippy::cast_possible_truncation)]
250            // Reason: nonce/key sizes are small constants (12, 32) well within u32 range
251            {
252                config.state_encryption.nonce_size =
253                    state_enc.get("nonceSize").and_then(|v| v.as_u64()).unwrap_or(12) as u32;
254                config.state_encryption.key_size =
255                    state_enc.get("keySize").and_then(|v| v.as_u64()).unwrap_or(32) as u32;
256            }
257        }
258
259        Ok(config)
260    }
261
262    /// Apply environment variable overrides
263    pub fn apply_env_overrides(&mut self) {
264        // Audit logging
265        if let Ok(level) = env::var("AUDIT_LOG_LEVEL") {
266            self.audit_logging.log_level = level;
267        }
268
269        // Rate limiting
270        if let Ok(val) = env::var("RATE_LIMIT_AUTH_START") {
271            if let Ok(n) = val.parse() {
272                self.rate_limiting.auth_start_max_requests = n;
273            }
274        }
275        if let Ok(val) = env::var("RATE_LIMIT_AUTH_CALLBACK") {
276            if let Ok(n) = val.parse() {
277                self.rate_limiting.auth_callback_max_requests = n;
278            }
279        }
280        if let Ok(val) = env::var("RATE_LIMIT_AUTH_REFRESH") {
281            if let Ok(n) = val.parse() {
282                self.rate_limiting.auth_refresh_max_requests = n;
283            }
284        }
285        if let Ok(val) = env::var("RATE_LIMIT_AUTH_LOGOUT") {
286            if let Ok(n) = val.parse() {
287                self.rate_limiting.auth_logout_max_requests = n;
288            }
289        }
290        if let Ok(val) = env::var("RATE_LIMIT_FAILED_LOGIN") {
291            if let Ok(n) = val.parse() {
292                self.rate_limiting.failed_login_max_requests = n;
293            }
294        }
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    #[allow(clippy::wildcard_imports)]
301    // Reason: test module — wildcard keeps test boilerplate minimal
302    use super::*;
303
304    #[test]
305    fn test_default_config() {
306        let config = SecurityConfigFromSchema::default();
307        assert!(config.audit_logging.enabled);
308        assert!(config.error_sanitization.enabled);
309        assert!(config.rate_limiting.enabled);
310        assert!(config.state_encryption.enabled);
311    }
312
313    #[test]
314    fn test_parse_from_json() {
315        let json = serde_json::json!({
316            "auditLogging": {
317                "enabled": true,
318                "logLevel": "debug",
319                "includeSensitiveData": false
320            },
321            "rateLimiting": {
322                "enabled": true,
323                "authStart": {
324                    "maxRequests": 200,
325                    "windowSecs": 60
326                }
327            }
328        });
329
330        let config = SecurityConfigFromSchema::from_json(&json).expect("Failed to parse");
331        assert_eq!(config.audit_logging.log_level, "debug");
332        assert_eq!(config.rate_limiting.auth_start_max_requests, 200);
333    }
334
335    #[test]
336    fn test_apply_env_overrides() {
337        // Note: This test would require setting env vars during test execution
338        // For now, we just verify the method works with defaults
339        let mut config = SecurityConfigFromSchema::default();
340        config.apply_env_overrides();
341        // No assertions needed, just verify it doesn't panic
342    }
343}