Skip to main content

fraiseql_cli/config/
security.rs

1//! Security configuration parsing from fraiseql.toml
2
3use anyhow::Result;
4use serde::{Deserialize, Serialize};
5
6/// Audit logging configuration
7#[derive(Debug, Clone, Deserialize, Serialize)]
8#[serde(default, deny_unknown_fields)]
9pub struct AuditLoggingConfig {
10    /// Enable audit logging
11    pub enabled:                bool,
12    /// Log level threshold ("debug", "info", "warn")
13    pub log_level:              String,
14    /// Include sensitive data in audit logs
15    pub include_sensitive_data: bool,
16    /// Use asynchronous logging
17    pub async_logging:          bool,
18    /// Buffer size for async logging
19    pub buffer_size:            u32,
20    /// Interval to flush logs in seconds
21    pub flush_interval_secs:    u32,
22}
23
24impl Default for AuditLoggingConfig {
25    fn default() -> Self {
26        Self {
27            enabled:                true,
28            log_level:              "info".to_string(),
29            include_sensitive_data: false,
30            async_logging:          true,
31            buffer_size:            1000,
32            flush_interval_secs:    5,
33        }
34    }
35}
36
37impl AuditLoggingConfig {
38    /// Convert to JSON representation for schema
39    pub fn to_json(&self) -> serde_json::Value {
40        serde_json::json!({
41            "enabled": self.enabled,
42            "logLevel": self.log_level,
43            "includeSensitiveData": self.include_sensitive_data,
44            "asyncLogging": self.async_logging,
45            "bufferSize": self.buffer_size,
46            "flushIntervalSecs": self.flush_interval_secs,
47        })
48    }
49}
50
51/// Error sanitization configuration
52#[derive(Debug, Clone, Deserialize, Serialize)]
53#[serde(default, deny_unknown_fields)]
54pub struct ErrorSanitizationConfig {
55    /// Enable error sanitization
56    pub enabled:                bool,
57    /// Use generic error messages for users
58    pub generic_messages:       bool,
59    /// Log full errors internally
60    pub internal_logging:       bool,
61    /// Never leak sensitive details (security flag)
62    pub leak_sensitive_details: bool,
63    /// User-facing error format ("generic", "simple", "detailed")
64    pub user_facing_format:     String,
65}
66
67impl Default for ErrorSanitizationConfig {
68    fn default() -> Self {
69        Self {
70            enabled:                true,
71            generic_messages:       true,
72            internal_logging:       true,
73            leak_sensitive_details: false,
74            user_facing_format:     "generic".to_string(),
75        }
76    }
77}
78
79impl ErrorSanitizationConfig {
80    /// Validate error sanitization configuration
81    ///
82    /// # Errors
83    ///
84    /// Returns an error if `leak_sensitive_details` is `true`, which is a
85    /// security risk that must not be enabled in production.
86    pub fn validate(&self) -> Result<()> {
87        if self.leak_sensitive_details {
88            anyhow::bail!(
89                "leak_sensitive_details=true is a security risk! Never enable in production."
90            );
91        }
92        Ok(())
93    }
94
95    /// Convert to JSON representation for schema
96    pub fn to_json(&self) -> serde_json::Value {
97        serde_json::json!({
98            "enabled": self.enabled,
99            "genericMessages": self.generic_messages,
100            "internalLogging": self.internal_logging,
101            "leakSensitiveDetails": self.leak_sensitive_details,
102            "userFacingFormat": self.user_facing_format,
103        })
104    }
105}
106
107/// Rate limiting configuration
108#[derive(Debug, Clone, Deserialize, Serialize)]
109#[serde(default, deny_unknown_fields)]
110pub struct RateLimitConfig {
111    /// Enable rate limiting
112    pub enabled: bool,
113
114    /// Max requests for auth start endpoint (per IP)
115    pub auth_start_max_requests: u32,
116    /// Time window for auth start in seconds
117    pub auth_start_window_secs:  u64,
118
119    /// Max requests for auth callback endpoint (per IP)
120    pub auth_callback_max_requests: u32,
121    /// Time window for auth callback in seconds
122    pub auth_callback_window_secs:  u64,
123
124    /// Max requests for auth refresh endpoint (per user)
125    pub auth_refresh_max_requests: u32,
126    /// Time window for auth refresh in seconds
127    pub auth_refresh_window_secs:  u64,
128
129    /// Max requests for auth logout endpoint (per user)
130    pub auth_logout_max_requests: u32,
131    /// Time window for auth logout in seconds
132    pub auth_logout_window_secs:  u64,
133
134    /// Max failed login attempts per IP
135    pub failed_login_max_requests: u32,
136    /// Time window for failed login tracking in seconds
137    pub failed_login_window_secs:  u64,
138}
139
140impl Default for RateLimitConfig {
141    fn default() -> Self {
142        Self {
143            enabled:                    true,
144            auth_start_max_requests:    100,
145            auth_start_window_secs:     60,
146            auth_callback_max_requests: 50,
147            auth_callback_window_secs:  60,
148            auth_refresh_max_requests:  10,
149            auth_refresh_window_secs:   60,
150            auth_logout_max_requests:   20,
151            auth_logout_window_secs:    60,
152            failed_login_max_requests:  5,
153            failed_login_window_secs:   3600,
154        }
155    }
156}
157
158impl RateLimitConfig {
159    /// Validate rate limiting configuration
160    ///
161    /// # Errors
162    ///
163    /// Returns an error if any time-window is zero or if any `max_requests`
164    /// value is zero (which would permanently block all requests).
165    pub fn validate(&self) -> Result<()> {
166        for (name, window) in &[
167            ("auth_start_window_secs", self.auth_start_window_secs),
168            ("auth_callback_window_secs", self.auth_callback_window_secs),
169            ("auth_refresh_window_secs", self.auth_refresh_window_secs),
170            ("auth_logout_window_secs", self.auth_logout_window_secs),
171            ("failed_login_window_secs", self.failed_login_window_secs),
172        ] {
173            if *window == 0 {
174                anyhow::bail!("{name} must be positive");
175            }
176        }
177        for (name, max_req) in &[
178            ("auth_start_max_requests", self.auth_start_max_requests),
179            ("auth_callback_max_requests", self.auth_callback_max_requests),
180            ("auth_refresh_max_requests", self.auth_refresh_max_requests),
181            ("auth_logout_max_requests", self.auth_logout_max_requests),
182            ("failed_login_max_requests", self.failed_login_max_requests),
183        ] {
184            if *max_req == 0 {
185                anyhow::bail!(
186                    "{name} must be at least 1; \
187                     setting it to 0 blocks all requests permanently"
188                );
189            }
190        }
191        Ok(())
192    }
193
194    /// Convert to JSON representation for schema
195    pub fn to_json(&self) -> serde_json::Value {
196        serde_json::json!({
197            "enabled": self.enabled,
198            "authStart": {
199                "maxRequests": self.auth_start_max_requests,
200                "windowSecs": self.auth_start_window_secs,
201            },
202            "authCallback": {
203                "maxRequests": self.auth_callback_max_requests,
204                "windowSecs": self.auth_callback_window_secs,
205            },
206            "authRefresh": {
207                "maxRequests": self.auth_refresh_max_requests,
208                "windowSecs": self.auth_refresh_window_secs,
209            },
210            "authLogout": {
211                "maxRequests": self.auth_logout_max_requests,
212                "windowSecs": self.auth_logout_window_secs,
213            },
214            "failedLogin": {
215                "maxRequests": self.failed_login_max_requests,
216                "windowSecs": self.failed_login_window_secs,
217            },
218        })
219    }
220}
221
222/// State encryption configuration
223#[derive(Debug, Clone, Deserialize, Serialize)]
224#[serde(default, deny_unknown_fields)]
225pub struct StateEncryptionConfig {
226    /// Enable state encryption
227    pub enabled:              bool,
228    /// Encryption algorithm ("chacha20-poly1305")
229    pub algorithm:            String,
230    /// Enable automatic key rotation
231    pub key_rotation_enabled: bool,
232    /// Nonce size in bytes (typically 12 for 96-bit)
233    pub nonce_size:           u32,
234    /// Key size in bytes (16, 24, or 32)
235    pub key_size:             u32,
236}
237
238impl Default for StateEncryptionConfig {
239    fn default() -> Self {
240        Self {
241            enabled:              true,
242            algorithm:            "chacha20-poly1305".to_string(),
243            key_rotation_enabled: false,
244            nonce_size:           12,
245            key_size:             32,
246        }
247    }
248}
249
250/// Supported encryption algorithms for `[security.state_encryption]`.
251const SUPPORTED_ALGORITHMS: &[&str] = &["chacha20-poly1305", "aes-256-gcm"];
252
253impl StateEncryptionConfig {
254    /// Validate state encryption configuration
255    ///
256    /// # Errors
257    ///
258    /// Returns an error if `algorithm` is not a supported value, if `key_size`
259    /// is not 16, 24, or 32 bytes, or if `nonce_size` is not 12 bytes.
260    pub fn validate(&self) -> Result<()> {
261        if !SUPPORTED_ALGORITHMS.contains(&self.algorithm.as_str()) {
262            anyhow::bail!(
263                "algorithm {:?} is not supported; must be one of: {}",
264                self.algorithm,
265                SUPPORTED_ALGORITHMS.join(", ")
266            );
267        }
268        if ![16, 24, 32].contains(&self.key_size) {
269            anyhow::bail!("key_size must be 16, 24, or 32 bytes");
270        }
271        if self.nonce_size != 12 {
272            anyhow::bail!("nonce_size must be 12 bytes (96-bit)");
273        }
274        Ok(())
275    }
276
277    /// Convert to JSON representation for schema
278    pub fn to_json(&self) -> serde_json::Value {
279        serde_json::json!({
280            "enabled": self.enabled,
281            "algorithm": self.algorithm,
282            "keyRotationEnabled": self.key_rotation_enabled,
283            "nonceSize": self.nonce_size,
284            "keySize": self.key_size,
285        })
286    }
287}
288
289/// Constant-time comparison configuration
290#[derive(Debug, Clone, Deserialize, Serialize)]
291#[serde(default, deny_unknown_fields)]
292pub struct ConstantTimeConfig {
293    /// Enable constant-time comparisons
294    pub enabled:                 bool,
295    /// Apply constant-time comparison to JWT tokens
296    pub apply_to_jwt:            bool,
297    /// Apply constant-time comparison to session tokens
298    pub apply_to_session_tokens: bool,
299    /// Apply constant-time comparison to CSRF tokens
300    pub apply_to_csrf_tokens:    bool,
301    /// Apply constant-time comparison to refresh tokens
302    pub apply_to_refresh_tokens: bool,
303}
304
305impl Default for ConstantTimeConfig {
306    fn default() -> Self {
307        Self {
308            enabled:                 true,
309            apply_to_jwt:            true,
310            apply_to_session_tokens: true,
311            apply_to_csrf_tokens:    true,
312            apply_to_refresh_tokens: true,
313        }
314    }
315}
316
317impl ConstantTimeConfig {
318    /// Convert to JSON representation for schema
319    pub fn to_json(&self) -> serde_json::Value {
320        serde_json::json!({
321            "enabled": self.enabled,
322            "applyToJwt": self.apply_to_jwt,
323            "applyToSessionTokens": self.apply_to_session_tokens,
324            "applytoCsrfTokens": self.apply_to_csrf_tokens,
325            "applyToRefreshTokens": self.apply_to_refresh_tokens,
326        })
327    }
328}
329
330/// Field-level RBAC role definition from fraiseql.toml
331#[derive(Debug, Clone, Deserialize, Serialize)]
332#[serde(deny_unknown_fields)]
333pub struct RoleDefinitionConfig {
334    /// Role name identifier
335    pub name:        String,
336    /// Role description
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub description: Option<String>,
339    /// Permission scopes assigned to this role
340    pub scopes:      Vec<String>,
341}
342
343/// Complete security configuration from fraiseql.toml
344#[derive(Debug, Clone, Default, Deserialize, Serialize)]
345#[serde(default, deny_unknown_fields)]
346pub struct SecurityConfig {
347    /// Audit logging configuration
348    #[serde(rename = "audit_logging")]
349    pub audit_logging:      AuditLoggingConfig,
350    /// Error sanitization configuration
351    #[serde(rename = "error_sanitization")]
352    pub error_sanitization: ErrorSanitizationConfig,
353    /// Rate limiting configuration
354    #[serde(rename = "rate_limiting")]
355    pub rate_limiting:      RateLimitConfig,
356    /// State encryption configuration
357    #[serde(rename = "state_encryption")]
358    pub state_encryption:   StateEncryptionConfig,
359    /// Constant-time comparison configuration
360    #[serde(rename = "constant_time")]
361    pub constant_time:      ConstantTimeConfig,
362    /// Field-level RBAC role definitions
363    #[serde(default, skip_serializing_if = "Vec::is_empty")]
364    pub role_definitions:   Vec<RoleDefinitionConfig>,
365    /// Default role when user has no explicit role assignment
366    #[serde(skip_serializing_if = "Option::is_none")]
367    pub default_role:       Option<String>,
368}
369
370impl SecurityConfig {
371    /// Validate all security configurations
372    ///
373    /// # Errors
374    ///
375    /// Returns an error if any sub-configuration is invalid, if a role name is
376    /// empty, or if a role definition contains no scopes.
377    pub fn validate(&self) -> Result<()> {
378        self.error_sanitization.validate()?;
379        self.rate_limiting.validate()?;
380        self.state_encryption.validate()?;
381
382        // Validate role definitions if present
383        for role in &self.role_definitions {
384            if role.name.is_empty() {
385                anyhow::bail!("Role name cannot be empty");
386            }
387            if role.scopes.is_empty() {
388                anyhow::bail!("Role '{}' must have at least one scope", role.name);
389            }
390        }
391
392        Ok(())
393    }
394
395    /// Convert to JSON representation for schema.json
396    pub fn to_json(&self) -> serde_json::Value {
397        let mut json = serde_json::json!({
398            "auditLogging": self.audit_logging.to_json(),
399            "errorSanitization": self.error_sanitization.to_json(),
400            "rateLimiting": self.rate_limiting.to_json(),
401            "stateEncryption": self.state_encryption.to_json(),
402            "constantTime": self.constant_time.to_json(),
403        });
404
405        // Add role definitions if present
406        if !self.role_definitions.is_empty() {
407            json["roleDefinitions"] = serde_json::to_value(
408                self.role_definitions
409                    .iter()
410                    .map(|r| {
411                        serde_json::json!({
412                            "name": r.name,
413                            "description": r.description,
414                            "scopes": r.scopes,
415                        })
416                    })
417                    .collect::<Vec<_>>(),
418            )
419            .unwrap_or_default();
420        }
421
422        // Add default role if present
423        if let Some(default_role) = &self.default_role {
424            json["defaultRole"] = serde_json::json!(default_role);
425        }
426
427        json
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    #![allow(clippy::unwrap_used)] // Reason: test code, panics acceptable
434    #![allow(clippy::field_reassign_with_default)] // Reason: test code, mutate-after-default is clearer
435
436    use super::*;
437
438    #[test]
439    fn test_default_security_config() {
440        let config = SecurityConfig::default();
441        assert!(config.audit_logging.enabled);
442        assert!(config.error_sanitization.enabled);
443        assert!(config.rate_limiting.enabled);
444        assert!(config.state_encryption.enabled);
445        assert!(config.constant_time.enabled);
446    }
447
448    #[test]
449    fn test_error_sanitization_validation() {
450        let mut config = ErrorSanitizationConfig::default();
451        config
452            .validate()
453            .unwrap_or_else(|e| panic!("expected Ok for default config: {e}"));
454
455        config.leak_sensitive_details = true;
456        assert!(
457            config.validate().is_err(),
458            "expected Err when leak_sensitive_details=true, got Ok"
459        );
460    }
461
462    #[test]
463    fn test_rate_limiting_validation() {
464        let mut config = RateLimitConfig::default();
465        config
466            .validate()
467            .unwrap_or_else(|e| panic!("expected Ok for default config: {e}"));
468
469        config.auth_start_window_secs = 0;
470        assert!(config.validate().is_err(), "expected Err when auth_start_window_secs=0, got Ok");
471    }
472
473    #[test]
474    fn test_rate_limiting_zero_max_requests_rejected() {
475        let mut config = RateLimitConfig::default();
476        config.auth_start_max_requests = 0;
477        let err = config.validate().unwrap_err();
478        assert!(
479            err.to_string().contains("auth_start_max_requests"),
480            "error should name the field: {err}"
481        );
482        assert!(
483            err.to_string().contains("blocks all requests"),
484            "error should explain the impact: {err}"
485        );
486    }
487
488    #[test]
489    fn test_rate_limiting_one_max_requests_accepted() {
490        let mut config = RateLimitConfig::default();
491        config.auth_start_max_requests = 1;
492        config
493            .validate()
494            .unwrap_or_else(|e| panic!("expected Ok for max_requests=1: {e}"));
495    }
496
497    #[test]
498    fn test_rate_limiting_callback_zero_max_requests_rejected() {
499        let mut config = RateLimitConfig::default();
500        config.auth_callback_max_requests = 0;
501        assert!(
502            config.validate().is_err(),
503            "expected Err when auth_callback_max_requests=0, got Ok"
504        );
505    }
506
507    #[test]
508    fn test_state_encryption_validation() {
509        let mut config = StateEncryptionConfig::default();
510        config
511            .validate()
512            .unwrap_or_else(|e| panic!("expected Ok for default config: {e}"));
513
514        config.key_size = 20;
515        assert!(config.validate().is_err(), "expected Err when key_size=20, got Ok");
516
517        config.key_size = 32;
518        config.nonce_size = 16;
519        assert!(config.validate().is_err(), "expected Err when nonce_size=16, got Ok");
520    }
521
522    #[test]
523    fn test_state_encryption_unsupported_algorithm_rejected() {
524        let mut config = StateEncryptionConfig::default();
525        config.algorithm = "rot13".to_string();
526        let err = config.validate().unwrap_err();
527        assert!(err.to_string().contains("rot13"), "error should name the bad algorithm: {err}");
528        assert!(
529            err.to_string().contains("chacha20-poly1305"),
530            "error should list supported algorithms: {err}"
531        );
532    }
533
534    #[test]
535    fn test_state_encryption_aes_256_gcm_accepted() {
536        let mut config = StateEncryptionConfig::default();
537        config.algorithm = "aes-256-gcm".to_string();
538        config.validate().unwrap_or_else(|e| panic!("expected Ok for aes-256-gcm: {e}"));
539    }
540
541    #[test]
542    fn test_state_encryption_chacha20_poly1305_accepted() {
543        let config = StateEncryptionConfig::default();
544        // default is "chacha20-poly1305"
545        config
546            .validate()
547            .unwrap_or_else(|e| panic!("expected Ok for default chacha20-poly1305: {e}"));
548    }
549
550    #[test]
551    fn test_security_config_serialization() {
552        let config = SecurityConfig::default();
553        let json = config.to_json();
554        assert!(json["auditLogging"]["enabled"].is_boolean());
555        assert!(json["rateLimiting"]["authStart"]["maxRequests"].is_number());
556        assert!(json["stateEncryption"]["algorithm"].is_string());
557    }
558}