1use anyhow::Result;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Deserialize, Serialize)]
8#[serde(default, deny_unknown_fields)]
9pub struct AuditLoggingConfig {
10 pub enabled: bool,
18}
19
20impl Default for AuditLoggingConfig {
21 fn default() -> Self {
22 Self { enabled: true }
23 }
24}
25
26#[derive(Debug, Clone, Deserialize, Serialize)]
28#[serde(default, deny_unknown_fields)]
29pub struct ErrorSanitizationConfig {
30 pub enabled: bool,
38}
39
40impl Default for ErrorSanitizationConfig {
41 fn default() -> Self {
42 Self { enabled: true }
43 }
44}
45
46impl ErrorSanitizationConfig {
47 pub const fn validate(&self) -> Result<()> {
54 Ok(())
55 }
56}
57
58const DEFAULT_AUTH: fraiseql_core::schema::RateLimitingSecurityConfig =
60 fraiseql_core::schema::RateLimitingSecurityConfig::DEFAULT;
61
62#[derive(Debug, Clone, Deserialize, Serialize)]
64#[serde(default, deny_unknown_fields)]
65pub struct RateLimitConfig {
66 pub enabled: bool,
68
69 pub auth_start_max_requests: u32,
71 pub auth_start_window_secs: u64,
73
74 pub auth_callback_max_requests: u32,
76 pub auth_callback_window_secs: u64,
78
79 pub auth_refresh_max_requests: u32,
81 pub auth_refresh_window_secs: u64,
83
84 pub auth_logout_max_requests: u32,
86 pub auth_logout_window_secs: u64,
88
89 pub auth_otp_send_max_requests: u32,
91 pub auth_otp_send_window_secs: u64,
93 pub auth_otp_verify_max_requests: u32,
95 pub auth_otp_verify_window_secs: u64,
97 pub auth_password_max_requests: u32,
99 pub auth_password_window_secs: u64,
101 pub auth_anonymous_signup_max_requests: u32,
103 pub auth_anonymous_signup_window_secs: u64,
105 pub auth_rules_enabled: bool,
108
109 pub failed_login_max_requests: u32,
116 pub failed_login_window_secs: u64,
119
120 pub requests_per_second: u32,
122 pub burst_size: u32,
124
125 pub trust_proxy_headers: bool,
130 pub trusted_proxy_cidrs: Vec<String>,
132
133 pub max_buckets: usize,
138}
139
140impl Default for RateLimitConfig {
141 fn default() -> Self {
142 Self {
143 enabled: true,
144 auth_start_max_requests: DEFAULT_AUTH.auth_start_max_requests,
149 auth_start_window_secs: DEFAULT_AUTH.auth_start_window_secs,
150 auth_callback_max_requests: DEFAULT_AUTH.auth_callback_max_requests,
151 auth_callback_window_secs: DEFAULT_AUTH.auth_callback_window_secs,
152 auth_refresh_max_requests: DEFAULT_AUTH.auth_refresh_max_requests,
153 auth_refresh_window_secs: DEFAULT_AUTH.auth_refresh_window_secs,
154 auth_logout_max_requests: DEFAULT_AUTH.auth_logout_max_requests,
155 auth_logout_window_secs: DEFAULT_AUTH.auth_logout_window_secs,
156 auth_otp_send_max_requests: DEFAULT_AUTH.auth_otp_send_max_requests,
157 auth_otp_send_window_secs: DEFAULT_AUTH.auth_otp_send_window_secs,
158 auth_otp_verify_max_requests: DEFAULT_AUTH.auth_otp_verify_max_requests,
159 auth_otp_verify_window_secs: DEFAULT_AUTH.auth_otp_verify_window_secs,
160 auth_password_max_requests: DEFAULT_AUTH.auth_password_max_requests,
161 auth_password_window_secs: DEFAULT_AUTH.auth_password_window_secs,
162 auth_anonymous_signup_max_requests: DEFAULT_AUTH.auth_anonymous_signup_max_requests,
163 auth_anonymous_signup_window_secs: DEFAULT_AUTH.auth_anonymous_signup_window_secs,
164 auth_rules_enabled: DEFAULT_AUTH.auth_rules_enabled,
165 failed_login_max_requests: 10,
170 failed_login_window_secs: 900,
171 requests_per_second: 100,
172 burst_size: 500,
173 trust_proxy_headers: false,
174 trusted_proxy_cidrs: Vec::new(),
175 max_buckets: fraiseql_core::schema::DEFAULT_RATE_LIMIT_MAX_BUCKETS,
176 }
177 }
178}
179
180impl RateLimitConfig {
181 pub fn validate(&self) -> Result<()> {
188 for (name, window) in &[
189 ("auth_start_window_secs", self.auth_start_window_secs),
190 ("auth_callback_window_secs", self.auth_callback_window_secs),
191 ("auth_refresh_window_secs", self.auth_refresh_window_secs),
192 ("auth_logout_window_secs", self.auth_logout_window_secs),
193 ("auth_otp_send_window_secs", self.auth_otp_send_window_secs),
194 ("auth_otp_verify_window_secs", self.auth_otp_verify_window_secs),
195 ("auth_password_window_secs", self.auth_password_window_secs),
196 ("auth_anonymous_signup_window_secs", self.auth_anonymous_signup_window_secs),
197 ("failed_login_window_secs", self.failed_login_window_secs),
198 ] {
199 if *window == 0 {
200 anyhow::bail!("{name} must be positive");
201 }
202 }
203 for (name, max_req) in &[
204 ("auth_start_max_requests", self.auth_start_max_requests),
205 ("auth_callback_max_requests", self.auth_callback_max_requests),
206 ("auth_refresh_max_requests", self.auth_refresh_max_requests),
207 ("auth_logout_max_requests", self.auth_logout_max_requests),
208 ("auth_otp_send_max_requests", self.auth_otp_send_max_requests),
209 ("auth_otp_verify_max_requests", self.auth_otp_verify_max_requests),
210 ("auth_password_max_requests", self.auth_password_max_requests),
211 ("auth_anonymous_signup_max_requests", self.auth_anonymous_signup_max_requests),
212 ("failed_login_max_requests", self.failed_login_max_requests),
213 ] {
214 if *max_req == 0 {
215 anyhow::bail!(
216 "{name} must be at least 1; \
217 setting it to 0 blocks all requests permanently"
218 );
219 }
220 }
221 Ok(())
222 }
223
224 pub fn to_json(&self) -> serde_json::Value {
226 serde_json::json!({
232 "enabled": self.enabled,
233 "requests_per_second": self.requests_per_second,
234 "burst_size": self.burst_size,
235 "trust_proxy_headers": self.trust_proxy_headers,
236 "trusted_proxy_cidrs": self.trusted_proxy_cidrs,
237 "auth_start_max_requests": self.auth_start_max_requests,
238 "auth_start_window_secs": self.auth_start_window_secs,
239 "auth_callback_max_requests": self.auth_callback_max_requests,
240 "auth_callback_window_secs": self.auth_callback_window_secs,
241 "auth_refresh_max_requests": self.auth_refresh_max_requests,
242 "auth_refresh_window_secs": self.auth_refresh_window_secs,
243 "auth_logout_max_requests": self.auth_logout_max_requests,
244 "auth_logout_window_secs": self.auth_logout_window_secs,
245 "auth_otp_send_max_requests": self.auth_otp_send_max_requests,
246 "auth_otp_send_window_secs": self.auth_otp_send_window_secs,
247 "auth_otp_verify_max_requests": self.auth_otp_verify_max_requests,
248 "auth_otp_verify_window_secs": self.auth_otp_verify_window_secs,
249 "auth_password_max_requests": self.auth_password_max_requests,
250 "auth_password_window_secs": self.auth_password_window_secs,
251 "auth_anonymous_signup_max_requests": self.auth_anonymous_signup_max_requests,
252 "auth_anonymous_signup_window_secs": self.auth_anonymous_signup_window_secs,
253 "auth_rules_enabled": self.auth_rules_enabled,
254 "failed_login_max_attempts": self.failed_login_max_requests,
255 "failed_login_lockout_secs": self.failed_login_window_secs,
256 })
257 }
258}
259
260#[derive(Debug, Clone, Deserialize, Serialize)]
262#[serde(default, deny_unknown_fields)]
263pub struct StateEncryptionConfig {
264 pub enabled: bool,
266 pub algorithm: String,
268 }
273
274impl Default for StateEncryptionConfig {
275 fn default() -> Self {
276 Self {
277 enabled: false,
285 algorithm: "chacha20-poly1305".to_string(),
286 }
287 }
288}
289
290const SUPPORTED_ALGORITHMS: &[&str] = &["chacha20-poly1305", "aes-256-gcm"];
292
293impl StateEncryptionConfig {
294 pub fn validate(&self) -> Result<()> {
301 if !SUPPORTED_ALGORITHMS.contains(&self.algorithm.as_str()) {
302 anyhow::bail!(
303 "algorithm {:?} is not supported; must be one of: {}",
304 self.algorithm,
305 SUPPORTED_ALGORITHMS.join(", ")
306 );
307 }
308 Ok(())
309 }
310}
311
312#[derive(Debug, Clone, Deserialize, Serialize)]
314#[serde(default, deny_unknown_fields)]
315pub struct ConstantTimeConfig {
316 pub enabled: bool,
318 pub apply_to_jwt: bool,
320 pub apply_to_session_tokens: bool,
322 pub apply_to_csrf_tokens: bool,
324 pub apply_to_refresh_tokens: bool,
326}
327
328impl Default for ConstantTimeConfig {
329 fn default() -> Self {
330 Self {
331 enabled: true,
332 apply_to_jwt: true,
333 apply_to_session_tokens: true,
334 apply_to_csrf_tokens: true,
335 apply_to_refresh_tokens: true,
336 }
337 }
338}
339
340#[derive(Debug, Clone, Deserialize, Serialize)]
342#[serde(deny_unknown_fields)]
343pub struct RoleDefinitionConfig {
344 pub name: String,
346 #[serde(skip_serializing_if = "Option::is_none")]
348 pub description: Option<String>,
349 pub scopes: Vec<String>,
351}
352
353impl RoleDefinitionConfig {
354 #[must_use]
362 pub fn to_runtime(&self) -> fraiseql_core::schema::RoleDefinition {
363 fraiseql_core::schema::RoleDefinition {
364 description: self.description.clone(),
365 ..fraiseql_core::schema::RoleDefinition::new(self.name.clone(), self.scopes.clone())
366 }
367 }
368
369 pub fn validate(&self) -> Result<()> {
375 if self.name.is_empty() {
376 anyhow::bail!("Role name cannot be empty");
377 }
378 if self.scopes.is_empty() {
379 anyhow::bail!("Role '{}' must have at least one scope", self.name);
380 }
381 Ok(())
382 }
383}
384
385#[derive(Debug, Clone, Default, Deserialize, Serialize)]
389#[serde(rename_all = "snake_case")]
390pub enum TenancyModeConfig {
391 #[default]
393 None,
394 Row,
396 Schema,
398}
399
400#[derive(Debug, Clone, Deserialize, Serialize)]
402#[serde(default, deny_unknown_fields)]
403pub struct TenancyTomlConfig {
404 pub mode: TenancyModeConfig,
406 pub tenant_claim: String,
408}
409
410impl Default for TenancyTomlConfig {
411 fn default() -> Self {
412 Self {
413 mode: TenancyModeConfig::None,
414 tenant_claim: "tenant_id".to_string(),
415 }
416 }
417}
418
419impl TenancyTomlConfig {
420 pub fn validate(&self) -> Result<()> {
427 if self.tenant_claim.is_empty() {
428 anyhow::bail!(
429 "tenancy.tenant_claim must not be empty: it names the JWT claim that carries the \
430 request's tenant (default \"tenant_id\")"
431 );
432 }
433 Ok(())
434 }
435
436 #[must_use]
451 pub fn to_runtime(&self) -> fraiseql_core::schema::TenancyConfig {
452 fraiseql_core::schema::TenancyConfig {
453 mode: match self.mode {
454 TenancyModeConfig::None => fraiseql_core::schema::TenancyMode::None,
455 TenancyModeConfig::Row => fraiseql_core::schema::TenancyMode::Row,
456 TenancyModeConfig::Schema => fraiseql_core::schema::TenancyMode::Schema,
457 },
458 tenant_claim: self.tenant_claim.clone(),
459 }
460 }
461
462 pub fn to_json(&self) -> serde_json::Value {
464 serde_json::to_value(self.to_runtime()).unwrap_or_else(|_| serde_json::json!({}))
465 }
466}
467
468#[derive(Debug, Clone, Default, Deserialize, Serialize)]
470#[serde(default, deny_unknown_fields)]
471pub struct SecurityConfig {
472 #[serde(rename = "audit_logging")]
474 pub audit_logging: AuditLoggingConfig,
475 #[serde(rename = "error_sanitization")]
477 pub error_sanitization: ErrorSanitizationConfig,
478 #[serde(rename = "rate_limiting")]
480 pub rate_limiting: RateLimitConfig,
481 #[serde(rename = "state_encryption")]
483 pub state_encryption: StateEncryptionConfig,
484 #[serde(rename = "constant_time")]
486 pub constant_time: ConstantTimeConfig,
487 #[serde(default, skip_serializing_if = "Vec::is_empty")]
489 pub role_definitions: Vec<RoleDefinitionConfig>,
490 #[serde(skip_serializing_if = "Option::is_none")]
492 pub default_role: Option<String>,
493 pub multi_tenant: bool,
500 pub rls: fraiseql_core::schema::RlsConfig,
504
505 #[serde(skip_serializing_if = "std::collections::HashMap::is_empty")]
519 pub service_accounts:
520 std::collections::HashMap<String, fraiseql_core::schema::ServiceAccountConfig>,
521}
522
523impl SecurityConfig {
524 pub fn validate(&self) -> Result<()> {
531 self.error_sanitization.validate()?;
532 self.rate_limiting.validate()?;
533 self.state_encryption.validate()?;
534 self.reject_constant_time_section()?;
535
536 for (name, account) in &self.service_accounts {
537 if account.secret_env.trim().is_empty() {
538 anyhow::bail!(
539 "service account '{name}' must set `secret_env` — the name of the \
540 environment variable holding its bearer secret. The secret itself is \
541 never written to the config."
542 );
543 }
544 if account.roles.is_empty() && account.scopes.is_empty() {
545 anyhow::bail!(
546 "service account '{name}' grants neither roles nor scopes, so it can do \
547 nothing. Give it a `run_as` ceiling, or remove it."
548 );
549 }
550 }
551
552 for role in &self.role_definitions {
554 role.validate()?;
555 }
556
557 Ok(())
558 }
559
560 fn reject_constant_time_section(&self) -> Result<()> {
572 let defaults = ConstantTimeConfig::default();
573 let configured = self.constant_time.enabled != defaults.enabled
574 || self.constant_time.apply_to_jwt != defaults.apply_to_jwt
575 || self.constant_time.apply_to_session_tokens != defaults.apply_to_session_tokens
576 || self.constant_time.apply_to_csrf_tokens != defaults.apply_to_csrf_tokens
577 || self.constant_time.apply_to_refresh_tokens != defaults.apply_to_refresh_tokens;
578
579 if configured {
580 anyhow::bail!(
581 "[security.constant_time] is accepted but not consumed: FraiseQL always compares \
582 tokens and secrets in constant time, and there is no code path that reads these \
583 toggles — so a `false` here would not disable anything, and a `true` does not \
584 enable anything that was off. Remove the section."
585 );
586 }
587 Ok(())
588 }
589
590 pub fn to_json(&self) -> serde_json::Value {
611 let role_definitions: Vec<fraiseql_core::schema::RoleDefinition> =
612 self.role_definitions.iter().map(RoleDefinitionConfig::to_runtime).collect();
613
614 let algorithm = if self.state_encryption.algorithm == "aes-256-gcm" {
615 fraiseql_core::schema::EncryptionAlgorithm::Aes256Gcm
616 } else {
617 fraiseql_core::schema::EncryptionAlgorithm::Chacha20Poly1305
619 };
620
621 let runtime = fraiseql_core::schema::SecurityConfig {
622 role_definitions,
623 default_role: self.default_role.clone(),
624 multi_tenant: self.multi_tenant,
625 rls: self.rls.clone(),
626 enterprise: Some(fraiseql_core::schema::EnterpriseSecurityConfig {
627 audit_logging_enabled: self.audit_logging.enabled,
628 error_sanitization: self.error_sanitization.enabled,
629 rate_limiting_enabled: self.rate_limiting.enabled,
630 ..Default::default()
631 }),
632 error_sanitization: Some(fraiseql_core::schema::ErrorSanitizationConfig {
633 enabled: self.error_sanitization.enabled,
634 ..Default::default()
635 }),
636 rate_limiting: Some(fraiseql_core::schema::RateLimitingSecurityConfig {
637 enabled: self.rate_limiting.enabled,
638 requests_per_second: self.rate_limiting.requests_per_second,
639 burst_size: self.rate_limiting.burst_size,
640 auth_start_max_requests: self.rate_limiting.auth_start_max_requests,
641 auth_start_window_secs: self.rate_limiting.auth_start_window_secs,
642 auth_callback_max_requests: self.rate_limiting.auth_callback_max_requests,
643 auth_callback_window_secs: self.rate_limiting.auth_callback_window_secs,
644 auth_refresh_max_requests: self.rate_limiting.auth_refresh_max_requests,
645 auth_refresh_window_secs: self.rate_limiting.auth_refresh_window_secs,
646 auth_logout_max_requests: self.rate_limiting.auth_logout_max_requests,
647 auth_logout_window_secs: self.rate_limiting.auth_logout_window_secs,
648 auth_otp_send_max_requests: self.rate_limiting.auth_otp_send_max_requests,
649 auth_otp_send_window_secs: self.rate_limiting.auth_otp_send_window_secs,
650 auth_otp_verify_max_requests: self.rate_limiting.auth_otp_verify_max_requests,
651 auth_otp_verify_window_secs: self.rate_limiting.auth_otp_verify_window_secs,
652 auth_password_max_requests: self.rate_limiting.auth_password_max_requests,
653 auth_password_window_secs: self.rate_limiting.auth_password_window_secs,
654 auth_anonymous_signup_max_requests: self
655 .rate_limiting
656 .auth_anonymous_signup_max_requests,
657 auth_anonymous_signup_window_secs: self
658 .rate_limiting
659 .auth_anonymous_signup_window_secs,
660 auth_rules_enabled: self.rate_limiting.auth_rules_enabled,
661 failed_login_max_attempts: self.rate_limiting.failed_login_max_requests,
662 failed_login_lockout_secs: self.rate_limiting.failed_login_window_secs,
663 requests_per_second_per_user: None,
664 redis_url: None,
665 trust_proxy_headers: self.rate_limiting.trust_proxy_headers,
666 trusted_proxy_cidrs: Some(self.rate_limiting.trusted_proxy_cidrs.clone()),
671 max_buckets: self.rate_limiting.max_buckets,
672 }),
673 state_encryption: Some(fraiseql_core::schema::StateEncryptionConfig {
674 enabled: self.state_encryption.enabled,
675 algorithm,
676 ..Default::default()
677 }),
678 service_accounts: (!self.service_accounts.is_empty())
681 .then(|| self.service_accounts.clone()),
682 ..fraiseql_core::schema::SecurityConfig::default()
683 };
684 serde_json::to_value(runtime).unwrap_or_else(|_| serde_json::json!({}))
685 }
686}
687
688#[cfg(test)]
689mod security_seam_tests {
690 #![allow(clippy::unwrap_used, clippy::expect_used)] use super::*;
693
694 const EXPECTED_SECTIONS: &[&str] = &[
705 "enterprise",
706 "error_sanitization",
707 "rate_limiting",
708 "state_encryption",
709 ];
710
711 #[test]
716 fn a_toml_service_account_reaches_the_compiled_seam() {
717 let toml = r#"
718[service_accounts.reconciler]
719secret_env = "FRAISEQL_SA_RECONCILER_SECRET"
720roles = ["reconciler"]
721scopes = ["write:Invoice"]
722"#;
723 let config: SecurityConfig = toml::from_str(toml).expect("the section must parse");
724 config
725 .validate()
726 .expect("a service account with a secret_env and a ceiling is valid");
727
728 let json = config.to_json();
729 let account = &json["service_accounts"]["reconciler"];
730 assert_eq!(account["secret_env"], "FRAISEQL_SA_RECONCILER_SECRET");
731 assert_eq!(account["roles"][0], "reconciler");
732 assert_eq!(account["scopes"][0], "write:Invoice");
733
734 let compiled: fraiseql_core::schema::SecurityConfig =
737 serde_json::from_value(json).expect("the emitted block must load as the consumer type");
738 let accounts = compiled.service_accounts.expect("service_accounts must survive");
739 assert_eq!(accounts["reconciler"].secret_env, "FRAISEQL_SA_RECONCILER_SECRET");
740 }
741
742 #[test]
745 fn a_service_account_with_no_ceiling_is_refused() {
746 let toml = r#"
747[service_accounts.ghost]
748secret_env = "FRAISEQL_SA_GHOST_SECRET"
749"#;
750 let config: SecurityConfig = toml::from_str(toml).expect("parses");
751 let err = config.validate().expect_err("no roles and no scopes must be refused");
752 assert!(
753 err.to_string().contains("neither roles nor scopes"),
754 "the refusal must say why; got: {err}"
755 );
756 }
757
758 #[test]
759 fn to_json_emits_exactly_the_sections_consumers_read() {
760 let json = SecurityConfig::default().to_json();
761 let obj = json.as_object().expect("an object");
762
763 let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
764 keys.sort_unstable();
765 let mut expected: Vec<&str> = EXPECTED_SECTIONS.to_vec();
766 expected.sort_unstable();
767
768 assert_eq!(
769 keys, expected,
770 "the compiled security block must carry exactly the sections consumers read; a \
771 camelCase spelling here reaches nothing (#893)"
772 );
773 }
774
775 #[test]
776 fn no_section_name_is_camel_case() {
777 let json = SecurityConfig::default().to_json();
778 for key in json.as_object().unwrap().keys() {
779 assert!(
780 !key.chars().any(char::is_uppercase),
781 "section {key:?} is camelCase; the consumers and the other compile path \
782 (schema/merger.rs) both use snake_case (#893)"
783 );
784 }
785 }
786
787 #[test]
788 fn rate_limiting_is_flat_and_carries_a_usable_budget() {
789 let json = SecurityConfig::default().to_json();
790 let rl = json.get("rate_limiting").expect("rate_limiting section").clone();
791
792 assert!(rl.get("authStart").is_none(), "the nested camelCase shape must be gone");
795 assert_eq!(
796 rl.get("auth_start_max_requests").and_then(serde_json::Value::as_u64),
797 Some(u64::from(DEFAULT_AUTH.auth_start_max_requests))
798 );
799
800 let rps = rl.get("requests_per_second").and_then(serde_json::Value::as_u64).unwrap();
803 let burst = rl.get("burst_size").and_then(serde_json::Value::as_u64).unwrap();
804 assert!(rps > 0, "a default project config must not compile to a zero-rps limiter");
805 assert!(burst > 0, "a default project config must not compile to a zero-burst limiter");
806 }
807
808 #[test]
815 fn an_untouched_project_emits_the_runtime_auth_budgets() {
816 let json = SecurityConfig::default().to_json();
817 let emitted: fraiseql_core::schema::RateLimitingSecurityConfig =
818 serde_json::from_value(json["rate_limiting"].clone()).expect("the consumer's shape");
819 let expected = fraiseql_core::schema::RateLimitingSecurityConfig {
820 enabled: emitted.enabled,
821 requests_per_second: emitted.requests_per_second,
822 burst_size: emitted.burst_size,
823 trusted_proxy_cidrs: emitted.trusted_proxy_cidrs.clone(),
824 ..DEFAULT_AUTH
825 };
826 assert_eq!(emitted, expected);
827 }
828
829 #[test]
830 fn failed_login_defaults_match_the_runtimes_so_an_untouched_config_boots() {
831 let json = SecurityConfig::default().to_json();
835 let rl = json.get("rate_limiting").unwrap();
836 assert_eq!(
837 rl.get("failed_login_max_attempts").and_then(serde_json::Value::as_u64),
838 Some(10)
839 );
840 assert_eq!(
841 rl.get("failed_login_lockout_secs").and_then(serde_json::Value::as_u64),
842 Some(900)
843 );
844 }
845
846 #[test]
847 fn a_configured_constant_time_section_is_refused() {
848 let config = SecurityConfig {
849 constant_time: ConstantTimeConfig {
850 apply_to_jwt: !ConstantTimeConfig::default().apply_to_jwt,
851 ..ConstantTimeConfig::default()
852 },
853 ..SecurityConfig::default()
854 };
855 let err = config.validate().expect_err("an inert section must be refused, not ignored");
856 assert!(err.to_string().contains("constant_time"), "{err}");
857 }
858
859 #[test]
860 fn an_untouched_constant_time_section_still_validates() {
861 SecurityConfig::default().validate().expect("defaults must validate");
862 }
863}