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
299fn strict_mailer_guard_check(admin: &Admin) -> std::result::Result<(), String> {
319 if admin.active_recovery_policy().strict_mailer_required() && !admin.has_custom_mailer() {
320 Err(
321 "rustio-admin: RecoveryPolicy::strict_mailer_required() = true but no mailer \
322 was registered via Admin::mailer(...).\n\n\
323 The framework's default LogMailer writes recovery emails to log::info! instead \
324 of sending them, which is unsuitable for production. Recovery routes are NOT \
325 registered with this configuration.\n\n\
326 To resolve, choose one:\n\
327 (a) register a real mailer before calling register_admin_routes:\n\
328 Admin::mailer(Arc::new(MyProjectMailer::new(...)))\n\
329 (b) opt the policy out of strict mode (the framework default — dev / CI / \
330 testing baseline):\n\
331 RecoveryPolicy::strict_mailer_required(false)\n\n\
332 See DESIGN_RECOVERY.md §12.1 for the contract."
333 .to_string(),
334 )
335 } else {
336 Ok(())
337 }
338}
339
340pub fn register_admin_routes(
341 router: Router,
342 admin: Admin,
343 db: Db,
344 templates: Arc<Templates>,
345) -> Router {
346 if let Err(msg) = strict_mailer_guard_check(&admin) {
354 panic!("{msg}");
355 }
356
357 let ctx = Arc::new(AdminCtx::new(
358 Arc::new(admin),
359 db.clone(),
360 templates.clone(),
361 ));
362
363 let auth_ctx = Arc::new(super::builtin::AuthAdminCtx {
366 admin: ctx.admin.clone(),
367 db,
368 templates,
369 });
370
371 let err_admin = ctx.admin.clone();
378 let err_templates = ctx.templates.clone();
379 let router = router.middleware(move |req, next| {
380 let admin = err_admin.clone();
381 let templates = err_templates.clone();
382 Box::pin(async move {
383 let is_admin_path = req.path().starts_with("/admin");
384 let result = next.run(req).await;
385 match result {
386 Ok(resp) => Ok(resp),
387 Err(err) if is_admin_path => Ok(render::render_admin_error_response(
388 &admin,
389 &templates,
390 None,
391 err.status(),
392 err.client_message().to_string(),
393 )),
394 Err(err) => Err(err),
395 }
396 })
397 });
398
399 let router = router.get("/static/admin.css", |_req| async move {
405 Ok(Response::new(
406 hyper::StatusCode::OK,
407 bytes::Bytes::from_static(ADMIN_CSS.as_bytes()),
408 )
409 .with_header("content-type", "text/css; charset=utf-8")
410 .with_header("cache-control", "no-cache, must-revalidate"))
411 });
412 let router = router.get("/static/admin.js", |_req| async move {
413 Ok(Response::new(
414 hyper::StatusCode::OK,
415 bytes::Bytes::from_static(ADMIN_JS.as_bytes()),
416 )
417 .with_header("content-type", "application/javascript; charset=utf-8")
418 .with_header("cache-control", "no-cache, must-revalidate"))
419 });
420
421 fn font_response(bytes: &'static [u8]) -> Response {
425 Response::new(hyper::StatusCode::OK, bytes::Bytes::from_static(bytes))
426 .with_header("content-type", "font/woff2")
427 .with_header("cache-control", "public, max-age=31536000, immutable")
428 }
429 let router = router.get("/static/fonts/Geist-Variable.woff2", |_req| async move {
430 Ok(font_response(FONT_GEIST))
431 });
432 let router = router.get(
433 "/static/fonts/GeistMono-Variable.woff2",
434 |_req| async move { Ok(font_response(FONT_GEIST_MONO)) },
435 );
436 let router = router.get("/static/fonts/Tajawal-Regular.woff2", |_req| async move {
437 Ok(font_response(FONT_TAJAWAL_REG))
438 });
439 let router = router.get("/static/fonts/Tajawal-Medium.woff2", |_req| async move {
440 Ok(font_response(FONT_TAJAWAL_MED))
441 });
442 let router = router.get("/static/fonts/Tajawal-Bold.woff2", |_req| async move {
443 Ok(font_response(FONT_TAJAWAL_BOLD))
444 });
445 let router = router.get(
446 "/static/fonts/NotoNaskhArabic-Variable.woff2",
447 |_req| async move { Ok(font_response(FONT_NOTO_NASKH_AR)) },
448 );
449
450 let c = ctx.clone();
452 let router = router.get("/admin/login", move |req| {
453 let c = c.clone();
454 async move { handlers::show_login(&c, req).await }
455 });
456
457 let c = ctx.clone();
458 let router = router.post("/admin/login", move |req| {
459 let c = c.clone();
460 async move { handlers::do_login(&c, req).await }
461 });
462
463 let c = ctx.clone();
464 let router = router.post("/admin/logout", move |req| {
465 let c = c.clone();
466 async move { handlers::do_logout(&c, req).await }
467 });
468
469 let recovery_state = Arc::new(super::recovery_handlers::RecoveryState::from_admin(
486 &ctx.admin,
487 ));
488
489 let c = ctx.clone();
490 let router = router.get("/admin/forgot-password", move |req| {
491 let c = c.clone();
492 async move { super::recovery_handlers::show_forgot_password(&c, &req).await }
493 });
494
495 let c = ctx.clone();
496 let rs = recovery_state.clone();
497 let router = router.post("/admin/forgot-password", move |req| {
498 let c = c.clone();
499 let rs = rs.clone();
500 async move { super::recovery_handlers::do_forgot_password(&c, &rs, req).await }
501 });
502
503 let c = ctx.clone();
504 let router = router.get("/admin/forgot-password/sent", move |req| {
505 let c = c.clone();
506 async move { super::recovery_handlers::show_forgot_password_sent(&c, &req).await }
507 });
508
509 let c = ctx.clone();
510 let router = router.get("/admin/reset-password/:token", move |req| {
511 let c = c.clone();
512 async move {
513 let token = req
514 .param("token")
515 .ok_or_else(|| Error::BadRequest("missing token".into()))?
516 .to_string();
517 super::recovery_handlers::show_reset_password(&c, &req, &token).await
518 }
519 });
520
521 let c = ctx.clone();
522 let rs = recovery_state.clone();
523 let router = router.post("/admin/reset-password/:token", move |req| {
524 let c = c.clone();
525 let rs = rs.clone();
526 async move {
527 let token = req
528 .param("token")
529 .ok_or_else(|| Error::BadRequest("missing token".into()))?
530 .to_string();
531 super::recovery_handlers::do_reset_password(&c, &rs, req, &token).await
532 }
533 });
534
535 let c = ctx.clone();
537 let router = router.get("/admin", move |req| {
538 let c = c.clone();
539 async move {
540 match role_guard(&c, &req, Role::Staff).await? {
541 Guard::Redirect(r) => Ok(r),
542 Guard::Allow(ident) => handlers::dashboard(&c, ident, &req).await,
543 }
544 }
545 });
546
547 let c = ctx.clone();
549 let router = router.get("/admin/history", move |req| {
550 let c = c.clone();
551 async move {
552 match role_guard(&c, &req, Role::Administrator).await? {
553 Guard::Redirect(r) => Ok(r),
554 Guard::Allow(ident) => handlers::show_log_entries(&c, ident, &req).await,
555 }
556 }
557 });
558
559 let c = ctx.clone();
562 let router = router.get("/admin/account/sessions", move |req| {
563 let c = c.clone();
564 async move {
565 match role_guard(&c, &req, Role::User).await? {
566 Guard::Redirect(r) => Ok(r),
567 Guard::Allow(ident) => handlers::show_account_sessions(&c, ident, &req).await,
568 }
569 }
570 });
571
572 let c = ctx.clone();
580 let router = router.post("/admin/account/sessions/revoke-others", move |req| {
581 let c = c.clone();
582 async move {
583 match role_guard(&c, &req, Role::User).await? {
584 Guard::Redirect(r) => Ok(r),
585 Guard::Allow(ident) => handlers::do_revoke_other_sessions(&c, ident, req).await,
586 }
587 }
588 });
589
590 let c = ctx.clone();
591 let router = router.post("/admin/account/sessions/revoke-all", move |req| {
592 let c = c.clone();
593 async move {
594 match role_guard(&c, &req, Role::User).await? {
595 Guard::Redirect(r) => Ok(r),
596 Guard::Allow(ident) => handlers::do_revoke_all_sessions(&c, ident, req).await,
597 }
598 }
599 });
600
601 let c = ctx.clone();
602 let router = router.post("/admin/account/sessions/:id/revoke", move |req| {
603 let c = c.clone();
604 async move {
605 match role_guard(&c, &req, Role::User).await? {
606 Guard::Redirect(r) => Ok(r),
607 Guard::Allow(ident) => {
608 let id = parse_id(req.param("id"))?;
609 handlers::do_revoke_session(&c, ident, req, id).await
610 }
611 }
612 }
613 });
614
615 let c = ctx.clone();
619 let router = router.get("/admin/password_change", move |req| {
620 let c = c.clone();
621 async move {
622 match role_guard(&c, &req, Role::User).await? {
623 Guard::Redirect(r) => Ok(r),
624 Guard::Allow(ident) => handlers::show_password_change(&c, ident, &req).await,
625 }
626 }
627 });
628 let c = ctx.clone();
629 let router = router.post("/admin/password_change", move |req| {
630 let c = c.clone();
631 async move {
632 match role_guard(&c, &req, Role::User).await? {
633 Guard::Redirect(r) => Ok(r),
634 Guard::Allow(ident) => handlers::do_password_change(&c, ident, req).await,
635 }
636 }
637 });
638
639 let c = ctx.clone();
648 let router = router.get("/admin/reauth", move |req| {
649 let c = c.clone();
650 async move {
651 match role_guard(&c, &req, Role::User).await? {
652 Guard::Redirect(r) => Ok(r),
653 Guard::Allow(ident) => {
654 super::admin_recovery_handlers::show_reauth(&c, ident, &req).await
655 }
656 }
657 }
658 });
659
660 let c = ctx.clone();
661 let router = router.post("/admin/reauth", move |req| {
662 let c = c.clone();
663 async move {
664 match role_guard(&c, &req, Role::User).await? {
665 Guard::Redirect(r) => Ok(r),
666 Guard::Allow(ident) => {
667 super::admin_recovery_handlers::do_reauth(&c, ident, req).await
668 }
669 }
670 }
671 });
672
673 let c = ctx.clone();
683 let router = router.get("/admin/must-change-password", move |req| {
684 let c = c.clone();
685 async move {
686 match role_guard(&c, &req, Role::User).await? {
687 Guard::Redirect(r) => Ok(r),
688 Guard::Allow(ident) => {
689 super::admin_recovery_handlers::show_must_change_password(&c, ident, &req).await
690 }
691 }
692 }
693 });
694
695 let c = ctx.clone();
696 let router = router.post("/admin/must-change-password", 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::do_must_change_password(&c, ident, req).await
703 }
704 }
705 }
706 });
707
708 let c = ctx.clone();
726 let router = router.get("/admin/mfa/verify", move |req| {
727 let c = c.clone();
728 async move {
729 match role_guard(&c, &req, Role::User).await? {
730 Guard::Redirect(r) => Ok(r),
731 Guard::Allow(ident) => super::mfa_handlers::show_verify(&c, ident, &req).await,
732 }
733 }
734 });
735
736 let c = ctx.clone();
737 let router = router.post("/admin/mfa/verify", move |req| {
738 let c = c.clone();
739 async move {
740 match role_guard(&c, &req, Role::User).await? {
741 Guard::Redirect(r) => Ok(r),
742 Guard::Allow(ident) => super::mfa_handlers::do_verify(&c, ident, req).await,
743 }
744 }
745 });
746
747 let c = ctx.clone();
749 let router = router.get("/admin/account/mfa/enroll", move |req| {
750 let c = c.clone();
751 async move {
752 match role_guard(&c, &req, Role::User).await? {
753 Guard::Redirect(r) => Ok(r),
754 Guard::Allow(ident) => super::mfa_handlers::show_enroll(&c, ident, &req).await,
755 }
756 }
757 });
758
759 let c = ctx.clone();
760 let router = router.post("/admin/account/mfa/enroll", move |req| {
761 let c = c.clone();
762 async move {
763 match role_guard(&c, &req, Role::User).await? {
764 Guard::Redirect(r) => Ok(r),
765 Guard::Allow(ident) => super::mfa_handlers::do_enroll(&c, ident, req).await,
766 }
767 }
768 });
769
770 let c = ctx.clone();
772 let router = router.get("/admin/account/mfa/regenerate-codes", move |req| {
773 let c = c.clone();
774 async move {
775 match role_guard(&c, &req, Role::User).await? {
776 Guard::Redirect(r) => Ok(r),
777 Guard::Allow(ident) => super::mfa_handlers::show_regenerate(&c, ident, &req).await,
778 }
779 }
780 });
781
782 let c = ctx.clone();
783 let router = router.post("/admin/account/mfa/regenerate-codes", move |req| {
784 let c = c.clone();
785 async move {
786 match role_guard(&c, &req, Role::User).await? {
787 Guard::Redirect(r) => Ok(r),
788 Guard::Allow(ident) => super::mfa_handlers::do_regenerate(&c, ident, req).await,
789 }
790 }
791 });
792
793 let c = ctx.clone();
795 let router = router.get("/admin/account/mfa/disable", move |req| {
796 let c = c.clone();
797 async move {
798 match role_guard(&c, &req, Role::User).await? {
799 Guard::Redirect(r) => Ok(r),
800 Guard::Allow(ident) => super::mfa_handlers::show_disable(&c, ident, &req).await,
801 }
802 }
803 });
804
805 let c = ctx.clone();
806 let router = router.post("/admin/account/mfa/disable", move |req| {
807 let c = c.clone();
808 async move {
809 match role_guard(&c, &req, Role::User).await? {
810 Guard::Redirect(r) => Ok(r),
811 Guard::Allow(ident) => super::mfa_handlers::do_disable(&c, ident, req).await,
812 }
813 }
814 });
815
816 let c = ctx.clone();
818 let ac = auth_ctx.clone();
819 let router = router.get("/admin/users", move |req| {
820 let c = c.clone();
821 let ac = ac.clone();
822 async move {
823 match role_guard(&c, &req, Role::Administrator).await? {
824 Guard::Redirect(r) => Ok(r),
825 Guard::Allow(ident) => {
826 super::builtin::list_users(&ac, ident, handlers::csrf_token(&req)).await
827 }
828 }
829 }
830 });
831
832 let c = ctx.clone();
833 let ac = auth_ctx.clone();
834 let router = router.get("/admin/users/new", move |req| {
835 let c = c.clone();
836 let ac = ac.clone();
837 async move {
838 match role_guard(&c, &req, Role::Administrator).await? {
839 Guard::Redirect(r) => Ok(r),
840 Guard::Allow(ident) => {
841 super::builtin::show_new_user(&ac, ident, handlers::csrf_token(&req)).await
842 }
843 }
844 }
845 });
846
847 let c = ctx.clone();
848 let ac = auth_ctx.clone();
849 let router = router.post("/admin/users/new", move |req| {
850 let c = c.clone();
851 let ac = ac.clone();
852 async move {
853 match role_guard(&c, &req, Role::Administrator).await? {
854 Guard::Redirect(r) => Ok(r),
855 Guard::Allow(ident) => super::builtin::do_new_user(&ac, ident, req).await,
856 }
857 }
858 });
859
860 let c = ctx.clone();
861 let ac = auth_ctx.clone();
862 let router = router.get("/admin/users/:id/edit", move |req| {
863 let c = c.clone();
864 let ac = ac.clone();
865 async move {
866 match role_guard(&c, &req, Role::Administrator).await? {
867 Guard::Redirect(r) => Ok(r),
868 Guard::Allow(ident) => {
869 let id = parse_id(req.param("id"))?;
870 super::builtin::show_user_edit(&ac, ident, id, handlers::csrf_token(&req)).await
871 }
872 }
873 }
874 });
875
876 let c = ctx.clone();
877 let ac = auth_ctx.clone();
878 let router = router.post("/admin/users/:id/edit", move |req| {
879 let c = c.clone();
880 let ac = ac.clone();
881 async move {
882 match role_guard(&c, &req, Role::Administrator).await? {
883 Guard::Redirect(r) => Ok(r),
884 Guard::Allow(ident) => {
885 let id = parse_id(req.param("id"))?;
886 super::builtin::do_user_edit(&ac, ident, id, req).await
887 }
888 }
889 }
890 });
891
892 let c = ctx.clone();
893 let ac = auth_ctx.clone();
894 let router = router.get("/admin/users/:id/delete", move |req| {
895 let c = c.clone();
896 let ac = ac.clone();
897 async move {
898 match role_guard(&c, &req, Role::Administrator).await? {
899 Guard::Redirect(r) => Ok(r),
900 Guard::Allow(ident) => {
901 let id = parse_id(req.param("id"))?;
902 super::builtin::show_user_delete(&ac, ident, id, handlers::csrf_token(&req))
903 .await
904 }
905 }
906 }
907 });
908
909 let c = ctx.clone();
910 let ac = auth_ctx.clone();
911 let router = router.post("/admin/users/:id/delete", move |req| {
912 let c = c.clone();
913 let ac = ac.clone();
914 async move {
915 match role_guard(&c, &req, Role::Administrator).await? {
916 Guard::Redirect(r) => Ok(r),
917 Guard::Allow(ident) => {
918 let id = parse_id(req.param("id"))?;
919 super::builtin::do_user_delete(&ac, ident, id, req).await
920 }
921 }
922 }
923 });
924
925 let c = ctx.clone();
941 let router = router.get("/admin/users/:id/reset-password", move |req| {
942 let c = c.clone();
943 async move {
944 match role_guard(&c, &req, Role::Administrator).await? {
945 Guard::Redirect(r) => Ok(r),
946 Guard::Allow(ident) => {
947 let id = parse_id(req.param("id"))?;
948 super::admin_recovery_handlers::show_admin_reset_password(&c, ident, id, &req)
949 .await
950 }
951 }
952 }
953 });
954
955 let c = ctx.clone();
957 let router = router.post("/admin/users/:id/reset-password", move |req| {
958 let c = c.clone();
959 async move {
960 match role_guard(&c, &req, Role::Administrator).await? {
961 Guard::Redirect(r) => Ok(r),
962 Guard::Allow(ident) => {
963 let id = parse_id(req.param("id"))?;
964 super::admin_recovery_handlers::do_admin_reset_password(&c, ident, id, req)
965 .await
966 }
967 }
968 }
969 });
970
971 let c = ctx.clone();
973 let router = router.get("/admin/users/:id/lock", move |req| {
974 let c = c.clone();
975 async move {
976 match role_guard(&c, &req, Role::Administrator).await? {
977 Guard::Redirect(r) => Ok(r),
978 Guard::Allow(ident) => {
979 let id = parse_id(req.param("id"))?;
980 super::admin_recovery_handlers::show_lock_user(&c, ident, id, &req).await
981 }
982 }
983 }
984 });
985
986 let c = ctx.clone();
988 let router = router.post("/admin/users/:id/lock", move |req| {
989 let c = c.clone();
990 async move {
991 match role_guard(&c, &req, Role::Administrator).await? {
992 Guard::Redirect(r) => Ok(r),
993 Guard::Allow(ident) => {
994 let id = parse_id(req.param("id"))?;
995 super::admin_recovery_handlers::do_lock_user(&c, ident, id, req).await
996 }
997 }
998 }
999 });
1000
1001 let c = ctx.clone();
1003 let router = router.get("/admin/users/:id/unlock", move |req| {
1004 let c = c.clone();
1005 async move {
1006 match role_guard(&c, &req, Role::Administrator).await? {
1007 Guard::Redirect(r) => Ok(r),
1008 Guard::Allow(ident) => {
1009 let id = parse_id(req.param("id"))?;
1010 super::admin_recovery_handlers::show_unlock_user(&c, ident, id, &req).await
1011 }
1012 }
1013 }
1014 });
1015
1016 let c = ctx.clone();
1018 let router = router.post("/admin/users/:id/unlock", move |req| {
1019 let c = c.clone();
1020 async move {
1021 match role_guard(&c, &req, Role::Administrator).await? {
1022 Guard::Redirect(r) => Ok(r),
1023 Guard::Allow(ident) => {
1024 let id = parse_id(req.param("id"))?;
1025 super::admin_recovery_handlers::do_unlock_user(&c, ident, id, req).await
1026 }
1027 }
1028 }
1029 });
1030
1031 let c = ctx.clone();
1034 let router = router.get("/admin/users/:id/revoke-sessions", move |req| {
1035 let c = c.clone();
1036 async move {
1037 match role_guard(&c, &req, Role::Administrator).await? {
1038 Guard::Redirect(r) => Ok(r),
1039 Guard::Allow(ident) => {
1040 let id = parse_id(req.param("id"))?;
1041 super::admin_recovery_handlers::show_admin_revoke_sessions(&c, ident, id, &req)
1042 .await
1043 }
1044 }
1045 }
1046 });
1047
1048 let c = ctx.clone();
1050 let router = router.post("/admin/users/:id/revoke-sessions", move |req| {
1051 let c = c.clone();
1052 async move {
1053 match role_guard(&c, &req, Role::Administrator).await? {
1054 Guard::Redirect(r) => Ok(r),
1055 Guard::Allow(ident) => {
1056 let id = parse_id(req.param("id"))?;
1057 super::admin_recovery_handlers::do_admin_revoke_sessions(&c, ident, id, req)
1058 .await
1059 }
1060 }
1061 }
1062 });
1063
1064 let c = ctx.clone();
1071 let ac = auth_ctx.clone();
1072 let router = router.get("/admin/users/:id", move |req| {
1073 let c = c.clone();
1074 let ac = ac.clone();
1075 async move {
1076 match role_guard(&c, &req, Role::Administrator).await? {
1077 Guard::Redirect(r) => Ok(r),
1078 Guard::Allow(ident) => {
1079 let id = parse_id(req.param("id"))?;
1080 let q = req.query();
1081 let tab = q.get("tab").map(|s| s.to_string());
1082 let page: i64 = q.get("page").and_then(|s| s.parse().ok()).unwrap_or(1);
1083 super::builtin::show_user_view(
1084 &ac,
1085 ident,
1086 id,
1087 handlers::csrf_token(&req),
1088 tab,
1089 page,
1090 )
1091 .await
1092 }
1093 }
1094 }
1095 });
1096
1097 let c = ctx.clone();
1099 let ac = auth_ctx.clone();
1100 let router = router.get("/admin/groups", move |req| {
1101 let c = c.clone();
1102 let ac = ac.clone();
1103 async move {
1104 match role_guard(&c, &req, Role::Administrator).await? {
1105 Guard::Redirect(r) => Ok(r),
1106 Guard::Allow(ident) => {
1107 super::builtin::list_groups(&ac, ident, handlers::csrf_token(&req)).await
1108 }
1109 }
1110 }
1111 });
1112
1113 let c = ctx.clone();
1114 let ac = auth_ctx.clone();
1115 let router = router.get("/admin/groups/new", move |req| {
1116 let c = c.clone();
1117 let ac = ac.clone();
1118 async move {
1119 match role_guard(&c, &req, Role::Administrator).await? {
1120 Guard::Redirect(r) => Ok(r),
1121 Guard::Allow(ident) => {
1122 super::builtin::show_new_group(&ac, ident, handlers::csrf_token(&req)).await
1123 }
1124 }
1125 }
1126 });
1127
1128 let c = ctx.clone();
1129 let ac = auth_ctx.clone();
1130 let router = router.post("/admin/groups/new", move |req| {
1131 let c = c.clone();
1132 let ac = ac.clone();
1133 async move {
1134 match role_guard(&c, &req, Role::Administrator).await? {
1135 Guard::Redirect(r) => Ok(r),
1136 Guard::Allow(ident) => super::builtin::do_new_group(&ac, ident, req).await,
1137 }
1138 }
1139 });
1140
1141 let c = ctx.clone();
1142 let ac = auth_ctx.clone();
1143 let router = router.get("/admin/groups/:id/edit", move |req| {
1144 let c = c.clone();
1145 let ac = ac.clone();
1146 async move {
1147 match role_guard(&c, &req, Role::Administrator).await? {
1148 Guard::Redirect(r) => Ok(r),
1149 Guard::Allow(ident) => {
1150 let id = parse_id(req.param("id"))?;
1151 super::builtin::show_group_edit(&ac, ident, id, handlers::csrf_token(&req))
1152 .await
1153 }
1154 }
1155 }
1156 });
1157
1158 let c = ctx.clone();
1159 let ac = auth_ctx.clone();
1160 let router = router.post("/admin/groups/:id/edit", move |req| {
1161 let c = c.clone();
1162 let ac = ac.clone();
1163 async move {
1164 match role_guard(&c, &req, Role::Administrator).await? {
1165 Guard::Redirect(r) => Ok(r),
1166 Guard::Allow(ident) => {
1167 let id = parse_id(req.param("id"))?;
1168 super::builtin::do_group_edit(&ac, ident, id, req).await
1169 }
1170 }
1171 }
1172 });
1173
1174 let c = ctx.clone();
1175 let ac = auth_ctx.clone();
1176 let router = router.get("/admin/groups/:id/delete", move |req| {
1177 let c = c.clone();
1178 let ac = ac.clone();
1179 async move {
1180 match role_guard(&c, &req, Role::Administrator).await? {
1181 Guard::Redirect(r) => Ok(r),
1182 Guard::Allow(ident) => {
1183 let id = parse_id(req.param("id"))?;
1184 super::builtin::show_group_delete(&ac, ident, id, handlers::csrf_token(&req))
1185 .await
1186 }
1187 }
1188 }
1189 });
1190
1191 let c = ctx.clone();
1192 let ac = auth_ctx.clone();
1193 let router = router.post("/admin/groups/:id/delete", move |req| {
1194 let c = c.clone();
1195 let ac = ac.clone();
1196 async move {
1197 match role_guard(&c, &req, Role::Administrator).await? {
1198 Guard::Redirect(r) => Ok(r),
1199 Guard::Allow(ident) => {
1200 let id = parse_id(req.param("id"))?;
1201 super::builtin::do_group_delete(&ac, ident, id, req).await
1202 }
1203 }
1204 }
1205 });
1206
1207 let c = ctx.clone();
1209 let router = router.get("/admin/:admin_name", move |req| {
1210 let c = c.clone();
1211 async move {
1212 let name = model_name_from_req(&req)?;
1213 let perm = perm_for(&c, &name, "view")?;
1214 match perm_guard(&c, &req, &perm).await? {
1215 Guard::Redirect(r) => Ok(r),
1216 Guard::Allow(ident) => handlers::list_model(&c, ident, &name, &req).await,
1217 }
1218 }
1219 });
1220
1221 let c = ctx.clone();
1223 let router = router.get("/admin/:admin_name/new", move |req| {
1224 let c = c.clone();
1225 async move {
1226 let name = model_name_from_req(&req)?;
1227 let perm = perm_for(&c, &name, "add")?;
1228 match perm_guard(&c, &req, &perm).await? {
1229 Guard::Redirect(r) => Ok(r),
1230 Guard::Allow(ident) => handlers::show_new_form(&c, ident, &name, &req).await,
1231 }
1232 }
1233 });
1234 let c = ctx.clone();
1235 let router = router.post("/admin/:admin_name/new", move |req| {
1236 let c = c.clone();
1237 async move {
1238 let name = model_name_from_req(&req)?;
1239 let perm = perm_for(&c, &name, "add")?;
1240 match perm_guard(&c, &req, &perm).await? {
1241 Guard::Redirect(r) => Ok(r),
1242 Guard::Allow(ident) => handlers::do_create(&c, ident, &name, req).await,
1243 }
1244 }
1245 });
1246
1247 let c = ctx.clone();
1249 let router = router.get("/admin/:admin_name/:id/edit", move |req| {
1250 let c = c.clone();
1251 async move {
1252 let name = model_name_from_req(&req)?;
1253 let perm = perm_for(&c, &name, "change")?;
1254 match perm_guard(&c, &req, &perm).await? {
1255 Guard::Redirect(r) => Ok(r),
1256 Guard::Allow(ident) => {
1257 let id = parse_id(req.param("id"))?;
1258 handlers::show_edit_form(&c, ident, &name, id, &req).await
1259 }
1260 }
1261 }
1262 });
1263 let c = ctx.clone();
1264 let router = router.post("/admin/:admin_name/:id/edit", move |req| {
1265 let c = c.clone();
1266 async move {
1267 let name = model_name_from_req(&req)?;
1268 let perm = perm_for(&c, &name, "change")?;
1269 match perm_guard(&c, &req, &perm).await? {
1270 Guard::Redirect(r) => Ok(r),
1271 Guard::Allow(ident) => {
1272 let id = parse_id(req.param("id"))?;
1273 handlers::do_update(&c, ident, &name, id, req).await
1274 }
1275 }
1276 }
1277 });
1278
1279 let c = ctx.clone();
1282 let router = router.get("/admin/:admin_name/:id/history", move |req| {
1283 let c = c.clone();
1284 async move {
1285 let name = model_name_from_req(&req)?;
1286 let perm = perm_for(&c, &name, "view")?;
1287 match perm_guard(&c, &req, &perm).await? {
1288 Guard::Redirect(r) => Ok(r),
1289 Guard::Allow(ident) => {
1290 let id = parse_id(req.param("id"))?;
1291 handlers::show_object_history(&c, ident, &name, id, &req).await
1292 }
1293 }
1294 }
1295 });
1296
1297 let c = ctx.clone();
1299 let router = router.get("/admin/:admin_name/:id/delete", move |req| {
1300 let c = c.clone();
1301 async move {
1302 let name = model_name_from_req(&req)?;
1303 let perm = perm_for(&c, &name, "delete")?;
1304 match perm_guard(&c, &req, &perm).await? {
1305 Guard::Redirect(r) => Ok(r),
1306 Guard::Allow(ident) => {
1307 let id = parse_id(req.param("id"))?;
1308 handlers::show_delete_confirm(&c, ident, &name, id, &req).await
1309 }
1310 }
1311 }
1312 });
1313 let c = ctx.clone();
1314 let router = router.post("/admin/:admin_name/:id/delete", move |req| {
1315 let c = c.clone();
1316 async move {
1317 let name = model_name_from_req(&req)?;
1318 let perm = perm_for(&c, &name, "delete")?;
1319 match perm_guard(&c, &req, &perm).await? {
1320 Guard::Redirect(r) => Ok(r),
1321 Guard::Allow(ident) => {
1322 let id = parse_id(req.param("id"))?;
1323 handlers::do_delete(&c, ident, &name, id).await
1324 }
1325 }
1326 }
1327 });
1328
1329 let c = ctx.clone();
1334 let router = router.post("/admin/:admin_name/bulk_delete", move |req| {
1335 let c = c.clone();
1336 async move {
1337 let name = model_name_from_req(&req)?;
1338 let perm = perm_for(&c, &name, "delete")?;
1339 match perm_guard(&c, &req, &perm).await? {
1340 Guard::Redirect(r) => Ok(r),
1341 Guard::Allow(ident) => handlers::handle_bulk_delete(&c, ident, &name, &req).await,
1342 }
1343 }
1344 });
1345
1346 let c = ctx.clone();
1351 router.post("/admin/:admin_name/bulk/:action", move |req| {
1352 let c = c.clone();
1353 async move {
1354 let name = model_name_from_req(&req)?;
1355 let action = req
1356 .param("action")
1357 .ok_or_else(|| Error::BadRequest("missing bulk action name".into()))?
1358 .to_string();
1359 let perm = perm_for(&c, &name, "change")?;
1360 match perm_guard(&c, &req, &perm).await? {
1361 Guard::Redirect(r) => Ok(r),
1362 Guard::Allow(ident) => {
1363 handlers::handle_bulk_action(&c, ident, &name, &action, &req).await
1364 }
1365 }
1366 }
1367 })
1368}
1369
1370#[cfg(test)]
1371mod tests {
1372 use super::*;
1373
1374 fn make_identity(role: Role, is_active: bool) -> Identity {
1375 Identity {
1376 user_id: 42,
1377 email: "test@example.com".into(),
1378 role,
1379 is_active,
1380 is_demo: false,
1381 demo_label: None,
1382 must_change_password: false,
1383 mfa_enabled: false,
1384 trust_level: crate::auth::SessionTrust::Authenticated,
1385 }
1386 }
1387
1388 #[test]
1393 fn role_guard_decision_admin_meets_staff_floor() {
1394 let id = make_identity(Role::Administrator, true);
1395 assert!(id.role.includes(Role::Staff));
1396 }
1397
1398 #[test]
1399 fn role_guard_decision_user_does_not_meet_staff() {
1400 let id = make_identity(Role::User, true);
1401 assert!(!id.role.includes(Role::Staff));
1402 }
1403
1404 #[test]
1405 fn role_guard_decision_administrator_does_not_meet_developer() {
1406 let id = make_identity(Role::Administrator, true);
1407 assert!(!id.role.includes(Role::Developer));
1408 }
1409
1410 #[test]
1411 fn role_guard_decision_developer_meets_everything() {
1412 let id = make_identity(Role::Developer, true);
1413 for &min in &[
1414 Role::User,
1415 Role::Staff,
1416 Role::Supervisor,
1417 Role::Administrator,
1418 Role::Developer,
1419 ] {
1420 assert!(id.role.includes(min), "Developer should meet {min:?}");
1421 }
1422 }
1423
1424 #[test]
1427 fn perm_guard_admin_short_circuits_without_perm() {
1428 let id = make_identity(Role::Administrator, true);
1429 assert!(perm_guard_verdict(&id, false));
1430 }
1431
1432 #[test]
1433 fn perm_guard_developer_short_circuits_without_perm() {
1434 let id = make_identity(Role::Developer, true);
1435 assert!(perm_guard_verdict(&id, false));
1436 }
1437
1438 #[test]
1439 fn perm_guard_staff_with_perm_passes() {
1440 let id = make_identity(Role::Staff, true);
1441 assert!(perm_guard_verdict(&id, true));
1442 }
1443
1444 #[test]
1445 fn perm_guard_staff_without_perm_denies() {
1446 let id = make_identity(Role::Staff, true);
1447 assert!(!perm_guard_verdict(&id, false));
1448 }
1449
1450 #[test]
1451 fn perm_guard_inactive_admin_denies_even_with_bypass() {
1452 let id = make_identity(Role::Administrator, false);
1454 assert!(!perm_guard_verdict(&id, true));
1455 }
1456
1457 #[test]
1458 fn perm_guard_supervisor_without_perm_denies() {
1459 let id = make_identity(Role::Supervisor, true);
1461 assert!(!perm_guard_verdict(&id, false));
1462 }
1463
1464 #[test]
1469 fn strict_mailer_guard_passes_for_default_admin() {
1470 let admin = super::super::types::Admin::new();
1471 assert!(strict_mailer_guard_check(&admin).is_ok());
1472 }
1473
1474 #[test]
1477 fn strict_mailer_guard_fails_when_required_but_default_mailer() {
1478 use crate::auth::DefaultRecoveryPolicy;
1479 let admin = super::super::types::Admin::new().recovery_policy(std::sync::Arc::new(
1480 DefaultRecoveryPolicy::new().with_strict_mailer_required(true),
1481 ));
1482 let err = strict_mailer_guard_check(&admin).expect_err("guard should fail");
1483 assert!(
1484 err.contains("strict_mailer_required"),
1485 "error message must name the policy method: {err}"
1486 );
1487 assert!(
1488 err.contains("Admin::mailer"),
1489 "error message must direct the operator to the fix: {err}"
1490 );
1491 }
1492
1493 #[test]
1498 fn strict_mailer_guard_passes_when_mailer_was_explicitly_overridden() {
1499 use crate::auth::DefaultRecoveryPolicy;
1500 use crate::email::LogMailer;
1501 let admin = super::super::types::Admin::new()
1502 .recovery_policy(std::sync::Arc::new(
1503 DefaultRecoveryPolicy::new().with_strict_mailer_required(true),
1504 ))
1505 .mailer(std::sync::Arc::new(LogMailer));
1506 assert!(strict_mailer_guard_check(&admin).is_ok());
1507 }
1508
1509 #[test]
1512 fn strict_mailer_guard_passes_when_strict_mode_disabled() {
1513 let admin = super::super::types::Admin::new();
1514 assert!(strict_mailer_guard_check(&admin).is_ok());
1515 }
1516
1517 #[test]
1520 fn whitelist_accepts_the_three_locked_paths() {
1521 assert!(super::is_must_change_whitelisted_path(
1523 "/admin/must-change-password"
1524 ));
1525 assert!(super::is_must_change_whitelisted_path("/admin/logout"));
1526 assert!(super::is_must_change_whitelisted_path(
1527 "/admin/account/sessions"
1528 ));
1529 }
1530
1531 #[test]
1532 fn whitelist_rejects_subpaths_of_account_sessions() {
1533 assert!(!super::is_must_change_whitelisted_path(
1538 "/admin/account/sessions/revoke"
1539 ));
1540 assert!(!super::is_must_change_whitelisted_path(
1541 "/admin/account/sessions/revoke-others"
1542 ));
1543 assert!(!super::is_must_change_whitelisted_path(
1544 "/admin/account/sessions/"
1545 ));
1546 }
1547
1548 #[test]
1549 fn whitelist_rejects_other_admin_paths() {
1550 for path in [
1551 "/admin",
1552 "/admin/",
1553 "/admin/users",
1554 "/admin/users/42",
1555 "/admin/login",
1556 "/admin/password_change",
1557 "/admin/forgot-password",
1558 "/admin/reauth",
1559 "/admin/must-change-password/", ] {
1561 assert!(
1562 !super::is_must_change_whitelisted_path(path),
1563 "expected reject for {path:?}"
1564 );
1565 }
1566 }
1567
1568 #[test]
1569 fn whitelist_rejects_paths_outside_admin_surface() {
1570 for path in ["/", "/login", "/static/admin.css", "/api"] {
1571 assert!(
1572 !super::is_must_change_whitelisted_path(path),
1573 "expected reject for {path:?}"
1574 );
1575 }
1576 }
1577}