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