1use std::sync::Arc;
25
26use crate::auth::{self, Identity, Role};
27use crate::error::{Error, Result};
28use crate::http::{Request, Response};
29use crate::orm::Db;
30use crate::router::Router;
31use crate::templates::Templates;
32
33const ADMIN_CSS: &str = include_str!("../../assets/static/admin.css");
38
39const ADMIN_JS: &str = include_str!("../../assets/static/admin.js");
42
43const FONT_GEIST: &[u8] = include_bytes!("../../assets/static/fonts/Geist-Variable.woff2");
53const FONT_GEIST_MONO: &[u8] = include_bytes!("../../assets/static/fonts/GeistMono-Variable.woff2");
54const FONT_TAJAWAL_REG: &[u8] = include_bytes!("../../assets/static/fonts/Tajawal-Regular.woff2");
55const FONT_TAJAWAL_MED: &[u8] = include_bytes!("../../assets/static/fonts/Tajawal-Medium.woff2");
56const FONT_TAJAWAL_BOLD: &[u8] = include_bytes!("../../assets/static/fonts/Tajawal-Bold.woff2");
57const FONT_NOTO_NASKH_AR: &[u8] =
58 include_bytes!("../../assets/static/fonts/NotoNaskhArabic-Variable.woff2");
59
60use super::handlers::{self, AdminCtx};
61use super::render;
62use super::types::Admin;
63
64enum Guard {
68 Allow(Identity),
69 Redirect(Response),
70}
71
72const MUST_CHANGE_WHITELIST: &[&str] = &[
82 "/admin/must-change-password",
83 "/admin/logout",
84 "/admin/account/sessions",
85];
86
87fn is_must_change_whitelisted_path(path: &str) -> bool {
91 MUST_CHANGE_WHITELIST.contains(&path)
92}
93
94const MFA_ENROLL_WHITELIST: &[&str] = &[
107 "/admin/account/mfa/enroll",
108 "/admin/logout",
109 "/admin/account/sessions",
110];
111
112fn is_mfa_enroll_whitelisted_path(path: &str) -> bool {
113 MFA_ENROLL_WHITELIST.contains(&path)
114}
115
116const MFA_VERIFY_WHITELIST: &[&str] = &[
126 "/admin/mfa/verify",
127 "/admin/logout",
128 "/admin/account/sessions",
129];
130
131fn is_mfa_verify_whitelisted_path(path: &str) -> bool {
132 MFA_VERIFY_WHITELIST.contains(&path)
133}
134
135fn mfa_required_for_role(policy: crate::auth::MfaPolicy, role: Role) -> bool {
145 use crate::auth::MfaPolicy;
146 match policy {
147 MfaPolicy::Disabled | MfaPolicy::Optional => false,
148 MfaPolicy::Required => true,
149 MfaPolicy::RequiredForRoles(roles) => roles.contains(&role),
150 }
151}
152
153async fn login_guard(ctx: &AdminCtx, req: &Request) -> Result<Guard> {
154 let cookie = match req.header("cookie") {
155 Some(c) => c,
156 None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
157 };
158 let token = match auth::session_token_from_cookie(cookie) {
159 Some(t) => t,
160 None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
161 };
162 let ident = match auth::identity_from_session(&ctx.db, &token).await? {
163 Some(i) => i,
164 None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
165 };
166 if !ident.is_active {
167 return Ok(Guard::Redirect(Response::redirect("/admin/login")));
168 }
169
170 if ident.must_change_password && !is_must_change_whitelisted_path(req.path()) {
176 return Ok(Guard::Redirect(Response::redirect(
177 "/admin/must-change-password",
178 )));
179 }
180
181 let policy = ctx.admin.active_mfa_policy();
190 if mfa_required_for_role(policy, ident.role)
191 && !ident.mfa_enabled
192 && !is_mfa_enroll_whitelisted_path(req.path())
193 {
194 return Ok(Guard::Redirect(Response::redirect(
195 "/admin/account/mfa/enroll",
196 )));
197 }
198
199 use crate::auth::SessionTrust;
207 if ident.mfa_enabled
208 && ident.trust_level != SessionTrust::MfaVerified
209 && !is_mfa_verify_whitelisted_path(req.path())
210 {
211 return Ok(Guard::Redirect(Response::redirect("/admin/mfa/verify")));
212 }
213
214 Ok(Guard::Allow(ident))
215}
216
217async fn role_guard(ctx: &AdminCtx, req: &Request, min: Role) -> Result<Guard> {
218 match login_guard(ctx, req).await? {
219 Guard::Redirect(r) => Ok(Guard::Redirect(r)),
220 Guard::Allow(ident) => {
221 if ident.role.includes(min) {
222 Ok(Guard::Allow(ident))
223 } else {
224 let body = render::render_forbidden_body(
225 &ctx.admin,
226 &ctx.templates,
227 &ident,
228 handlers::csrf_token(req),
229 None,
230 Some(min.label()),
231 )?;
232 Ok(Guard::Redirect(
233 Response::html(body).with_status(hyper::StatusCode::FORBIDDEN),
234 ))
235 }
236 }
237 }
238}
239
240async fn perm_guard(ctx: &AdminCtx, req: &Request, perm: &str) -> Result<Guard> {
241 match role_guard(ctx, req, Role::Staff).await? {
242 Guard::Redirect(r) => Ok(Guard::Redirect(r)),
243 Guard::Allow(ident) => {
244 if ident.role.bypasses_group_checks() {
245 return Ok(Guard::Allow(ident));
246 }
247 if auth::check_permission(&ctx.db, &ident, perm).await? {
248 Ok(Guard::Allow(ident))
249 } else {
250 let body = render::render_forbidden_body(
251 &ctx.admin,
252 &ctx.templates,
253 &ident,
254 handlers::csrf_token(req),
255 Some(perm.to_string()),
256 None,
257 )?;
258 Ok(Guard::Redirect(
259 Response::html(body).with_status(hyper::StatusCode::FORBIDDEN),
260 ))
261 }
262 }
263 }
264}
265
266#[cfg(test)]
269fn perm_guard_verdict(ident: &Identity, perm_held: bool) -> bool {
270 if !ident.is_active {
271 return false;
272 }
273 if ident.role.bypasses_group_checks() {
274 return true;
275 }
276 perm_held
277}
278
279fn parse_id(raw: Option<&str>) -> Result<i64> {
280 raw.and_then(|s| s.parse().ok())
281 .ok_or_else(|| Error::BadRequest("invalid id".into()))
282}
283
284fn model_name_from_req(req: &Request) -> Result<String> {
285 req.param("admin_name")
286 .map(|s| s.to_string())
287 .ok_or_else(|| Error::BadRequest("missing model".into()))
288}
289
290fn perm_for(ctx: &AdminCtx, admin_name: &str, action: &str) -> Result<String> {
291 let entry = ctx
292 .admin
293 .find(admin_name)
294 .ok_or_else(|| Error::NotFound(format!("no admin model: {admin_name}")))?;
295 let singular = entry.singular_name.to_ascii_lowercase();
296 Ok(format!("{admin_name}.{action}_{singular}"))
297}
298
299async fn resolve_identity_for_error_page(db: &Db, cookie_header: &str) -> Option<Identity> {
329 let token = auth::session_token_from_cookie(cookie_header)?;
330 let identity = auth::identity_from_session(db, token.as_str())
331 .await
332 .ok()
333 .flatten()?;
334 if !identity.is_active {
335 return None;
336 }
337 Some(identity)
338}
339
340fn strict_mailer_guard_check(admin: &Admin) -> std::result::Result<(), String> {
341 if admin.active_recovery_policy().strict_mailer_required() && !admin.has_custom_mailer() {
342 Err(
343 "rustio-admin: RecoveryPolicy::strict_mailer_required() = true but no mailer \
344 was registered via Admin::mailer(...).\n\n\
345 The framework's default LogMailer writes recovery emails to log::info! instead \
346 of sending them, which is unsuitable for production. Recovery routes are NOT \
347 registered with this configuration.\n\n\
348 To resolve, choose one:\n\
349 (a) register a real mailer before calling register_admin_routes:\n\
350 Admin::mailer(Arc::new(MyProjectMailer::new(...)))\n\
351 (b) opt the policy out of strict mode (the framework default — dev / CI / \
352 testing baseline):\n\
353 RecoveryPolicy::strict_mailer_required(false)\n\n\
354 See DESIGN_RECOVERY.md §12.1 for the contract."
355 .to_string(),
356 )
357 } else {
358 Ok(())
359 }
360}
361
362pub fn register_admin_routes(
363 router: Router,
364 admin: Admin,
365 db: Db,
366 templates: Arc<Templates>,
367) -> Router {
368 if let Err(msg) = strict_mailer_guard_check(&admin) {
376 panic!("{msg}");
377 }
378
379 let ctx = Arc::new(AdminCtx::new(
380 Arc::new(admin),
381 db.clone(),
382 templates.clone(),
383 ));
384
385 let auth_ctx = Arc::new(super::builtin::AuthAdminCtx {
388 admin: ctx.admin.clone(),
389 db,
390 templates,
391 });
392
393 let err_admin = ctx.admin.clone();
408 let err_templates = ctx.templates.clone();
409 let err_db = ctx.db.clone();
410 let router = router.middleware(move |req, next| {
411 let admin = err_admin.clone();
412 let templates = err_templates.clone();
413 let db = err_db.clone();
414 Box::pin(async move {
415 let is_admin_path = req.path().starts_with("/admin");
416 let cookie_header = if is_admin_path {
422 req.header("cookie").map(|s| s.to_string())
423 } else {
424 None
425 };
426 let result = next.run(req).await;
427 match result {
428 Ok(resp) => Ok(resp),
429 Err(err) if is_admin_path => {
430 let identity = match cookie_header.as_deref() {
431 Some(cookie) => resolve_identity_for_error_page(&db, cookie).await,
432 None => None,
433 };
434 Ok(render::render_admin_error_response(
435 &admin,
436 &templates,
437 identity.as_ref(),
438 err.status(),
439 err.client_message().to_string(),
440 ))
441 }
442 Err(err) => Err(err),
443 }
444 })
445 });
446
447 let router = router.get("/static/admin.css", |_req| async move {
453 Ok(Response::new(
454 hyper::StatusCode::OK,
455 bytes::Bytes::from_static(ADMIN_CSS.as_bytes()),
456 )
457 .with_header("content-type", "text/css; charset=utf-8")
458 .with_header("cache-control", "no-cache, must-revalidate"))
459 });
460 let router = router.get("/static/admin.js", |_req| async move {
461 Ok(Response::new(
462 hyper::StatusCode::OK,
463 bytes::Bytes::from_static(ADMIN_JS.as_bytes()),
464 )
465 .with_header("content-type", "application/javascript; charset=utf-8")
466 .with_header("cache-control", "no-cache, must-revalidate"))
467 });
468
469 fn font_response(bytes: &'static [u8]) -> Response {
473 Response::new(hyper::StatusCode::OK, bytes::Bytes::from_static(bytes))
474 .with_header("content-type", "font/woff2")
475 .with_header("cache-control", "public, max-age=31536000, immutable")
476 }
477 let router = router.get("/static/fonts/Geist-Variable.woff2", |_req| async move {
478 Ok(font_response(FONT_GEIST))
479 });
480 let router = router.get(
481 "/static/fonts/GeistMono-Variable.woff2",
482 |_req| async move { Ok(font_response(FONT_GEIST_MONO)) },
483 );
484 let router = router.get("/static/fonts/Tajawal-Regular.woff2", |_req| async move {
485 Ok(font_response(FONT_TAJAWAL_REG))
486 });
487 let router = router.get("/static/fonts/Tajawal-Medium.woff2", |_req| async move {
488 Ok(font_response(FONT_TAJAWAL_MED))
489 });
490 let router = router.get("/static/fonts/Tajawal-Bold.woff2", |_req| async move {
491 Ok(font_response(FONT_TAJAWAL_BOLD))
492 });
493 let router = router.get(
494 "/static/fonts/NotoNaskhArabic-Variable.woff2",
495 |_req| async move { Ok(font_response(FONT_NOTO_NASKH_AR)) },
496 );
497
498 let c = ctx.clone();
500 let router = router.get("/admin/login", move |req| {
501 let c = c.clone();
502 async move { handlers::show_login(&c, req).await }
503 });
504
505 let c = ctx.clone();
506 let router = router.post("/admin/login", move |req| {
507 let c = c.clone();
508 async move { handlers::do_login(&c, req).await }
509 });
510
511 let c = ctx.clone();
512 let router = router.post("/admin/logout", move |req| {
513 let c = c.clone();
514 async move { handlers::do_logout(&c, req).await }
515 });
516
517 let recovery_state = Arc::new(super::recovery_handlers::RecoveryState::from_admin(
534 &ctx.admin,
535 ));
536
537 let c = ctx.clone();
538 let router = router.get("/admin/forgot-password", move |req| {
539 let c = c.clone();
540 async move { super::recovery_handlers::show_forgot_password(&c, &req).await }
541 });
542
543 let c = ctx.clone();
544 let rs = recovery_state.clone();
545 let router = router.post("/admin/forgot-password", move |req| {
546 let c = c.clone();
547 let rs = rs.clone();
548 async move { super::recovery_handlers::do_forgot_password(&c, &rs, req).await }
549 });
550
551 let c = ctx.clone();
552 let router = router.get("/admin/forgot-password/sent", move |req| {
553 let c = c.clone();
554 async move { super::recovery_handlers::show_forgot_password_sent(&c, &req).await }
555 });
556
557 let c = ctx.clone();
558 let router = router.get("/admin/reset-password/:token", move |req| {
559 let c = c.clone();
560 async move {
561 let token = req
562 .param("token")
563 .ok_or_else(|| Error::BadRequest("missing token".into()))?
564 .to_string();
565 super::recovery_handlers::show_reset_password(&c, &req, &token).await
566 }
567 });
568
569 let c = ctx.clone();
570 let rs = recovery_state.clone();
571 let router = router.post("/admin/reset-password/:token", move |req| {
572 let c = c.clone();
573 let rs = rs.clone();
574 async move {
575 let token = req
576 .param("token")
577 .ok_or_else(|| Error::BadRequest("missing token".into()))?
578 .to_string();
579 super::recovery_handlers::do_reset_password(&c, &rs, req, &token).await
580 }
581 });
582
583 let c = ctx.clone();
585 let router = router.get("/admin", move |req| {
586 let c = c.clone();
587 async move {
588 match role_guard(&c, &req, Role::Staff).await? {
589 Guard::Redirect(r) => Ok(r),
590 Guard::Allow(ident) => handlers::dashboard(&c, ident, &req).await,
591 }
592 }
593 });
594
595 let c = ctx.clone();
597 let router = router.get("/admin/history", move |req| {
598 let c = c.clone();
599 async move {
600 match role_guard(&c, &req, Role::Administrator).await? {
601 Guard::Redirect(r) => Ok(r),
602 Guard::Allow(ident) => handlers::show_log_entries(&c, ident, &req).await,
603 }
604 }
605 });
606
607 let c = ctx.clone();
610 let router = router.get("/admin/account/sessions", move |req| {
611 let c = c.clone();
612 async move {
613 match role_guard(&c, &req, Role::User).await? {
614 Guard::Redirect(r) => Ok(r),
615 Guard::Allow(ident) => handlers::show_account_sessions(&c, ident, &req).await,
616 }
617 }
618 });
619
620 let c = ctx.clone();
628 let router = router.post("/admin/account/sessions/revoke-others", move |req| {
629 let c = c.clone();
630 async move {
631 match role_guard(&c, &req, Role::User).await? {
632 Guard::Redirect(r) => Ok(r),
633 Guard::Allow(ident) => handlers::do_revoke_other_sessions(&c, ident, req).await,
634 }
635 }
636 });
637
638 let c = ctx.clone();
639 let router = router.post("/admin/account/sessions/revoke-all", move |req| {
640 let c = c.clone();
641 async move {
642 match role_guard(&c, &req, Role::User).await? {
643 Guard::Redirect(r) => Ok(r),
644 Guard::Allow(ident) => handlers::do_revoke_all_sessions(&c, ident, req).await,
645 }
646 }
647 });
648
649 let c = ctx.clone();
650 let router = router.post("/admin/account/sessions/:id/revoke", move |req| {
651 let c = c.clone();
652 async move {
653 match role_guard(&c, &req, Role::User).await? {
654 Guard::Redirect(r) => Ok(r),
655 Guard::Allow(ident) => {
656 let id = parse_id(req.param("id"))?;
657 handlers::do_revoke_session(&c, ident, req, id).await
658 }
659 }
660 }
661 });
662
663 let c = ctx.clone();
667 let router = router.get("/admin/password_change", move |req| {
668 let c = c.clone();
669 async move {
670 match role_guard(&c, &req, Role::User).await? {
671 Guard::Redirect(r) => Ok(r),
672 Guard::Allow(ident) => handlers::show_password_change(&c, ident, &req).await,
673 }
674 }
675 });
676 let c = ctx.clone();
677 let router = router.post("/admin/password_change", move |req| {
678 let c = c.clone();
679 async move {
680 match role_guard(&c, &req, Role::User).await? {
681 Guard::Redirect(r) => Ok(r),
682 Guard::Allow(ident) => handlers::do_password_change(&c, ident, req).await,
683 }
684 }
685 });
686
687 let c = ctx.clone();
696 let router = router.get("/admin/reauth", move |req| {
697 let c = c.clone();
698 async move {
699 match role_guard(&c, &req, Role::User).await? {
700 Guard::Redirect(r) => Ok(r),
701 Guard::Allow(ident) => {
702 super::admin_recovery_handlers::show_reauth(&c, ident, &req).await
703 }
704 }
705 }
706 });
707
708 let c = ctx.clone();
709 let router = router.post("/admin/reauth", move |req| {
710 let c = c.clone();
711 async move {
712 match role_guard(&c, &req, Role::User).await? {
713 Guard::Redirect(r) => Ok(r),
714 Guard::Allow(ident) => {
715 super::admin_recovery_handlers::do_reauth(&c, ident, req).await
716 }
717 }
718 }
719 });
720
721 let c = ctx.clone();
731 let router = router.get("/admin/must-change-password", move |req| {
732 let c = c.clone();
733 async move {
734 match role_guard(&c, &req, Role::User).await? {
735 Guard::Redirect(r) => Ok(r),
736 Guard::Allow(ident) => {
737 super::admin_recovery_handlers::show_must_change_password(&c, ident, &req).await
738 }
739 }
740 }
741 });
742
743 let c = ctx.clone();
744 let router = router.post("/admin/must-change-password", move |req| {
745 let c = c.clone();
746 async move {
747 match role_guard(&c, &req, Role::User).await? {
748 Guard::Redirect(r) => Ok(r),
749 Guard::Allow(ident) => {
750 super::admin_recovery_handlers::do_must_change_password(&c, ident, req).await
751 }
752 }
753 }
754 });
755
756 let c = ctx.clone();
774 let router = router.get("/admin/mfa/verify", move |req| {
775 let c = c.clone();
776 async move {
777 match role_guard(&c, &req, Role::User).await? {
778 Guard::Redirect(r) => Ok(r),
779 Guard::Allow(ident) => super::mfa_handlers::show_verify(&c, ident, &req).await,
780 }
781 }
782 });
783
784 let c = ctx.clone();
785 let router = router.post("/admin/mfa/verify", move |req| {
786 let c = c.clone();
787 async move {
788 match role_guard(&c, &req, Role::User).await? {
789 Guard::Redirect(r) => Ok(r),
790 Guard::Allow(ident) => super::mfa_handlers::do_verify(&c, ident, req).await,
791 }
792 }
793 });
794
795 let c = ctx.clone();
797 let router = router.get("/admin/account/mfa/enroll", move |req| {
798 let c = c.clone();
799 async move {
800 match role_guard(&c, &req, Role::User).await? {
801 Guard::Redirect(r) => Ok(r),
802 Guard::Allow(ident) => super::mfa_handlers::show_enroll(&c, ident, &req).await,
803 }
804 }
805 });
806
807 let c = ctx.clone();
808 let router = router.post("/admin/account/mfa/enroll", move |req| {
809 let c = c.clone();
810 async move {
811 match role_guard(&c, &req, Role::User).await? {
812 Guard::Redirect(r) => Ok(r),
813 Guard::Allow(ident) => super::mfa_handlers::do_enroll(&c, ident, req).await,
814 }
815 }
816 });
817
818 let c = ctx.clone();
820 let router = router.get("/admin/account/mfa/regenerate-codes", move |req| {
821 let c = c.clone();
822 async move {
823 match role_guard(&c, &req, Role::User).await? {
824 Guard::Redirect(r) => Ok(r),
825 Guard::Allow(ident) => super::mfa_handlers::show_regenerate(&c, ident, &req).await,
826 }
827 }
828 });
829
830 let c = ctx.clone();
831 let router = router.post("/admin/account/mfa/regenerate-codes", move |req| {
832 let c = c.clone();
833 async move {
834 match role_guard(&c, &req, Role::User).await? {
835 Guard::Redirect(r) => Ok(r),
836 Guard::Allow(ident) => super::mfa_handlers::do_regenerate(&c, ident, req).await,
837 }
838 }
839 });
840
841 let c = ctx.clone();
843 let router = router.get("/admin/account/mfa/disable", move |req| {
844 let c = c.clone();
845 async move {
846 match role_guard(&c, &req, Role::User).await? {
847 Guard::Redirect(r) => Ok(r),
848 Guard::Allow(ident) => super::mfa_handlers::show_disable(&c, ident, &req).await,
849 }
850 }
851 });
852
853 let c = ctx.clone();
854 let router = router.post("/admin/account/mfa/disable", move |req| {
855 let c = c.clone();
856 async move {
857 match role_guard(&c, &req, Role::User).await? {
858 Guard::Redirect(r) => Ok(r),
859 Guard::Allow(ident) => super::mfa_handlers::do_disable(&c, ident, req).await,
860 }
861 }
862 });
863
864 let c = ctx.clone();
866 let ac = auth_ctx.clone();
867 let router = router.get("/admin/users", move |req| {
868 let c = c.clone();
869 let ac = ac.clone();
870 async move {
871 match role_guard(&c, &req, Role::Administrator).await? {
872 Guard::Redirect(r) => Ok(r),
873 Guard::Allow(ident) => {
874 super::builtin::list_users(&ac, ident, handlers::csrf_token(&req)).await
875 }
876 }
877 }
878 });
879
880 let c = ctx.clone();
881 let ac = auth_ctx.clone();
882 let router = router.get("/admin/users/new", move |req| {
883 let c = c.clone();
884 let ac = ac.clone();
885 async move {
886 match role_guard(&c, &req, Role::Administrator).await? {
887 Guard::Redirect(r) => Ok(r),
888 Guard::Allow(ident) => {
889 super::builtin::show_new_user(&ac, ident, handlers::csrf_token(&req)).await
890 }
891 }
892 }
893 });
894
895 let c = ctx.clone();
896 let ac = auth_ctx.clone();
897 let router = router.post("/admin/users/new", move |req| {
898 let c = c.clone();
899 let ac = ac.clone();
900 async move {
901 match role_guard(&c, &req, Role::Administrator).await? {
902 Guard::Redirect(r) => Ok(r),
903 Guard::Allow(ident) => super::builtin::do_new_user(&ac, ident, req).await,
904 }
905 }
906 });
907
908 let c = ctx.clone();
909 let ac = auth_ctx.clone();
910 let router = router.get("/admin/users/:id/edit", move |req| {
911 let c = c.clone();
912 let ac = ac.clone();
913 async move {
914 match role_guard(&c, &req, Role::Administrator).await? {
915 Guard::Redirect(r) => Ok(r),
916 Guard::Allow(ident) => {
917 let id = parse_id(req.param("id"))?;
918 super::builtin::show_user_edit(&ac, ident, id, handlers::csrf_token(&req)).await
919 }
920 }
921 }
922 });
923
924 let c = ctx.clone();
925 let ac = auth_ctx.clone();
926 let router = router.post("/admin/users/:id/edit", move |req| {
927 let c = c.clone();
928 let ac = ac.clone();
929 async move {
930 match role_guard(&c, &req, Role::Administrator).await? {
931 Guard::Redirect(r) => Ok(r),
932 Guard::Allow(ident) => {
933 let id = parse_id(req.param("id"))?;
934 super::builtin::do_user_edit(&ac, ident, id, req).await
935 }
936 }
937 }
938 });
939
940 let c = ctx.clone();
941 let ac = auth_ctx.clone();
942 let router = router.get("/admin/users/:id/delete", move |req| {
943 let c = c.clone();
944 let ac = ac.clone();
945 async move {
946 match role_guard(&c, &req, Role::Administrator).await? {
947 Guard::Redirect(r) => Ok(r),
948 Guard::Allow(ident) => {
949 let id = parse_id(req.param("id"))?;
950 super::builtin::show_user_delete(&ac, ident, id, handlers::csrf_token(&req))
951 .await
952 }
953 }
954 }
955 });
956
957 let c = ctx.clone();
958 let ac = auth_ctx.clone();
959 let router = router.post("/admin/users/:id/delete", move |req| {
960 let c = c.clone();
961 let ac = ac.clone();
962 async move {
963 match role_guard(&c, &req, Role::Administrator).await? {
964 Guard::Redirect(r) => Ok(r),
965 Guard::Allow(ident) => {
966 let id = parse_id(req.param("id"))?;
967 super::builtin::do_user_delete(&ac, ident, id, req).await
968 }
969 }
970 }
971 });
972
973 let c = ctx.clone();
989 let router = router.get("/admin/users/:id/reset-password", move |req| {
990 let c = c.clone();
991 async move {
992 match role_guard(&c, &req, Role::Administrator).await? {
993 Guard::Redirect(r) => Ok(r),
994 Guard::Allow(ident) => {
995 let id = parse_id(req.param("id"))?;
996 super::admin_recovery_handlers::show_admin_reset_password(&c, ident, id, &req)
997 .await
998 }
999 }
1000 }
1001 });
1002
1003 let c = ctx.clone();
1005 let router = router.post("/admin/users/:id/reset-password", move |req| {
1006 let c = c.clone();
1007 async move {
1008 match role_guard(&c, &req, Role::Administrator).await? {
1009 Guard::Redirect(r) => Ok(r),
1010 Guard::Allow(ident) => {
1011 let id = parse_id(req.param("id"))?;
1012 super::admin_recovery_handlers::do_admin_reset_password(&c, ident, id, req)
1013 .await
1014 }
1015 }
1016 }
1017 });
1018
1019 let c = ctx.clone();
1021 let router = router.get("/admin/users/:id/lock", move |req| {
1022 let c = c.clone();
1023 async move {
1024 match role_guard(&c, &req, Role::Administrator).await? {
1025 Guard::Redirect(r) => Ok(r),
1026 Guard::Allow(ident) => {
1027 let id = parse_id(req.param("id"))?;
1028 super::admin_recovery_handlers::show_lock_user(&c, ident, id, &req).await
1029 }
1030 }
1031 }
1032 });
1033
1034 let c = ctx.clone();
1036 let router = router.post("/admin/users/:id/lock", move |req| {
1037 let c = c.clone();
1038 async move {
1039 match role_guard(&c, &req, Role::Administrator).await? {
1040 Guard::Redirect(r) => Ok(r),
1041 Guard::Allow(ident) => {
1042 let id = parse_id(req.param("id"))?;
1043 super::admin_recovery_handlers::do_lock_user(&c, ident, id, req).await
1044 }
1045 }
1046 }
1047 });
1048
1049 let c = ctx.clone();
1051 let router = router.get("/admin/users/:id/unlock", move |req| {
1052 let c = c.clone();
1053 async move {
1054 match role_guard(&c, &req, Role::Administrator).await? {
1055 Guard::Redirect(r) => Ok(r),
1056 Guard::Allow(ident) => {
1057 let id = parse_id(req.param("id"))?;
1058 super::admin_recovery_handlers::show_unlock_user(&c, ident, id, &req).await
1059 }
1060 }
1061 }
1062 });
1063
1064 let c = ctx.clone();
1066 let router = router.post("/admin/users/:id/unlock", move |req| {
1067 let c = c.clone();
1068 async move {
1069 match role_guard(&c, &req, Role::Administrator).await? {
1070 Guard::Redirect(r) => Ok(r),
1071 Guard::Allow(ident) => {
1072 let id = parse_id(req.param("id"))?;
1073 super::admin_recovery_handlers::do_unlock_user(&c, ident, id, req).await
1074 }
1075 }
1076 }
1077 });
1078
1079 let c = ctx.clone();
1082 let router = router.get("/admin/users/:id/revoke-sessions", move |req| {
1083 let c = c.clone();
1084 async move {
1085 match role_guard(&c, &req, Role::Administrator).await? {
1086 Guard::Redirect(r) => Ok(r),
1087 Guard::Allow(ident) => {
1088 let id = parse_id(req.param("id"))?;
1089 super::admin_recovery_handlers::show_admin_revoke_sessions(&c, ident, id, &req)
1090 .await
1091 }
1092 }
1093 }
1094 });
1095
1096 let c = ctx.clone();
1098 let router = router.post("/admin/users/:id/revoke-sessions", move |req| {
1099 let c = c.clone();
1100 async move {
1101 match role_guard(&c, &req, Role::Administrator).await? {
1102 Guard::Redirect(r) => Ok(r),
1103 Guard::Allow(ident) => {
1104 let id = parse_id(req.param("id"))?;
1105 super::admin_recovery_handlers::do_admin_revoke_sessions(&c, ident, id, req)
1106 .await
1107 }
1108 }
1109 }
1110 });
1111
1112 let c = ctx.clone();
1119 let ac = auth_ctx.clone();
1120 let router = router.get("/admin/users/:id", move |req| {
1121 let c = c.clone();
1122 let ac = ac.clone();
1123 async move {
1124 match role_guard(&c, &req, Role::Administrator).await? {
1125 Guard::Redirect(r) => Ok(r),
1126 Guard::Allow(ident) => {
1127 let id = parse_id(req.param("id"))?;
1128 let q = req.query();
1129 let tab = q.get("tab").map(|s| s.to_string());
1130 let page: i64 = q.get("page").and_then(|s| s.parse().ok()).unwrap_or(1);
1131 super::builtin::show_user_view(
1132 &ac,
1133 ident,
1134 id,
1135 handlers::csrf_token(&req),
1136 tab,
1137 page,
1138 )
1139 .await
1140 }
1141 }
1142 }
1143 });
1144
1145 let c = ctx.clone();
1147 let ac = auth_ctx.clone();
1148 let router = router.get("/admin/groups", move |req| {
1149 let c = c.clone();
1150 let ac = ac.clone();
1151 async move {
1152 match role_guard(&c, &req, Role::Administrator).await? {
1153 Guard::Redirect(r) => Ok(r),
1154 Guard::Allow(ident) => {
1155 super::builtin::list_groups(&ac, ident, handlers::csrf_token(&req)).await
1156 }
1157 }
1158 }
1159 });
1160
1161 let c = ctx.clone();
1162 let ac = auth_ctx.clone();
1163 let router = router.get("/admin/groups/new", move |req| {
1164 let c = c.clone();
1165 let ac = ac.clone();
1166 async move {
1167 match role_guard(&c, &req, Role::Administrator).await? {
1168 Guard::Redirect(r) => Ok(r),
1169 Guard::Allow(ident) => {
1170 super::builtin::show_new_group(&ac, ident, handlers::csrf_token(&req)).await
1171 }
1172 }
1173 }
1174 });
1175
1176 let c = ctx.clone();
1177 let ac = auth_ctx.clone();
1178 let router = router.post("/admin/groups/new", move |req| {
1179 let c = c.clone();
1180 let ac = ac.clone();
1181 async move {
1182 match role_guard(&c, &req, Role::Administrator).await? {
1183 Guard::Redirect(r) => Ok(r),
1184 Guard::Allow(ident) => super::builtin::do_new_group(&ac, ident, req).await,
1185 }
1186 }
1187 });
1188
1189 let c = ctx.clone();
1190 let ac = auth_ctx.clone();
1191 let router = router.get("/admin/groups/:id/edit", move |req| {
1192 let c = c.clone();
1193 let ac = ac.clone();
1194 async move {
1195 match role_guard(&c, &req, Role::Administrator).await? {
1196 Guard::Redirect(r) => Ok(r),
1197 Guard::Allow(ident) => {
1198 let id = parse_id(req.param("id"))?;
1199 super::builtin::show_group_edit(&ac, ident, id, handlers::csrf_token(&req))
1200 .await
1201 }
1202 }
1203 }
1204 });
1205
1206 let c = ctx.clone();
1207 let ac = auth_ctx.clone();
1208 let router = router.post("/admin/groups/:id/edit", move |req| {
1209 let c = c.clone();
1210 let ac = ac.clone();
1211 async move {
1212 match role_guard(&c, &req, Role::Administrator).await? {
1213 Guard::Redirect(r) => Ok(r),
1214 Guard::Allow(ident) => {
1215 let id = parse_id(req.param("id"))?;
1216 super::builtin::do_group_edit(&ac, ident, id, req).await
1217 }
1218 }
1219 }
1220 });
1221
1222 let c = ctx.clone();
1223 let ac = auth_ctx.clone();
1224 let router = router.get("/admin/groups/:id/delete", move |req| {
1225 let c = c.clone();
1226 let ac = ac.clone();
1227 async move {
1228 match role_guard(&c, &req, Role::Administrator).await? {
1229 Guard::Redirect(r) => Ok(r),
1230 Guard::Allow(ident) => {
1231 let id = parse_id(req.param("id"))?;
1232 super::builtin::show_group_delete(&ac, ident, id, handlers::csrf_token(&req))
1233 .await
1234 }
1235 }
1236 }
1237 });
1238
1239 let c = ctx.clone();
1240 let ac = auth_ctx.clone();
1241 let router = router.post("/admin/groups/:id/delete", move |req| {
1242 let c = c.clone();
1243 let ac = ac.clone();
1244 async move {
1245 match role_guard(&c, &req, Role::Administrator).await? {
1246 Guard::Redirect(r) => Ok(r),
1247 Guard::Allow(ident) => {
1248 let id = parse_id(req.param("id"))?;
1249 super::builtin::do_group_delete(&ac, ident, id, req).await
1250 }
1251 }
1252 }
1253 });
1254
1255 let c = ctx.clone();
1257 let router = router.get("/admin/:admin_name", move |req| {
1258 let c = c.clone();
1259 async move {
1260 let name = model_name_from_req(&req)?;
1261 let perm = perm_for(&c, &name, "view")?;
1262 match perm_guard(&c, &req, &perm).await? {
1263 Guard::Redirect(r) => Ok(r),
1264 Guard::Allow(ident) => handlers::list_model(&c, ident, &name, &req).await,
1265 }
1266 }
1267 });
1268
1269 let c = ctx.clone();
1271 let router = router.get("/admin/:admin_name/new", move |req| {
1272 let c = c.clone();
1273 async move {
1274 let name = model_name_from_req(&req)?;
1275 let perm = perm_for(&c, &name, "add")?;
1276 match perm_guard(&c, &req, &perm).await? {
1277 Guard::Redirect(r) => Ok(r),
1278 Guard::Allow(ident) => handlers::show_new_form(&c, ident, &name, &req).await,
1279 }
1280 }
1281 });
1282 let c = ctx.clone();
1283 let router = router.post("/admin/:admin_name/new", move |req| {
1284 let c = c.clone();
1285 async move {
1286 let name = model_name_from_req(&req)?;
1287 let perm = perm_for(&c, &name, "add")?;
1288 match perm_guard(&c, &req, &perm).await? {
1289 Guard::Redirect(r) => Ok(r),
1290 Guard::Allow(ident) => handlers::do_create(&c, ident, &name, req).await,
1291 }
1292 }
1293 });
1294
1295 let c = ctx.clone();
1297 let router = router.get("/admin/:admin_name/:id/edit", move |req| {
1298 let c = c.clone();
1299 async move {
1300 let name = model_name_from_req(&req)?;
1301 let perm = perm_for(&c, &name, "change")?;
1302 match perm_guard(&c, &req, &perm).await? {
1303 Guard::Redirect(r) => Ok(r),
1304 Guard::Allow(ident) => {
1305 let id = parse_id(req.param("id"))?;
1306 handlers::show_edit_form(&c, ident, &name, id, &req).await
1307 }
1308 }
1309 }
1310 });
1311 let c = ctx.clone();
1312 let router = router.post("/admin/:admin_name/:id/edit", move |req| {
1313 let c = c.clone();
1314 async move {
1315 let name = model_name_from_req(&req)?;
1316 let perm = perm_for(&c, &name, "change")?;
1317 match perm_guard(&c, &req, &perm).await? {
1318 Guard::Redirect(r) => Ok(r),
1319 Guard::Allow(ident) => {
1320 let id = parse_id(req.param("id"))?;
1321 handlers::do_update(&c, ident, &name, id, req).await
1322 }
1323 }
1324 }
1325 });
1326
1327 let c = ctx.clone();
1330 let router = router.get("/admin/:admin_name/:id/history", move |req| {
1331 let c = c.clone();
1332 async move {
1333 let name = model_name_from_req(&req)?;
1334 let perm = perm_for(&c, &name, "view")?;
1335 match perm_guard(&c, &req, &perm).await? {
1336 Guard::Redirect(r) => Ok(r),
1337 Guard::Allow(ident) => {
1338 let id = parse_id(req.param("id"))?;
1339 handlers::show_object_history(&c, ident, &name, id, &req).await
1340 }
1341 }
1342 }
1343 });
1344
1345 let c = ctx.clone();
1347 let router = router.get("/admin/:admin_name/:id/delete", move |req| {
1348 let c = c.clone();
1349 async move {
1350 let name = model_name_from_req(&req)?;
1351 let perm = perm_for(&c, &name, "delete")?;
1352 match perm_guard(&c, &req, &perm).await? {
1353 Guard::Redirect(r) => Ok(r),
1354 Guard::Allow(ident) => {
1355 let id = parse_id(req.param("id"))?;
1356 handlers::show_delete_confirm(&c, ident, &name, id, &req).await
1357 }
1358 }
1359 }
1360 });
1361 let c = ctx.clone();
1362 let router = router.post("/admin/:admin_name/:id/delete", move |req| {
1363 let c = c.clone();
1364 async move {
1365 let name = model_name_from_req(&req)?;
1366 let perm = perm_for(&c, &name, "delete")?;
1367 match perm_guard(&c, &req, &perm).await? {
1368 Guard::Redirect(r) => Ok(r),
1369 Guard::Allow(ident) => {
1370 let id = parse_id(req.param("id"))?;
1371 handlers::do_delete(&c, ident, &name, id).await
1372 }
1373 }
1374 }
1375 });
1376
1377 let c = ctx.clone();
1382 let router = router.post("/admin/:admin_name/bulk_delete", move |req| {
1383 let c = c.clone();
1384 async move {
1385 let name = model_name_from_req(&req)?;
1386 let perm = perm_for(&c, &name, "delete")?;
1387 match perm_guard(&c, &req, &perm).await? {
1388 Guard::Redirect(r) => Ok(r),
1389 Guard::Allow(ident) => handlers::handle_bulk_delete(&c, ident, &name, &req).await,
1390 }
1391 }
1392 });
1393
1394 let c = ctx.clone();
1399 router.post("/admin/:admin_name/bulk/:action", move |req| {
1400 let c = c.clone();
1401 async move {
1402 let name = model_name_from_req(&req)?;
1403 let action = req
1404 .param("action")
1405 .ok_or_else(|| Error::BadRequest("missing bulk action name".into()))?
1406 .to_string();
1407 let perm = perm_for(&c, &name, "change")?;
1408 match perm_guard(&c, &req, &perm).await? {
1409 Guard::Redirect(r) => Ok(r),
1410 Guard::Allow(ident) => {
1411 handlers::handle_bulk_action(&c, ident, &name, &action, &req).await
1412 }
1413 }
1414 }
1415 })
1416}
1417
1418#[cfg(test)]
1419mod tests {
1420 use super::*;
1421
1422 fn make_identity(role: Role, is_active: bool) -> Identity {
1423 Identity {
1424 user_id: 42,
1425 email: "test@example.com".into(),
1426 role,
1427 is_active,
1428 is_demo: false,
1429 demo_label: None,
1430 must_change_password: false,
1431 mfa_enabled: false,
1432 trust_level: crate::auth::SessionTrust::Authenticated,
1433 }
1434 }
1435
1436 #[test]
1441 fn role_guard_decision_admin_meets_staff_floor() {
1442 let id = make_identity(Role::Administrator, true);
1443 assert!(id.role.includes(Role::Staff));
1444 }
1445
1446 #[test]
1447 fn role_guard_decision_user_does_not_meet_staff() {
1448 let id = make_identity(Role::User, true);
1449 assert!(!id.role.includes(Role::Staff));
1450 }
1451
1452 #[test]
1453 fn role_guard_decision_administrator_does_not_meet_developer() {
1454 let id = make_identity(Role::Administrator, true);
1455 assert!(!id.role.includes(Role::Developer));
1456 }
1457
1458 #[test]
1459 fn role_guard_decision_developer_meets_everything() {
1460 let id = make_identity(Role::Developer, true);
1461 for &min in &[
1462 Role::User,
1463 Role::Staff,
1464 Role::Supervisor,
1465 Role::Administrator,
1466 Role::Developer,
1467 ] {
1468 assert!(id.role.includes(min), "Developer should meet {min:?}");
1469 }
1470 }
1471
1472 #[test]
1475 fn perm_guard_admin_short_circuits_without_perm() {
1476 let id = make_identity(Role::Administrator, true);
1477 assert!(perm_guard_verdict(&id, false));
1478 }
1479
1480 #[test]
1481 fn perm_guard_developer_short_circuits_without_perm() {
1482 let id = make_identity(Role::Developer, true);
1483 assert!(perm_guard_verdict(&id, false));
1484 }
1485
1486 #[test]
1487 fn perm_guard_staff_with_perm_passes() {
1488 let id = make_identity(Role::Staff, true);
1489 assert!(perm_guard_verdict(&id, true));
1490 }
1491
1492 #[test]
1493 fn perm_guard_staff_without_perm_denies() {
1494 let id = make_identity(Role::Staff, true);
1495 assert!(!perm_guard_verdict(&id, false));
1496 }
1497
1498 #[test]
1499 fn perm_guard_inactive_admin_denies_even_with_bypass() {
1500 let id = make_identity(Role::Administrator, false);
1502 assert!(!perm_guard_verdict(&id, true));
1503 }
1504
1505 #[test]
1506 fn perm_guard_supervisor_without_perm_denies() {
1507 let id = make_identity(Role::Supervisor, true);
1509 assert!(!perm_guard_verdict(&id, false));
1510 }
1511
1512 #[test]
1517 fn strict_mailer_guard_passes_for_default_admin() {
1518 let admin = super::super::types::Admin::new();
1519 assert!(strict_mailer_guard_check(&admin).is_ok());
1520 }
1521
1522 #[test]
1525 fn strict_mailer_guard_fails_when_required_but_default_mailer() {
1526 use crate::auth::DefaultRecoveryPolicy;
1527 let admin = super::super::types::Admin::new().recovery_policy(std::sync::Arc::new(
1528 DefaultRecoveryPolicy::new().with_strict_mailer_required(true),
1529 ));
1530 let err = strict_mailer_guard_check(&admin).expect_err("guard should fail");
1531 assert!(
1532 err.contains("strict_mailer_required"),
1533 "error message must name the policy method: {err}"
1534 );
1535 assert!(
1536 err.contains("Admin::mailer"),
1537 "error message must direct the operator to the fix: {err}"
1538 );
1539 }
1540
1541 #[test]
1546 fn strict_mailer_guard_passes_when_mailer_was_explicitly_overridden() {
1547 use crate::auth::DefaultRecoveryPolicy;
1548 use crate::email::LogMailer;
1549 let admin = super::super::types::Admin::new()
1550 .recovery_policy(std::sync::Arc::new(
1551 DefaultRecoveryPolicy::new().with_strict_mailer_required(true),
1552 ))
1553 .mailer(std::sync::Arc::new(LogMailer));
1554 assert!(strict_mailer_guard_check(&admin).is_ok());
1555 }
1556
1557 #[test]
1560 fn strict_mailer_guard_passes_when_strict_mode_disabled() {
1561 let admin = super::super::types::Admin::new();
1562 assert!(strict_mailer_guard_check(&admin).is_ok());
1563 }
1564
1565 #[test]
1568 fn whitelist_accepts_the_three_locked_paths() {
1569 assert!(super::is_must_change_whitelisted_path(
1571 "/admin/must-change-password"
1572 ));
1573 assert!(super::is_must_change_whitelisted_path("/admin/logout"));
1574 assert!(super::is_must_change_whitelisted_path(
1575 "/admin/account/sessions"
1576 ));
1577 }
1578
1579 #[test]
1580 fn whitelist_rejects_subpaths_of_account_sessions() {
1581 assert!(!super::is_must_change_whitelisted_path(
1586 "/admin/account/sessions/revoke"
1587 ));
1588 assert!(!super::is_must_change_whitelisted_path(
1589 "/admin/account/sessions/revoke-others"
1590 ));
1591 assert!(!super::is_must_change_whitelisted_path(
1592 "/admin/account/sessions/"
1593 ));
1594 }
1595
1596 #[test]
1597 fn whitelist_rejects_other_admin_paths() {
1598 for path in [
1599 "/admin",
1600 "/admin/",
1601 "/admin/users",
1602 "/admin/users/42",
1603 "/admin/login",
1604 "/admin/password_change",
1605 "/admin/forgot-password",
1606 "/admin/reauth",
1607 "/admin/must-change-password/", ] {
1609 assert!(
1610 !super::is_must_change_whitelisted_path(path),
1611 "expected reject for {path:?}"
1612 );
1613 }
1614 }
1615
1616 #[test]
1617 fn whitelist_rejects_paths_outside_admin_surface() {
1618 for path in ["/", "/login", "/static/admin.css", "/api"] {
1619 assert!(
1620 !super::is_must_change_whitelisted_path(path),
1621 "expected reject for {path:?}"
1622 );
1623 }
1624 }
1625}