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/// Tenancy isolation mode from fraiseql.toml.
344///
345/// Determines how tenant data is separated at the database level.
346#[derive(Debug, Clone, Default, Deserialize, Serialize)]
347#[serde(rename_all = "snake_case")]
348pub enum TenancyModeConfig {
349    /// Single-tenant deployment, no isolation machinery.
350    #[default]
351    None,
352    /// Row-level isolation via `@tenant_id` column injection.
353    Row,
354    /// Schema-level isolation via PostgreSQL schemas.
355    Schema,
356}
357
358/// Tenancy configuration from `[fraiseql.tenancy]` in fraiseql.toml.
359#[derive(Debug, Clone, Deserialize, Serialize)]
360#[serde(default, deny_unknown_fields)]
361pub struct TenancyTomlConfig {
362    /// Isolation strategy: `"none"`, `"row"`, or `"schema"`.
363    pub mode:         TenancyModeConfig,
364    /// JWT claim name that carries the tenant identifier.
365    pub tenant_claim: String,
366}
367
368impl Default for TenancyTomlConfig {
369    fn default() -> Self {
370        Self {
371            mode:         TenancyModeConfig::None,
372            tenant_claim: "tenant_id".to_string(),
373        }
374    }
375}
376
377impl TenancyTomlConfig {
378    /// Validate tenancy configuration.
379    ///
380    /// # Errors
381    ///
382    /// Returns an error if `tenant_claim` is empty when mode is not `none`.
383    pub fn validate(&self) -> Result<()> {
384        if !matches!(self.mode, TenancyModeConfig::None) && self.tenant_claim.is_empty() {
385            anyhow::bail!("tenancy.tenant_claim must not be empty when mode is not 'none'");
386        }
387        Ok(())
388    }
389
390    /// Convert to JSON representation for compiled schema.
391    pub fn to_json(&self) -> serde_json::Value {
392        serde_json::json!({
393            "mode": match self.mode {
394                TenancyModeConfig::None => "none",
395                TenancyModeConfig::Row => "row",
396                TenancyModeConfig::Schema => "schema",
397            },
398            "tenantClaim": self.tenant_claim,
399        })
400    }
401}
402
403/// Complete security configuration from fraiseql.toml
404#[derive(Debug, Clone, Default, Deserialize, Serialize)]
405#[serde(default, deny_unknown_fields)]
406pub struct SecurityConfig {
407    /// Audit logging configuration
408    #[serde(rename = "audit_logging")]
409    pub audit_logging:      AuditLoggingConfig,
410    /// Error sanitization configuration
411    #[serde(rename = "error_sanitization")]
412    pub error_sanitization: ErrorSanitizationConfig,
413    /// Rate limiting configuration
414    #[serde(rename = "rate_limiting")]
415    pub rate_limiting:      RateLimitConfig,
416    /// State encryption configuration
417    #[serde(rename = "state_encryption")]
418    pub state_encryption:   StateEncryptionConfig,
419    /// Constant-time comparison configuration
420    #[serde(rename = "constant_time")]
421    pub constant_time:      ConstantTimeConfig,
422    /// Field-level RBAC role definitions
423    #[serde(default, skip_serializing_if = "Vec::is_empty")]
424    pub role_definitions:   Vec<RoleDefinitionConfig>,
425    /// Default role when user has no explicit role assignment
426    #[serde(skip_serializing_if = "Option::is_none")]
427    pub default_role:       Option<String>,
428}
429
430impl SecurityConfig {
431    /// Validate all security configurations
432    ///
433    /// # Errors
434    ///
435    /// Returns an error if any sub-configuration is invalid, if a role name is
436    /// empty, or if a role definition contains no scopes.
437    pub fn validate(&self) -> Result<()> {
438        self.error_sanitization.validate()?;
439        self.rate_limiting.validate()?;
440        self.state_encryption.validate()?;
441
442        // Validate role definitions if present
443        for role in &self.role_definitions {
444            if role.name.is_empty() {
445                anyhow::bail!("Role name cannot be empty");
446            }
447            if role.scopes.is_empty() {
448                anyhow::bail!("Role '{}' must have at least one scope", role.name);
449            }
450        }
451
452        Ok(())
453    }
454
455    /// Convert to JSON representation for schema.json
456    pub fn to_json(&self) -> serde_json::Value {
457        let mut json = serde_json::json!({
458            "auditLogging": self.audit_logging.to_json(),
459            "errorSanitization": self.error_sanitization.to_json(),
460            "rateLimiting": self.rate_limiting.to_json(),
461            "stateEncryption": self.state_encryption.to_json(),
462            "constantTime": self.constant_time.to_json(),
463        });
464
465        // Add role definitions if present
466        if !self.role_definitions.is_empty() {
467            json["roleDefinitions"] = serde_json::to_value(
468                self.role_definitions
469                    .iter()
470                    .map(|r| {
471                        serde_json::json!({
472                            "name": r.name,
473                            "description": r.description,
474                            "scopes": r.scopes,
475                        })
476                    })
477                    .collect::<Vec<_>>(),
478            )
479            .unwrap_or_default();
480        }
481
482        // Add default role if present
483        if let Some(default_role) = &self.default_role {
484            json["defaultRole"] = serde_json::json!(default_role);
485        }
486
487        json
488    }
489}