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)]
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)]
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    pub fn validate(&self) -> Result<()> {
82        if self.leak_sensitive_details {
83            anyhow::bail!(
84                "leak_sensitive_details=true is a security risk! Never enable in production."
85            );
86        }
87        Ok(())
88    }
89
90    /// Convert to JSON representation for schema
91    pub fn to_json(&self) -> serde_json::Value {
92        serde_json::json!({
93            "enabled": self.enabled,
94            "genericMessages": self.generic_messages,
95            "internalLogging": self.internal_logging,
96            "leakSensitiveDetails": self.leak_sensitive_details,
97            "userFacingFormat": self.user_facing_format,
98        })
99    }
100}
101
102/// Rate limiting per endpoint
103///
104/// Reason: Included for forward compatibility with per-endpoint rate limiting.
105/// Currently unused but provided for API completeness in security configuration.
106#[allow(dead_code)]
107#[derive(Debug, Clone, Deserialize, Serialize)]
108pub struct RateLimitingPerEndpoint {
109    /// Maximum requests per window
110    pub max_requests: u32,
111    /// Time window in seconds
112    pub window_secs:  u64,
113}
114
115#[allow(dead_code)]
116impl RateLimitingPerEndpoint {
117    /// Convert to JSON representation for schema
118    pub fn to_json(&self) -> serde_json::Value {
119        serde_json::json!({
120            "maxRequests": self.max_requests,
121            "windowSecs": self.window_secs,
122        })
123    }
124}
125
126/// Rate limiting configuration
127#[derive(Debug, Clone, Deserialize, Serialize)]
128#[serde(default)]
129pub struct RateLimitConfig {
130    /// Enable rate limiting
131    pub enabled: bool,
132
133    /// Max requests for auth start endpoint (per IP)
134    pub auth_start_max_requests: u32,
135    /// Time window for auth start in seconds
136    pub auth_start_window_secs:  u64,
137
138    /// Max requests for auth callback endpoint (per IP)
139    pub auth_callback_max_requests: u32,
140    /// Time window for auth callback in seconds
141    pub auth_callback_window_secs:  u64,
142
143    /// Max requests for auth refresh endpoint (per user)
144    pub auth_refresh_max_requests: u32,
145    /// Time window for auth refresh in seconds
146    pub auth_refresh_window_secs:  u64,
147
148    /// Max requests for auth logout endpoint (per user)
149    pub auth_logout_max_requests: u32,
150    /// Time window for auth logout in seconds
151    pub auth_logout_window_secs:  u64,
152
153    /// Max failed login attempts per IP
154    pub failed_login_max_requests: u32,
155    /// Time window for failed login tracking in seconds
156    pub failed_login_window_secs:  u64,
157}
158
159impl Default for RateLimitConfig {
160    fn default() -> Self {
161        Self {
162            enabled:                    true,
163            auth_start_max_requests:    100,
164            auth_start_window_secs:     60,
165            auth_callback_max_requests: 50,
166            auth_callback_window_secs:  60,
167            auth_refresh_max_requests:  10,
168            auth_refresh_window_secs:   60,
169            auth_logout_max_requests:   20,
170            auth_logout_window_secs:    60,
171            failed_login_max_requests:  5,
172            failed_login_window_secs:   3600,
173        }
174    }
175}
176
177impl RateLimitConfig {
178    /// Validate rate limiting configuration
179    pub fn validate(&self) -> Result<()> {
180        for (name, window) in &[
181            ("auth_start_window_secs", self.auth_start_window_secs),
182            ("auth_callback_window_secs", self.auth_callback_window_secs),
183            ("auth_refresh_window_secs", self.auth_refresh_window_secs),
184            ("auth_logout_window_secs", self.auth_logout_window_secs),
185            ("failed_login_window_secs", self.failed_login_window_secs),
186        ] {
187            if *window == 0 {
188                anyhow::bail!("{name} must be positive");
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)]
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
250impl StateEncryptionConfig {
251    /// Validate state encryption configuration
252    pub fn validate(&self) -> Result<()> {
253        if ![16, 24, 32].contains(&self.key_size) {
254            anyhow::bail!("key_size must be 16, 24, or 32 bytes");
255        }
256        if self.nonce_size != 12 {
257            anyhow::bail!("nonce_size must be 12 bytes (96-bit)");
258        }
259        Ok(())
260    }
261
262    /// Convert to JSON representation for schema
263    pub fn to_json(&self) -> serde_json::Value {
264        serde_json::json!({
265            "enabled": self.enabled,
266            "algorithm": self.algorithm,
267            "keyRotationEnabled": self.key_rotation_enabled,
268            "nonceSize": self.nonce_size,
269            "keySize": self.key_size,
270        })
271    }
272}
273
274/// Constant-time comparison configuration
275#[derive(Debug, Clone, Deserialize, Serialize)]
276#[serde(default)]
277pub struct ConstantTimeConfig {
278    /// Enable constant-time comparisons
279    pub enabled:                 bool,
280    /// Apply constant-time comparison to JWT tokens
281    pub apply_to_jwt:            bool,
282    /// Apply constant-time comparison to session tokens
283    pub apply_to_session_tokens: bool,
284    /// Apply constant-time comparison to CSRF tokens
285    pub apply_to_csrf_tokens:    bool,
286    /// Apply constant-time comparison to refresh tokens
287    pub apply_to_refresh_tokens: bool,
288}
289
290impl Default for ConstantTimeConfig {
291    fn default() -> Self {
292        Self {
293            enabled:                 true,
294            apply_to_jwt:            true,
295            apply_to_session_tokens: true,
296            apply_to_csrf_tokens:    true,
297            apply_to_refresh_tokens: true,
298        }
299    }
300}
301
302impl ConstantTimeConfig {
303    /// Convert to JSON representation for schema
304    pub fn to_json(&self) -> serde_json::Value {
305        serde_json::json!({
306            "enabled": self.enabled,
307            "applyToJwt": self.apply_to_jwt,
308            "applyToSessionTokens": self.apply_to_session_tokens,
309            "applytoCsrfTokens": self.apply_to_csrf_tokens,
310            "applyToRefreshTokens": self.apply_to_refresh_tokens,
311        })
312    }
313}
314
315/// Field-level RBAC role definition from fraiseql.toml
316#[derive(Debug, Clone, Deserialize, Serialize)]
317pub struct RoleDefinitionConfig {
318    /// Role name identifier
319    pub name:        String,
320    /// Role description
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub description: Option<String>,
323    /// Permission scopes assigned to this role
324    pub scopes:      Vec<String>,
325}
326
327impl RoleDefinitionConfig {
328    /// Convert to core RoleDefinition for schema compilation
329    /// Used in runtime field filtering (Cycle 5)
330    #[allow(dead_code)]
331    pub fn to_core_role_definition(&self) -> fraiseql_core::schema::RoleDefinition {
332        fraiseql_core::schema::RoleDefinition {
333            name:        self.name.clone(),
334            description: self.description.clone(),
335            scopes:      self.scopes.clone(),
336        }
337    }
338}
339
340/// Complete security configuration from fraiseql.toml
341#[derive(Debug, Clone, Default, Deserialize, Serialize)]
342#[serde(default)]
343pub struct SecurityConfig {
344    /// Audit logging configuration
345    #[serde(rename = "audit_logging")]
346    pub audit_logging:      AuditLoggingConfig,
347    /// Error sanitization configuration
348    #[serde(rename = "error_sanitization")]
349    pub error_sanitization: ErrorSanitizationConfig,
350    /// Rate limiting configuration
351    #[serde(rename = "rate_limiting")]
352    pub rate_limiting:      RateLimitConfig,
353    /// State encryption configuration
354    #[serde(rename = "state_encryption")]
355    pub state_encryption:   StateEncryptionConfig,
356    /// Constant-time comparison configuration
357    #[serde(rename = "constant_time")]
358    pub constant_time:      ConstantTimeConfig,
359    /// Field-level RBAC role definitions
360    #[serde(default, skip_serializing_if = "Vec::is_empty")]
361    pub role_definitions:   Vec<RoleDefinitionConfig>,
362    /// Default role when user has no explicit role assignment
363    #[serde(skip_serializing_if = "Option::is_none")]
364    pub default_role:       Option<String>,
365}
366
367impl SecurityConfig {
368    /// Validate all security configurations
369    pub fn validate(&self) -> Result<()> {
370        self.error_sanitization.validate()?;
371        self.rate_limiting.validate()?;
372        self.state_encryption.validate()?;
373
374        // Validate role definitions if present
375        for role in &self.role_definitions {
376            if role.name.is_empty() {
377                anyhow::bail!("Role name cannot be empty");
378            }
379            if role.scopes.is_empty() {
380                anyhow::bail!("Role '{}' must have at least one scope", role.name);
381            }
382        }
383
384        Ok(())
385    }
386
387    /// Find a role definition by name
388    /// Used in runtime field filtering (Cycle 5)
389    #[allow(dead_code)]
390    pub fn find_role(&self, name: &str) -> Option<&RoleDefinitionConfig> {
391        self.role_definitions.iter().find(|r| r.name == name)
392    }
393
394    /// Get all scopes for a role
395    /// Used in runtime field filtering (Cycle 5)
396    #[allow(dead_code)]
397    pub fn get_role_scopes(&self, role_name: &str) -> Vec<String> {
398        self.find_role(role_name).map(|role| role.scopes.clone()).unwrap_or_default()
399    }
400
401    /// Convert to JSON representation for schema.json
402    pub fn to_json(&self) -> serde_json::Value {
403        let mut json = serde_json::json!({
404            "auditLogging": self.audit_logging.to_json(),
405            "errorSanitization": self.error_sanitization.to_json(),
406            "rateLimiting": self.rate_limiting.to_json(),
407            "stateEncryption": self.state_encryption.to_json(),
408            "constantTime": self.constant_time.to_json(),
409        });
410
411        // Add role definitions if present
412        if !self.role_definitions.is_empty() {
413            json["roleDefinitions"] = serde_json::to_value(
414                self.role_definitions
415                    .iter()
416                    .map(|r| {
417                        serde_json::json!({
418                            "name": r.name,
419                            "description": r.description,
420                            "scopes": r.scopes,
421                        })
422                    })
423                    .collect::<Vec<_>>(),
424            )
425            .unwrap_or_default();
426        }
427
428        // Add default role if present
429        if let Some(default_role) = &self.default_role {
430            json["defaultRole"] = serde_json::json!(default_role);
431        }
432
433        json
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    #[test]
442    fn test_default_security_config() {
443        let config = SecurityConfig::default();
444        assert!(config.audit_logging.enabled);
445        assert!(config.error_sanitization.enabled);
446        assert!(config.rate_limiting.enabled);
447        assert!(config.state_encryption.enabled);
448        assert!(config.constant_time.enabled);
449    }
450
451    #[test]
452    fn test_error_sanitization_validation() {
453        let mut config = ErrorSanitizationConfig::default();
454        assert!(config.validate().is_ok());
455
456        config.leak_sensitive_details = true;
457        assert!(config.validate().is_err());
458    }
459
460    #[test]
461    fn test_rate_limiting_validation() {
462        let mut config = RateLimitConfig::default();
463        assert!(config.validate().is_ok());
464
465        config.auth_start_window_secs = 0;
466        assert!(config.validate().is_err());
467    }
468
469    #[test]
470    fn test_state_encryption_validation() {
471        let mut config = StateEncryptionConfig::default();
472        assert!(config.validate().is_ok());
473
474        config.key_size = 20;
475        assert!(config.validate().is_err());
476
477        config.key_size = 32;
478        config.nonce_size = 16;
479        assert!(config.validate().is_err());
480    }
481
482    #[test]
483    fn test_security_config_serialization() {
484        let config = SecurityConfig::default();
485        let json = config.to_json();
486        assert!(json["auditLogging"]["enabled"].is_boolean());
487        assert!(json["rateLimiting"]["authStart"]["maxRequests"].is_number());
488        assert!(json["stateEncryption"]["algorithm"].is_string());
489    }
490}