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