fraiseql_cli/config/toml_schema/security.rs
1//! Security configuration types for `[security.*]` and `[auth]` TOML sections.
2
3use std::fmt;
4
5use serde::{Deserialize, Serialize};
6
7/// Security configuration
8#[derive(Debug, Clone, Deserialize, Serialize)]
9#[serde(default, deny_unknown_fields)]
10pub struct SecuritySettings {
11 /// Default policy to apply if none specified
12 pub default_policy: Option<String>,
13 /// Custom authorization rules
14 pub rules: Vec<AuthorizationRule>,
15 /// Authorization policies
16 pub policies: Vec<AuthorizationPolicy>,
17 /// Field-level authorization rules
18 pub field_auth: Vec<FieldAuthRule>,
19 /// Enterprise security configuration (legacy flags)
20 pub enterprise: EnterpriseSecurityConfig,
21 /// Error sanitization — controls what detail clients see in error responses
22 pub error_sanitization: Option<ErrorSanitizationTomlConfig>,
23 /// Rate limiting — per-endpoint request caps
24 pub rate_limiting: Option<RateLimitingSecurityConfig>,
25 /// State encryption — AEAD encryption for OAuth state and PKCE blobs
26 pub state_encryption: Option<StateEncryptionConfig>,
27 /// PKCE — Proof Key for Code Exchange for OAuth Authorization Code flows
28 pub pkce: Option<PkceConfig>,
29 /// API key authentication — static or database-backed key-based auth
30 pub api_keys: Option<ApiKeySecurityConfig>,
31 /// Token revocation — reject JWTs by `jti` after revocation
32 pub token_revocation: Option<TokenRevocationSecurityConfig>,
33 /// Trusted documents — query allowlist (strict or permissive mode)
34 pub trusted_documents: Option<TrustedDocumentsConfig>,
35}
36
37impl Default for SecuritySettings {
38 fn default() -> Self {
39 Self {
40 default_policy: Some("authenticated".to_string()),
41 rules: vec![],
42 policies: vec![],
43 field_auth: vec![],
44 enterprise: EnterpriseSecurityConfig::default(),
45 error_sanitization: None,
46 rate_limiting: None,
47 state_encryption: None,
48 pkce: None,
49 api_keys: None,
50 token_revocation: None,
51 trusted_documents: None,
52 }
53 }
54}
55
56/// Authorization rule (custom expressions)
57#[derive(Debug, Clone, Deserialize, Serialize)]
58#[serde(deny_unknown_fields)]
59pub struct AuthorizationRule {
60 /// Rule name
61 pub name: String,
62 /// Rule expression or condition
63 pub rule: String,
64 /// Rule description
65 pub description: Option<String>,
66 /// Whether rule result can be cached
67 #[serde(default)]
68 pub cacheable: bool,
69 /// Cache time-to-live in seconds
70 pub cache_ttl_seconds: Option<u32>,
71}
72
73/// Authorization policy (RBAC/ABAC)
74#[derive(Debug, Clone, Deserialize, Serialize)]
75#[serde(deny_unknown_fields)]
76pub struct AuthorizationPolicy {
77 /// Policy name
78 pub name: String,
79 /// Policy type (RBAC, ABAC, CUSTOM, HYBRID)
80 #[serde(rename = "type")]
81 pub policy_type: String,
82 /// Optional rule expression
83 pub rule: Option<String>,
84 /// Roles this policy applies to
85 pub roles: Vec<String>,
86 /// Combination strategy (ANY, ALL, EXACTLY)
87 pub strategy: Option<String>,
88 /// Attributes for attribute-based access control
89 #[serde(default)]
90 pub attributes: Vec<String>,
91 /// Policy description
92 pub description: Option<String>,
93 /// Cache time-to-live in seconds
94 pub cache_ttl_seconds: Option<u32>,
95}
96
97/// Field-level authorization rule
98#[derive(Debug, Clone, Deserialize, Serialize)]
99#[serde(deny_unknown_fields)]
100pub struct FieldAuthRule {
101 /// Type name this rule applies to
102 pub type_name: String,
103 /// Field name this rule applies to
104 pub field_name: String,
105 /// Policy to enforce
106 pub policy: String,
107}
108
109/// Enterprise security configuration
110#[derive(Debug, Clone, Deserialize, Serialize)]
111#[serde(default, deny_unknown_fields)]
112pub struct EnterpriseSecurityConfig {
113 /// Enable rate limiting
114 pub rate_limiting_enabled: bool,
115 /// Max requests per auth endpoint
116 pub auth_endpoint_max_requests: u32,
117 /// Rate limit window in seconds
118 pub auth_endpoint_window_seconds: u64,
119 /// Enable audit logging
120 pub audit_logging_enabled: bool,
121 /// Audit log backend service
122 pub audit_log_backend: String,
123 /// Audit log retention in days
124 pub audit_retention_days: u32,
125 /// Enable error sanitization
126 pub error_sanitization: bool,
127 /// Hide implementation details in errors
128 pub hide_implementation_details: bool,
129 /// Enable constant-time token comparison
130 pub constant_time_comparison: bool,
131 /// Enable PKCE for OAuth flows
132 pub pkce_enabled: bool,
133}
134
135impl Default for EnterpriseSecurityConfig {
136 fn default() -> Self {
137 Self {
138 rate_limiting_enabled: true,
139 auth_endpoint_max_requests: 100,
140 auth_endpoint_window_seconds: 60,
141 audit_logging_enabled: true,
142 audit_log_backend: "postgresql".to_string(),
143 audit_retention_days: 365,
144 error_sanitization: true,
145 hide_implementation_details: true,
146 constant_time_comparison: true,
147 pkce_enabled: true,
148 }
149 }
150}
151
152/// Controls how much error detail is exposed to API clients.
153/// When enabled, internal error messages, SQL, and stack traces are stripped.
154///
155/// Note: named `ErrorSanitizationTomlConfig` to avoid collision with the identically-named
156/// struct in `config::security` which serves `FraiseQLConfig`.
157#[derive(Debug, Clone, Deserialize, Serialize)]
158#[serde(default, deny_unknown_fields)]
159pub struct ErrorSanitizationTomlConfig {
160 /// Enable error sanitization (default: false — opt-in)
161 pub enabled: bool,
162 /// Strip stack traces, SQL fragments, file paths (default: true)
163 #[serde(default = "default_true")]
164 pub hide_implementation_details: bool,
165 /// Replace raw database error messages with a generic message (default: true)
166 #[serde(default = "default_true")]
167 pub sanitize_database_errors: bool,
168 /// Replacement message shown to clients when an internal error is sanitized
169 pub custom_error_message: Option<String>,
170}
171
172impl Default for ErrorSanitizationTomlConfig {
173 fn default() -> Self {
174 Self {
175 enabled: false,
176 hide_implementation_details: true,
177 sanitize_database_errors: true,
178 custom_error_message: None,
179 }
180 }
181}
182
183/// Per-endpoint and global rate limiting configuration for `[security.rate_limiting]`.
184#[derive(Debug, Clone, Deserialize, Serialize)]
185#[serde(default, deny_unknown_fields)]
186pub struct RateLimitingSecurityConfig {
187 /// Enable rate limiting
188 pub enabled: bool,
189 /// Global request rate cap (requests per second, per IP)
190 pub requests_per_second: u32,
191 /// Burst allowance above the steady-state rate
192 pub burst_size: u32,
193 /// Auth initiation endpoint — max requests per window
194 pub auth_start_max_requests: u32,
195 /// Auth initiation window in seconds
196 pub auth_start_window_secs: u64,
197 /// OAuth callback endpoint — max requests per window
198 pub auth_callback_max_requests: u32,
199 /// OAuth callback window in seconds
200 pub auth_callback_window_secs: u64,
201 /// Token refresh endpoint — max requests per window
202 pub auth_refresh_max_requests: u32,
203 /// Token refresh window in seconds
204 pub auth_refresh_window_secs: u64,
205 /// Logout endpoint — max requests per window
206 pub auth_logout_max_requests: u32,
207 /// Logout window in seconds
208 pub auth_logout_window_secs: u64,
209 /// Failed login attempts before lockout
210 pub failed_login_max_attempts: u32,
211 /// Duration of failed-login lockout in seconds
212 pub failed_login_lockout_secs: u64,
213 /// Per-authenticated-user request rate in requests/second.
214 /// Defaults to 10× `requests_per_second` if not set.
215 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub requests_per_second_per_user: Option<u32>,
217 /// Redis URL for distributed rate limiting (optional — falls back to in-memory)
218 pub redis_url: Option<String>,
219 /// Trust `X-Real-IP` / `X-Forwarded-For` headers for client IP extraction.
220 ///
221 /// Set to `true` only when FraiseQL is deployed behind a trusted reverse proxy
222 /// (e.g. nginx, Cloudflare, AWS ALB) that sets these headers.
223 /// Enabling without a trusted proxy allows clients to spoof their IP address.
224 #[serde(default)]
225 pub trust_proxy_headers: bool,
226}
227
228impl Default for RateLimitingSecurityConfig {
229 fn default() -> Self {
230 Self {
231 enabled: false,
232 requests_per_second: 100,
233 requests_per_second_per_user: None,
234 burst_size: 200,
235 auth_start_max_requests: 5,
236 auth_start_window_secs: 60,
237 auth_callback_max_requests: 10,
238 auth_callback_window_secs: 60,
239 auth_refresh_max_requests: 20,
240 auth_refresh_window_secs: 300,
241 auth_logout_max_requests: 30,
242 auth_logout_window_secs: 60,
243 failed_login_max_attempts: 10,
244 failed_login_lockout_secs: 900,
245 redis_url: None,
246 trust_proxy_headers: false,
247 }
248 }
249}
250
251/// AEAD algorithm for OAuth state and PKCE state blobs.
252#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
253#[non_exhaustive]
254pub enum EncryptionAlgorithm {
255 /// ChaCha20-Poly1305 (recommended — constant-time, software-friendly)
256 #[default]
257 #[serde(rename = "chacha20-poly1305")]
258 Chacha20Poly1305,
259 /// AES-256-GCM (hardware-accelerated on modern CPUs)
260 #[serde(rename = "aes-256-gcm")]
261 Aes256Gcm,
262}
263
264impl fmt::Display for EncryptionAlgorithm {
265 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
266 match self {
267 Self::Chacha20Poly1305 => f.write_str("chacha20-poly1305"),
268 Self::Aes256Gcm => f.write_str("aes-256-gcm"),
269 }
270 }
271}
272
273/// Where the encryption key is sourced from.
274#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
275#[serde(rename_all = "lowercase")]
276#[non_exhaustive]
277pub enum KeySource {
278 /// Read key from an environment variable
279 #[default]
280 Env,
281}
282
283/// AEAD encryption for OAuth state parameter and PKCE code challenges.
284#[derive(Debug, Clone, Deserialize, Serialize)]
285#[serde(default, deny_unknown_fields)]
286pub struct StateEncryptionConfig {
287 /// Enable state encryption
288 pub enabled: bool,
289 /// AEAD algorithm to use
290 pub algorithm: EncryptionAlgorithm,
291 /// Where to source the encryption key
292 pub key_source: KeySource,
293 /// Environment variable holding the 32-byte hex-encoded key
294 pub key_env: Option<String>,
295}
296
297impl Default for StateEncryptionConfig {
298 fn default() -> Self {
299 Self {
300 enabled: false,
301 algorithm: EncryptionAlgorithm::default(),
302 key_source: KeySource::Env,
303 key_env: Some("STATE_ENCRYPTION_KEY".to_string()),
304 }
305 }
306}
307
308/// PKCE code challenge method.
309#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
310#[non_exhaustive]
311pub enum CodeChallengeMethod {
312 /// SHA-256 (required in production)
313 #[default]
314 #[serde(rename = "S256")]
315 S256,
316 /// Plain (spec-allowed but insecure — warns at runtime)
317 #[serde(rename = "plain")]
318 Plain,
319}
320
321/// PKCE (Proof Key for Code Exchange) configuration.
322/// Requires `state_encryption` to be enabled for secure state storage.
323#[derive(Debug, Clone, Deserialize, Serialize)]
324#[serde(default, deny_unknown_fields)]
325pub struct PkceConfig {
326 /// Enable PKCE for OAuth Authorization Code flows
327 pub enabled: bool,
328 /// Code challenge method (`S256` recommended)
329 pub code_challenge_method: CodeChallengeMethod,
330 /// How long the PKCE state is valid before the auth flow expires (seconds)
331 pub state_ttl_secs: u64,
332 /// Redis URL for distributed PKCE state storage across multiple replicas.
333 ///
334 /// Required for multi-replica deployments (Kubernetes, ECS, fly.io with
335 /// multiple instances). Without Redis, `/auth/start` and `/auth/callback`
336 /// must hit the same replica.
337 ///
338 /// Requires the `redis-pkce` Cargo feature to be compiled in.
339 /// Example: `"redis://localhost:6379"` or `"${REDIS_URL}"`.
340 #[serde(skip_serializing_if = "Option::is_none")]
341 pub redis_url: Option<String>,
342}
343
344impl Default for PkceConfig {
345 fn default() -> Self {
346 Self {
347 enabled: false,
348 code_challenge_method: CodeChallengeMethod::S256,
349 state_ttl_secs: 600,
350 redis_url: None,
351 }
352 }
353}
354
355/// API key authentication configuration.
356///
357/// ```toml
358/// [security.api_keys]
359/// enabled = true
360/// header = "X-API-Key"
361/// hash_algorithm = "sha256"
362/// storage = "env"
363///
364/// [[security.api_keys.static]]
365/// key_hash = "sha256:abc123..."
366/// scopes = ["read:*"]
367/// name = "ci-readonly"
368/// ```
369#[derive(Debug, Clone, Deserialize, Serialize)]
370#[serde(default, deny_unknown_fields)]
371pub struct ApiKeySecurityConfig {
372 /// Enable API key authentication
373 pub enabled: bool,
374 /// HTTP header name to read the API key from
375 pub header: String,
376 /// Hash algorithm for key verification (`sha256`)
377 pub hash_algorithm: String,
378 /// Storage backend: `"env"` for static keys, `"postgres"` for DB-backed
379 pub storage: String,
380 /// Static API key entries (only for `storage = "env"`)
381 #[serde(default, rename = "static")]
382 pub static_keys: Vec<StaticApiKeyEntry>,
383}
384
385impl Default for ApiKeySecurityConfig {
386 fn default() -> Self {
387 Self {
388 enabled: false,
389 header: "X-API-Key".to_string(),
390 hash_algorithm: "sha256".to_string(),
391 storage: "env".to_string(),
392 static_keys: vec![],
393 }
394 }
395}
396
397/// A single static API key entry.
398#[derive(Debug, Clone, Deserialize, Serialize)]
399#[serde(deny_unknown_fields)]
400pub struct StaticApiKeyEntry {
401 /// Hex-encoded hash, optionally prefixed with algorithm (e.g. `sha256:abc...`)
402 pub key_hash: String,
403 /// Scopes granted by this key
404 #[serde(default)]
405 pub scopes: Vec<String>,
406 /// Human-readable name for audit logging
407 pub name: String,
408}
409
410/// Trusted document mode.
411#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
412#[serde(rename_all = "lowercase")]
413#[non_exhaustive]
414pub enum TrustedDocumentMode {
415 /// Only documentId requests allowed; raw query strings rejected
416 Strict,
417 /// documentId requests use the manifest; raw queries fall through
418 #[default]
419 Permissive,
420}
421
422/// Trusted documents / query allowlist configuration.
423///
424/// ```toml
425/// [security.trusted_documents]
426/// enabled = true
427/// mode = "strict"
428/// manifest_path = "./trusted-documents.json"
429/// reload_interval_secs = 0
430/// ```
431#[derive(Debug, Clone, Deserialize, Serialize)]
432#[serde(default, deny_unknown_fields)]
433pub struct TrustedDocumentsConfig {
434 /// Enable trusted documents
435 pub enabled: bool,
436 /// Enforcement mode: "strict" or "permissive"
437 pub mode: TrustedDocumentMode,
438 /// Path to the trusted documents manifest JSON file
439 #[serde(skip_serializing_if = "Option::is_none")]
440 pub manifest_path: Option<String>,
441 /// URL to fetch the trusted documents manifest from at startup
442 #[serde(skip_serializing_if = "Option::is_none")]
443 pub manifest_url: Option<String>,
444 /// Poll interval in seconds for hot-reloading the manifest (0 = no reload)
445 #[serde(default)]
446 pub reload_interval_secs: u64,
447}
448
449impl Default for TrustedDocumentsConfig {
450 fn default() -> Self {
451 Self {
452 enabled: false,
453 mode: TrustedDocumentMode::Permissive,
454 manifest_path: None,
455 manifest_url: None,
456 reload_interval_secs: 0,
457 }
458 }
459}
460
461/// Token revocation configuration.
462///
463/// ```toml
464/// [security.token_revocation]
465/// enabled = true
466/// backend = "redis"
467/// require_jti = true
468/// fail_open = false
469/// ```
470#[derive(Debug, Clone, Deserialize, Serialize)]
471#[serde(default, deny_unknown_fields)]
472pub struct TokenRevocationSecurityConfig {
473 /// Enable token revocation
474 pub enabled: bool,
475 /// Backend: `"redis"`, `"postgres"`, or `"memory"`
476 pub backend: String,
477 /// Reject JWTs without a `jti` claim when revocation is enabled
478 #[serde(default = "default_true")]
479 pub require_jti: bool,
480 /// If revocation store is unreachable: `false` = reject (fail-closed), `true` = allow
481 /// (fail-open)
482 #[serde(default)]
483 pub fail_open: bool,
484 /// Redis URL for distributed revocation (optional — inherited from `[fraiseql.redis]` if
485 /// absent)
486 #[serde(skip_serializing_if = "Option::is_none")]
487 pub redis_url: Option<String>,
488}
489
490impl Default for TokenRevocationSecurityConfig {
491 fn default() -> Self {
492 Self {
493 enabled: false,
494 backend: "memory".to_string(),
495 require_jti: true,
496 fail_open: false,
497 redis_url: None,
498 }
499 }
500}
501
502/// OAuth2 client configuration for server-side PKCE flows.
503///
504/// The client secret is intentionally absent — use `client_secret_env` to
505/// name the environment variable that holds the secret at runtime.
506///
507/// ```toml
508/// [auth]
509/// discovery_url = "https://accounts.google.com"
510/// client_id = "my-fraiseql-client"
511/// client_secret_env = "OIDC_CLIENT_SECRET"
512/// server_redirect_uri = "https://api.example.com/auth/callback"
513/// ```
514#[derive(Debug, Clone, Deserialize, Serialize)]
515#[serde(deny_unknown_fields)]
516pub struct OidcClientConfig {
517 /// OIDC provider discovery URL (e.g. `"https://accounts.google.com"`).
518 /// Used to fetch `authorization_endpoint` and `token_endpoint` at compile time.
519 pub discovery_url: String,
520 /// OAuth2 `client_id` registered with the provider.
521 pub client_id: String,
522 /// Name of the environment variable that holds the client secret.
523 /// The secret itself must never appear in TOML or the compiled schema.
524 pub client_secret_env: String,
525 /// The full URL of this server's `/auth/callback` endpoint,
526 /// e.g. `"https://api.example.com/auth/callback"`.
527 pub server_redirect_uri: String,
528}
529
530fn default_true() -> bool {
531 true
532}