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}
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}