Skip to main content

reinhardt_admin/server/
admin_auth.rs

1//! Type-erased admin user authentication.
2//!
3//! Provides [`AdminAuthenticatedUser`], a type-erased user extractor for admin
4//! server functions. Instead of hardcoding a specific user model, this module
5//! uses a registered loader function to query whichever concrete user type the
6//! project has configured via [`AdminSite::set_user_type`].
7//!
8//! [`AdminSite::set_user_type`]: crate::core::AdminSite::set_user_type
9
10use crate::core::AdminUser;
11use async_trait::async_trait;
12use reinhardt_auth::BaseUser;
13use reinhardt_db::orm::{CustomManager, DatabaseConnection, Model};
14use reinhardt_di::{DiError, DiResult, Injectable, InjectionContext};
15use reinhardt_http::AuthState;
16use std::future::Future;
17use std::pin::Pin;
18use std::sync::Arc;
19
20/// Type-erased async loader that queries a user from the database and returns
21/// a boxed [`AdminUser`] trait object.
22///
23/// The closure captures the concrete user type `U` at registration time via
24/// [`create_admin_user_loader`], but the returned signature is fully erased.
25pub(crate) type AdminUserLoaderFn = Arc<
26	dyn Fn(
27			String,
28			Arc<DatabaseConnection>,
29		) -> Pin<Box<dyn Future<Output = Result<Arc<dyn AdminUser>, DiError>> + Send>>
30		+ Send
31		+ Sync,
32>;
33
34/// Newtype wrapper around [`AdminUserLoaderFn`] for DI registration.
35///
36/// Stored as a singleton in the DI scope so that [`AdminAuthenticatedUser`]
37/// can retrieve it during injection.
38#[derive(Clone)]
39pub(crate) struct AdminUserLoader(pub(crate) AdminUserLoaderFn);
40
41/// Type-erased authenticated admin user.
42///
43/// This replaces the hardcoded `CurrentUser<AdminDefaultUser>` in admin server
44/// functions. It loads the user from the database using whichever concrete
45/// user type was registered via [`AdminSite::set_user_type`]. If no custom
46/// type was registered, [`AdminDefaultUser`] is used as a fallback.
47///
48/// The inner `Arc<dyn AdminUser>` provides access to authentication and
49/// permission methods without exposing the concrete user type. `Arc` is
50/// used instead of `Box` because the `#[server_fn]` macro requires
51/// injected types to implement `Clone`.
52///
53/// # Usage in server functions
54///
55/// ```rust,ignore
56/// use crate::server::admin_auth::AdminAuthenticatedUser;
57///
58/// #[server_fn]
59/// pub async fn my_admin_endpoint(
60///     #[inject] AdminAuthenticatedUser(user): AdminAuthenticatedUser,
61/// ) -> Result<(), ServerFnError> {
62///     // user is Arc<dyn AdminUser>
63///     if user.is_superuser() {
64///         // ...
65///     }
66///     Ok(())
67/// }
68/// ```
69///
70/// [`AdminSite::set_user_type`]: crate::core::AdminSite::set_user_type
71/// [`AdminDefaultUser`]: crate::server::user::AdminDefaultUser
72#[derive(Clone)]
73pub struct AdminAuthenticatedUser(pub Arc<dyn AdminUser>);
74
75impl std::fmt::Debug for AdminAuthenticatedUser {
76	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77		f.debug_struct("AdminAuthenticatedUser")
78			.field("username", &self.0.get_username())
79			.finish()
80	}
81}
82
83#[async_trait]
84impl Injectable for AdminAuthenticatedUser {
85	async fn inject(ctx: &InjectionContext) -> DiResult<Self> {
86		// Get HTTP request from context
87		let request = ctx.get_http_request().ok_or_else(|| {
88			DiError::Authentication(
89				"AdminAuthenticatedUser: No HTTP request available in InjectionContext".to_string(),
90			)
91		})?;
92
93		// Get AuthState from request extensions
94		let auth_state: AuthState = request.extensions.get().ok_or_else(|| {
95			DiError::Authentication(
96				"AdminAuthenticatedUser: No AuthState found in request extensions".to_string(),
97			)
98		})?;
99
100		if !auth_state.is_authenticated() {
101			return Err(DiError::Authentication(
102				"AdminAuthenticatedUser: User is not authenticated".to_string(),
103			));
104		}
105
106		let user_id = auth_state.user_id().to_string();
107
108		// Get the type-erased loader from DI singleton scope (check early to
109		// provide a clear error message if admin routes were not set up)
110		let loader: Arc<AdminUserLoader> =
111			ctx.get_singleton::<AdminUserLoader>()
112				.ok_or_else(|| DiError::NotRegistered {
113					type_name: "AdminUserLoader".into(),
114					hint: "Call AdminSite::set_user_type::<U>() before building admin routes, \
115					       or use the default by calling admin_routes_with_di() which \
116					       registers AdminDefaultUser as a fallback."
117						.into(),
118				})?;
119
120		// Resolve DatabaseConnection from DI (singleton-first, request-scope fallback)
121		let db: Arc<DatabaseConnection> = ctx
122			.get_singleton::<DatabaseConnection>()
123			.or_else(|| ctx.get_request::<DatabaseConnection>())
124			.ok_or_else(|| {
125				::tracing::warn!(
126					"AdminAuthenticatedUser: DatabaseConnection not available for user resolution"
127				);
128				DiError::Internal {
129					message:
130						"AdminAuthenticatedUser: DatabaseConnection not registered in DI context"
131							.to_string(),
132				}
133			})?;
134
135		// Call the type-erased loader to query the user from the database
136		let user = (loader.0)(user_id, db).await?;
137
138		// Verify user account is active
139		if !user.is_active() {
140			return Err(DiError::Authentication(
141				"User account is not active".to_string(),
142			));
143		}
144
145		// Verify user has staff privileges
146		if !user.is_staff() {
147			return Err(DiError::Authentication(
148				"User does not have staff privileges".to_string(),
149			));
150		}
151
152		Ok(AdminAuthenticatedUser(user))
153	}
154}
155
156/// Creates an [`AdminUserLoader`] that queries user type `U` from the database.
157///
158/// The returned loader captures the concrete type `U` in a closure, replicating
159/// the same database query logic as [`CurrentUser<U>::inject`] but returning a
160/// type-erased `Arc<dyn AdminUser>`.
161///
162/// # Type requirements
163///
164/// `U` must implement `BaseUser`, `AdminUser`, and the ORM trait (`Model`).
165/// Types with `FullUser` satisfy `AdminUser` automatically via the blanket impl.
166/// Simpler `BaseUser`-only models can manually implement `AdminUser`.
167///
168/// [`CurrentUser<U>::inject`]: reinhardt_auth::CurrentUser
169pub(crate) fn create_admin_user_loader<U>() -> AdminUserLoader
170where
171	U: BaseUser + AdminUser + Model + Clone + Send + Sync + 'static,
172	<U as BaseUser>::PrimaryKey: std::str::FromStr + ToString + Send + Sync,
173	<<U as BaseUser>::PrimaryKey as std::str::FromStr>::Err: std::fmt::Debug,
174	<U as Model>::PrimaryKey: From<<U as BaseUser>::PrimaryKey>,
175{
176	let loader: AdminUserLoaderFn = Arc::new(move |user_id, db| {
177		Box::pin(async move {
178			// Parse user_id — NO fallback to nil UUID
179			let pk = user_id
180				.parse::<<U as BaseUser>::PrimaryKey>()
181				.map_err(|e| {
182					::tracing::warn!(
183						user_id = %user_id,
184						error = ?e,
185						"AdminUserLoader: failed to parse user_id"
186					);
187					DiError::Authentication("AdminUserLoader: Invalid user_id format".to_string())
188				})?;
189
190			let model_pk = <U as Model>::PrimaryKey::from(pk);
191
192			// Query user from database
193			let user = U::objects()
194				.get(model_pk)
195				.first_with_db(&db)
196				.await
197				.map_err(|e| {
198					::tracing::warn!(error = ?e, "AdminUserLoader: Database query failed");
199					DiError::Internal {
200						message: "AdminUserLoader: Database query failed".to_string(),
201					}
202				})?
203				.ok_or_else(|| {
204					::tracing::warn!(
205						user_id = %user_id,
206						"AdminUserLoader: User not found in database"
207					);
208					DiError::NotFound("AdminUserLoader: User not found".to_string())
209				})?;
210
211			Ok(Arc::new(user) as Arc<dyn AdminUser>)
212		})
213	});
214
215	AdminUserLoader(loader)
216}
217
218/// Authenticated user info returned by [`AdminLoginAuthenticator`].
219///
220/// Contains the minimal user data needed to generate a JWT token
221/// and populate client-side auth state.
222pub(crate) struct AuthenticatedUserInfo {
223	/// Primary key as string (typically a UUID).
224	pub(crate) user_id: String,
225	/// Username used for login.
226	pub(crate) username: String,
227	/// Whether the user is a staff member.
228	pub(crate) is_staff: bool,
229	/// Whether the user is a superuser.
230	pub(crate) is_superuser: bool,
231}
232
233/// Type-erased async function that authenticates a user by username and password.
234///
235/// Returns user info on success, or `None` if credentials are invalid
236/// (wrong username, wrong password, or user is not active/staff).
237pub(crate) type AdminLoginAuthenticatorFn = Arc<
238	dyn Fn(
239			String,
240			String,
241			Arc<DatabaseConnection>,
242		)
243			-> Pin<Box<dyn Future<Output = Result<Option<AuthenticatedUserInfo>, DiError>> + Send>>
244		+ Send
245		+ Sync,
246>;
247
248/// Newtype wrapper around [`AdminLoginAuthenticatorFn`] for DI registration.
249///
250/// This type is public because the `#[server_fn]` macro generates a public
251/// function signature that references it via `#[inject]`. The inner function
252/// pointer is `pub(crate)` to prevent external use.
253#[derive(Clone)]
254pub struct AdminLoginAuthenticator(pub(crate) AdminLoginAuthenticatorFn);
255
256#[async_trait]
257impl Injectable for AdminLoginAuthenticator {
258	async fn inject(ctx: &InjectionContext) -> DiResult<Self> {
259		ctx.get_singleton::<AdminLoginAuthenticator>()
260			.map(|arc| (*arc).clone())
261			.ok_or_else(|| DiError::NotRegistered {
262				type_name: "AdminLoginAuthenticator".into(),
263				hint: "Call AdminSite::set_user_type::<U>() or use admin_routes_with_di() \
264				       which registers AdminDefaultUser as a fallback."
265					.into(),
266			})
267	}
268}
269
270/// Creates an [`AdminLoginAuthenticator`] for user type `U`.
271///
272/// The authenticator:
273/// 1. Queries the user by username using ORM filter
274/// 2. Verifies the password using `BaseUser::check_password()`
275/// 3. Checks that the user is active and has staff privileges (via `AdminUser`)
276/// 4. Returns user info for JWT token generation
277pub(crate) fn create_admin_login_authenticator<U>() -> AdminLoginAuthenticator
278where
279	U: BaseUser + AdminUser + Model + Clone + Send + Sync + 'static,
280	<U as BaseUser>::PrimaryKey: ToString + Send + Sync,
281{
282	use reinhardt_db::orm::{CustomManager, Filter, FilterOperator, FilterValue};
283
284	let authenticator: AdminLoginAuthenticatorFn = Arc::new(move |username, password, db| {
285		Box::pin(async move {
286			// Query user by username
287			let user: Option<U> = U::objects()
288				.filter(Filter::new(
289					"username",
290					FilterOperator::Eq,
291					FilterValue::String(username.clone()),
292				))
293				.first_with_db(&db)
294				.await
295				.map_err(|e| {
296					::tracing::warn!(error = ?e, "AdminLoginAuthenticator: Database query failed");
297					DiError::Internal {
298						message: "AdminLoginAuthenticator: Database query failed".to_string(),
299					}
300				})?;
301
302			let Some(user) = user else {
303				::tracing::debug!(username = %username, "AdminLoginAuthenticator: User not found");
304				return Ok(None);
305			};
306
307			// Verify password
308			let password_valid = user.check_password(&password).map_err(|e| {
309				::tracing::warn!(error = ?e, "AdminLoginAuthenticator: Password check failed");
310				DiError::Internal {
311					message: "AdminLoginAuthenticator: Password verification error".to_string(),
312				}
313			})?;
314
315			if !password_valid {
316				::tracing::debug!(username = %username, "AdminLoginAuthenticator: Invalid password");
317				return Ok(None);
318			}
319
320			// Check active and staff status
321			if !AdminUser::is_active(&user) {
322				::tracing::debug!(username = %username, "AdminLoginAuthenticator: User is not active");
323				return Ok(None);
324			}
325
326			if !user.is_staff() {
327				::tracing::debug!(username = %username, "AdminLoginAuthenticator: User is not staff");
328				return Ok(None);
329			}
330
331			let user_id = user
332				.primary_key()
333				.map(|pk| pk.to_string())
334				.unwrap_or_default();
335
336			Ok(Some(AuthenticatedUserInfo {
337				user_id,
338				username: AdminUser::get_username(&user).to_string(),
339				is_staff: user.is_staff(),
340				is_superuser: user.is_superuser(),
341			}))
342		})
343	});
344
345	AdminLoginAuthenticator(authenticator)
346}
347
348#[cfg(all(test, server))]
349mod tests {
350	use super::*;
351	use reinhardt_di::SingletonScope;
352	use rstest::rstest;
353
354	#[rstest]
355	#[tokio::test]
356	async fn test_inject_returns_error_when_no_http_request() {
357		// Arrange
358		let singleton = Arc::new(SingletonScope::new());
359		let ctx = InjectionContext::builder(singleton).build();
360
361		// Act
362		let result = AdminAuthenticatedUser::inject(&ctx).await;
363
364		// Assert
365		assert!(result.is_err());
366		let err = result.unwrap_err();
367		assert!(
368			err.to_string().contains("No HTTP request"),
369			"Expected 'No HTTP request' error, got: {}",
370			err
371		);
372	}
373
374	#[rstest]
375	#[tokio::test]
376	async fn test_inject_returns_error_when_no_auth_state() {
377		// Arrange
378		let singleton = Arc::new(SingletonScope::new());
379		let request = reinhardt_http::Request::builder()
380			.uri("/admin/test")
381			.build()
382			.expect("Failed to build test request");
383		let ctx = InjectionContext::builder(singleton)
384			.with_request(request)
385			.build();
386
387		// Act
388		let result = AdminAuthenticatedUser::inject(&ctx).await;
389
390		// Assert
391		assert!(result.is_err());
392		let err = result.unwrap_err();
393		assert!(
394			err.to_string().contains("No AuthState"),
395			"Expected 'No AuthState' error, got: {}",
396			err
397		);
398	}
399
400	#[rstest]
401	#[tokio::test]
402	async fn test_inject_returns_error_when_not_authenticated() {
403		// Arrange
404		let singleton = Arc::new(SingletonScope::new());
405		let request = reinhardt_http::Request::builder()
406			.uri("/admin/test")
407			.build()
408			.expect("Failed to build test request");
409		// Insert unauthenticated AuthState
410		request.extensions.insert(AuthState::anonymous());
411		let ctx = InjectionContext::builder(singleton)
412			.with_request(request)
413			.build();
414
415		// Act
416		let result = AdminAuthenticatedUser::inject(&ctx).await;
417
418		// Assert
419		assert!(result.is_err());
420		let err = result.unwrap_err();
421		assert!(
422			err.to_string().contains("not authenticated"),
423			"Expected 'not authenticated' error, got: {}",
424			err
425		);
426	}
427
428	#[rstest]
429	#[tokio::test]
430	async fn test_inject_returns_error_when_no_loader_registered() {
431		// Arrange
432		let singleton = Arc::new(SingletonScope::new());
433		let request = reinhardt_http::Request::builder()
434			.uri("/admin/test")
435			.build()
436			.expect("Failed to build test request");
437		request
438			.extensions
439			.insert(AuthState::authenticated("user-123", true, true));
440		let ctx = InjectionContext::builder(singleton)
441			.with_request(request)
442			.build();
443
444		// Act
445		let result = AdminAuthenticatedUser::inject(&ctx).await;
446
447		// Assert
448		assert!(result.is_err());
449		let err = result.unwrap_err();
450		assert!(
451			err.to_string().contains("AdminUserLoader"),
452			"Expected 'AdminUserLoader' error, got: {}",
453			err
454		);
455	}
456
457	#[rstest]
458	#[tokio::test]
459	async fn test_inject_returns_error_when_no_database_connection() {
460		// Arrange: singleton with AdminUserLoader but NO DatabaseConnection
461		let singleton = Arc::new(SingletonScope::new());
462		let loader = AdminUserLoader(Arc::new(|_user_id, _db| {
463			Box::pin(async { Err(DiError::NotFound("should not be called".to_string())) })
464		}));
465		singleton.set_arc(Arc::new(loader));
466		let request = reinhardt_http::Request::builder()
467			.uri("/admin/test")
468			.build()
469			.expect("Failed to build test request");
470		request
471			.extensions
472			.insert(AuthState::authenticated("user-123", true, true));
473		let ctx = InjectionContext::builder(singleton)
474			.with_request(request)
475			.build();
476
477		// Act
478		let result = AdminAuthenticatedUser::inject(&ctx).await;
479
480		// Assert
481		assert!(result.is_err());
482		let err = result.unwrap_err();
483		assert!(
484			err.to_string().contains("DatabaseConnection"),
485			"Expected error mentioning DatabaseConnection, got: {}",
486			err
487		);
488	}
489
490	#[rstest]
491	fn test_admin_user_loader_can_be_stored_in_singleton_scope() {
492		// Arrange
493		let singleton = SingletonScope::new();
494		let loader = AdminUserLoader(Arc::new(|_user_id, _db| {
495			Box::pin(async { Err(DiError::NotFound("test loader".to_string())) })
496		}));
497
498		// Act
499		singleton.set_arc(Arc::new(loader));
500
501		// Assert
502		let retrieved = singleton.get::<AdminUserLoader>();
503		assert!(
504			retrieved.is_some(),
505			"AdminUserLoader should be retrievable from singleton scope"
506		);
507	}
508}