Skip to main content

fraiseql_cli/config/
security.rs

1//! Security configuration parsing from fraiseql.toml
2
3use anyhow::Result;
4use serde::{Deserialize, Serialize};
5
6/// Audit logging configuration
7#[derive(Debug, Clone, Deserialize, Serialize)]
8#[serde(default, deny_unknown_fields)]
9pub struct AuditLoggingConfig {
10    /// Enable audit logging.
11    ///
12    /// The only key with a consumer: it lowers onto
13    /// `enterprise.audit_logging_enabled`, which the runtime reads. `log_level`,
14    /// `include_sensitive_data`, `async_logging`, `buffer_size` and
15    /// `flush_interval_secs` were accepted here and read by nothing, so they are
16    /// gone and `deny_unknown_fields` now refuses them by name (#983).
17    pub enabled: bool,
18}
19
20impl Default for AuditLoggingConfig {
21    fn default() -> Self {
22        Self { enabled: true }
23    }
24}
25
26/// Error sanitization configuration
27#[derive(Debug, Clone, Deserialize, Serialize)]
28#[serde(default, deny_unknown_fields)]
29pub struct ErrorSanitizationConfig {
30    /// Enable error sanitization.
31    ///
32    /// The only key with a consumer. `generic_messages`, `internal_logging`,
33    /// `leak_sensitive_details` and `user_facing_format` reached no runtime shape,
34    /// so they are gone and `deny_unknown_fields` refuses them by name (#983).
35    /// `leak_sensitive_details = true` used to be refused here as "a security risk";
36    /// it switched nothing, which is the more alarming half of that sentence.
37    pub enabled: bool,
38}
39
40impl Default for ErrorSanitizationConfig {
41    fn default() -> Self {
42        Self { enabled: true }
43    }
44}
45
46impl ErrorSanitizationConfig {
47    /// Validate error sanitization configuration.
48    ///
49    /// # Errors
50    ///
51    /// Currently infallible; kept so the section keeps its place in
52    /// [`SecurityConfig::validate`]'s sequence as keys are added.
53    pub const fn validate(&self) -> Result<()> {
54        Ok(())
55    }
56}
57
58/// The consumer's defaults for the auth endpoint budgets, which this table mirrors.
59const DEFAULT_AUTH: fraiseql_core::schema::RateLimitingSecurityConfig =
60    fraiseql_core::schema::RateLimitingSecurityConfig::DEFAULT;
61
62/// Rate limiting configuration
63#[derive(Debug, Clone, Deserialize, Serialize)]
64#[serde(default, deny_unknown_fields)]
65pub struct RateLimitConfig {
66    /// Enable rate limiting
67    pub enabled: bool,
68
69    /// Max requests for auth start endpoint (per IP)
70    pub auth_start_max_requests: u32,
71    /// Time window for auth start in seconds
72    pub auth_start_window_secs:  u64,
73
74    /// Max requests for auth callback endpoint (per IP)
75    pub auth_callback_max_requests: u32,
76    /// Time window for auth callback in seconds
77    pub auth_callback_window_secs:  u64,
78
79    /// Max requests for auth refresh endpoint (per user)
80    pub auth_refresh_max_requests: u32,
81    /// Time window for auth refresh in seconds
82    pub auth_refresh_window_secs:  u64,
83
84    /// Max requests for auth logout endpoint (per user)
85    pub auth_logout_max_requests: u32,
86    /// Time window for auth logout in seconds
87    pub auth_logout_window_secs:  u64,
88
89    /// Max `OTP` send requests (`/auth/v1/otp`) per client per window
90    pub auth_otp_send_max_requests:         u32,
91    /// Time window for `OTP` send in seconds
92    pub auth_otp_send_window_secs:          u64,
93    /// Max `OTP` verify requests (`/auth/v1/verify`) per client per window
94    pub auth_otp_verify_max_requests:       u32,
95    /// Time window for `OTP` verify in seconds
96    pub auth_otp_verify_window_secs:        u64,
97    /// Max requests per client per window to each local-password endpoint
98    pub auth_password_max_requests:         u32,
99    /// Time window for the local-password endpoints in seconds
100    pub auth_password_window_secs:          u64,
101    /// Max anonymous signups (`/auth/v1/signup`) per client per window
102    pub auth_anonymous_signup_max_requests: u32,
103    /// Time window for anonymous signup in seconds
104    pub auth_anonymous_signup_window_secs:  u64,
105    /// Apply the per-client auth endpoint rules. On unless written `false`; the
106    /// rules run whether or not `enabled` turns the global limit on.
107    pub auth_rules_enabled:                 bool,
108
109    /// Max failed login attempts per IP.
110    ///
111    /// Emitted as the consumer's `failed_login_max_attempts`. The binary performs no
112    /// first-factor login, so a value tuned away from the default is refused at boot
113    /// in production (#356) — hence the default matching the consumer's, so an
114    /// untouched config signals no intent and boots.
115    pub failed_login_max_requests: u32,
116    /// Time window for failed login tracking in seconds. See
117    /// [`failed_login_max_requests`](Self::failed_login_max_requests).
118    pub failed_login_window_secs:  u64,
119
120    /// Global per-IP request rate (requests/second) for the GraphQL surface.
121    pub requests_per_second: u32,
122    /// Burst allowance above `requests_per_second`.
123    pub burst_size:          u32,
124
125    /// Trust `X-Real-IP` / `X-Forwarded-For` for the client IP.
126    ///
127    /// Only safe behind a proxy named in `trusted_proxy_cidrs`; the server refuses to
128    /// boot in production if this is on and that list is empty (#618).
129    pub trust_proxy_headers: bool,
130    /// CIDR ranges trusted as proxies, e.g. `["10.0.0.0/8"]`.
131    pub trusted_proxy_cidrs: Vec<String>,
132
133    /// Maximum tracked rate-limit buckets per map — a memory ceiling, not a security
134    /// control (#1171). ~200 bytes a bucket, so the default is about 20 MiB per map.
135    /// Reaching it evicts the least-recently-used entry rather than refusing the new
136    /// client, so a low value costs accuracy and never availability.
137    pub max_buckets: usize,
138}
139
140impl Default for RateLimitConfig {
141    fn default() -> Self {
142        Self {
143            enabled: true,
144            // The consumer's defaults, so a project that writes `[fraiseql.security]`
145            // gets the same budgets as one that does not. They differed: this
146            // table allowed 100 starts and 50 callbacks a minute where the server's own
147            // default allowed 5 and 10.
148            auth_start_max_requests: DEFAULT_AUTH.auth_start_max_requests,
149            auth_start_window_secs: DEFAULT_AUTH.auth_start_window_secs,
150            auth_callback_max_requests: DEFAULT_AUTH.auth_callback_max_requests,
151            auth_callback_window_secs: DEFAULT_AUTH.auth_callback_window_secs,
152            auth_refresh_max_requests: DEFAULT_AUTH.auth_refresh_max_requests,
153            auth_refresh_window_secs: DEFAULT_AUTH.auth_refresh_window_secs,
154            auth_logout_max_requests: DEFAULT_AUTH.auth_logout_max_requests,
155            auth_logout_window_secs: DEFAULT_AUTH.auth_logout_window_secs,
156            auth_otp_send_max_requests: DEFAULT_AUTH.auth_otp_send_max_requests,
157            auth_otp_send_window_secs: DEFAULT_AUTH.auth_otp_send_window_secs,
158            auth_otp_verify_max_requests: DEFAULT_AUTH.auth_otp_verify_max_requests,
159            auth_otp_verify_window_secs: DEFAULT_AUTH.auth_otp_verify_window_secs,
160            auth_password_max_requests: DEFAULT_AUTH.auth_password_max_requests,
161            auth_password_window_secs: DEFAULT_AUTH.auth_password_window_secs,
162            auth_anonymous_signup_max_requests: DEFAULT_AUTH.auth_anonymous_signup_max_requests,
163            auth_anonymous_signup_window_secs: DEFAULT_AUTH.auth_anonymous_signup_window_secs,
164            auth_rules_enabled: DEFAULT_AUTH.auth_rules_enabled,
165            // Matching `DEFAULT_FAILED_LOGIN_MAX_ATTEMPTS` / `_LOCKOUT_SECS` in the
166            // consumer, so an untouched config is recognisably untouched. The previous
167            // 5 / 3600 read as deliberately tuned, and now that this section actually
168            // reaches the runtime that would refuse every production boot (#356).
169            failed_login_max_requests: 10,
170            failed_login_window_secs: 900,
171            requests_per_second: 100,
172            burst_size: 500,
173            trust_proxy_headers: false,
174            trusted_proxy_cidrs: Vec::new(),
175            max_buckets: fraiseql_core::schema::DEFAULT_RATE_LIMIT_MAX_BUCKETS,
176        }
177    }
178}
179
180impl RateLimitConfig {
181    /// Validate rate limiting configuration
182    ///
183    /// # Errors
184    ///
185    /// Returns an error if any time-window is zero or if any `max_requests`
186    /// value is zero (which would permanently block all requests).
187    pub fn validate(&self) -> Result<()> {
188        for (name, window) in &[
189            ("auth_start_window_secs", self.auth_start_window_secs),
190            ("auth_callback_window_secs", self.auth_callback_window_secs),
191            ("auth_refresh_window_secs", self.auth_refresh_window_secs),
192            ("auth_logout_window_secs", self.auth_logout_window_secs),
193            ("auth_otp_send_window_secs", self.auth_otp_send_window_secs),
194            ("auth_otp_verify_window_secs", self.auth_otp_verify_window_secs),
195            ("auth_password_window_secs", self.auth_password_window_secs),
196            ("auth_anonymous_signup_window_secs", self.auth_anonymous_signup_window_secs),
197            ("failed_login_window_secs", self.failed_login_window_secs),
198        ] {
199            if *window == 0 {
200                anyhow::bail!("{name} must be positive");
201            }
202        }
203        for (name, max_req) in &[
204            ("auth_start_max_requests", self.auth_start_max_requests),
205            ("auth_callback_max_requests", self.auth_callback_max_requests),
206            ("auth_refresh_max_requests", self.auth_refresh_max_requests),
207            ("auth_logout_max_requests", self.auth_logout_max_requests),
208            ("auth_otp_send_max_requests", self.auth_otp_send_max_requests),
209            ("auth_otp_verify_max_requests", self.auth_otp_verify_max_requests),
210            ("auth_password_max_requests", self.auth_password_max_requests),
211            ("auth_anonymous_signup_max_requests", self.auth_anonymous_signup_max_requests),
212            ("failed_login_max_requests", self.failed_login_max_requests),
213        ] {
214            if *max_req == 0 {
215                anyhow::bail!(
216                    "{name} must be at least 1; \
217                     setting it to 0 blocks all requests permanently"
218                );
219            }
220        }
221        Ok(())
222    }
223
224    /// Convert to JSON representation for schema
225    pub fn to_json(&self) -> serde_json::Value {
226        // Flat, snake_case, one key per field of the consumer's
227        // `RateLimitingSecurityConfig`. The nested camelCase shape below
228        // (`authStart.maxRequests`) matched no reader anywhere, so a project
229        // `fraiseql.toml` that configured rate limiting produced a section the server
230        // could not use even after the section name was corrected (#893).
231        serde_json::json!({
232            "enabled": self.enabled,
233            "requests_per_second": self.requests_per_second,
234            "burst_size": self.burst_size,
235            "trust_proxy_headers": self.trust_proxy_headers,
236            "trusted_proxy_cidrs": self.trusted_proxy_cidrs,
237            "auth_start_max_requests": self.auth_start_max_requests,
238            "auth_start_window_secs": self.auth_start_window_secs,
239            "auth_callback_max_requests": self.auth_callback_max_requests,
240            "auth_callback_window_secs": self.auth_callback_window_secs,
241            "auth_refresh_max_requests": self.auth_refresh_max_requests,
242            "auth_refresh_window_secs": self.auth_refresh_window_secs,
243            "auth_logout_max_requests": self.auth_logout_max_requests,
244            "auth_logout_window_secs": self.auth_logout_window_secs,
245            "auth_otp_send_max_requests": self.auth_otp_send_max_requests,
246            "auth_otp_send_window_secs": self.auth_otp_send_window_secs,
247            "auth_otp_verify_max_requests": self.auth_otp_verify_max_requests,
248            "auth_otp_verify_window_secs": self.auth_otp_verify_window_secs,
249            "auth_password_max_requests": self.auth_password_max_requests,
250            "auth_password_window_secs": self.auth_password_window_secs,
251            "auth_anonymous_signup_max_requests": self.auth_anonymous_signup_max_requests,
252            "auth_anonymous_signup_window_secs": self.auth_anonymous_signup_window_secs,
253            "auth_rules_enabled": self.auth_rules_enabled,
254            "failed_login_max_attempts": self.failed_login_max_requests,
255            "failed_login_lockout_secs": self.failed_login_window_secs,
256        })
257    }
258}
259
260/// State encryption configuration
261#[derive(Debug, Clone, Deserialize, Serialize)]
262#[serde(default, deny_unknown_fields)]
263pub struct StateEncryptionConfig {
264    /// Enable state encryption
265    pub enabled:   bool,
266    /// Encryption algorithm ("chacha20-poly1305")
267    pub algorithm: String,
268    // `key_rotation_enabled`, `nonce_size` and `key_size` were accepted here and
269    // reached no runtime shape — nonce and key sizes are fixed by the algorithm, so
270    // there was nothing for them to size (#983). `deny_unknown_fields` refuses them
271    // by name now.
272}
273
274impl Default for StateEncryptionConfig {
275    fn default() -> Self {
276        Self {
277            // Off unless the operator asks for it, matching
278            // `fraiseql_core::schema::StateEncryptionConfig` (#1151). This field is not an
279            // `Option` and the enclosing `SecurityConfig` carries `#[serde(default)]`, so an
280            // absent `[security.state_encryption]` table lands here and is lowered verbatim
281            // into the compiled artifact. Defaulting it on therefore enabled a control the
282            // operator never chose, and the server refuses to boot when the key env var is
283            // unset — a hard upgrade failure for every project that never opted in.
284            enabled:   false,
285            algorithm: "chacha20-poly1305".to_string(),
286        }
287    }
288}
289
290/// Supported encryption algorithms for `[security.state_encryption]`.
291const SUPPORTED_ALGORITHMS: &[&str] = &["chacha20-poly1305", "aes-256-gcm"];
292
293impl StateEncryptionConfig {
294    /// Validate state encryption configuration
295    ///
296    /// # Errors
297    ///
298    /// Returns an error if `algorithm` is not a supported value, if `key_size`
299    /// is not 16, 24, or 32 bytes, or if `nonce_size` is not 12 bytes.
300    pub fn validate(&self) -> Result<()> {
301        if !SUPPORTED_ALGORITHMS.contains(&self.algorithm.as_str()) {
302            anyhow::bail!(
303                "algorithm {:?} is not supported; must be one of: {}",
304                self.algorithm,
305                SUPPORTED_ALGORITHMS.join(", ")
306            );
307        }
308        Ok(())
309    }
310}
311
312/// Constant-time comparison configuration
313#[derive(Debug, Clone, Deserialize, Serialize)]
314#[serde(default, deny_unknown_fields)]
315pub struct ConstantTimeConfig {
316    /// Enable constant-time comparisons
317    pub enabled:                 bool,
318    /// Apply constant-time comparison to JWT tokens
319    pub apply_to_jwt:            bool,
320    /// Apply constant-time comparison to session tokens
321    pub apply_to_session_tokens: bool,
322    /// Apply constant-time comparison to CSRF tokens
323    pub apply_to_csrf_tokens:    bool,
324    /// Apply constant-time comparison to refresh tokens
325    pub apply_to_refresh_tokens: bool,
326}
327
328impl Default for ConstantTimeConfig {
329    fn default() -> Self {
330        Self {
331            enabled:                 true,
332            apply_to_jwt:            true,
333            apply_to_session_tokens: true,
334            apply_to_csrf_tokens:    true,
335            apply_to_refresh_tokens: true,
336        }
337    }
338}
339
340/// Field-level RBAC role definition from fraiseql.toml
341#[derive(Debug, Clone, Deserialize, Serialize)]
342#[serde(deny_unknown_fields)]
343pub struct RoleDefinitionConfig {
344    /// Role name identifier
345    pub name:        String,
346    /// Role description
347    #[serde(skip_serializing_if = "Option::is_none")]
348    pub description: Option<String>,
349    /// Permission scopes assigned to this role
350    pub scopes:      Vec<String>,
351}
352
353impl RoleDefinitionConfig {
354    /// Lower into the type the runtime's `role_has_scope` consults.
355    ///
356    /// The single lowering shared by both security producers — `SecurityConfig::to_json`
357    /// for a project `fraiseql.toml` and `schema::merger` for a TOML schema (`#897`).
358    /// `role_has_scope` is the sole input to field-level access, so a grant that does not
359    /// arrive here is a grant that does not exist; #757 is what a second hand-written copy
360    /// of this mapping costs.
361    #[must_use]
362    pub fn to_runtime(&self) -> fraiseql_core::schema::RoleDefinition {
363        fraiseql_core::schema::RoleDefinition {
364            description: self.description.clone(),
365            ..fraiseql_core::schema::RoleDefinition::new(self.name.clone(), self.scopes.clone())
366        }
367    }
368
369    /// Reject a role that cannot grant anything.
370    ///
371    /// # Errors
372    ///
373    /// Returns an error if the name is empty or the role declares no scopes.
374    pub fn validate(&self) -> Result<()> {
375        if self.name.is_empty() {
376            anyhow::bail!("Role name cannot be empty");
377        }
378        if self.scopes.is_empty() {
379            anyhow::bail!("Role '{}' must have at least one scope", self.name);
380        }
381        Ok(())
382    }
383}
384
385/// Tenancy isolation mode from fraiseql.toml.
386///
387/// Determines how tenant data is separated at the database level.
388#[derive(Debug, Clone, Default, Deserialize, Serialize)]
389#[serde(rename_all = "snake_case")]
390pub enum TenancyModeConfig {
391    /// Single-tenant deployment, no isolation machinery.
392    #[default]
393    None,
394    /// Row-level isolation via `@tenant_id` column injection.
395    Row,
396    /// Schema-level isolation via PostgreSQL schemas.
397    Schema,
398}
399
400/// Tenancy configuration from `[fraiseql.tenancy]` in fraiseql.toml.
401#[derive(Debug, Clone, Deserialize, Serialize)]
402#[serde(default, deny_unknown_fields)]
403pub struct TenancyTomlConfig {
404    /// Isolation strategy: `"none"`, `"row"`, or `"schema"`.
405    pub mode:         TenancyModeConfig,
406    /// JWT claim name that carries the tenant identifier.
407    pub tenant_claim: String,
408}
409
410impl Default for TenancyTomlConfig {
411    fn default() -> Self {
412        Self {
413            mode:         TenancyModeConfig::None,
414            tenant_claim: "tenant_id".to_string(),
415        }
416    }
417}
418
419impl TenancyTomlConfig {
420    /// Validate tenancy configuration.
421    ///
422    /// # Errors
423    ///
424    /// Returns an error if `tenant_claim` is empty: it names the request's tenant in every
425    /// mode (#1542).
426    pub fn validate(&self) -> Result<()> {
427        if self.tenant_claim.is_empty() {
428            anyhow::bail!(
429                "tenancy.tenant_claim must not be empty: it names the JWT claim that carries the \
430                 request's tenant (default \"tenant_id\")"
431            );
432        }
433        Ok(())
434    }
435
436    /// Lower into the type the runtime deserializes.
437    ///
438    /// This is the **only** lowering of authored tenancy into the compiled shape, and
439    /// both compile workflows go through it: `SecurityConfig::to_json` for a project
440    /// `fraiseql.toml`, and `schema::merger` for a TOML schema (`#892`). A second
441    /// hand-written copy is exactly how the seam produced `#757` — `to_json` emitted
442    /// `tenantClaim`, which `TenancyConfig` does not declare, so a configured claim
443    /// vanished into the untyped catch-all and the runtime silently reverted to its
444    /// `"tenant_id"` default while compile-time `@tenant_id` validation read the
445    /// camelCase key. Compiler and server then disagreed about which claim carries
446    /// the tenant.
447    ///
448    /// Going through `TenancyConfig` makes them agree by construction: a rename on
449    /// either side is a compile error here, not a silent default at runtime.
450    #[must_use]
451    pub fn to_runtime(&self) -> fraiseql_core::schema::TenancyConfig {
452        fraiseql_core::schema::TenancyConfig {
453            mode:         match self.mode {
454                TenancyModeConfig::None => fraiseql_core::schema::TenancyMode::None,
455                TenancyModeConfig::Row => fraiseql_core::schema::TenancyMode::Row,
456                TenancyModeConfig::Schema => fraiseql_core::schema::TenancyMode::Schema,
457            },
458            tenant_claim: self.tenant_claim.clone(),
459        }
460    }
461
462    /// Convert to JSON representation for compiled schema.
463    pub fn to_json(&self) -> serde_json::Value {
464        serde_json::to_value(self.to_runtime()).unwrap_or_else(|_| serde_json::json!({}))
465    }
466}
467
468/// Complete security configuration from fraiseql.toml
469#[derive(Debug, Clone, Default, Deserialize, Serialize)]
470#[serde(default, deny_unknown_fields)]
471pub struct SecurityConfig {
472    /// Audit logging configuration
473    #[serde(rename = "audit_logging")]
474    pub audit_logging:      AuditLoggingConfig,
475    /// Error sanitization configuration
476    #[serde(rename = "error_sanitization")]
477    pub error_sanitization: ErrorSanitizationConfig,
478    /// Rate limiting configuration
479    #[serde(rename = "rate_limiting")]
480    pub rate_limiting:      RateLimitConfig,
481    /// State encryption configuration
482    #[serde(rename = "state_encryption")]
483    pub state_encryption:   StateEncryptionConfig,
484    /// Constant-time comparison configuration
485    #[serde(rename = "constant_time")]
486    pub constant_time:      ConstantTimeConfig,
487    /// Field-level RBAC role definitions
488    #[serde(default, skip_serializing_if = "Vec::is_empty")]
489    pub role_definitions:   Vec<RoleDefinitionConfig>,
490    /// Default role when user has no explicit role assignment
491    #[serde(skip_serializing_if = "Option::is_none")]
492    pub default_role:       Option<String>,
493    /// Declare that this schema serves multiple tenants (`multi_tenant = true`).
494    ///
495    /// A non-`none` `[fraiseql.tenancy] mode` implies it. Activates the
496    /// subscription tenant fail-closed gate and the cache+RLS boot gate — both of
497    /// which named this key in their error messages while `deny_unknown_fields`
498    /// made it a compile error to set (#758).
499    pub multi_tenant:       bool,
500    /// Declare that database Row-Level Security isolates this schema's data
501    /// (`[fraiseql.security.rls] enabled = true`). Verified against the live
502    /// catalog at boot.
503    pub rls:                fraiseql_core::schema::RlsConfig,
504
505    /// Machine service accounts, keyed by account name (#983).
506    ///
507    /// ```toml
508    /// [fraiseql.security.service_accounts.reconciler]
509    /// secret_env = "FRAISEQL_SA_RECONCILER_SECRET"
510    /// roles      = ["reconciler"]
511    /// scopes     = ["write:Invoice"]
512    /// ```
513    ///
514    /// The server has consumed `security.service_accounts` since #977, but no
515    /// authoring workflow could write it: only a hand-written `schema.json` could,
516    /// so the feature was reachable only by accident. The secret is never inlined —
517    /// `secret_env` names the environment variable holding it (ADR-0018).
518    #[serde(skip_serializing_if = "std::collections::HashMap::is_empty")]
519    pub service_accounts:
520        std::collections::HashMap<String, fraiseql_core::schema::ServiceAccountConfig>,
521}
522
523impl SecurityConfig {
524    /// Validate all security configurations
525    ///
526    /// # Errors
527    ///
528    /// Returns an error if any sub-configuration is invalid, if a role name is
529    /// empty, or if a role definition contains no scopes.
530    pub fn validate(&self) -> Result<()> {
531        self.error_sanitization.validate()?;
532        self.rate_limiting.validate()?;
533        self.state_encryption.validate()?;
534        self.reject_constant_time_section()?;
535
536        for (name, account) in &self.service_accounts {
537            if account.secret_env.trim().is_empty() {
538                anyhow::bail!(
539                    "service account '{name}' must set `secret_env` — the name of the \
540                     environment variable holding its bearer secret. The secret itself is \
541                     never written to the config."
542                );
543            }
544            if account.roles.is_empty() && account.scopes.is_empty() {
545                anyhow::bail!(
546                    "service account '{name}' grants neither roles nor scopes, so it can do \
547                     nothing. Give it a `run_as` ceiling, or remove it."
548                );
549            }
550        }
551
552        // Validate role definitions if present
553        for role in &self.role_definitions {
554            role.validate()?;
555        }
556
557        Ok(())
558    }
559
560    /// Refuse `[fraiseql.security.constant_time]`, which configures something that is
561    /// not configurable.
562    ///
563    /// Constant-time comparison is applied unconditionally wherever a secret is
564    /// compared, so there is nothing for these toggles to switch. They were emitted as
565    /// `constantTime`, which no consumer read in either casing — and one of the keys
566    /// inside was misspelled `applytoCsrfTokens`, which nothing noticed precisely
567    /// because nothing read it (#893).
568    ///
569    /// Refused rather than dropped: an operator who writes `apply_to_jwt = false`
570    /// should learn that the setting does not exist, not have it silently accepted.
571    fn reject_constant_time_section(&self) -> Result<()> {
572        let defaults = ConstantTimeConfig::default();
573        let configured = self.constant_time.enabled != defaults.enabled
574            || self.constant_time.apply_to_jwt != defaults.apply_to_jwt
575            || self.constant_time.apply_to_session_tokens != defaults.apply_to_session_tokens
576            || self.constant_time.apply_to_csrf_tokens != defaults.apply_to_csrf_tokens
577            || self.constant_time.apply_to_refresh_tokens != defaults.apply_to_refresh_tokens;
578
579        if configured {
580            anyhow::bail!(
581                "[security.constant_time] is accepted but not consumed: FraiseQL always compares \
582                 tokens and secrets in constant time, and there is no code path that reads these \
583                 toggles — so a `false` here would not disable anything, and a `true` does not \
584                 enable anything that was off. Remove the section."
585            );
586        }
587        Ok(())
588    }
589
590    /// Convert to JSON representation for schema.json.
591    ///
592    /// Built by constructing the typed `fraiseql_core::schema::SecurityConfig` and
593    /// serializing it (#977): the compiled seam is `deny_unknown_fields`, so a key
594    /// this path emitted under a name the consumer does not declare would fail the
595    /// compile instead of landing in a catch-all nothing reads. That catch-all is
596    /// how this path shipped camelCase sections (`auditLogging`, `constantTime`
597    /// with a misspelled `applytoCsrfTokens`) that no consumer read in any casing
598    /// (#757, #893).
599    ///
600    /// Mapping notes:
601    /// - `[fraiseql.security.audit_logging] enabled` lowers onto `enterprise.audit_logging_enabled`
602    ///   — the field the runtime actually reads. Its other keys (`log_level`, `buffer_size`, …)
603    ///   have no consumer anywhere and are not emitted.
604    /// - `[fraiseql.security.error_sanitization] enabled` lowers onto the compiled
605    ///   `error_sanitization.enabled`; its other keys (`generic_messages`, `user_facing_format`, …)
606    ///   likewise have no consumer and are not emitted.
607    /// - `state_encryption` lowers `enabled` + `algorithm`; `key_rotation_enabled`, `nonce_size`
608    ///   and `key_size` have no consumer (nonce/key sizes are fixed by the algorithm) and are not
609    ///   emitted.
610    pub fn to_json(&self) -> serde_json::Value {
611        let role_definitions: Vec<fraiseql_core::schema::RoleDefinition> =
612            self.role_definitions.iter().map(RoleDefinitionConfig::to_runtime).collect();
613
614        let algorithm = if self.state_encryption.algorithm == "aes-256-gcm" {
615            fraiseql_core::schema::EncryptionAlgorithm::Aes256Gcm
616        } else {
617            // `validate()` restricts the field to the two supported names.
618            fraiseql_core::schema::EncryptionAlgorithm::Chacha20Poly1305
619        };
620
621        let runtime = fraiseql_core::schema::SecurityConfig {
622            role_definitions,
623            default_role: self.default_role.clone(),
624            multi_tenant: self.multi_tenant,
625            rls: self.rls.clone(),
626            enterprise: Some(fraiseql_core::schema::EnterpriseSecurityConfig {
627                audit_logging_enabled: self.audit_logging.enabled,
628                error_sanitization: self.error_sanitization.enabled,
629                rate_limiting_enabled: self.rate_limiting.enabled,
630                ..Default::default()
631            }),
632            error_sanitization: Some(fraiseql_core::schema::ErrorSanitizationConfig {
633                enabled: self.error_sanitization.enabled,
634                ..Default::default()
635            }),
636            rate_limiting: Some(fraiseql_core::schema::RateLimitingSecurityConfig {
637                enabled: self.rate_limiting.enabled,
638                requests_per_second: self.rate_limiting.requests_per_second,
639                burst_size: self.rate_limiting.burst_size,
640                auth_start_max_requests: self.rate_limiting.auth_start_max_requests,
641                auth_start_window_secs: self.rate_limiting.auth_start_window_secs,
642                auth_callback_max_requests: self.rate_limiting.auth_callback_max_requests,
643                auth_callback_window_secs: self.rate_limiting.auth_callback_window_secs,
644                auth_refresh_max_requests: self.rate_limiting.auth_refresh_max_requests,
645                auth_refresh_window_secs: self.rate_limiting.auth_refresh_window_secs,
646                auth_logout_max_requests: self.rate_limiting.auth_logout_max_requests,
647                auth_logout_window_secs: self.rate_limiting.auth_logout_window_secs,
648                auth_otp_send_max_requests: self.rate_limiting.auth_otp_send_max_requests,
649                auth_otp_send_window_secs: self.rate_limiting.auth_otp_send_window_secs,
650                auth_otp_verify_max_requests: self.rate_limiting.auth_otp_verify_max_requests,
651                auth_otp_verify_window_secs: self.rate_limiting.auth_otp_verify_window_secs,
652                auth_password_max_requests: self.rate_limiting.auth_password_max_requests,
653                auth_password_window_secs: self.rate_limiting.auth_password_window_secs,
654                auth_anonymous_signup_max_requests: self
655                    .rate_limiting
656                    .auth_anonymous_signup_max_requests,
657                auth_anonymous_signup_window_secs: self
658                    .rate_limiting
659                    .auth_anonymous_signup_window_secs,
660                auth_rules_enabled: self.rate_limiting.auth_rules_enabled,
661                failed_login_max_attempts: self.rate_limiting.failed_login_max_requests,
662                failed_login_lockout_secs: self.rate_limiting.failed_login_window_secs,
663                requests_per_second_per_user: None,
664                redis_url: None,
665                trust_proxy_headers: self.rate_limiting.trust_proxy_headers,
666                // Emitted even when empty: with `trust_proxy_headers = true`, an
667                // explicit empty list is what the #618 boot guard refuses, while an
668                // absent list means "trust every proxy" (warned). Collapsing empty
669                // to absent would silently weaken the guard.
670                trusted_proxy_cidrs: Some(self.rate_limiting.trusted_proxy_cidrs.clone()),
671                max_buckets: self.rate_limiting.max_buckets,
672            }),
673            state_encryption: Some(fraiseql_core::schema::StateEncryptionConfig {
674                enabled: self.state_encryption.enabled,
675                algorithm,
676                ..Default::default()
677            }),
678            // #983: the server has read this since #977; this is the authoring side
679            // it never had. Absent when empty so the compiled section stays quiet.
680            service_accounts: (!self.service_accounts.is_empty())
681                .then(|| self.service_accounts.clone()),
682            ..fraiseql_core::schema::SecurityConfig::default()
683        };
684        serde_json::to_value(runtime).unwrap_or_else(|_| serde_json::json!({}))
685    }
686}
687
688#[cfg(test)]
689mod security_seam_tests {
690    #![allow(clippy::unwrap_used, clippy::expect_used)] // Reason: test code.
691
692    use super::*;
693
694    /// Every section name `SecurityConfig::to_json` emits, and the consumer that reads it.
695    ///
696    /// Hand-written producer keys drift from the consumer struct's field names, which
697    /// is the whole of #757 and #893. The list is asserted rather than described so a
698    /// rename on either side has to come here.
699    // A default config serializes exactly these sections: the typed
700    // `SecurityConfig` (#977) skips fields carrying no information
701    // (`multi_tenant = false`, a default `rls`, empty lists, `None`s), and
702    // `audit_logging` lowers onto `enterprise.audit_logging_enabled` — the key
703    // the runtime actually reads.
704    const EXPECTED_SECTIONS: &[&str] = &[
705        "enterprise",
706        "error_sanitization",
707        "rate_limiting",
708        "state_encryption",
709    ];
710
711    /// #983 — the server has consumed `security.service_accounts` since #977, but no
712    /// authoring workflow could write it: only a hand-written `schema.json` could, so
713    /// the feature was reachable only by accident. This is the authoring side, and it
714    /// must reach the compiled seam under the name the consumer declares.
715    #[test]
716    fn a_toml_service_account_reaches_the_compiled_seam() {
717        let toml = r#"
718[service_accounts.reconciler]
719secret_env = "FRAISEQL_SA_RECONCILER_SECRET"
720roles      = ["reconciler"]
721scopes     = ["write:Invoice"]
722"#;
723        let config: SecurityConfig = toml::from_str(toml).expect("the section must parse");
724        config
725            .validate()
726            .expect("a service account with a secret_env and a ceiling is valid");
727
728        let json = config.to_json();
729        let account = &json["service_accounts"]["reconciler"];
730        assert_eq!(account["secret_env"], "FRAISEQL_SA_RECONCILER_SECRET");
731        assert_eq!(account["roles"][0], "reconciler");
732        assert_eq!(account["scopes"][0], "write:Invoice");
733
734        // The compiled seam is `deny_unknown_fields`, so round-tripping through the
735        // consumer's own type is what proves the key names match.
736        let compiled: fraiseql_core::schema::SecurityConfig =
737            serde_json::from_value(json).expect("the emitted block must load as the consumer type");
738        let accounts = compiled.service_accounts.expect("service_accounts must survive");
739        assert_eq!(accounts["reconciler"].secret_env, "FRAISEQL_SA_RECONCILER_SECRET");
740    }
741
742    /// A service account that grants nothing can do nothing — refused rather than
743    /// compiled into a credential with no authority.
744    #[test]
745    fn a_service_account_with_no_ceiling_is_refused() {
746        let toml = r#"
747[service_accounts.ghost]
748secret_env = "FRAISEQL_SA_GHOST_SECRET"
749"#;
750        let config: SecurityConfig = toml::from_str(toml).expect("parses");
751        let err = config.validate().expect_err("no roles and no scopes must be refused");
752        assert!(
753            err.to_string().contains("neither roles nor scopes"),
754            "the refusal must say why; got: {err}"
755        );
756    }
757
758    #[test]
759    fn to_json_emits_exactly_the_sections_consumers_read() {
760        let json = SecurityConfig::default().to_json();
761        let obj = json.as_object().expect("an object");
762
763        let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
764        keys.sort_unstable();
765        let mut expected: Vec<&str> = EXPECTED_SECTIONS.to_vec();
766        expected.sort_unstable();
767
768        assert_eq!(
769            keys, expected,
770            "the compiled security block must carry exactly the sections consumers read; a \
771             camelCase spelling here reaches nothing (#893)"
772        );
773    }
774
775    #[test]
776    fn no_section_name_is_camel_case() {
777        let json = SecurityConfig::default().to_json();
778        for key in json.as_object().unwrap().keys() {
779            assert!(
780                !key.chars().any(char::is_uppercase),
781                "section {key:?} is camelCase; the consumers and the other compile path \
782                 (schema/merger.rs) both use snake_case (#893)"
783            );
784        }
785    }
786
787    #[test]
788    fn rate_limiting_is_flat_and_carries_a_usable_budget() {
789        let json = SecurityConfig::default().to_json();
790        let rl = json.get("rate_limiting").expect("rate_limiting section").clone();
791
792        // Flat, not the old nested `authStart: { maxRequests }` shape, which matched
793        // no reader even once the section name was right.
794        assert!(rl.get("authStart").is_none(), "the nested camelCase shape must be gone");
795        assert_eq!(
796            rl.get("auth_start_max_requests").and_then(serde_json::Value::as_u64),
797            Some(u64::from(DEFAULT_AUTH.auth_start_max_requests))
798        );
799
800        // The trap in #893: a section emitted without these two keys deserializes them
801        // to zero on the consumer side, giving a limiter that denies every request.
802        let rps = rl.get("requests_per_second").and_then(serde_json::Value::as_u64).unwrap();
803        let burst = rl.get("burst_size").and_then(serde_json::Value::as_u64).unwrap();
804        assert!(rps > 0, "a default project config must not compile to a zero-rps limiter");
805        assert!(burst > 0, "a default project config must not compile to a zero-burst limiter");
806    }
807
808    /// This table and the runtime's kept two sets of auth budgets, and this one
809    /// was the looser — 100 starts and 50 callbacks a minute against the runtime's 5 and
810    /// 10 — so writing `[fraiseql.security]` weakened sign-in throttling. Every auth field
811    /// an untouched project emits must be the runtime default. The fields this table owns
812    /// on purpose (the global switch and budget, the proxy list) are taken from the output;
813    /// a new auth field is compared without being listed here.
814    #[test]
815    fn an_untouched_project_emits_the_runtime_auth_budgets() {
816        let json = SecurityConfig::default().to_json();
817        let emitted: fraiseql_core::schema::RateLimitingSecurityConfig =
818            serde_json::from_value(json["rate_limiting"].clone()).expect("the consumer's shape");
819        let expected = fraiseql_core::schema::RateLimitingSecurityConfig {
820            enabled: emitted.enabled,
821            requests_per_second: emitted.requests_per_second,
822            burst_size: emitted.burst_size,
823            trusted_proxy_cidrs: emitted.trusted_proxy_cidrs.clone(),
824            ..DEFAULT_AUTH
825        };
826        assert_eq!(emitted, expected);
827    }
828
829    #[test]
830    fn failed_login_defaults_match_the_runtimes_so_an_untouched_config_boots() {
831        // The runtime refuses to boot in production when these are tuned away from its
832        // defaults (#356), because the binary has no first-factor login to enforce
833        // them. An untouched project config must therefore emit exactly those defaults.
834        let json = SecurityConfig::default().to_json();
835        let rl = json.get("rate_limiting").unwrap();
836        assert_eq!(
837            rl.get("failed_login_max_attempts").and_then(serde_json::Value::as_u64),
838            Some(10)
839        );
840        assert_eq!(
841            rl.get("failed_login_lockout_secs").and_then(serde_json::Value::as_u64),
842            Some(900)
843        );
844    }
845
846    #[test]
847    fn a_configured_constant_time_section_is_refused() {
848        let config = SecurityConfig {
849            constant_time: ConstantTimeConfig {
850                apply_to_jwt: !ConstantTimeConfig::default().apply_to_jwt,
851                ..ConstantTimeConfig::default()
852            },
853            ..SecurityConfig::default()
854        };
855        let err = config.validate().expect_err("an inert section must be refused, not ignored");
856        assert!(err.to_string().contains("constant_time"), "{err}");
857    }
858
859    #[test]
860    fn an_untouched_constant_time_section_still_validates() {
861        SecurityConfig::default().validate().expect("defaults must validate");
862    }
863}