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}