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}