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 = concat!(
51 include_str!("../../assets/static/admin/tokens/colors.css"),
53 "\n",
54 include_str!("../../assets/static/admin/tokens/spacing.css"),
55 "\n",
56 include_str!("../../assets/static/admin/tokens/radius.css"),
57 "\n",
58 include_str!("../../assets/static/admin/tokens/shadows.css"),
59 "\n",
60 include_str!("../../assets/static/admin/tokens/typography.css"),
61 "\n",
62 include_str!("../../assets/static/admin/themes/dark.css"),
64 "\n",
65 include_str!("../../assets/static/admin/themes/light.css"),
66 "\n",
67 include_str!("../../assets/static/admin/base/reset.css"),
69 "\n",
70 include_str!("../../assets/static/admin/base/base.css"),
71 "\n",
72 include_str!("../../assets/static/admin/base/typography.css"),
73 "\n",
74 include_str!("../../assets/static/admin/base/utilities.css"),
75 "\n",
76 include_str!("../../assets/static/admin/layout/shell.css"),
78 "\n",
79 include_str!("../../assets/static/admin/layout/topbar.css"),
80 "\n",
81 include_str!("../../assets/static/admin/layout/sidebar.css"),
82 "\n",
83 include_str!("../../assets/static/admin/layout/footer.css"),
84 "\n",
85 include_str!("../../assets/static/admin/components/cards.css"),
87 "\n",
88 include_str!("../../assets/static/admin/components/buttons.css"),
89 "\n",
90 include_str!("../../assets/static/admin/components/forms.css"),
91 "\n",
92 include_str!("../../assets/static/admin/components/tables.css"),
93 "\n",
94 include_str!("../../assets/static/admin/components/filters.css"),
95 "\n",
96 include_str!("../../assets/static/admin/components/dropdowns.css"),
97 "\n",
98 include_str!("../../assets/static/admin/components/pagination.css"),
99 "\n",
100 include_str!("../../assets/static/admin/components/pills.css"),
101 "\n",
102 include_str!("../../assets/static/admin/components/flashes.css"),
103 "\n",
104 include_str!("../../assets/static/admin/components/timeline.css"),
105 "\n",
106 include_str!("../../assets/static/admin/components/tabs.css"),
107 "\n",
108 include_str!("../../assets/static/admin/pages/auth.css"),
110 "\n",
111 include_str!("../../assets/static/admin/pages/dashboard.css"),
112 "\n",
113 include_str!("../../assets/static/admin/pages/permissions.css"),
114 "\n",
115 include_str!("../../assets/static/admin/pages/sessions.css"),
116 "\n",
117 include_str!("../../assets/static/admin/pages/errors.css"),
118 "\n",
119 include_str!("../../assets/static/admin/layout/responsive.css"),
121 "\n",
122 include_str!("../../assets/static/admin/print/print.css"),
124);
125
126const ADMIN_JS: &str = include_str!("../../assets/static/admin.js");
129
130const FONT_GEIST: &[u8] = include_bytes!("../../assets/static/fonts/Geist-Variable.woff2");
140const FONT_GEIST_MONO: &[u8] = include_bytes!("../../assets/static/fonts/GeistMono-Variable.woff2");
141const FONT_TAJAWAL_REG: &[u8] = include_bytes!("../../assets/static/fonts/Tajawal-Regular.woff2");
142const FONT_TAJAWAL_MED: &[u8] = include_bytes!("../../assets/static/fonts/Tajawal-Medium.woff2");
143const FONT_TAJAWAL_BOLD: &[u8] = include_bytes!("../../assets/static/fonts/Tajawal-Bold.woff2");
144const FONT_NOTO_NASKH_AR: &[u8] =
145 include_bytes!("../../assets/static/fonts/NotoNaskhArabic-Variable.woff2");
146
147use super::handlers::{self, AdminCtx};
148use super::render;
149use super::types::Admin;
150
151enum Guard {
155 Allow(Identity),
156 Redirect(Response),
157}
158
159const MUST_CHANGE_WHITELIST: &[&str] = &[
169 "/admin/must-change-password",
170 "/admin/logout",
171 "/admin/account/sessions",
172];
173
174fn is_must_change_whitelisted_path(path: &str) -> bool {
178 MUST_CHANGE_WHITELIST.contains(&path)
179}
180
181const MFA_ENROLL_WHITELIST: &[&str] = &[
194 "/admin/account/mfa/enroll",
195 "/admin/logout",
196 "/admin/account/sessions",
197];
198
199fn is_mfa_enroll_whitelisted_path(path: &str) -> bool {
200 MFA_ENROLL_WHITELIST.contains(&path)
201}
202
203const MFA_VERIFY_WHITELIST: &[&str] = &[
213 "/admin/mfa/verify",
214 "/admin/logout",
215 "/admin/account/sessions",
216];
217
218fn is_mfa_verify_whitelisted_path(path: &str) -> bool {
219 MFA_VERIFY_WHITELIST.contains(&path)
220}
221
222fn mfa_required_for_role(policy: crate::auth::MfaPolicy, role: Role) -> bool {
232 use crate::auth::MfaPolicy;
233 match policy {
234 MfaPolicy::Disabled | MfaPolicy::Optional => false,
235 MfaPolicy::Required => true,
236 MfaPolicy::RequiredForRoles(roles) => roles.contains(&role),
237 }
238}
239
240async fn login_guard(ctx: &AdminCtx, req: &Request) -> Result<Guard> {
241 let cookie = match req.header("cookie") {
242 Some(c) => c,
243 None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
244 };
245 let token = match auth::session_token_from_cookie(cookie) {
246 Some(t) => t,
247 None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
248 };
249 let ident = match auth::identity_from_session(&ctx.db, &token).await? {
250 Some(i) => i,
251 None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
252 };
253 if !ident.is_active {
254 return Ok(Guard::Redirect(Response::redirect("/admin/login")));
255 }
256
257 if ident.must_change_password && !is_must_change_whitelisted_path(req.path()) {
263 return Ok(Guard::Redirect(Response::redirect(
264 "/admin/must-change-password",
265 )));
266 }
267
268 let policy = ctx.admin.active_mfa_policy();
277 if mfa_required_for_role(policy, ident.role)
278 && !ident.mfa_enabled
279 && !is_mfa_enroll_whitelisted_path(req.path())
280 {
281 return Ok(Guard::Redirect(Response::redirect(
282 "/admin/account/mfa/enroll",
283 )));
284 }
285
286 use crate::auth::SessionTrust;
294 if ident.mfa_enabled
295 && ident.trust_level != SessionTrust::MfaVerified
296 && !is_mfa_verify_whitelisted_path(req.path())
297 {
298 return Ok(Guard::Redirect(Response::redirect("/admin/mfa/verify")));
299 }
300
301 Ok(Guard::Allow(ident))
302}
303
304async fn role_guard(ctx: &AdminCtx, req: &Request, min: Role) -> Result<Guard> {
305 match login_guard(ctx, req).await? {
306 Guard::Redirect(r) => Ok(Guard::Redirect(r)),
307 Guard::Allow(ident) => {
308 if ident.role.includes(min) {
309 Ok(Guard::Allow(ident))
310 } else {
311 let body = render::render_forbidden_body(
312 &ctx.admin,
313 &ctx.templates,
314 &ident,
315 handlers::csrf_token(req),
316 None,
317 Some(min.label()),
318 )?;
319 Ok(Guard::Redirect(
320 Response::html(body).with_status(hyper::StatusCode::FORBIDDEN),
321 ))
322 }
323 }
324 }
325}
326
327async fn perm_guard(ctx: &AdminCtx, req: &Request, perm: &str) -> Result<Guard> {
328 match role_guard(ctx, req, Role::Staff).await? {
329 Guard::Redirect(r) => Ok(Guard::Redirect(r)),
330 Guard::Allow(ident) => {
331 if ident.role.bypasses_group_checks() {
332 return Ok(Guard::Allow(ident));
333 }
334 if auth::check_permission(&ctx.db, &ident, perm).await? {
335 Ok(Guard::Allow(ident))
336 } else {
337 let body = render::render_forbidden_body(
338 &ctx.admin,
339 &ctx.templates,
340 &ident,
341 handlers::csrf_token(req),
342 Some(perm.to_string()),
343 None,
344 )?;
345 Ok(Guard::Redirect(
346 Response::html(body).with_status(hyper::StatusCode::FORBIDDEN),
347 ))
348 }
349 }
350 }
351}
352
353#[cfg(test)]
356fn perm_guard_verdict(ident: &Identity, perm_held: bool) -> bool {
357 if !ident.is_active {
358 return false;
359 }
360 if ident.role.bypasses_group_checks() {
361 return true;
362 }
363 perm_held
364}
365
366fn parse_id(raw: Option<&str>) -> Result<i64> {
367 raw.and_then(|s| s.parse().ok())
368 .ok_or_else(|| Error::BadRequest("invalid id".into()))
369}
370
371fn model_name_from_req(req: &Request) -> Result<String> {
372 req.param("admin_name")
373 .map(|s| s.to_string())
374 .ok_or_else(|| Error::BadRequest("missing model".into()))
375}
376
377fn perm_for(ctx: &AdminCtx, admin_name: &str, action: &str) -> Result<String> {
378 let entry = ctx
379 .admin
380 .find(admin_name)
381 .ok_or_else(|| Error::NotFound(format!("no admin model: {admin_name}")))?;
382 let singular = entry.singular_name.to_ascii_lowercase();
383 Ok(format!("{admin_name}.{action}_{singular}"))
384}
385
386async fn resolve_identity_for_error_page(db: &Db, cookie_header: &str) -> Option<Identity> {
416 let token = auth::session_token_from_cookie(cookie_header)?;
417 let identity = auth::identity_from_session(db, token.as_str())
418 .await
419 .ok()
420 .flatten()?;
421 if !identity.is_active {
422 return None;
423 }
424 Some(identity)
425}
426
427fn strict_mailer_guard_check(admin: &Admin) -> std::result::Result<(), String> {
428 if admin.active_recovery_policy().strict_mailer_required() && !admin.has_custom_mailer() {
429 Err(
430 "rustio-admin: RecoveryPolicy::strict_mailer_required() = true but no mailer \
431 was registered via Admin::mailer(...).\n\n\
432 The framework's default LogMailer writes recovery emails to log::info! instead \
433 of sending them, which is unsuitable for production. Recovery routes are NOT \
434 registered with this configuration.\n\n\
435 To resolve, choose one:\n\
436 (a) register a real mailer before calling register_admin_routes:\n\
437 Admin::mailer(Arc::new(MyProjectMailer::new(...)))\n\
438 (b) opt the policy out of strict mode (the framework default — dev / CI / \
439 testing baseline):\n\
440 RecoveryPolicy::strict_mailer_required(false)\n\n\
441 See DESIGN_RECOVERY.md §12.1 for the contract."
442 .to_string(),
443 )
444 } else {
445 Ok(())
446 }
447}
448
449pub fn register_admin_routes(
451 router: Router,
452 admin: Admin,
453 db: Db,
454 templates: Arc<Templates>,
455) -> Router {
456 if let Err(msg) = strict_mailer_guard_check(&admin) {
464 panic!("{msg}");
465 }
466
467 let ctx = Arc::new(AdminCtx::new(
468 Arc::new(admin),
469 db.clone(),
470 templates.clone(),
471 ));
472
473 let auth_ctx = Arc::new(super::builtin::AuthAdminCtx {
476 admin: ctx.admin.clone(),
477 db,
478 templates,
479 });
480
481 let err_admin = ctx.admin.clone();
496 let err_templates = ctx.templates.clone();
497 let err_db = ctx.db.clone();
498 let router = router.middleware(move |req, next| {
499 let admin = err_admin.clone();
500 let templates = err_templates.clone();
501 let db = err_db.clone();
502 Box::pin(async move {
503 let is_admin_path = req.path().starts_with("/admin");
504 let cookie_header = if is_admin_path {
510 req.header("cookie").map(|s| s.to_string())
511 } else {
512 None
513 };
514 let result = next.run(req).await;
515 match result {
516 Ok(resp) => Ok(resp),
517 Err(err) if is_admin_path => {
518 let identity = match cookie_header.as_deref() {
519 Some(cookie) => resolve_identity_for_error_page(&db, cookie).await,
520 None => None,
521 };
522 Ok(render::render_admin_error_response(
523 &admin,
524 &templates,
525 identity.as_ref(),
526 err.status(),
527 err.client_message().to_string(),
528 ))
529 }
530 Err(err) => Err(err),
531 }
532 })
533 });
534
535 let router = router.get("/static/admin.css", |_req| async move {
541 Ok(Response::new(
542 hyper::StatusCode::OK,
543 bytes::Bytes::from_static(ADMIN_CSS.as_bytes()),
544 )
545 .with_header("content-type", "text/css; charset=utf-8")
546 .with_header("cache-control", "no-cache, must-revalidate"))
547 });
548 let router = router.get("/static/admin.js", |_req| async move {
549 Ok(Response::new(
550 hyper::StatusCode::OK,
551 bytes::Bytes::from_static(ADMIN_JS.as_bytes()),
552 )
553 .with_header("content-type", "application/javascript; charset=utf-8")
554 .with_header("cache-control", "no-cache, must-revalidate"))
555 });
556
557 fn font_response(bytes: &'static [u8]) -> Response {
561 Response::new(hyper::StatusCode::OK, bytes::Bytes::from_static(bytes))
562 .with_header("content-type", "font/woff2")
563 .with_header("cache-control", "public, max-age=31536000, immutable")
564 }
565 let router = router.get("/static/fonts/Geist-Variable.woff2", |_req| async move {
566 Ok(font_response(FONT_GEIST))
567 });
568 let router = router.get(
569 "/static/fonts/GeistMono-Variable.woff2",
570 |_req| async move { Ok(font_response(FONT_GEIST_MONO)) },
571 );
572 let router = router.get("/static/fonts/Tajawal-Regular.woff2", |_req| async move {
573 Ok(font_response(FONT_TAJAWAL_REG))
574 });
575 let router = router.get("/static/fonts/Tajawal-Medium.woff2", |_req| async move {
576 Ok(font_response(FONT_TAJAWAL_MED))
577 });
578 let router = router.get("/static/fonts/Tajawal-Bold.woff2", |_req| async move {
579 Ok(font_response(FONT_TAJAWAL_BOLD))
580 });
581 let router = router.get(
582 "/static/fonts/NotoNaskhArabic-Variable.woff2",
583 |_req| async move { Ok(font_response(FONT_NOTO_NASKH_AR)) },
584 );
585
586 let c = ctx.clone();
588 let router = router.get("/admin/login", move |req| {
589 let c = c.clone();
590 async move { handlers::show_login(&c, req).await }
591 });
592
593 let c = ctx.clone();
594 let router = router.post("/admin/login", move |req| {
595 let c = c.clone();
596 async move { handlers::do_login(&c, req).await }
597 });
598
599 let c = ctx.clone();
600 let router = router.post("/admin/logout", move |req| {
601 let c = c.clone();
602 async move { handlers::do_logout(&c, req).await }
603 });
604
605 let recovery_state = Arc::new(super::recovery_handlers::RecoveryState::from_admin(
622 &ctx.admin,
623 ));
624
625 let c = ctx.clone();
626 let router = router.get("/admin/forgot-password", move |req| {
627 let c = c.clone();
628 async move { super::recovery_handlers::show_forgot_password(&c, &req).await }
629 });
630
631 let c = ctx.clone();
632 let rs = recovery_state.clone();
633 let router = router.post("/admin/forgot-password", move |req| {
634 let c = c.clone();
635 let rs = rs.clone();
636 async move { super::recovery_handlers::do_forgot_password(&c, &rs, req).await }
637 });
638
639 let c = ctx.clone();
640 let router = router.get("/admin/forgot-password/sent", move |req| {
641 let c = c.clone();
642 async move { super::recovery_handlers::show_forgot_password_sent(&c, &req).await }
643 });
644
645 let c = ctx.clone();
646 let router = router.get("/admin/reset-password/:token", move |req| {
647 let c = c.clone();
648 async move {
649 let token = req
650 .param("token")
651 .ok_or_else(|| Error::BadRequest("missing token".into()))?
652 .to_string();
653 super::recovery_handlers::show_reset_password(&c, &req, &token).await
654 }
655 });
656
657 let c = ctx.clone();
658 let rs = recovery_state.clone();
659 let router = router.post("/admin/reset-password/:token", move |req| {
660 let c = c.clone();
661 let rs = rs.clone();
662 async move {
663 let token = req
664 .param("token")
665 .ok_or_else(|| Error::BadRequest("missing token".into()))?
666 .to_string();
667 super::recovery_handlers::do_reset_password(&c, &rs, req, &token).await
668 }
669 });
670
671 let c = ctx.clone();
673 let router = router.get("/admin", move |req| {
674 let c = c.clone();
675 async move {
676 match role_guard(&c, &req, Role::Staff).await? {
677 Guard::Redirect(r) => Ok(r),
678 Guard::Allow(ident) => handlers::dashboard(&c, ident, &req).await,
679 }
680 }
681 });
682
683 let c = ctx.clone();
685 let router = router.get("/admin/history", move |req| {
686 let c = c.clone();
687 async move {
688 match role_guard(&c, &req, Role::Administrator).await? {
689 Guard::Redirect(r) => Ok(r),
690 Guard::Allow(ident) => handlers::show_log_entries(&c, ident, &req).await,
691 }
692 }
693 });
694
695 let c = ctx.clone();
698 let router = router.get("/admin/account/sessions", move |req| {
699 let c = c.clone();
700 async move {
701 match role_guard(&c, &req, Role::User).await? {
702 Guard::Redirect(r) => Ok(r),
703 Guard::Allow(ident) => handlers::show_account_sessions(&c, ident, &req).await,
704 }
705 }
706 });
707
708 let c = ctx.clone();
716 let router = router.post("/admin/account/sessions/revoke-others", move |req| {
717 let c = c.clone();
718 async move {
719 match role_guard(&c, &req, Role::User).await? {
720 Guard::Redirect(r) => Ok(r),
721 Guard::Allow(ident) => handlers::do_revoke_other_sessions(&c, ident, req).await,
722 }
723 }
724 });
725
726 let c = ctx.clone();
727 let router = router.post("/admin/account/sessions/revoke-all", move |req| {
728 let c = c.clone();
729 async move {
730 match role_guard(&c, &req, Role::User).await? {
731 Guard::Redirect(r) => Ok(r),
732 Guard::Allow(ident) => handlers::do_revoke_all_sessions(&c, ident, req).await,
733 }
734 }
735 });
736
737 let c = ctx.clone();
738 let router = router.post("/admin/account/sessions/:id/revoke", move |req| {
739 let c = c.clone();
740 async move {
741 match role_guard(&c, &req, Role::User).await? {
742 Guard::Redirect(r) => Ok(r),
743 Guard::Allow(ident) => {
744 let id = parse_id(req.param("id"))?;
745 handlers::do_revoke_session(&c, ident, req, id).await
746 }
747 }
748 }
749 });
750
751 let c = ctx.clone();
755 let router = router.get("/admin/password_change", move |req| {
756 let c = c.clone();
757 async move {
758 match role_guard(&c, &req, Role::User).await? {
759 Guard::Redirect(r) => Ok(r),
760 Guard::Allow(ident) => handlers::show_password_change(&c, ident, &req).await,
761 }
762 }
763 });
764 let c = ctx.clone();
765 let router = router.post("/admin/password_change", move |req| {
766 let c = c.clone();
767 async move {
768 match role_guard(&c, &req, Role::User).await? {
769 Guard::Redirect(r) => Ok(r),
770 Guard::Allow(ident) => handlers::do_password_change(&c, ident, req).await,
771 }
772 }
773 });
774
775 let c = ctx.clone();
784 let router = router.get("/admin/reauth", move |req| {
785 let c = c.clone();
786 async move {
787 match role_guard(&c, &req, Role::User).await? {
788 Guard::Redirect(r) => Ok(r),
789 Guard::Allow(ident) => {
790 super::admin_recovery_handlers::show_reauth(&c, ident, &req).await
791 }
792 }
793 }
794 });
795
796 let c = ctx.clone();
797 let router = router.post("/admin/reauth", 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) => {
803 super::admin_recovery_handlers::do_reauth(&c, ident, req).await
804 }
805 }
806 }
807 });
808
809 let c = ctx.clone();
819 let router = router.get("/admin/must-change-password", move |req| {
820 let c = c.clone();
821 async move {
822 match role_guard(&c, &req, Role::User).await? {
823 Guard::Redirect(r) => Ok(r),
824 Guard::Allow(ident) => {
825 super::admin_recovery_handlers::show_must_change_password(&c, ident, &req).await
826 }
827 }
828 }
829 });
830
831 let c = ctx.clone();
832 let router = router.post("/admin/must-change-password", move |req| {
833 let c = c.clone();
834 async move {
835 match role_guard(&c, &req, Role::User).await? {
836 Guard::Redirect(r) => Ok(r),
837 Guard::Allow(ident) => {
838 super::admin_recovery_handlers::do_must_change_password(&c, ident, req).await
839 }
840 }
841 }
842 });
843
844 let c = ctx.clone();
862 let router = router.get("/admin/mfa/verify", move |req| {
863 let c = c.clone();
864 async move {
865 match role_guard(&c, &req, Role::User).await? {
866 Guard::Redirect(r) => Ok(r),
867 Guard::Allow(ident) => super::mfa_handlers::show_verify(&c, ident, &req).await,
868 }
869 }
870 });
871
872 let c = ctx.clone();
873 let router = router.post("/admin/mfa/verify", move |req| {
874 let c = c.clone();
875 async move {
876 match role_guard(&c, &req, Role::User).await? {
877 Guard::Redirect(r) => Ok(r),
878 Guard::Allow(ident) => super::mfa_handlers::do_verify(&c, ident, req).await,
879 }
880 }
881 });
882
883 let c = ctx.clone();
885 let router = router.get("/admin/account/mfa/enroll", move |req| {
886 let c = c.clone();
887 async move {
888 match role_guard(&c, &req, Role::User).await? {
889 Guard::Redirect(r) => Ok(r),
890 Guard::Allow(ident) => super::mfa_handlers::show_enroll(&c, ident, &req).await,
891 }
892 }
893 });
894
895 let c = ctx.clone();
896 let router = router.post("/admin/account/mfa/enroll", move |req| {
897 let c = c.clone();
898 async move {
899 match role_guard(&c, &req, Role::User).await? {
900 Guard::Redirect(r) => Ok(r),
901 Guard::Allow(ident) => super::mfa_handlers::do_enroll(&c, ident, req).await,
902 }
903 }
904 });
905
906 let c = ctx.clone();
908 let router = router.get("/admin/account/mfa/regenerate-codes", move |req| {
909 let c = c.clone();
910 async move {
911 match role_guard(&c, &req, Role::User).await? {
912 Guard::Redirect(r) => Ok(r),
913 Guard::Allow(ident) => super::mfa_handlers::show_regenerate(&c, ident, &req).await,
914 }
915 }
916 });
917
918 let c = ctx.clone();
919 let router = router.post("/admin/account/mfa/regenerate-codes", move |req| {
920 let c = c.clone();
921 async move {
922 match role_guard(&c, &req, Role::User).await? {
923 Guard::Redirect(r) => Ok(r),
924 Guard::Allow(ident) => super::mfa_handlers::do_regenerate(&c, ident, req).await,
925 }
926 }
927 });
928
929 let c = ctx.clone();
931 let router = router.get("/admin/account/mfa/disable", move |req| {
932 let c = c.clone();
933 async move {
934 match role_guard(&c, &req, Role::User).await? {
935 Guard::Redirect(r) => Ok(r),
936 Guard::Allow(ident) => super::mfa_handlers::show_disable(&c, ident, &req).await,
937 }
938 }
939 });
940
941 let c = ctx.clone();
942 let router = router.post("/admin/account/mfa/disable", move |req| {
943 let c = c.clone();
944 async move {
945 match role_guard(&c, &req, Role::User).await? {
946 Guard::Redirect(r) => Ok(r),
947 Guard::Allow(ident) => super::mfa_handlers::do_disable(&c, ident, req).await,
948 }
949 }
950 });
951
952 let c = ctx.clone();
954 let ac = auth_ctx.clone();
955 let router = router.get("/admin/users", move |req| {
956 let c = c.clone();
957 let ac = ac.clone();
958 async move {
959 match role_guard(&c, &req, Role::Administrator).await? {
960 Guard::Redirect(r) => Ok(r),
961 Guard::Allow(ident) => {
962 super::builtin::list_users(&ac, ident, handlers::csrf_token(&req)).await
963 }
964 }
965 }
966 });
967
968 let c = ctx.clone();
969 let ac = auth_ctx.clone();
970 let router = router.get("/admin/users/new", move |req| {
971 let c = c.clone();
972 let ac = ac.clone();
973 async move {
974 match role_guard(&c, &req, Role::Administrator).await? {
975 Guard::Redirect(r) => Ok(r),
976 Guard::Allow(ident) => {
977 super::builtin::show_new_user(&ac, ident, handlers::csrf_token(&req)).await
978 }
979 }
980 }
981 });
982
983 let c = ctx.clone();
984 let ac = auth_ctx.clone();
985 let router = router.post("/admin/users/new", move |req| {
986 let c = c.clone();
987 let ac = ac.clone();
988 async move {
989 match role_guard(&c, &req, Role::Administrator).await? {
990 Guard::Redirect(r) => Ok(r),
991 Guard::Allow(ident) => super::builtin::do_new_user(&ac, ident, req).await,
992 }
993 }
994 });
995
996 let c = ctx.clone();
997 let ac = auth_ctx.clone();
998 let router = router.get("/admin/users/:id/edit", move |req| {
999 let c = c.clone();
1000 let ac = ac.clone();
1001 async move {
1002 match role_guard(&c, &req, Role::Administrator).await? {
1003 Guard::Redirect(r) => Ok(r),
1004 Guard::Allow(ident) => {
1005 let id = parse_id(req.param("id"))?;
1006 super::builtin::show_user_edit(&ac, ident, id, handlers::csrf_token(&req)).await
1007 }
1008 }
1009 }
1010 });
1011
1012 let c = ctx.clone();
1013 let ac = auth_ctx.clone();
1014 let router = router.post("/admin/users/:id/edit", move |req| {
1015 let c = c.clone();
1016 let ac = ac.clone();
1017 async move {
1018 match role_guard(&c, &req, Role::Administrator).await? {
1019 Guard::Redirect(r) => Ok(r),
1020 Guard::Allow(ident) => {
1021 let id = parse_id(req.param("id"))?;
1022 super::builtin::do_user_edit(&ac, ident, id, req).await
1023 }
1024 }
1025 }
1026 });
1027
1028 let c = ctx.clone();
1029 let ac = auth_ctx.clone();
1030 let router = router.get("/admin/users/:id/delete", move |req| {
1031 let c = c.clone();
1032 let ac = ac.clone();
1033 async move {
1034 match role_guard(&c, &req, Role::Administrator).await? {
1035 Guard::Redirect(r) => Ok(r),
1036 Guard::Allow(ident) => {
1037 let id = parse_id(req.param("id"))?;
1038 super::builtin::show_user_delete(&ac, ident, id, handlers::csrf_token(&req))
1039 .await
1040 }
1041 }
1042 }
1043 });
1044
1045 let c = ctx.clone();
1046 let ac = auth_ctx.clone();
1047 let router = router.post("/admin/users/:id/delete", move |req| {
1048 let c = c.clone();
1049 let ac = ac.clone();
1050 async move {
1051 match role_guard(&c, &req, Role::Administrator).await? {
1052 Guard::Redirect(r) => Ok(r),
1053 Guard::Allow(ident) => {
1054 let id = parse_id(req.param("id"))?;
1055 super::builtin::do_user_delete(&ac, ident, id, req).await
1056 }
1057 }
1058 }
1059 });
1060
1061 let c = ctx.clone();
1077 let router = router.get("/admin/users/:id/reset-password", move |req| {
1078 let c = c.clone();
1079 async move {
1080 match role_guard(&c, &req, Role::Administrator).await? {
1081 Guard::Redirect(r) => Ok(r),
1082 Guard::Allow(ident) => {
1083 let id = parse_id(req.param("id"))?;
1084 super::admin_recovery_handlers::show_admin_reset_password(&c, ident, id, &req)
1085 .await
1086 }
1087 }
1088 }
1089 });
1090
1091 let c = ctx.clone();
1093 let router = router.post("/admin/users/:id/reset-password", move |req| {
1094 let c = c.clone();
1095 async move {
1096 match role_guard(&c, &req, Role::Administrator).await? {
1097 Guard::Redirect(r) => Ok(r),
1098 Guard::Allow(ident) => {
1099 let id = parse_id(req.param("id"))?;
1100 super::admin_recovery_handlers::do_admin_reset_password(&c, ident, id, req)
1101 .await
1102 }
1103 }
1104 }
1105 });
1106
1107 let c = ctx.clone();
1109 let router = router.get("/admin/users/:id/lock", move |req| {
1110 let c = c.clone();
1111 async move {
1112 match role_guard(&c, &req, Role::Administrator).await? {
1113 Guard::Redirect(r) => Ok(r),
1114 Guard::Allow(ident) => {
1115 let id = parse_id(req.param("id"))?;
1116 super::admin_recovery_handlers::show_lock_user(&c, ident, id, &req).await
1117 }
1118 }
1119 }
1120 });
1121
1122 let c = ctx.clone();
1124 let router = router.post("/admin/users/:id/lock", move |req| {
1125 let c = c.clone();
1126 async move {
1127 match role_guard(&c, &req, Role::Administrator).await? {
1128 Guard::Redirect(r) => Ok(r),
1129 Guard::Allow(ident) => {
1130 let id = parse_id(req.param("id"))?;
1131 super::admin_recovery_handlers::do_lock_user(&c, ident, id, req).await
1132 }
1133 }
1134 }
1135 });
1136
1137 let c = ctx.clone();
1139 let router = router.get("/admin/users/:id/unlock", move |req| {
1140 let c = c.clone();
1141 async move {
1142 match role_guard(&c, &req, Role::Administrator).await? {
1143 Guard::Redirect(r) => Ok(r),
1144 Guard::Allow(ident) => {
1145 let id = parse_id(req.param("id"))?;
1146 super::admin_recovery_handlers::show_unlock_user(&c, ident, id, &req).await
1147 }
1148 }
1149 }
1150 });
1151
1152 let c = ctx.clone();
1154 let router = router.post("/admin/users/:id/unlock", move |req| {
1155 let c = c.clone();
1156 async move {
1157 match role_guard(&c, &req, Role::Administrator).await? {
1158 Guard::Redirect(r) => Ok(r),
1159 Guard::Allow(ident) => {
1160 let id = parse_id(req.param("id"))?;
1161 super::admin_recovery_handlers::do_unlock_user(&c, ident, id, req).await
1162 }
1163 }
1164 }
1165 });
1166
1167 let c = ctx.clone();
1170 let router = router.get("/admin/users/:id/revoke-sessions", move |req| {
1171 let c = c.clone();
1172 async move {
1173 match role_guard(&c, &req, Role::Administrator).await? {
1174 Guard::Redirect(r) => Ok(r),
1175 Guard::Allow(ident) => {
1176 let id = parse_id(req.param("id"))?;
1177 super::admin_recovery_handlers::show_admin_revoke_sessions(&c, ident, id, &req)
1178 .await
1179 }
1180 }
1181 }
1182 });
1183
1184 let c = ctx.clone();
1186 let router = router.post("/admin/users/:id/revoke-sessions", move |req| {
1187 let c = c.clone();
1188 async move {
1189 match role_guard(&c, &req, Role::Administrator).await? {
1190 Guard::Redirect(r) => Ok(r),
1191 Guard::Allow(ident) => {
1192 let id = parse_id(req.param("id"))?;
1193 super::admin_recovery_handlers::do_admin_revoke_sessions(&c, ident, id, req)
1194 .await
1195 }
1196 }
1197 }
1198 });
1199
1200 let c = ctx.clone();
1207 let ac = auth_ctx.clone();
1208 let router = router.get("/admin/users/:id", 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 let q = req.query();
1217 let tab = q.get("tab").map(|s| s.to_string());
1218 let page: i64 = q.get("page").and_then(|s| s.parse().ok()).unwrap_or(1);
1219 super::builtin::show_user_view(
1220 &ac,
1221 ident,
1222 id,
1223 handlers::csrf_token(&req),
1224 tab,
1225 page,
1226 )
1227 .await
1228 }
1229 }
1230 }
1231 });
1232
1233 let c = ctx.clone();
1235 let ac = auth_ctx.clone();
1236 let router = router.get("/admin/groups", move |req| {
1237 let c = c.clone();
1238 let ac = ac.clone();
1239 async move {
1240 match role_guard(&c, &req, Role::Administrator).await? {
1241 Guard::Redirect(r) => Ok(r),
1242 Guard::Allow(ident) => {
1243 super::builtin::list_groups(&ac, ident, handlers::csrf_token(&req)).await
1244 }
1245 }
1246 }
1247 });
1248
1249 let c = ctx.clone();
1250 let ac = auth_ctx.clone();
1251 let router = router.get("/admin/groups/new", move |req| {
1252 let c = c.clone();
1253 let ac = ac.clone();
1254 async move {
1255 match role_guard(&c, &req, Role::Administrator).await? {
1256 Guard::Redirect(r) => Ok(r),
1257 Guard::Allow(ident) => {
1258 super::builtin::show_new_group(&ac, ident, handlers::csrf_token(&req)).await
1259 }
1260 }
1261 }
1262 });
1263
1264 let c = ctx.clone();
1265 let ac = auth_ctx.clone();
1266 let router = router.post("/admin/groups/new", move |req| {
1267 let c = c.clone();
1268 let ac = ac.clone();
1269 async move {
1270 match role_guard(&c, &req, Role::Administrator).await? {
1271 Guard::Redirect(r) => Ok(r),
1272 Guard::Allow(ident) => super::builtin::do_new_group(&ac, ident, req).await,
1273 }
1274 }
1275 });
1276
1277 let c = ctx.clone();
1278 let ac = auth_ctx.clone();
1279 let router = router.get("/admin/groups/:id/edit", move |req| {
1280 let c = c.clone();
1281 let ac = ac.clone();
1282 async move {
1283 match role_guard(&c, &req, Role::Administrator).await? {
1284 Guard::Redirect(r) => Ok(r),
1285 Guard::Allow(ident) => {
1286 let id = parse_id(req.param("id"))?;
1287 super::builtin::show_group_edit(&ac, ident, id, handlers::csrf_token(&req))
1288 .await
1289 }
1290 }
1291 }
1292 });
1293
1294 let c = ctx.clone();
1295 let ac = auth_ctx.clone();
1296 let router = router.post("/admin/groups/:id/edit", move |req| {
1297 let c = c.clone();
1298 let ac = ac.clone();
1299 async move {
1300 match role_guard(&c, &req, Role::Administrator).await? {
1301 Guard::Redirect(r) => Ok(r),
1302 Guard::Allow(ident) => {
1303 let id = parse_id(req.param("id"))?;
1304 super::builtin::do_group_edit(&ac, ident, id, req).await
1305 }
1306 }
1307 }
1308 });
1309
1310 let c = ctx.clone();
1311 let ac = auth_ctx.clone();
1312 let router = router.get("/admin/groups/:id/delete", move |req| {
1313 let c = c.clone();
1314 let ac = ac.clone();
1315 async move {
1316 match role_guard(&c, &req, Role::Administrator).await? {
1317 Guard::Redirect(r) => Ok(r),
1318 Guard::Allow(ident) => {
1319 let id = parse_id(req.param("id"))?;
1320 super::builtin::show_group_delete(&ac, ident, id, handlers::csrf_token(&req))
1321 .await
1322 }
1323 }
1324 }
1325 });
1326
1327 let c = ctx.clone();
1328 let ac = auth_ctx.clone();
1329 let router = router.post("/admin/groups/:id/delete", move |req| {
1330 let c = c.clone();
1331 let ac = ac.clone();
1332 async move {
1333 match role_guard(&c, &req, Role::Administrator).await? {
1334 Guard::Redirect(r) => Ok(r),
1335 Guard::Allow(ident) => {
1336 let id = parse_id(req.param("id"))?;
1337 super::builtin::do_group_delete(&ac, ident, id, req).await
1338 }
1339 }
1340 }
1341 });
1342
1343 let c = ctx.clone();
1345 let router = router.get("/admin/:admin_name", move |req| {
1346 let c = c.clone();
1347 async move {
1348 let name = model_name_from_req(&req)?;
1349 let perm = perm_for(&c, &name, "view")?;
1350 match perm_guard(&c, &req, &perm).await? {
1351 Guard::Redirect(r) => Ok(r),
1352 Guard::Allow(ident) => handlers::list_model(&c, ident, &name, &req).await,
1353 }
1354 }
1355 });
1356
1357 let c = ctx.clone();
1359 let router = router.get("/admin/:admin_name/new", move |req| {
1360 let c = c.clone();
1361 async move {
1362 let name = model_name_from_req(&req)?;
1363 let perm = perm_for(&c, &name, "add")?;
1364 match perm_guard(&c, &req, &perm).await? {
1365 Guard::Redirect(r) => Ok(r),
1366 Guard::Allow(ident) => handlers::show_new_form(&c, ident, &name, &req).await,
1367 }
1368 }
1369 });
1370 let c = ctx.clone();
1371 let router = router.post("/admin/:admin_name/new", move |req| {
1372 let c = c.clone();
1373 async move {
1374 let name = model_name_from_req(&req)?;
1375 let perm = perm_for(&c, &name, "add")?;
1376 match perm_guard(&c, &req, &perm).await? {
1377 Guard::Redirect(r) => Ok(r),
1378 Guard::Allow(ident) => handlers::do_create(&c, ident, &name, req).await,
1379 }
1380 }
1381 });
1382
1383 let c = ctx.clone();
1385 let router = router.get("/admin/:admin_name/:id/edit", move |req| {
1386 let c = c.clone();
1387 async move {
1388 let name = model_name_from_req(&req)?;
1389 let perm = perm_for(&c, &name, "change")?;
1390 match perm_guard(&c, &req, &perm).await? {
1391 Guard::Redirect(r) => Ok(r),
1392 Guard::Allow(ident) => {
1393 let id = parse_id(req.param("id"))?;
1394 handlers::show_edit_form(&c, ident, &name, id, &req).await
1395 }
1396 }
1397 }
1398 });
1399 let c = ctx.clone();
1400 let router = router.post("/admin/:admin_name/:id/edit", move |req| {
1401 let c = c.clone();
1402 async move {
1403 let name = model_name_from_req(&req)?;
1404 let perm = perm_for(&c, &name, "change")?;
1405 match perm_guard(&c, &req, &perm).await? {
1406 Guard::Redirect(r) => Ok(r),
1407 Guard::Allow(ident) => {
1408 let id = parse_id(req.param("id"))?;
1409 handlers::do_update(&c, ident, &name, id, req).await
1410 }
1411 }
1412 }
1413 });
1414
1415 let c = ctx.clone();
1418 let router = router.get("/admin/:admin_name/:id/history", move |req| {
1419 let c = c.clone();
1420 async move {
1421 let name = model_name_from_req(&req)?;
1422 let perm = perm_for(&c, &name, "view")?;
1423 match perm_guard(&c, &req, &perm).await? {
1424 Guard::Redirect(r) => Ok(r),
1425 Guard::Allow(ident) => {
1426 let id = parse_id(req.param("id"))?;
1427 handlers::show_object_history(&c, ident, &name, id, &req).await
1428 }
1429 }
1430 }
1431 });
1432
1433 let c = ctx.clone();
1435 let router = router.get("/admin/:admin_name/:id/delete", move |req| {
1436 let c = c.clone();
1437 async move {
1438 let name = model_name_from_req(&req)?;
1439 let perm = perm_for(&c, &name, "delete")?;
1440 match perm_guard(&c, &req, &perm).await? {
1441 Guard::Redirect(r) => Ok(r),
1442 Guard::Allow(ident) => {
1443 let id = parse_id(req.param("id"))?;
1444 handlers::show_delete_confirm(&c, ident, &name, id, &req).await
1445 }
1446 }
1447 }
1448 });
1449 let c = ctx.clone();
1450 let router = router.post("/admin/:admin_name/:id/delete", move |req| {
1451 let c = c.clone();
1452 async move {
1453 let name = model_name_from_req(&req)?;
1454 let perm = perm_for(&c, &name, "delete")?;
1455 match perm_guard(&c, &req, &perm).await? {
1456 Guard::Redirect(r) => Ok(r),
1457 Guard::Allow(ident) => {
1458 let id = parse_id(req.param("id"))?;
1459 handlers::do_delete(&c, ident, &name, id).await
1460 }
1461 }
1462 }
1463 });
1464
1465 let c = ctx.clone();
1470 let router = router.post("/admin/:admin_name/bulk_delete", move |req| {
1471 let c = c.clone();
1472 async move {
1473 let name = model_name_from_req(&req)?;
1474 let perm = perm_for(&c, &name, "delete")?;
1475 match perm_guard(&c, &req, &perm).await? {
1476 Guard::Redirect(r) => Ok(r),
1477 Guard::Allow(ident) => handlers::handle_bulk_delete(&c, ident, &name, &req).await,
1478 }
1479 }
1480 });
1481
1482 let c = ctx.clone();
1487 router.post("/admin/:admin_name/bulk/:action", move |req| {
1488 let c = c.clone();
1489 async move {
1490 let name = model_name_from_req(&req)?;
1491 let action = req
1492 .param("action")
1493 .ok_or_else(|| Error::BadRequest("missing bulk action name".into()))?
1494 .to_string();
1495 let perm = perm_for(&c, &name, "change")?;
1496 match perm_guard(&c, &req, &perm).await? {
1497 Guard::Redirect(r) => Ok(r),
1498 Guard::Allow(ident) => {
1499 handlers::handle_bulk_action(&c, ident, &name, &action, &req).await
1500 }
1501 }
1502 }
1503 })
1504}
1505
1506#[cfg(test)]
1507mod tests {
1508 use super::*;
1509
1510 fn make_identity(role: Role, is_active: bool) -> Identity {
1511 Identity {
1512 user_id: 42,
1513 email: "test@example.com".into(),
1514 role,
1515 is_active,
1516 is_demo: false,
1517 demo_label: None,
1518 must_change_password: false,
1519 mfa_enabled: false,
1520 trust_level: crate::auth::SessionTrust::Authenticated,
1521 }
1522 }
1523
1524 #[test]
1529 fn role_guard_decision_admin_meets_staff_floor() {
1530 let id = make_identity(Role::Administrator, true);
1531 assert!(id.role.includes(Role::Staff));
1532 }
1533
1534 #[test]
1535 fn role_guard_decision_user_does_not_meet_staff() {
1536 let id = make_identity(Role::User, true);
1537 assert!(!id.role.includes(Role::Staff));
1538 }
1539
1540 #[test]
1541 fn role_guard_decision_administrator_does_not_meet_developer() {
1542 let id = make_identity(Role::Administrator, true);
1543 assert!(!id.role.includes(Role::Developer));
1544 }
1545
1546 #[test]
1547 fn role_guard_decision_developer_meets_everything() {
1548 let id = make_identity(Role::Developer, true);
1549 for &min in &[
1550 Role::User,
1551 Role::Staff,
1552 Role::Supervisor,
1553 Role::Administrator,
1554 Role::Developer,
1555 ] {
1556 assert!(id.role.includes(min), "Developer should meet {min:?}");
1557 }
1558 }
1559
1560 #[test]
1563 fn perm_guard_admin_short_circuits_without_perm() {
1564 let id = make_identity(Role::Administrator, true);
1565 assert!(perm_guard_verdict(&id, false));
1566 }
1567
1568 #[test]
1569 fn perm_guard_developer_short_circuits_without_perm() {
1570 let id = make_identity(Role::Developer, true);
1571 assert!(perm_guard_verdict(&id, false));
1572 }
1573
1574 #[test]
1575 fn perm_guard_staff_with_perm_passes() {
1576 let id = make_identity(Role::Staff, true);
1577 assert!(perm_guard_verdict(&id, true));
1578 }
1579
1580 #[test]
1581 fn perm_guard_staff_without_perm_denies() {
1582 let id = make_identity(Role::Staff, true);
1583 assert!(!perm_guard_verdict(&id, false));
1584 }
1585
1586 #[test]
1587 fn perm_guard_inactive_admin_denies_even_with_bypass() {
1588 let id = make_identity(Role::Administrator, false);
1590 assert!(!perm_guard_verdict(&id, true));
1591 }
1592
1593 #[test]
1594 fn perm_guard_supervisor_without_perm_denies() {
1595 let id = make_identity(Role::Supervisor, true);
1597 assert!(!perm_guard_verdict(&id, false));
1598 }
1599
1600 #[test]
1605 fn strict_mailer_guard_passes_for_default_admin() {
1606 let admin = super::super::types::Admin::new();
1607 assert!(strict_mailer_guard_check(&admin).is_ok());
1608 }
1609
1610 #[test]
1613 fn strict_mailer_guard_fails_when_required_but_default_mailer() {
1614 use crate::auth::DefaultRecoveryPolicy;
1615 let admin = super::super::types::Admin::new().recovery_policy(std::sync::Arc::new(
1616 DefaultRecoveryPolicy::new().with_strict_mailer_required(true),
1617 ));
1618 let err = strict_mailer_guard_check(&admin).expect_err("guard should fail");
1619 assert!(
1620 err.contains("strict_mailer_required"),
1621 "error message must name the policy method: {err}"
1622 );
1623 assert!(
1624 err.contains("Admin::mailer"),
1625 "error message must direct the operator to the fix: {err}"
1626 );
1627 }
1628
1629 #[test]
1634 fn strict_mailer_guard_passes_when_mailer_was_explicitly_overridden() {
1635 use crate::auth::DefaultRecoveryPolicy;
1636 use crate::email::LogMailer;
1637 let admin = super::super::types::Admin::new()
1638 .recovery_policy(std::sync::Arc::new(
1639 DefaultRecoveryPolicy::new().with_strict_mailer_required(true),
1640 ))
1641 .mailer(std::sync::Arc::new(LogMailer));
1642 assert!(strict_mailer_guard_check(&admin).is_ok());
1643 }
1644
1645 #[test]
1648 fn strict_mailer_guard_passes_when_strict_mode_disabled() {
1649 let admin = super::super::types::Admin::new();
1650 assert!(strict_mailer_guard_check(&admin).is_ok());
1651 }
1652
1653 #[test]
1656 fn whitelist_accepts_the_three_locked_paths() {
1657 assert!(super::is_must_change_whitelisted_path(
1659 "/admin/must-change-password"
1660 ));
1661 assert!(super::is_must_change_whitelisted_path("/admin/logout"));
1662 assert!(super::is_must_change_whitelisted_path(
1663 "/admin/account/sessions"
1664 ));
1665 }
1666
1667 #[test]
1668 fn whitelist_rejects_subpaths_of_account_sessions() {
1669 assert!(!super::is_must_change_whitelisted_path(
1674 "/admin/account/sessions/revoke"
1675 ));
1676 assert!(!super::is_must_change_whitelisted_path(
1677 "/admin/account/sessions/revoke-others"
1678 ));
1679 assert!(!super::is_must_change_whitelisted_path(
1680 "/admin/account/sessions/"
1681 ));
1682 }
1683
1684 #[test]
1685 fn whitelist_rejects_other_admin_paths() {
1686 for path in [
1687 "/admin",
1688 "/admin/",
1689 "/admin/users",
1690 "/admin/users/42",
1691 "/admin/login",
1692 "/admin/password_change",
1693 "/admin/forgot-password",
1694 "/admin/reauth",
1695 "/admin/must-change-password/", ] {
1697 assert!(
1698 !super::is_must_change_whitelisted_path(path),
1699 "expected reject for {path:?}"
1700 );
1701 }
1702 }
1703
1704 #[test]
1705 fn whitelist_rejects_paths_outside_admin_surface() {
1706 for path in ["/", "/login", "/static/admin.css", "/api"] {
1707 assert!(
1708 !super::is_must_change_whitelisted_path(path),
1709 "expected reject for {path:?}"
1710 );
1711 }
1712 }
1713}