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///
12/// Note: rate limiting is intentionally **not** represented here. The live
13/// rate-limit configuration is read by the server middleware from the compiled
14/// schema's flat `security.rate_limiting` snake_case key
15/// (`fraiseql-server middleware/rate_limit/config.rs`, `RateLimitingSecurityConfig`).
16/// A former nested-camelCase reader on this struct (`rateLimiting.authStart.maxRequests`)
17/// never matched the merger's emitted flat shape, so it silently fed hardcoded
18/// defaults; it was removed under #612 (item 5b) to eliminate that drift.
19#[derive(Debug, Clone)]
20pub struct SecurityConfigFromSchema {
21    /// Audit logging configuration
22    pub audit_logging:      AuditLoggingSettings,
23    /// Error sanitization configuration
24    pub error_sanitization: ErrorSanitizationSettings,
25    /// State encryption configuration
26    pub state_encryption:   StateEncryptionSettings,
27}
28
29/// Audit logging subsystem settings loaded from the compiled schema.
30#[derive(Debug, Clone)]
31pub struct AuditLoggingSettings {
32    /// Whether audit logging is active.
33    pub enabled:                bool,
34    /// Minimum tracing level for audit records (e.g., `"info"`, `"debug"`).
35    pub log_level:              String,
36    /// When `true`, raw credential values may appear in log records.
37    /// Must be `false` in production deployments.
38    pub include_sensitive_data: bool,
39    /// When `true`, log records are written from a background task rather than
40    /// the request thread, reducing latency at the cost of some delivery guarantees.
41    pub async_logging:          bool,
42    /// Number of audit records to buffer before flushing (async mode only).
43    pub buffer_size:            u32,
44    /// How frequently (in seconds) the async buffer is flushed.
45    pub flush_interval_secs:    u32,
46}
47
48/// Error sanitization settings — controls how authentication errors are presented to clients.
49#[derive(Debug, Clone)]
50pub struct ErrorSanitizationSettings {
51    /// Whether error sanitization is active.
52    /// When `false`, internal error details may be forwarded to API clients.
53    pub enabled:                bool,
54    /// Replace specific internal error messages with generic user-safe strings.
55    pub generic_messages:       bool,
56    /// Log the full internal error message via `tracing` before sanitizing.
57    pub internal_logging:       bool,
58    /// When `true`, sensitive field values (tokens, keys, etc.) may appear in error messages.
59    /// **Must be `false` in production** — setting this to `true` fails
60    /// [`crate::security_init::validate_security_config`].
61    pub leak_sensitive_details: bool,
62    /// Format template for user-facing error messages (e.g., `"generic"`).
63    pub user_facing_format:     String,
64}
65
66/// OAuth state encryption settings loaded from the compiled schema.
67#[derive(Debug, Clone)]
68pub struct StateEncryptionSettings {
69    /// Whether OAuth PKCE state tokens are encrypted before being sent to the provider.
70    pub enabled:              bool,
71    /// AEAD algorithm to use (e.g., `"chacha20-poly1305"`, `"aes-256-gcm"`).
72    pub algorithm:            String,
73    /// When `true`, keys are rotated automatically.
74    pub key_rotation_enabled: bool,
75    /// Nonce size in bytes (must be 12 for both ChaCha20-Poly1305 and AES-256-GCM).
76    pub nonce_size:           u32,
77    /// Encryption key size in bytes (must be 32 for both supported algorithms).
78    pub key_size:             u32,
79}
80
81impl Default for SecurityConfigFromSchema {
82    fn default() -> Self {
83        Self {
84            audit_logging:      AuditLoggingSettings {
85                enabled:                true,
86                log_level:              "info".to_string(),
87                include_sensitive_data: false,
88                async_logging:          true,
89                buffer_size:            1000,
90                flush_interval_secs:    5,
91            },
92            error_sanitization: ErrorSanitizationSettings {
93                enabled:                true,
94                generic_messages:       true,
95                internal_logging:       true,
96                leak_sensitive_details: false,
97                user_facing_format:     "generic".to_string(),
98            },
99            state_encryption:   StateEncryptionSettings {
100                enabled:              true,
101                algorithm:            "chacha20-poly1305".to_string(),
102                key_rotation_enabled: false,
103                nonce_size:           12,
104                key_size:             32,
105            },
106        }
107    }
108}
109
110impl SecurityConfigFromSchema {
111    /// Parse security configuration from JSON (from schema.compiled.json)
112    ///
113    /// # Errors
114    ///
115    /// Returns an error if the JSON structure contains invalid or unparseable fields.
116    pub fn from_json(value: &JsonValue) -> anyhow::Result<Self> {
117        let mut config = Self::default();
118
119        if let Some(audit) = value.get("auditLogging").and_then(|v| v.as_object()) {
120            config.audit_logging.enabled =
121                audit.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
122            config.audit_logging.log_level =
123                audit.get("logLevel").and_then(|v| v.as_str()).unwrap_or("info").to_string();
124            config.audit_logging.include_sensitive_data =
125                audit.get("includeSensitiveData").and_then(|v| v.as_bool()).unwrap_or(false);
126            config.audit_logging.async_logging =
127                audit.get("asyncLogging").and_then(|v| v.as_bool()).unwrap_or(true);
128            #[allow(clippy::cast_possible_truncation)]
129            // Reason: buffer_size is a config value bounded well within u32 range
130            {
131                config.audit_logging.buffer_size =
132                    audit.get("bufferSize").and_then(|v| v.as_u64()).unwrap_or(1000) as u32;
133                config.audit_logging.flush_interval_secs =
134                    audit.get("flushIntervalSecs").and_then(|v| v.as_u64()).unwrap_or(5) as u32;
135            }
136        }
137
138        if let Some(error_san) = value.get("errorSanitization").and_then(|v| v.as_object()) {
139            config.error_sanitization.enabled =
140                error_san.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
141            config.error_sanitization.generic_messages =
142                error_san.get("genericMessages").and_then(|v| v.as_bool()).unwrap_or(true);
143            config.error_sanitization.internal_logging =
144                error_san.get("internalLogging").and_then(|v| v.as_bool()).unwrap_or(true);
145            config.error_sanitization.leak_sensitive_details =
146                error_san.get("leakSensitiveDetails").and_then(|v| v.as_bool()).unwrap_or(false);
147            config.error_sanitization.user_facing_format = error_san
148                .get("userFacingFormat")
149                .and_then(|v| v.as_str())
150                .unwrap_or("generic")
151                .to_string();
152        }
153
154        // Rate limiting is read from the compiled schema's flat `security.rate_limiting`
155        // key by the server middleware (`RateLimitingSecurityConfig`), not here. The
156        // former nested-camelCase reader was removed under #612 (item 5b).
157
158        if let Some(state_enc) = value.get("stateEncryption").and_then(|v| v.as_object()) {
159            config.state_encryption.enabled =
160                state_enc.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
161            config.state_encryption.algorithm = state_enc
162                .get("algorithm")
163                .and_then(|v| v.as_str())
164                .unwrap_or("chacha20-poly1305")
165                .to_string();
166            config.state_encryption.key_rotation_enabled =
167                state_enc.get("keyRotationEnabled").and_then(|v| v.as_bool()).unwrap_or(false);
168            #[allow(clippy::cast_possible_truncation)]
169            // Reason: nonce/key sizes are small constants (12, 32) well within u32 range
170            {
171                config.state_encryption.nonce_size =
172                    state_enc.get("nonceSize").and_then(|v| v.as_u64()).unwrap_or(12) as u32;
173                config.state_encryption.key_size =
174                    state_enc.get("keySize").and_then(|v| v.as_u64()).unwrap_or(32) as u32;
175            }
176        }
177
178        Ok(config)
179    }
180
181    /// Apply environment variable overrides
182    pub fn apply_env_overrides(&mut self) {
183        // Audit logging
184        if let Ok(level) = env::var("AUDIT_LOG_LEVEL") {
185            self.audit_logging.log_level = level;
186        }
187
188        // Rate limiting env overrides (RATE_LIMIT_*) apply to the live server-side
189        // `RateLimitingSecurityConfig`, not to this struct — the nested-camelCase
190        // reader that owned them here was removed under #612 (item 5b).
191    }
192}