Skip to main content

reinhardt_admin/
settings.rs

1//! Admin panel settings
2//!
3//! Provides [`AdminSettings`], [`AdminCspSettings`], and [`AdminSecuritySettings`]
4//! for configuring the admin panel via TOML configuration files.
5
6#[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	// ============================================================
14	// Default value functions
15	// ============================================================
16
17	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	// ============================================================
70	// AdminCspSettings
71	// ============================================================
72
73	/// Content Security Policy settings for the admin panel.
74	///
75	/// Controls which resources can be loaded by the admin UI.
76	/// Default values match [`ContentSecurityPolicy::admin_default()`] in the
77	/// security module, ensuring consistency between hardcoded and
78	/// configuration-driven CSP.
79	///
80	/// [`ContentSecurityPolicy::admin_default()`]: crate::server::security::ContentSecurityPolicy::admin_default
81	///
82	/// # Examples
83	///
84	/// ```
85	/// use reinhardt_admin::settings::AdminCspSettings;
86	///
87	/// let csp = AdminCspSettings::default();
88	/// assert_eq!(csp.default_src, vec!["'self'"]);
89	/// assert_eq!(csp.script_src, vec!["'self'", "'wasm-unsafe-eval'"]);
90	/// ```
91	#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
92	pub struct AdminCspSettings {
93		/// Sources allowed for default resource loading.
94		#[serde(default = "default_self_only")]
95		pub default_src: Vec<String>,
96		/// Sources allowed for script execution.
97		#[serde(default = "default_script_src")]
98		pub script_src: Vec<String>,
99		/// Sources allowed for stylesheets.
100		#[serde(default = "default_style_src")]
101		pub style_src: Vec<String>,
102		/// Sources allowed for images.
103		#[serde(default = "default_img_src")]
104		pub img_src: Vec<String>,
105		/// Sources allowed for fonts.
106		#[serde(default = "default_self_only")]
107		pub font_src: Vec<String>,
108		/// Sources allowed for fetch/XHR/WebSocket connections.
109		#[serde(default = "default_self_only")]
110		pub connect_src: Vec<String>,
111		/// Restricts which origins can embed the page in a frame.
112		#[serde(default = "default_frame_ancestors")]
113		pub frame_ancestors: Vec<String>,
114		/// Restricts base URI for relative URLs.
115		#[serde(default = "default_self_only")]
116		pub base_uri: Vec<String>,
117		/// Restricts form submission targets.
118		#[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	// ============================================================
139	// AdminSecuritySettings
140	// ============================================================
141
142	/// Security header settings for the admin panel.
143	///
144	/// Controls HTTP security headers applied to admin responses,
145	/// including frame options, referrer policy, and permissions policy.
146	///
147	/// # Examples
148	///
149	/// ```
150	/// use reinhardt_admin::settings::AdminSecuritySettings;
151	///
152	/// let security = AdminSecuritySettings::default();
153	/// assert_eq!(security.frame_options, "deny");
154	/// assert_eq!(security.referrer_policy, "strict-origin-when-cross-origin");
155	/// ```
156	#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
157	pub struct AdminSecuritySettings {
158		/// X-Frame-Options header value (e.g., "deny", "sameorigin").
159		#[serde(default = "default_frame_options")]
160		pub frame_options: String,
161		/// Referrer-Policy header value.
162		#[serde(default = "default_referrer_policy")]
163		pub referrer_policy: String,
164		/// Permissions-Policy header value.
165		#[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	// ============================================================
180	// AdminSettings
181	// ============================================================
182
183	/// Top-level admin panel settings.
184	///
185	/// Combines UI configuration, CSP directives, and security headers
186	/// into a single settings struct that can be deserialized from TOML.
187	/// All fields have sensible defaults, so partial TOML is fully supported.
188	///
189	/// # Examples
190	///
191	/// ```
192	/// use reinhardt_admin::settings::AdminSettings;
193	///
194	/// let settings = AdminSettings::default();
195	/// assert_eq!(settings.site_title, "Reinhardt Admin");
196	/// assert_eq!(settings.list_per_page, 100);
197	/// ```
198	#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
199	pub struct AdminSettings {
200		/// Title displayed in the admin panel browser tab.
201		#[serde(default = "default_site_title")]
202		pub site_title: String,
203		/// Header text displayed at the top of the admin panel.
204		#[serde(default = "default_site_header")]
205		pub site_header: String,
206		/// Number of items per page in list views.
207		#[serde(default = "default_list_per_page")]
208		pub list_per_page: usize,
209		/// URL path for the admin login page.
210		#[serde(default = "default_login_url")]
211		pub login_url: String,
212		/// URL path for the admin logout page.
213		#[serde(default = "default_logout_url")]
214		pub logout_url: String,
215		/// Content Security Policy settings.
216		#[serde(default)]
217		pub csp: AdminCspSettings,
218		/// Security header settings.
219		#[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	/// Trait for accessing [`AdminSettings`] from a composed settings type.
258	pub trait HasAdminSettings {
259		/// Get a reference to the admin settings.
260		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		/// Build [`SecurityHeaders`] from these settings.
273		///
274		/// Converts the TOML-friendly string configuration into the typed
275		/// security headers used by the admin SPA handler.
276		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		/// Emit tracing warnings for CSP misconfigurations.
296		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		/// Emit tracing warnings for security header misconfigurations.
330		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	// ============================================================
342	// Global settings accessor (OnceLock)
343	// ============================================================
344
345	use std::sync::OnceLock;
346
347	/// Global admin settings instance, set once at application startup.
348	static ADMIN_SETTINGS: OnceLock<AdminSettings> = OnceLock::new();
349
350	/// Register custom admin settings for the application.
351	///
352	/// Call this once during application startup to customize admin CSP,
353	/// security headers, and site configuration. If not called, the admin
354	/// panel uses [`AdminSettings::default()`].
355	///
356	/// # Panics
357	///
358	/// Panics if called more than once (settings are immutable after initialization).
359	///
360	/// # Examples
361	///
362	/// ```
363	/// use reinhardt_admin::settings::{configure, AdminSettings};
364	///
365	/// let mut settings = AdminSettings::default();
366	/// settings.site_title = "My Admin".to_string();
367	/// configure(settings);
368	/// ```
369	pub fn configure(settings: AdminSettings) {
370		ADMIN_SETTINGS
371			.set(settings)
372			.expect("AdminSettings can only be configured once");
373	}
374
375	/// Get the current admin settings.
376	///
377	/// Returns the settings registered via [`configure()`], or
378	/// [`AdminSettings::default()`] if no custom settings were registered.
379	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		// ============================================================
389		// Default value tests
390		// ============================================================
391
392		#[rstest]
393		fn test_admin_settings_default_values() {
394			// Arrange & Act
395			let settings = AdminSettings::default();
396
397			// Assert
398			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			// Arrange & Act
408			let csp = AdminCspSettings::default();
409
410			// Assert — each field must match ContentSecurityPolicy::admin_default()
411			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			// Arrange & Act
425			let security = AdminSecuritySettings::default();
426
427			// Assert
428			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		// ============================================================
437		// TOML deserialization tests
438		// ============================================================
439
440		#[rstest]
441		fn test_toml_partial_deserialization() {
442			// Arrange
443			let toml_str = r#"
444site_title = "My Admin"
445list_per_page = 50
446"#;
447
448			// Act
449			let settings: AdminSettings = toml::from_str(toml_str).unwrap();
450
451			// Assert — overridden fields
452			assert_eq!(settings.site_title, "My Admin");
453			assert_eq!(settings.list_per_page, 50);
454			// Assert — default fields preserved
455			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			// Arrange
464			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			// Act
471			let settings: AdminSettings = toml::from_str(toml_str).unwrap();
472
473			// Assert — overridden CSP fields
474			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 — non-overridden CSP fields keep defaults
483			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		// ============================================================
489		// SettingsFragment tests
490		// ============================================================
491
492		#[rstest]
493		fn test_settings_fragment_section_is_admin() {
494			// Arrange / Act
495			use reinhardt_conf::SettingsFragment;
496			let section = AdminSettings::section();
497
498			// Assert
499			assert_eq!(section, "admin");
500		}
501
502		#[rstest]
503		fn test_validate_warns_on_missing_self_in_script_src() {
504			// Arrange
505			let mut settings = AdminSettings::default();
506			settings.csp.script_src = vec!["'wasm-unsafe-eval'".to_string()];
507
508			// Act
509			use reinhardt_conf::settings::fragment::SettingsValidation;
510			let result = SettingsValidation::validate(
511				&settings,
512				&reinhardt_conf::settings::profile::Profile::Development,
513			);
514
515			// Assert
516			assert!(result.is_ok());
517		}
518
519		#[rstest]
520		fn test_validate_warns_on_missing_wasm_unsafe_eval() {
521			// Arrange
522			let mut settings = AdminSettings::default();
523			settings.csp.script_src = vec!["'self'".to_string()];
524
525			// Act
526			use reinhardt_conf::settings::fragment::SettingsValidation;
527			let result = SettingsValidation::validate(
528				&settings,
529				&reinhardt_conf::settings::profile::Profile::Development,
530			);
531
532			// Assert
533			assert!(result.is_ok());
534		}
535
536		#[rstest]
537		fn test_validate_warns_on_unrecognized_frame_options() {
538			// Arrange
539			let mut settings = AdminSettings::default();
540			settings.security.frame_options = "invalid-value".to_string();
541
542			// Act
543			use reinhardt_conf::settings::fragment::SettingsValidation;
544			let result = SettingsValidation::validate(
545				&settings,
546				&reinhardt_conf::settings::profile::Profile::Production,
547			);
548
549			// Assert
550			assert!(result.is_ok());
551		}
552
553		#[rstest]
554		fn test_validate_ok_with_defaults() {
555			// Arrange
556			let settings = AdminSettings::default();
557
558			// Act
559			use reinhardt_conf::settings::fragment::SettingsValidation;
560			let result = SettingsValidation::validate(
561				&settings,
562				&reinhardt_conf::settings::profile::Profile::Production,
563			);
564
565			// Assert
566			assert!(result.is_ok());
567		}
568
569		#[rstest]
570		fn test_toml_empty_deserialization() {
571			// Arrange
572			let toml_str = "";
573
574			// Act
575			let settings: AdminSettings = toml::from_str(toml_str).unwrap();
576
577			// Assert — all defaults applied
578			assert_eq!(settings, AdminSettings::default());
579		}
580
581		// ============================================================
582		// to_security_headers tests
583		// ============================================================
584
585		#[rstest]
586		fn test_to_security_headers_default_matches_security_headers_default() {
587			// Arrange
588			let admin_settings = AdminSettings::default();
589			let direct_headers = crate::server::security::SecurityHeaders::default();
590
591			// Act
592			let converted_headers = admin_settings.to_security_headers();
593
594			// Assert
595			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			// Arrange
618			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			// Act
626			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
631			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			// Arrange
639			let mut settings = AdminSettings::default();
640			settings.security.frame_options = "sameorigin".to_string();
641
642			// Act
643			let headers = settings.to_security_headers();
644			let map = headers.to_header_map();
645
646			// Assert
647			assert_eq!(map.get("X-Frame-Options").unwrap(), "SAMEORIGIN");
648		}
649
650		#[rstest]
651		fn test_to_security_headers_custom_permissions_policy() {
652			// Arrange
653			let mut settings = AdminSettings::default();
654			settings.security.permissions_policy = "camera=(), microphone=()".to_string();
655
656			// Act
657			let headers = settings.to_security_headers();
658			let map = headers.to_header_map();
659
660			// Assert
661			assert_eq!(
662				map.get("Permissions-Policy").unwrap(),
663				"camera=(), microphone=()"
664			);
665		}
666	}
667}
668
669#[cfg(server)]
670pub use inner::*;