Skip to main content

reinhardt_admin/server/
error.rs

1//! Error conversion for Server Functions
2//!
3//! This module provides error conversion from AdminError to ServerFnError
4//! and authentication/authorization helpers for admin panel endpoints.
5
6use crate::types::AdminError;
7use reinhardt_http::AuthState;
8use reinhardt_pages::server_fn::{ServerFnError, ServerFnRequest};
9use std::sync::Arc;
10
11/// Extension trait for converting AdminError to ServerFnError
12pub trait IntoServerFnError {
13	/// Convert AdminError to ServerFnError
14	fn into_server_fn_error(self) -> ServerFnError;
15}
16
17impl IntoServerFnError for AdminError {
18	fn into_server_fn_error(self) -> ServerFnError {
19		match self {
20			AdminError::ModelNotRegistered(msg) => ServerFnError::server(404, msg),
21			AdminError::PermissionDenied(msg) => ServerFnError::server(403, msg),
22			AdminError::InvalidAction(msg) | AdminError::ValidationError(msg) => {
23				ServerFnError::application(msg)
24			}
25			AdminError::DatabaseError(_) => {
26				// Hide internal database error details from clients
27				ServerFnError::server(500, "Database operation failed")
28			}
29			AdminError::TemplateError(_) => {
30				// Hide internal template error details from clients
31				ServerFnError::server(500, "Template rendering failed")
32			}
33		}
34	}
35}
36
37/// Convert `Result<T, AdminError>` to `Result<T, ServerFnError>`
38pub trait MapServerFnError<T> {
39	/// Map AdminError to ServerFnError
40	fn map_server_fn_error(self) -> Result<T, ServerFnError>;
41}
42
43impl<T> MapServerFnError<T> for Result<T, AdminError> {
44	fn map_server_fn_error(self) -> Result<T, ServerFnError> {
45		self.map_err(|e| e.into_server_fn_error())
46	}
47}
48
49/// Permission types for model-level access control.
50///
51/// Used with [`AdminAuth::require_model_permission`] to specify which
52/// permission to check against the `ModelAdmin`.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum ModelPermission {
55	/// Permission to view model instances
56	View,
57	/// Permission to add (create) model instances
58	Add,
59	/// Permission to change (update) model instances
60	Change,
61	/// Permission to delete model instances
62	Delete,
63}
64
65/// Authentication and authorization checker for admin panel.
66///
67/// This struct extracts authentication state from the HTTP request
68/// and provides methods to check authentication and permissions.
69pub struct AdminAuth {
70	/// The authentication state from the request
71	auth_state: Option<AuthState>,
72}
73
74impl AdminAuth {
75	/// Creates a new AdminAuth from a ServerFnRequest.
76	///
77	/// # Arguments
78	///
79	/// * `request` - The server function request wrapper
80	///
81	/// # Returns
82	///
83	/// A new AdminAuth instance
84	pub fn from_request(request: &ServerFnRequest) -> Self {
85		let auth_state = request.inner().extensions.get::<AuthState>();
86		Self { auth_state }
87	}
88
89	/// Creates a new AdminAuth from an `Arc<Request>`.
90	///
91	/// # Arguments
92	///
93	/// * `request` - The HTTP request
94	///
95	/// # Returns
96	///
97	/// A new AdminAuth instance
98	pub fn from_arc_request(request: &Arc<reinhardt_http::Request>) -> Self {
99		let auth_state = request.extensions.get::<AuthState>();
100		Self { auth_state }
101	}
102
103	/// Returns the AuthState if available.
104	pub fn auth_state(&self) -> Option<&AuthState> {
105		self.auth_state.as_ref()
106	}
107
108	/// Checks if the user is authenticated.
109	///
110	/// # Returns
111	///
112	/// `true` if the user is authenticated, `false` otherwise
113	pub fn is_authenticated(&self) -> bool {
114		self.auth_state
115			.as_ref()
116			.is_some_and(|s| s.is_authenticated())
117	}
118
119	/// Checks if the user is a staff member (admin access).
120	///
121	/// # Returns
122	///
123	/// `true` if the user is staff/admin, `false` otherwise
124	pub fn is_staff(&self) -> bool {
125		self.auth_state.as_ref().is_some_and(|s| s.is_admin())
126	}
127
128	/// Checks if the user is active.
129	///
130	/// # Returns
131	///
132	/// `true` if the user is active, `false` otherwise
133	pub fn is_active(&self) -> bool {
134		self.auth_state.as_ref().is_some_and(|s| s.is_active())
135	}
136
137	/// Returns the user ID if authenticated.
138	pub fn user_id(&self) -> Option<&str> {
139		self.auth_state.as_ref().map(|s| s.user_id())
140	}
141
142	/// Requires authentication, returning an error if not authenticated.
143	///
144	/// # Errors
145	///
146	/// Returns `ServerFnError` with status 401 if not authenticated
147	pub fn require_authenticated(&self) -> Result<(), ServerFnError> {
148		if !self.is_authenticated() {
149			return Err(ServerFnError::server(
150				401,
151				"Authentication required to access admin panel",
152			));
153		}
154		Ok(())
155	}
156
157	/// Requires staff (admin) status, returning an error if not staff.
158	///
159	/// # Errors
160	///
161	/// Returns `ServerFnError` with status 403 if not staff
162	pub fn require_staff(&self) -> Result<(), ServerFnError> {
163		self.require_authenticated()?;
164		if !self.is_staff() {
165			return Err(ServerFnError::server(
166				403,
167				"Staff access required for admin panel",
168			));
169		}
170		Ok(())
171	}
172
173	/// Checks model-level permission using `ModelAdmin`, returning an error if denied.
174	///
175	/// This method first verifies staff status, then delegates to the
176	/// `ModelAdmin`'s permission method for the specified permission type.
177	///
178	/// The caller is responsible for providing the authenticated user object
179	/// extracted from the DI context via [`AdminAuthenticatedUser`]. The user
180	/// is passed as a `&dyn AdminUser` trait object, which is produced by the
181	/// type-erased user loader registered during admin route setup.
182	///
183	/// [`AdminAuthenticatedUser`]: crate::server::admin_auth::AdminAuthenticatedUser
184	///
185	/// # Arguments
186	///
187	/// * `model_admin` - The model admin to check permissions against
188	/// * `user` - The authenticated user object as a trait object
189	/// * `permission` - The type of permission to check
190	///
191	/// # Errors
192	///
193	/// Returns `ServerFnError` with status 401 if not authenticated,
194	/// 403 if not staff or if model-level permission is denied
195	pub async fn require_model_permission(
196		&self,
197		model_admin: &dyn crate::core::ModelAdmin,
198		user: &dyn crate::core::AdminUser,
199		permission: ModelPermission,
200	) -> Result<(), ServerFnError> {
201		self.require_staff()?;
202
203		// require_staff() already guarantees auth_state is Some and authenticated,
204		// so we can proceed directly to the permission check.
205		let has_permission = match permission {
206			ModelPermission::View => model_admin.has_view_permission(user).await,
207			ModelPermission::Add => model_admin.has_add_permission(user).await,
208			ModelPermission::Change => model_admin.has_change_permission(user).await,
209			ModelPermission::Delete => model_admin.has_delete_permission(user).await,
210		};
211
212		if !has_permission {
213			return Err(ServerFnError::server(403, "Permission denied"));
214		}
215
216		Ok(())
217	}
218}
219
220#[cfg(all(test, server))]
221mod tests {
222	use super::*;
223	use async_trait::async_trait;
224	use rstest::rstest;
225	use std::sync::Arc;
226
227	// --- Helper structs for require_model_permission tests ---
228
229	/// Test user implementing AdminUser for permission tests
230	struct TestUser;
231
232	impl crate::core::AdminUser for TestUser {
233		fn is_active(&self) -> bool {
234			true
235		}
236		fn is_staff(&self) -> bool {
237			true
238		}
239		fn is_superuser(&self) -> bool {
240			false
241		}
242		fn get_username(&self) -> &str {
243			"test_user"
244		}
245	}
246
247	/// Always denies all permissions (uses default trait behavior)
248	struct DenyAllAdmin;
249
250	#[async_trait]
251	impl crate::core::ModelAdmin for DenyAllAdmin {
252		fn model_name(&self) -> &str {
253			"DenyModel"
254		}
255	}
256
257	/// Always grants all permissions
258	struct AllowAllAdmin;
259
260	#[async_trait]
261	impl crate::core::ModelAdmin for AllowAllAdmin {
262		fn model_name(&self) -> &str {
263			"AllowModel"
264		}
265
266		async fn has_view_permission(&self, _: &dyn crate::core::AdminUser) -> bool {
267			true
268		}
269		async fn has_add_permission(&self, _: &dyn crate::core::AdminUser) -> bool {
270			true
271		}
272		async fn has_change_permission(&self, _: &dyn crate::core::AdminUser) -> bool {
273			true
274		}
275		async fn has_delete_permission(&self, _: &dyn crate::core::AdminUser) -> bool {
276			true
277		}
278	}
279
280	/// Grants only a specific permission type
281	struct SelectiveAdmin {
282		allowed: ModelPermission,
283	}
284
285	#[async_trait]
286	impl crate::core::ModelAdmin for SelectiveAdmin {
287		fn model_name(&self) -> &str {
288			"SelectiveModel"
289		}
290
291		async fn has_view_permission(&self, _: &dyn crate::core::AdminUser) -> bool {
292			self.allowed == ModelPermission::View
293		}
294		async fn has_add_permission(&self, _: &dyn crate::core::AdminUser) -> bool {
295			self.allowed == ModelPermission::Add
296		}
297		async fn has_change_permission(&self, _: &dyn crate::core::AdminUser) -> bool {
298			self.allowed == ModelPermission::Change
299		}
300		async fn has_delete_permission(&self, _: &dyn crate::core::AdminUser) -> bool {
301			self.allowed == ModelPermission::Delete
302		}
303	}
304
305	/// Create AdminAuth from an optional AuthState
306	fn make_admin_auth(auth_state: Option<AuthState>) -> AdminAuth {
307		let request = reinhardt_http::Request::builder()
308			.uri("/admin/test")
309			.build()
310			.expect("Failed to build test request");
311		if let Some(state) = auth_state {
312			request.extensions.insert(state);
313		}
314		AdminAuth::from_arc_request(&Arc::new(request))
315	}
316
317	// --- require_model_permission tests ---
318
319	#[rstest]
320	#[tokio::test]
321	async fn test_require_model_permission_staff_with_permission() {
322		// Arrange
323		let auth = make_admin_auth(Some(AuthState::authenticated("user1", true, true)));
324		let admin = AllowAllAdmin;
325		let user_obj = TestUser;
326
327		// Act
328		let result = auth
329			.require_model_permission(
330				&admin,
331				&user_obj as &dyn crate::core::AdminUser,
332				ModelPermission::View,
333			)
334			.await;
335
336		// Assert
337		assert!(result.is_ok());
338	}
339
340	#[rstest]
341	#[tokio::test]
342	async fn test_require_model_permission_staff_denied_by_model() {
343		// Arrange
344		let auth = make_admin_auth(Some(AuthState::authenticated("user1", true, true)));
345		let admin = DenyAllAdmin;
346		let user_obj = TestUser;
347
348		// Act
349		let result = auth
350			.require_model_permission(
351				&admin,
352				&user_obj as &dyn crate::core::AdminUser,
353				ModelPermission::View,
354			)
355			.await;
356
357		// Assert
358		assert!(result.is_err());
359		match result.unwrap_err() {
360			ServerFnError::Server { status, message } => {
361				assert_eq!(status, 403);
362				assert_eq!(message, "Permission denied");
363			}
364			other => panic!("Expected Server error with 403, got: {other:?}"),
365		}
366	}
367
368	#[rstest]
369	#[tokio::test]
370	async fn test_require_model_permission_non_staff_denied() {
371		// Arrange
372		let auth = make_admin_auth(Some(AuthState::authenticated("user1", false, true)));
373		let admin = AllowAllAdmin;
374		let user_obj = TestUser;
375
376		// Act
377		let result = auth
378			.require_model_permission(
379				&admin,
380				&user_obj as &dyn crate::core::AdminUser,
381				ModelPermission::View,
382			)
383			.await;
384
385		// Assert
386		assert!(result.is_err());
387		match result.unwrap_err() {
388			ServerFnError::Server { status, message } => {
389				assert_eq!(status, 403);
390				assert_eq!(message, "Staff access required for admin panel");
391			}
392			other => panic!("Expected Server error with 403, got: {other:?}"),
393		}
394	}
395
396	#[rstest]
397	#[tokio::test]
398	async fn test_require_model_permission_unauthenticated() {
399		// Arrange
400		let auth = make_admin_auth(None);
401		let admin = AllowAllAdmin;
402		let user_obj = TestUser;
403
404		// Act
405		let result = auth
406			.require_model_permission(
407				&admin,
408				&user_obj as &dyn crate::core::AdminUser,
409				ModelPermission::View,
410			)
411			.await;
412
413		// Assert
414		assert!(result.is_err());
415		match result.unwrap_err() {
416			ServerFnError::Server { status, message } => {
417				assert_eq!(status, 401);
418				assert_eq!(message, "Authentication required to access admin panel");
419			}
420			other => panic!("Expected Server error with 401, got: {other:?}"),
421		}
422	}
423
424	#[rstest]
425	#[case::view_matches_view(ModelPermission::View, ModelPermission::View, true)]
426	#[case::view_does_not_match_add(ModelPermission::View, ModelPermission::Add, false)]
427	#[case::add_matches_add(ModelPermission::Add, ModelPermission::Add, true)]
428	#[case::change_does_not_match_delete(ModelPermission::Change, ModelPermission::Delete, false)]
429	#[tokio::test]
430	async fn test_require_model_permission_selective_permissions(
431		#[case] granted: ModelPermission,
432		#[case] requested: ModelPermission,
433		#[case] expected_ok: bool,
434	) {
435		// Arrange
436		let auth = make_admin_auth(Some(AuthState::authenticated("user1", true, true)));
437		let admin = SelectiveAdmin { allowed: granted };
438		let user_obj = TestUser;
439
440		// Act
441		let result = auth
442			.require_model_permission(&admin, &user_obj as &dyn crate::core::AdminUser, requested)
443			.await;
444
445		// Assert
446		assert_eq!(
447			result.is_ok(),
448			expected_ok,
449			"granted={granted:?}, requested={requested:?}: expected is_ok()={expected_ok}"
450		);
451	}
452
453	// --- Error conversion tests ---
454
455	#[rstest]
456	#[test]
457	fn test_model_not_registered_converts_to_404() {
458		let admin_err = AdminError::ModelNotRegistered("User".into());
459		let server_err = admin_err.into_server_fn_error();
460
461		match server_err {
462			ServerFnError::Server { status, message } => {
463				assert_eq!(status, 404);
464				assert_eq!(message, "User");
465			}
466			_ => panic!("Expected Server error"),
467		}
468	}
469
470	#[rstest]
471	#[test]
472	fn test_permission_denied_converts_to_403() {
473		let admin_err = AdminError::PermissionDenied("Access denied".into());
474		let server_err = admin_err.into_server_fn_error();
475
476		match server_err {
477			ServerFnError::Server { status, message } => {
478				assert_eq!(status, 403);
479				assert_eq!(message, "Access denied");
480			}
481			_ => panic!("Expected Server error"),
482		}
483	}
484
485	#[rstest]
486	#[test]
487	fn test_validation_error_converts_to_application() {
488		let admin_err = AdminError::ValidationError("Invalid input".into());
489		let server_err = admin_err.into_server_fn_error();
490
491		match server_err {
492			ServerFnError::Application(msg) => {
493				assert_eq!(msg, "Invalid input");
494			}
495			_ => panic!("Expected Application error"),
496		}
497	}
498
499	#[rstest]
500	#[test]
501	fn test_database_error_hides_details() {
502		let admin_err = AdminError::DatabaseError("SQL syntax error at line 42".into());
503		let server_err = admin_err.into_server_fn_error();
504
505		match server_err {
506			ServerFnError::Server { status, message } => {
507				assert_eq!(status, 500);
508				assert_eq!(message, "Database operation failed");
509				// Verify that the original error details are hidden
510				assert!(!message.contains("SQL"));
511				assert!(!message.contains("42"));
512			}
513			_ => panic!("Expected Server error"),
514		}
515	}
516
517	#[rstest]
518	#[test]
519	fn test_result_conversion() {
520		let result: Result<String, AdminError> = Err(AdminError::ModelNotRegistered("Post".into()));
521		let server_result = result.map_server_fn_error();
522
523		assert!(server_result.is_err());
524		match server_result.unwrap_err() {
525			ServerFnError::Server { status, .. } => assert_eq!(status, 404),
526			_ => panic!("Expected Server error"),
527		}
528	}
529}