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    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 configuration
103#[derive(Debug, Clone, Deserialize, Serialize)]
104#[serde(default, deny_unknown_fields)]
105pub struct RateLimitConfig {
106    /// Enable rate limiting
107    pub enabled: bool,
108
109    /// Max requests for auth start endpoint (per IP)
110    pub auth_start_max_requests: u32,
111    /// Time window for auth start in seconds
112    pub auth_start_window_secs:  u64,
113
114    /// Max requests for auth callback endpoint (per IP)
115    pub auth_callback_max_requests: u32,
116    /// Time window for auth callback in seconds
117    pub auth_callback_window_secs:  u64,
118
119    /// Max requests for auth refresh endpoint (per user)
120    pub auth_refresh_max_requests: u32,
121    /// Time window for auth refresh in seconds
122    pub auth_refresh_window_secs:  u64,
123
124    /// Max requests for auth logout endpoint (per user)
125    pub auth_logout_max_requests: u32,
126    /// Time window for auth logout in seconds
127    pub auth_logout_window_secs:  u64,
128
129    /// Max failed login attempts per IP
130    pub failed_login_max_requests: u32,
131    /// Time window for failed login tracking in seconds
132    pub failed_login_window_secs:  u64,
133}
134
135impl Default for RateLimitConfig {
136    fn default() -> Self {
137        Self {
138            enabled:                    true,
139            auth_start_max_requests:    100,
140            auth_start_window_secs:     60,
141            auth_callback_max_requests: 50,
142            auth_callback_window_secs:  60,
143            auth_refresh_max_requests:  10,
144            auth_refresh_window_secs:   60,
145            auth_logout_max_requests:   20,
146            auth_logout_window_secs:    60,
147            failed_login_max_requests:  5,
148            failed_login_window_secs:   3600,
149        }
150    }
151}
152
153impl RateLimitConfig {
154    /// Validate rate limiting configuration
155    pub fn validate(&self) -> Result<()> {
156        for (name, window) in &[
157            ("auth_start_window_secs", self.auth_start_window_secs),
158            ("auth_callback_window_secs", self.auth_callback_window_secs),
159            ("auth_refresh_window_secs", self.auth_refresh_window_secs),
160            ("auth_logout_window_secs", self.auth_logout_window_secs),
161            ("failed_login_window_secs", self.failed_login_window_secs),
162        ] {
163            if *window == 0 {
164                anyhow::bail!("{name} must be positive");
165            }
166        }
167        Ok(())
168    }
169
170    /// Convert to JSON representation for schema
171    pub fn to_json(&self) -> serde_json::Value {
172        serde_json::json!({
173            "enabled": self.enabled,
174            "authStart": {
175                "maxRequests": self.auth_start_max_requests,
176                "windowSecs": self.auth_start_window_secs,
177            },
178            "authCallback": {
179                "maxRequests": self.auth_callback_max_requests,
180                "windowSecs": self.auth_callback_window_secs,
181            },
182            "authRefresh": {
183                "maxRequests": self.auth_refresh_max_requests,
184                "windowSecs": self.auth_refresh_window_secs,
185            },
186            "authLogout": {
187                "maxRequests": self.auth_logout_max_requests,
188                "windowSecs": self.auth_logout_window_secs,
189            },
190            "failedLogin": {
191                "maxRequests": self.failed_login_max_requests,
192                "windowSecs": self.failed_login_window_secs,
193            },
194        })
195    }
196}
197
198/// State encryption configuration
199#[derive(Debug, Clone, Deserialize, Serialize)]
200#[serde(default, deny_unknown_fields)]
201pub struct StateEncryptionConfig {
202    /// Enable state encryption
203    pub enabled:              bool,
204    /// Encryption algorithm ("chacha20-poly1305")
205    pub algorithm:            String,
206    /// Enable automatic key rotation
207    pub key_rotation_enabled: bool,
208    /// Nonce size in bytes (typically 12 for 96-bit)
209    pub nonce_size:           u32,
210    /// Key size in bytes (16, 24, or 32)
211    pub key_size:             u32,
212}
213
214impl Default for StateEncryptionConfig {
215    fn default() -> Self {
216        Self {
217            enabled:              true,
218            algorithm:            "chacha20-poly1305".to_string(),
219            key_rotation_enabled: false,
220            nonce_size:           12,
221            key_size:             32,
222        }
223    }
224}
225
226impl StateEncryptionConfig {
227    /// Validate state encryption configuration
228    pub fn validate(&self) -> Result<()> {
229        if ![16, 24, 32].contains(&self.key_size) {
230            anyhow::bail!("key_size must be 16, 24, or 32 bytes");
231        }
232        if self.nonce_size != 12 {
233            anyhow::bail!("nonce_size must be 12 bytes (96-bit)");
234        }
235        Ok(())
236    }
237
238    /// Convert to JSON representation for schema
239    pub fn to_json(&self) -> serde_json::Value {
240        serde_json::json!({
241            "enabled": self.enabled,
242            "algorithm": self.algorithm,
243            "keyRotationEnabled": self.key_rotation_enabled,
244            "nonceSize": self.nonce_size,
245            "keySize": self.key_size,
246        })
247    }
248}
249
250/// Constant-time comparison configuration
251#[derive(Debug, Clone, Deserialize, Serialize)]
252#[serde(default, deny_unknown_fields)]
253pub struct ConstantTimeConfig {
254    /// Enable constant-time comparisons
255    pub enabled:                 bool,
256    /// Apply constant-time comparison to JWT tokens
257    pub apply_to_jwt:            bool,
258    /// Apply constant-time comparison to session tokens
259    pub apply_to_session_tokens: bool,
260    /// Apply constant-time comparison to CSRF tokens
261    pub apply_to_csrf_tokens:    bool,
262    /// Apply constant-time comparison to refresh tokens
263    pub apply_to_refresh_tokens: bool,
264}
265
266impl Default for ConstantTimeConfig {
267    fn default() -> Self {
268        Self {
269            enabled:                 true,
270            apply_to_jwt:            true,
271            apply_to_session_tokens: true,
272            apply_to_csrf_tokens:    true,
273            apply_to_refresh_tokens: true,
274        }
275    }
276}
277
278impl ConstantTimeConfig {
279    /// Convert to JSON representation for schema
280    pub fn to_json(&self) -> serde_json::Value {
281        serde_json::json!({
282            "enabled": self.enabled,
283            "applyToJwt": self.apply_to_jwt,
284            "applyToSessionTokens": self.apply_to_session_tokens,
285            "applytoCsrfTokens": self.apply_to_csrf_tokens,
286            "applyToRefreshTokens": self.apply_to_refresh_tokens,
287        })
288    }
289}
290
291/// Field-level RBAC role definition from fraiseql.toml
292#[derive(Debug, Clone, Deserialize, Serialize)]
293#[serde(deny_unknown_fields)]
294pub struct RoleDefinitionConfig {
295    /// Role name identifier
296    pub name:        String,
297    /// Role description
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub description: Option<String>,
300    /// Permission scopes assigned to this role
301    pub scopes:      Vec<String>,
302}
303
304/// Complete security configuration from fraiseql.toml
305#[derive(Debug, Clone, Default, Deserialize, Serialize)]
306#[serde(default, deny_unknown_fields)]
307pub struct SecurityConfig {
308    /// Audit logging configuration
309    #[serde(rename = "audit_logging")]
310    pub audit_logging:      AuditLoggingConfig,
311    /// Error sanitization configuration
312    #[serde(rename = "error_sanitization")]
313    pub error_sanitization: ErrorSanitizationConfig,
314    /// Rate limiting configuration
315    #[serde(rename = "rate_limiting")]
316    pub rate_limiting:      RateLimitConfig,
317    /// State encryption configuration
318    #[serde(rename = "state_encryption")]
319    pub state_encryption:   StateEncryptionConfig,
320    /// Constant-time comparison configuration
321    #[serde(rename = "constant_time")]
322    pub constant_time:      ConstantTimeConfig,
323    /// Field-level RBAC role definitions
324    #[serde(default, skip_serializing_if = "Vec::is_empty")]
325    pub role_definitions:   Vec<RoleDefinitionConfig>,
326    /// Default role when user has no explicit role assignment
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub default_role:       Option<String>,
329}
330
331impl SecurityConfig {
332    /// Validate all security configurations
333    pub fn validate(&self) -> Result<()> {
334        self.error_sanitization.validate()?;
335        self.rate_limiting.validate()?;
336        self.state_encryption.validate()?;
337
338        // Validate role definitions if present
339        for role in &self.role_definitions {
340            if role.name.is_empty() {
341                anyhow::bail!("Role name cannot be empty");
342            }
343            if role.scopes.is_empty() {
344                anyhow::bail!("Role '{}' must have at least one scope", role.name);
345            }
346        }
347
348        Ok(())
349    }
350
351    /// Convert to JSON representation for schema.json
352    pub fn to_json(&self) -> serde_json::Value {
353        let mut json = serde_json::json!({
354            "auditLogging": self.audit_logging.to_json(),
355            "errorSanitization": self.error_sanitization.to_json(),
356            "rateLimiting": self.rate_limiting.to_json(),
357            "stateEncryption": self.state_encryption.to_json(),
358            "constantTime": self.constant_time.to_json(),
359        });
360
361        // Add role definitions if present
362        if !self.role_definitions.is_empty() {
363            json["roleDefinitions"] = serde_json::to_value(
364                self.role_definitions
365                    .iter()
366                    .map(|r| {
367                        serde_json::json!({
368                            "name": r.name,
369                            "description": r.description,
370                            "scopes": r.scopes,
371                        })
372                    })
373                    .collect::<Vec<_>>(),
374            )
375            .unwrap_or_default();
376        }
377
378        // Add default role if present
379        if let Some(default_role) = &self.default_role {
380            json["defaultRole"] = serde_json::json!(default_role);
381        }
382
383        json
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    #[test]
392    fn test_default_security_config() {
393        let config = SecurityConfig::default();
394        assert!(config.audit_logging.enabled);
395        assert!(config.error_sanitization.enabled);
396        assert!(config.rate_limiting.enabled);
397        assert!(config.state_encryption.enabled);
398        assert!(config.constant_time.enabled);
399    }
400
401    #[test]
402    fn test_error_sanitization_validation() {
403        let mut config = ErrorSanitizationConfig::default();
404        assert!(config.validate().is_ok());
405
406        config.leak_sensitive_details = true;
407        assert!(config.validate().is_err());
408    }
409
410    #[test]
411    fn test_rate_limiting_validation() {
412        let mut config = RateLimitConfig::default();
413        assert!(config.validate().is_ok());
414
415        config.auth_start_window_secs = 0;
416        assert!(config.validate().is_err());
417    }
418
419    #[test]
420    fn test_state_encryption_validation() {
421        let mut config = StateEncryptionConfig::default();
422        assert!(config.validate().is_ok());
423
424        config.key_size = 20;
425        assert!(config.validate().is_err());
426
427        config.key_size = 32;
428        config.nonce_size = 16;
429        assert!(config.validate().is_err());
430    }
431
432    #[test]
433    fn test_security_config_serialization() {
434        let config = SecurityConfig::default();
435        let json = config.to_json();
436        assert!(json["auditLogging"]["enabled"].is_boolean());
437        assert!(json["rateLimiting"]["authStart"]["maxRequests"].is_number());
438        assert!(json["stateEncryption"]["algorithm"].is_string());
439    }
440}