Skip to main content

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}