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: it names the request's tenant in every
371    /// mode (#1542).
372    pub fn validate(&self) -> Result<()> {
373        if self.tenant_claim.is_empty() {
374            anyhow::bail!(
375                "tenancy.tenant_claim must not be empty: it names the JWT claim that carries the \
376                 request's tenant (default \"tenant_id\")"
377            );
378        }
379        Ok(())
380    }
381
382    /// Lower into the type the runtime deserializes.
383    ///
384    /// This is the **only** lowering of authored tenancy into the compiled shape, and
385    /// both compile workflows go through it: `SecurityConfig::to_json` for a project
386    /// `fraiseql.toml`, and `schema::merger` for a TOML schema (`#892`). A second
387    /// hand-written copy is exactly how the seam produced `#757` — `to_json` emitted
388    /// `tenantClaim`, which `TenancyConfig` does not declare, so a configured claim
389    /// vanished into the untyped catch-all and the runtime silently reverted to its
390    /// `"tenant_id"` default while compile-time `@tenant_id` validation read the
391    /// camelCase key. Compiler and server then disagreed about which claim carries
392    /// the tenant.
393    ///
394    /// Going through `TenancyConfig` makes them agree by construction: a rename on
395    /// either side is a compile error here, not a silent default at runtime.
396    #[must_use]
397    pub fn to_runtime(&self) -> fraiseql_core::schema::TenancyConfig {
398        fraiseql_core::schema::TenancyConfig {
399            mode:         match self.mode {
400                TenancyModeConfig::None => fraiseql_core::schema::TenancyMode::None,
401                TenancyModeConfig::Row => fraiseql_core::schema::TenancyMode::Row,
402                TenancyModeConfig::Schema => fraiseql_core::schema::TenancyMode::Schema,
403            },
404            tenant_claim: self.tenant_claim.clone(),
405        }
406    }
407
408    /// Convert to JSON representation for compiled schema.
409    pub fn to_json(&self) -> serde_json::Value {
410        serde_json::to_value(self.to_runtime()).unwrap_or_else(|_| serde_json::json!({}))
411    }
412}
413
414/// Complete security configuration from fraiseql.toml
415#[derive(Debug, Clone, Default, Deserialize, Serialize)]
416#[serde(default, deny_unknown_fields)]
417pub struct SecurityConfig {
418    /// Audit logging configuration
419    #[serde(rename = "audit_logging")]
420    pub audit_logging:      AuditLoggingConfig,
421    /// Error sanitization configuration
422    #[serde(rename = "error_sanitization")]
423    pub error_sanitization: ErrorSanitizationConfig,
424    /// Rate limiting configuration
425    #[serde(rename = "rate_limiting")]
426    pub rate_limiting:      RateLimitConfig,
427    /// State encryption configuration
428    #[serde(rename = "state_encryption")]
429    pub state_encryption:   StateEncryptionConfig,
430    /// Constant-time comparison configuration
431    #[serde(rename = "constant_time")]
432    pub constant_time:      ConstantTimeConfig,
433    /// Field-level RBAC role definitions
434    #[serde(default, skip_serializing_if = "Vec::is_empty")]
435    pub role_definitions:   Vec<RoleDefinitionConfig>,
436    /// Default role when user has no explicit role assignment
437    #[serde(skip_serializing_if = "Option::is_none")]
438    pub default_role:       Option<String>,
439    /// Declare that this schema serves multiple tenants (`multi_tenant = true`).
440    ///
441    /// A non-`none` `[fraiseql.tenancy] mode` implies it. Activates the
442    /// subscription tenant fail-closed gate and the cache+RLS boot gate — both of
443    /// which named this key in their error messages while `deny_unknown_fields`
444    /// made it a compile error to set (#758).
445    pub multi_tenant:       bool,
446    /// Declare that database Row-Level Security isolates this schema's data
447    /// (`[fraiseql.security.rls] enabled = true`). Verified against the live
448    /// catalog at boot.
449    pub rls:                fraiseql_core::schema::RlsConfig,
450
451    /// Machine service accounts, keyed by account name (#983).
452    ///
453    /// ```toml
454    /// [fraiseql.security.service_accounts.reconciler]
455    /// secret_env = "FRAISEQL_SA_RECONCILER_SECRET"
456    /// roles      = ["reconciler"]
457    /// scopes     = ["write:Invoice"]
458    /// ```
459    ///
460    /// The server has consumed `security.service_accounts` since #977, but no
461    /// authoring workflow could write it: only a hand-written `schema.json` could,
462    /// so the feature was reachable only by accident. The secret is never inlined —
463    /// `secret_env` names the environment variable holding it (ADR-0018).
464    #[serde(skip_serializing_if = "std::collections::HashMap::is_empty")]
465    pub service_accounts:
466        std::collections::HashMap<String, fraiseql_core::schema::ServiceAccountConfig>,
467}
468
469impl SecurityConfig {
470    /// Validate all security configurations
471    ///
472    /// # Errors
473    ///
474    /// Returns an error if any sub-configuration is invalid, if a role name is
475    /// empty, or if a role definition contains no scopes.
476    pub fn validate(&self) -> Result<()> {
477        self.error_sanitization.validate()?;
478        self.rate_limiting.validate()?;
479        self.state_encryption.validate()?;
480        self.reject_constant_time_section()?;
481
482        for (name, account) in &self.service_accounts {
483            if account.secret_env.trim().is_empty() {
484                anyhow::bail!(
485                    "service account '{name}' must set `secret_env` — the name of the \
486                     environment variable holding its bearer secret. The secret itself is \
487                     never written to the config."
488                );
489            }
490            if account.roles.is_empty() && account.scopes.is_empty() {
491                anyhow::bail!(
492                    "service account '{name}' grants neither roles nor scopes, so it can do \
493                     nothing. Give it a `run_as` ceiling, or remove it."
494                );
495            }
496        }
497
498        // Validate role definitions if present
499        for role in &self.role_definitions {
500            role.validate()?;
501        }
502
503        Ok(())
504    }
505
506    /// Refuse `[fraiseql.security.constant_time]`, which configures something that is
507    /// not configurable.
508    ///
509    /// Constant-time comparison is applied unconditionally wherever a secret is
510    /// compared, so there is nothing for these toggles to switch. They were emitted as
511    /// `constantTime`, which no consumer read in either casing — and one of the keys
512    /// inside was misspelled `applytoCsrfTokens`, which nothing noticed precisely
513    /// because nothing read it (#893).
514    ///
515    /// Refused rather than dropped: an operator who writes `apply_to_jwt = false`
516    /// should learn that the setting does not exist, not have it silently accepted.
517    fn reject_constant_time_section(&self) -> Result<()> {
518        let defaults = ConstantTimeConfig::default();
519        let configured = self.constant_time.enabled != defaults.enabled
520            || self.constant_time.apply_to_jwt != defaults.apply_to_jwt
521            || self.constant_time.apply_to_session_tokens != defaults.apply_to_session_tokens
522            || self.constant_time.apply_to_csrf_tokens != defaults.apply_to_csrf_tokens
523            || self.constant_time.apply_to_refresh_tokens != defaults.apply_to_refresh_tokens;
524
525        if configured {
526            anyhow::bail!(
527                "[security.constant_time] is accepted but not consumed: FraiseQL always compares \
528                 tokens and secrets in constant time, and there is no code path that reads these \
529                 toggles — so a `false` here would not disable anything, and a `true` does not \
530                 enable anything that was off. Remove the section."
531            );
532        }
533        Ok(())
534    }
535
536    /// Convert to JSON representation for schema.json.
537    ///
538    /// Built by constructing the typed `fraiseql_core::schema::SecurityConfig` and
539    /// serializing it (#977): the compiled seam is `deny_unknown_fields`, so a key
540    /// this path emitted under a name the consumer does not declare would fail the
541    /// compile instead of landing in a catch-all nothing reads. That catch-all is
542    /// how this path shipped camelCase sections (`auditLogging`, `constantTime`
543    /// with a misspelled `applytoCsrfTokens`) that no consumer read in any casing
544    /// (#757, #893).
545    ///
546    /// Mapping notes:
547    /// - `[fraiseql.security.audit_logging] enabled` lowers onto `enterprise.audit_logging_enabled`
548    ///   — the field the runtime actually reads. Its other keys (`log_level`, `buffer_size`, …)
549    ///   have no consumer anywhere and are not emitted.
550    /// - `[fraiseql.security.error_sanitization] enabled` lowers onto the compiled
551    ///   `error_sanitization.enabled`; its other keys (`generic_messages`, `user_facing_format`, …)
552    ///   likewise have no consumer and are not emitted.
553    /// - `state_encryption` lowers `enabled` + `algorithm`; `key_rotation_enabled`, `nonce_size`
554    ///   and `key_size` have no consumer (nonce/key sizes are fixed by the algorithm) and are not
555    ///   emitted.
556    pub fn to_json(&self) -> serde_json::Value {
557        let role_definitions: Vec<fraiseql_core::schema::RoleDefinition> =
558            self.role_definitions.iter().map(RoleDefinitionConfig::to_runtime).collect();
559
560        let algorithm = if self.state_encryption.algorithm == "aes-256-gcm" {
561            fraiseql_core::schema::EncryptionAlgorithm::Aes256Gcm
562        } else {
563            // `validate()` restricts the field to the two supported names.
564            fraiseql_core::schema::EncryptionAlgorithm::Chacha20Poly1305
565        };
566
567        let runtime = fraiseql_core::schema::SecurityConfig {
568            role_definitions,
569            default_role: self.default_role.clone(),
570            multi_tenant: self.multi_tenant,
571            rls: self.rls.clone(),
572            enterprise: Some(fraiseql_core::schema::EnterpriseSecurityConfig {
573                audit_logging_enabled: self.audit_logging.enabled,
574                error_sanitization: self.error_sanitization.enabled,
575                rate_limiting_enabled: self.rate_limiting.enabled,
576                ..Default::default()
577            }),
578            error_sanitization: Some(fraiseql_core::schema::ErrorSanitizationConfig {
579                enabled: self.error_sanitization.enabled,
580                ..Default::default()
581            }),
582            rate_limiting: Some(fraiseql_core::schema::RateLimitingSecurityConfig {
583                enabled: self.rate_limiting.enabled,
584                requests_per_second: self.rate_limiting.requests_per_second,
585                burst_size: self.rate_limiting.burst_size,
586                auth_start_max_requests: self.rate_limiting.auth_start_max_requests,
587                auth_start_window_secs: self.rate_limiting.auth_start_window_secs,
588                auth_callback_max_requests: self.rate_limiting.auth_callback_max_requests,
589                auth_callback_window_secs: self.rate_limiting.auth_callback_window_secs,
590                auth_refresh_max_requests: self.rate_limiting.auth_refresh_max_requests,
591                auth_refresh_window_secs: self.rate_limiting.auth_refresh_window_secs,
592                auth_logout_max_requests: self.rate_limiting.auth_logout_max_requests,
593                auth_logout_window_secs: self.rate_limiting.auth_logout_window_secs,
594                failed_login_max_attempts: self.rate_limiting.failed_login_max_requests,
595                failed_login_lockout_secs: self.rate_limiting.failed_login_window_secs,
596                requests_per_second_per_user: None,
597                redis_url: None,
598                trust_proxy_headers: self.rate_limiting.trust_proxy_headers,
599                // Emitted even when empty: with `trust_proxy_headers = true`, an
600                // explicit empty list is what the #618 boot guard refuses, while an
601                // absent list means "trust every proxy" (warned). Collapsing empty
602                // to absent would silently weaken the guard.
603                trusted_proxy_cidrs: Some(self.rate_limiting.trusted_proxy_cidrs.clone()),
604                max_buckets: self.rate_limiting.max_buckets,
605            }),
606            state_encryption: Some(fraiseql_core::schema::StateEncryptionConfig {
607                enabled: self.state_encryption.enabled,
608                algorithm,
609                ..Default::default()
610            }),
611            // #983: the server has read this since #977; this is the authoring side
612            // it never had. Absent when empty so the compiled section stays quiet.
613            service_accounts: (!self.service_accounts.is_empty())
614                .then(|| self.service_accounts.clone()),
615            ..fraiseql_core::schema::SecurityConfig::default()
616        };
617        serde_json::to_value(runtime).unwrap_or_else(|_| serde_json::json!({}))
618    }
619}
620
621#[cfg(test)]
622mod security_seam_tests {
623    #![allow(clippy::unwrap_used, clippy::expect_used)] // Reason: test code.
624
625    use super::*;
626
627    /// Every section name `SecurityConfig::to_json` emits, and the consumer that reads it.
628    ///
629    /// Hand-written producer keys drift from the consumer struct's field names, which
630    /// is the whole of #757 and #893. The list is asserted rather than described so a
631    /// rename on either side has to come here.
632    // A default config serializes exactly these sections: the typed
633    // `SecurityConfig` (#977) skips fields carrying no information
634    // (`multi_tenant = false`, a default `rls`, empty lists, `None`s), and
635    // `audit_logging` lowers onto `enterprise.audit_logging_enabled` — the key
636    // the runtime actually reads.
637    const EXPECTED_SECTIONS: &[&str] = &[
638        "enterprise",
639        "error_sanitization",
640        "rate_limiting",
641        "state_encryption",
642    ];
643
644    /// #983 — the server has consumed `security.service_accounts` since #977, but no
645    /// authoring workflow could write it: only a hand-written `schema.json` could, so
646    /// the feature was reachable only by accident. This is the authoring side, and it
647    /// must reach the compiled seam under the name the consumer declares.
648    #[test]
649    fn a_toml_service_account_reaches_the_compiled_seam() {
650        let toml = r#"
651[service_accounts.reconciler]
652secret_env = "FRAISEQL_SA_RECONCILER_SECRET"
653roles      = ["reconciler"]
654scopes     = ["write:Invoice"]
655"#;
656        let config: SecurityConfig = toml::from_str(toml).expect("the section must parse");
657        config
658            .validate()
659            .expect("a service account with a secret_env and a ceiling is valid");
660
661        let json = config.to_json();
662        let account = &json["service_accounts"]["reconciler"];
663        assert_eq!(account["secret_env"], "FRAISEQL_SA_RECONCILER_SECRET");
664        assert_eq!(account["roles"][0], "reconciler");
665        assert_eq!(account["scopes"][0], "write:Invoice");
666
667        // The compiled seam is `deny_unknown_fields`, so round-tripping through the
668        // consumer's own type is what proves the key names match.
669        let compiled: fraiseql_core::schema::SecurityConfig =
670            serde_json::from_value(json).expect("the emitted block must load as the consumer type");
671        let accounts = compiled.service_accounts.expect("service_accounts must survive");
672        assert_eq!(accounts["reconciler"].secret_env, "FRAISEQL_SA_RECONCILER_SECRET");
673    }
674
675    /// A service account that grants nothing can do nothing — refused rather than
676    /// compiled into a credential with no authority.
677    #[test]
678    fn a_service_account_with_no_ceiling_is_refused() {
679        let toml = r#"
680[service_accounts.ghost]
681secret_env = "FRAISEQL_SA_GHOST_SECRET"
682"#;
683        let config: SecurityConfig = toml::from_str(toml).expect("parses");
684        let err = config.validate().expect_err("no roles and no scopes must be refused");
685        assert!(
686            err.to_string().contains("neither roles nor scopes"),
687            "the refusal must say why; got: {err}"
688        );
689    }
690
691    #[test]
692    fn to_json_emits_exactly_the_sections_consumers_read() {
693        let json = SecurityConfig::default().to_json();
694        let obj = json.as_object().expect("an object");
695
696        let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
697        keys.sort_unstable();
698        let mut expected: Vec<&str> = EXPECTED_SECTIONS.to_vec();
699        expected.sort_unstable();
700
701        assert_eq!(
702            keys, expected,
703            "the compiled security block must carry exactly the sections consumers read; a \
704             camelCase spelling here reaches nothing (#893)"
705        );
706    }
707
708    #[test]
709    fn no_section_name_is_camel_case() {
710        let json = SecurityConfig::default().to_json();
711        for key in json.as_object().unwrap().keys() {
712            assert!(
713                !key.chars().any(char::is_uppercase),
714                "section {key:?} is camelCase; the consumers and the other compile path \
715                 (schema/merger.rs) both use snake_case (#893)"
716            );
717        }
718    }
719
720    #[test]
721    fn rate_limiting_is_flat_and_carries_a_usable_budget() {
722        let json = SecurityConfig::default().to_json();
723        let rl = json.get("rate_limiting").expect("rate_limiting section").clone();
724
725        // Flat, not the old nested `authStart: { maxRequests }` shape, which matched
726        // no reader even once the section name was right.
727        assert!(rl.get("authStart").is_none(), "the nested camelCase shape must be gone");
728        assert_eq!(
729            rl.get("auth_start_max_requests").and_then(serde_json::Value::as_u64),
730            Some(100)
731        );
732
733        // The trap in #893: a section emitted without these two keys deserializes them
734        // to zero on the consumer side, giving a limiter that denies every request.
735        let rps = rl.get("requests_per_second").and_then(serde_json::Value::as_u64).unwrap();
736        let burst = rl.get("burst_size").and_then(serde_json::Value::as_u64).unwrap();
737        assert!(rps > 0, "a default project config must not compile to a zero-rps limiter");
738        assert!(burst > 0, "a default project config must not compile to a zero-burst limiter");
739    }
740
741    #[test]
742    fn failed_login_defaults_match_the_runtimes_so_an_untouched_config_boots() {
743        // The runtime refuses to boot in production when these are tuned away from its
744        // defaults (#356), because the binary has no first-factor login to enforce
745        // them. An untouched project config must therefore emit exactly those defaults.
746        let json = SecurityConfig::default().to_json();
747        let rl = json.get("rate_limiting").unwrap();
748        assert_eq!(
749            rl.get("failed_login_max_attempts").and_then(serde_json::Value::as_u64),
750            Some(10)
751        );
752        assert_eq!(
753            rl.get("failed_login_lockout_secs").and_then(serde_json::Value::as_u64),
754            Some(900)
755        );
756    }
757
758    #[test]
759    fn a_configured_constant_time_section_is_refused() {
760        let config = SecurityConfig {
761            constant_time: ConstantTimeConfig {
762                apply_to_jwt: !ConstantTimeConfig::default().apply_to_jwt,
763                ..ConstantTimeConfig::default()
764            },
765            ..SecurityConfig::default()
766        };
767        let err = config.validate().expect_err("an inert section must be refused, not ignored");
768        assert!(err.to_string().contains("constant_time"), "{err}");
769    }
770
771    #[test]
772    fn an_untouched_constant_time_section_still_validates() {
773        SecurityConfig::default().validate().expect("defaults must validate");
774    }
775}