1#[cfg(server)]
7mod inner {
8 use reinhardt_conf::settings::fragment::{HasSettings, SettingsFragment, SettingsValidation};
9 use reinhardt_conf::settings::profile::Profile;
10 use reinhardt_conf::settings::validation::ValidationResult;
11 use serde::{Deserialize, Serialize};
12
13 fn default_site_title() -> String {
18 "Reinhardt Admin".to_string()
19 }
20
21 fn default_site_header() -> String {
22 "Administration".to_string()
23 }
24
25 fn default_list_per_page() -> usize {
26 100
27 }
28
29 fn default_login_url() -> String {
30 "/admin/login".to_string()
31 }
32
33 fn default_logout_url() -> String {
34 "/admin/logout".to_string()
35 }
36
37 fn default_self_only() -> Vec<String> {
38 vec!["'self'".to_string()]
39 }
40
41 fn default_script_src() -> Vec<String> {
42 vec!["'self'".to_string(), "'wasm-unsafe-eval'".to_string()]
43 }
44
45 fn default_style_src() -> Vec<String> {
46 vec!["'self'".to_string(), "'unsafe-inline'".to_string()]
47 }
48
49 fn default_img_src() -> Vec<String> {
50 vec!["'self'".to_string(), "data:".to_string()]
51 }
52
53 fn default_frame_ancestors() -> Vec<String> {
54 vec!["'none'".to_string()]
55 }
56
57 fn default_frame_options() -> String {
58 "deny".to_string()
59 }
60
61 fn default_referrer_policy() -> String {
62 "strict-origin-when-cross-origin".to_string()
63 }
64
65 fn default_permissions_policy() -> String {
66 "camera=(), microphone=(), geolocation=(), payment=()".to_string()
67 }
68
69 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
92 pub struct AdminCspSettings {
93 #[serde(default = "default_self_only")]
95 pub default_src: Vec<String>,
96 #[serde(default = "default_script_src")]
98 pub script_src: Vec<String>,
99 #[serde(default = "default_style_src")]
101 pub style_src: Vec<String>,
102 #[serde(default = "default_img_src")]
104 pub img_src: Vec<String>,
105 #[serde(default = "default_self_only")]
107 pub font_src: Vec<String>,
108 #[serde(default = "default_self_only")]
110 pub connect_src: Vec<String>,
111 #[serde(default = "default_frame_ancestors")]
113 pub frame_ancestors: Vec<String>,
114 #[serde(default = "default_self_only")]
116 pub base_uri: Vec<String>,
117 #[serde(default = "default_self_only")]
119 pub form_action: Vec<String>,
120 }
121
122 impl Default for AdminCspSettings {
123 fn default() -> Self {
124 Self {
125 default_src: default_self_only(),
126 script_src: default_script_src(),
127 style_src: default_style_src(),
128 img_src: default_img_src(),
129 font_src: default_self_only(),
130 connect_src: default_self_only(),
131 frame_ancestors: default_frame_ancestors(),
132 base_uri: default_self_only(),
133 form_action: default_self_only(),
134 }
135 }
136 }
137
138 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
157 pub struct AdminSecuritySettings {
158 #[serde(default = "default_frame_options")]
160 pub frame_options: String,
161 #[serde(default = "default_referrer_policy")]
163 pub referrer_policy: String,
164 #[serde(default = "default_permissions_policy")]
166 pub permissions_policy: String,
167 }
168
169 impl Default for AdminSecuritySettings {
170 fn default() -> Self {
171 Self {
172 frame_options: default_frame_options(),
173 referrer_policy: default_referrer_policy(),
174 permissions_policy: default_permissions_policy(),
175 }
176 }
177 }
178
179 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
199 pub struct AdminSettings {
200 #[serde(default = "default_site_title")]
202 pub site_title: String,
203 #[serde(default = "default_site_header")]
205 pub site_header: String,
206 #[serde(default = "default_list_per_page")]
208 pub list_per_page: usize,
209 #[serde(default = "default_login_url")]
211 pub login_url: String,
212 #[serde(default = "default_logout_url")]
214 pub logout_url: String,
215 #[serde(default)]
217 pub csp: AdminCspSettings,
218 #[serde(default)]
220 pub security: AdminSecuritySettings,
221 }
222
223 impl Default for AdminSettings {
224 fn default() -> Self {
225 Self {
226 site_title: default_site_title(),
227 site_header: default_site_header(),
228 list_per_page: default_list_per_page(),
229 login_url: default_login_url(),
230 logout_url: default_logout_url(),
231 csp: AdminCspSettings::default(),
232 security: AdminSecuritySettings::default(),
233 }
234 }
235 }
236
237 impl SettingsValidation for AdminSettings {
238 fn validate(&self, _profile: &Profile) -> ValidationResult {
239 self.warn_csp_misconfigurations();
240 self.warn_security_misconfigurations();
241 Ok(())
242 }
243 }
244
245 impl SettingsFragment for AdminSettings {
246 type Accessor = dyn HasAdminSettings;
247
248 fn section() -> &'static str {
249 "admin"
250 }
251
252 fn validate(&self, profile: &Profile) -> ValidationResult {
253 <Self as SettingsValidation>::validate(self, profile)
254 }
255 }
256
257 pub trait HasAdminSettings {
259 fn admin(&self) -> &AdminSettings;
261 }
262
263 impl<T: HasSettings<AdminSettings>> HasAdminSettings for T {
264 fn admin(&self) -> &AdminSettings {
265 self.get_settings()
266 }
267 }
268
269 use crate::server::security::{ContentSecurityPolicy, SecurityHeaders};
270
271 impl AdminSettings {
272 pub fn to_security_headers(&self) -> SecurityHeaders {
277 SecurityHeaders {
278 csp: ContentSecurityPolicy {
279 default_src: self.csp.default_src.clone(),
280 script_src: self.csp.script_src.clone(),
281 style_src: self.csp.style_src.clone(),
282 img_src: self.csp.img_src.clone(),
283 font_src: self.csp.font_src.clone(),
284 connect_src: self.csp.connect_src.clone(),
285 frame_ancestors: self.csp.frame_ancestors.clone(),
286 base_uri: self.csp.base_uri.clone(),
287 form_action: self.csp.form_action.clone(),
288 },
289 frame_options: self.security.frame_options.parse().unwrap(),
290 referrer_policy: self.security.referrer_policy.parse().unwrap(),
291 permissions_policy: self.security.permissions_policy.clone(),
292 }
293 }
294
295 fn warn_csp_misconfigurations(&self) {
297 if !self.csp.default_src.iter().any(|s| s == "'self'") {
298 tracing::warn!(
299 "Admin CSP: default_src is missing `'self'`, this may block admin resources"
300 );
301 }
302 if !self.csp.script_src.iter().any(|s| s == "'self'") {
303 tracing::warn!(
304 "Admin CSP: script_src is missing `'self'`, admin panel assets may not load"
305 );
306 }
307 if !self
308 .csp
309 .script_src
310 .iter()
311 .any(|s| s == "'wasm-unsafe-eval'")
312 {
313 tracing::warn!(
314 "Admin CSP: script_src is missing `'wasm-unsafe-eval'`, WASM SPA will not function"
315 );
316 }
317 if !self.csp.style_src.iter().any(|s| s == "'self'") {
318 tracing::warn!(
319 "Admin CSP: style_src is missing `'self'`, admin styles may not load"
320 );
321 }
322 if !self.csp.connect_src.iter().any(|s| s == "'self'") {
323 tracing::warn!(
324 "Admin CSP: connect_src is missing `'self'`, API calls will be blocked"
325 );
326 }
327 }
328
329 fn warn_security_misconfigurations(&self) {
331 let fo = self.security.frame_options.to_lowercase();
332 if fo != "deny" && fo != "sameorigin" {
333 tracing::warn!(
334 "Admin security: unrecognized frame_options value '{}', expected 'deny' or 'sameorigin'",
335 self.security.frame_options
336 );
337 }
338 }
339 }
340
341 use std::sync::OnceLock;
346
347 static ADMIN_SETTINGS: OnceLock<AdminSettings> = OnceLock::new();
349
350 pub fn configure(settings: AdminSettings) {
370 ADMIN_SETTINGS
371 .set(settings)
372 .expect("AdminSettings can only be configured once");
373 }
374
375 pub fn get_admin_settings() -> &'static AdminSettings {
380 ADMIN_SETTINGS.get_or_init(AdminSettings::default)
381 }
382
383 #[cfg(test)]
384 mod tests {
385 use super::*;
386 use rstest::rstest;
387
388 #[rstest]
393 fn test_admin_settings_default_values() {
394 let settings = AdminSettings::default();
396
397 assert_eq!(settings.site_title, "Reinhardt Admin");
399 assert_eq!(settings.site_header, "Administration");
400 assert_eq!(settings.list_per_page, 100);
401 assert_eq!(settings.login_url, "/admin/login");
402 assert_eq!(settings.logout_url, "/admin/logout");
403 }
404
405 #[rstest]
406 fn test_admin_csp_settings_default_matches_admin_default() {
407 let csp = AdminCspSettings::default();
409
410 assert_eq!(csp.default_src, vec!["'self'"]);
412 assert_eq!(csp.script_src, vec!["'self'", "'wasm-unsafe-eval'"]);
413 assert_eq!(csp.style_src, vec!["'self'", "'unsafe-inline'"]);
414 assert_eq!(csp.img_src, vec!["'self'", "data:"]);
415 assert_eq!(csp.font_src, vec!["'self'"]);
416 assert_eq!(csp.connect_src, vec!["'self'"]);
417 assert_eq!(csp.frame_ancestors, vec!["'none'"]);
418 assert_eq!(csp.base_uri, vec!["'self'"]);
419 assert_eq!(csp.form_action, vec!["'self'"]);
420 }
421
422 #[rstest]
423 fn test_admin_security_settings_default_values() {
424 let security = AdminSecuritySettings::default();
426
427 assert_eq!(security.frame_options, "deny");
429 assert_eq!(security.referrer_policy, "strict-origin-when-cross-origin");
430 assert_eq!(
431 security.permissions_policy,
432 "camera=(), microphone=(), geolocation=(), payment=()"
433 );
434 }
435
436 #[rstest]
441 fn test_toml_partial_deserialization() {
442 let toml_str = r#"
444site_title = "My Admin"
445list_per_page = 50
446"#;
447
448 let settings: AdminSettings = toml::from_str(toml_str).unwrap();
450
451 assert_eq!(settings.site_title, "My Admin");
453 assert_eq!(settings.list_per_page, 50);
454 assert_eq!(settings.site_header, "Administration");
456 assert_eq!(settings.login_url, "/admin/login");
457 assert_eq!(settings.csp, AdminCspSettings::default());
458 assert_eq!(settings.security, AdminSecuritySettings::default());
459 }
460
461 #[rstest]
462 fn test_toml_csp_override() {
463 let toml_str = r#"
465[csp]
466script_src = ["'self'", "'wasm-unsafe-eval'", "https://cdn.example.com"]
467img_src = ["'self'", "data:", "https://images.example.com"]
468"#;
469
470 let settings: AdminSettings = toml::from_str(toml_str).unwrap();
472
473 assert_eq!(
475 settings.csp.script_src,
476 vec!["'self'", "'wasm-unsafe-eval'", "https://cdn.example.com"]
477 );
478 assert_eq!(
479 settings.csp.img_src,
480 vec!["'self'", "data:", "https://images.example.com"]
481 );
482 assert_eq!(settings.csp.default_src, vec!["'self'"]);
484 assert_eq!(settings.csp.font_src, vec!["'self'"]);
485 assert_eq!(settings.csp.frame_ancestors, vec!["'none'"]);
486 }
487
488 #[rstest]
493 fn test_settings_fragment_section_is_admin() {
494 use reinhardt_conf::SettingsFragment;
496 let section = AdminSettings::section();
497
498 assert_eq!(section, "admin");
500 }
501
502 #[rstest]
503 fn test_validate_warns_on_missing_self_in_script_src() {
504 let mut settings = AdminSettings::default();
506 settings.csp.script_src = vec!["'wasm-unsafe-eval'".to_string()];
507
508 use reinhardt_conf::settings::fragment::SettingsValidation;
510 let result = SettingsValidation::validate(
511 &settings,
512 &reinhardt_conf::settings::profile::Profile::Development,
513 );
514
515 assert!(result.is_ok());
517 }
518
519 #[rstest]
520 fn test_validate_warns_on_missing_wasm_unsafe_eval() {
521 let mut settings = AdminSettings::default();
523 settings.csp.script_src = vec!["'self'".to_string()];
524
525 use reinhardt_conf::settings::fragment::SettingsValidation;
527 let result = SettingsValidation::validate(
528 &settings,
529 &reinhardt_conf::settings::profile::Profile::Development,
530 );
531
532 assert!(result.is_ok());
534 }
535
536 #[rstest]
537 fn test_validate_warns_on_unrecognized_frame_options() {
538 let mut settings = AdminSettings::default();
540 settings.security.frame_options = "invalid-value".to_string();
541
542 use reinhardt_conf::settings::fragment::SettingsValidation;
544 let result = SettingsValidation::validate(
545 &settings,
546 &reinhardt_conf::settings::profile::Profile::Production,
547 );
548
549 assert!(result.is_ok());
551 }
552
553 #[rstest]
554 fn test_validate_ok_with_defaults() {
555 let settings = AdminSettings::default();
557
558 use reinhardt_conf::settings::fragment::SettingsValidation;
560 let result = SettingsValidation::validate(
561 &settings,
562 &reinhardt_conf::settings::profile::Profile::Production,
563 );
564
565 assert!(result.is_ok());
567 }
568
569 #[rstest]
570 fn test_toml_empty_deserialization() {
571 let toml_str = "";
573
574 let settings: AdminSettings = toml::from_str(toml_str).unwrap();
576
577 assert_eq!(settings, AdminSettings::default());
579 }
580
581 #[rstest]
586 fn test_to_security_headers_default_matches_security_headers_default() {
587 let admin_settings = AdminSettings::default();
589 let direct_headers = crate::server::security::SecurityHeaders::default();
590
591 let converted_headers = admin_settings.to_security_headers();
593
594 let direct_map = direct_headers.to_header_map();
596 let converted_map = converted_headers.to_header_map();
597 assert_eq!(
598 direct_map.get("Content-Security-Policy"),
599 converted_map.get("Content-Security-Policy")
600 );
601 assert_eq!(
602 direct_map.get("X-Frame-Options"),
603 converted_map.get("X-Frame-Options")
604 );
605 assert_eq!(
606 direct_map.get("Referrer-Policy"),
607 converted_map.get("Referrer-Policy")
608 );
609 assert_eq!(
610 direct_map.get("Permissions-Policy"),
611 converted_map.get("Permissions-Policy")
612 );
613 }
614
615 #[rstest]
616 fn test_to_security_headers_custom_csp() {
617 let mut settings = AdminSettings::default();
619 settings.csp.script_src = vec![
620 "'self'".to_string(),
621 "'wasm-unsafe-eval'".to_string(),
622 "https://cdn.example.com".to_string(),
623 ];
624
625 let headers = settings.to_security_headers();
627 let map = headers.to_header_map();
628 let csp = map.get("Content-Security-Policy").unwrap();
629
630 assert!(csp.contains("https://cdn.example.com"));
632 assert!(csp.contains("'self'"));
633 assert!(csp.contains("'wasm-unsafe-eval'"));
634 }
635
636 #[rstest]
637 fn test_to_security_headers_custom_frame_options() {
638 let mut settings = AdminSettings::default();
640 settings.security.frame_options = "sameorigin".to_string();
641
642 let headers = settings.to_security_headers();
644 let map = headers.to_header_map();
645
646 assert_eq!(map.get("X-Frame-Options").unwrap(), "SAMEORIGIN");
648 }
649
650 #[rstest]
651 fn test_to_security_headers_custom_permissions_policy() {
652 let mut settings = AdminSettings::default();
654 settings.security.permissions_policy = "camera=(), microphone=()".to_string();
655
656 let headers = settings.to_security_headers();
658 let map = headers.to_header_map();
659
660 assert_eq!(
662 map.get("Permissions-Policy").unwrap(),
663 "camera=(), microphone=()"
664 );
665 }
666 }
667}
668
669#[cfg(server)]
670pub use inner::*;