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
94async fn login_guard(ctx: &AdminCtx, req: &Request) -> Result<Guard> {
95 let cookie = match req.header("cookie") {
96 Some(c) => c,
97 None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
98 };
99 let token = match auth::session_token_from_cookie(cookie) {
100 Some(t) => t,
101 None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
102 };
103 let ident = match auth::identity_from_session(&ctx.db, &token).await? {
104 Some(i) => i,
105 None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
106 };
107 if !ident.is_active {
108 return Ok(Guard::Redirect(Response::redirect("/admin/login")));
109 }
110
111 if ident.must_change_password && !is_must_change_whitelisted_path(req.path()) {
117 return Ok(Guard::Redirect(Response::redirect(
118 "/admin/must-change-password",
119 )));
120 }
121
122 Ok(Guard::Allow(ident))
123}
124
125async fn role_guard(ctx: &AdminCtx, req: &Request, min: Role) -> Result<Guard> {
126 match login_guard(ctx, req).await? {
127 Guard::Redirect(r) => Ok(Guard::Redirect(r)),
128 Guard::Allow(ident) => {
129 if ident.role.includes(min) {
130 Ok(Guard::Allow(ident))
131 } else {
132 let body = render::render_forbidden_body(
133 &ctx.admin,
134 &ctx.templates,
135 &ident,
136 handlers::csrf_token(req),
137 None,
138 Some(min.label()),
139 )?;
140 Ok(Guard::Redirect(
141 Response::html(body).with_status(hyper::StatusCode::FORBIDDEN),
142 ))
143 }
144 }
145 }
146}
147
148async fn perm_guard(ctx: &AdminCtx, req: &Request, perm: &str) -> Result<Guard> {
149 match role_guard(ctx, req, Role::Staff).await? {
150 Guard::Redirect(r) => Ok(Guard::Redirect(r)),
151 Guard::Allow(ident) => {
152 if ident.role.bypasses_group_checks() {
153 return Ok(Guard::Allow(ident));
154 }
155 if auth::check_permission(&ctx.db, &ident, perm).await? {
156 Ok(Guard::Allow(ident))
157 } else {
158 let body = render::render_forbidden_body(
159 &ctx.admin,
160 &ctx.templates,
161 &ident,
162 handlers::csrf_token(req),
163 Some(perm.to_string()),
164 None,
165 )?;
166 Ok(Guard::Redirect(
167 Response::html(body).with_status(hyper::StatusCode::FORBIDDEN),
168 ))
169 }
170 }
171 }
172}
173
174#[cfg(test)]
177fn perm_guard_verdict(ident: &Identity, perm_held: bool) -> bool {
178 if !ident.is_active {
179 return false;
180 }
181 if ident.role.bypasses_group_checks() {
182 return true;
183 }
184 perm_held
185}
186
187fn parse_id(raw: Option<&str>) -> Result<i64> {
188 raw.and_then(|s| s.parse().ok())
189 .ok_or_else(|| Error::BadRequest("invalid id".into()))
190}
191
192fn model_name_from_req(req: &Request) -> Result<String> {
193 req.param("admin_name")
194 .map(|s| s.to_string())
195 .ok_or_else(|| Error::BadRequest("missing model".into()))
196}
197
198fn perm_for(ctx: &AdminCtx, admin_name: &str, action: &str) -> Result<String> {
199 let entry = ctx
200 .admin
201 .find(admin_name)
202 .ok_or_else(|| Error::NotFound(format!("no admin model: {admin_name}")))?;
203 let singular = entry.singular_name.to_ascii_lowercase();
204 Ok(format!("{admin_name}.{action}_{singular}"))
205}
206
207fn strict_mailer_guard_check(admin: &Admin) -> std::result::Result<(), String> {
227 if admin.active_recovery_policy().strict_mailer_required() && !admin.has_custom_mailer() {
228 Err(
229 "rustio-admin: RecoveryPolicy::strict_mailer_required() = true but no mailer \
230 was registered via Admin::mailer(...).\n\n\
231 The framework's default LogMailer writes recovery emails to log::info! instead \
232 of sending them, which is unsuitable for production. Recovery routes are NOT \
233 registered with this configuration.\n\n\
234 To resolve, choose one:\n\
235 (a) register a real mailer before calling register_admin_routes:\n\
236 Admin::mailer(Arc::new(MyProjectMailer::new(...)))\n\
237 (b) opt the policy out of strict mode (the framework default — dev / CI / \
238 testing baseline):\n\
239 RecoveryPolicy::strict_mailer_required(false)\n\n\
240 See DESIGN_RECOVERY.md §12.1 for the contract."
241 .to_string(),
242 )
243 } else {
244 Ok(())
245 }
246}
247
248pub fn register_admin_routes(
249 router: Router,
250 admin: Admin,
251 db: Db,
252 templates: Arc<Templates>,
253) -> Router {
254 if let Err(msg) = strict_mailer_guard_check(&admin) {
262 panic!("{msg}");
263 }
264
265 let ctx = Arc::new(AdminCtx::new(
266 Arc::new(admin),
267 db.clone(),
268 templates.clone(),
269 ));
270
271 let auth_ctx = Arc::new(super::builtin::AuthAdminCtx {
274 admin: ctx.admin.clone(),
275 db,
276 templates,
277 });
278
279 let err_admin = ctx.admin.clone();
286 let err_templates = ctx.templates.clone();
287 let router = router.middleware(move |req, next| {
288 let admin = err_admin.clone();
289 let templates = err_templates.clone();
290 Box::pin(async move {
291 let is_admin_path = req.path().starts_with("/admin");
292 let result = next.run(req).await;
293 match result {
294 Ok(resp) => Ok(resp),
295 Err(err) if is_admin_path => Ok(render::render_admin_error_response(
296 &admin,
297 &templates,
298 None,
299 err.status(),
300 err.client_message().to_string(),
301 )),
302 Err(err) => Err(err),
303 }
304 })
305 });
306
307 let router = router.get("/static/admin.css", |_req| async move {
313 Ok(Response::new(
314 hyper::StatusCode::OK,
315 bytes::Bytes::from_static(ADMIN_CSS.as_bytes()),
316 )
317 .with_header("content-type", "text/css; charset=utf-8")
318 .with_header("cache-control", "no-cache, must-revalidate"))
319 });
320 let router = router.get("/static/admin.js", |_req| async move {
321 Ok(Response::new(
322 hyper::StatusCode::OK,
323 bytes::Bytes::from_static(ADMIN_JS.as_bytes()),
324 )
325 .with_header("content-type", "application/javascript; charset=utf-8")
326 .with_header("cache-control", "no-cache, must-revalidate"))
327 });
328
329 fn font_response(bytes: &'static [u8]) -> Response {
333 Response::new(hyper::StatusCode::OK, bytes::Bytes::from_static(bytes))
334 .with_header("content-type", "font/woff2")
335 .with_header("cache-control", "public, max-age=31536000, immutable")
336 }
337 let router = router.get("/static/fonts/Geist-Variable.woff2", |_req| async move {
338 Ok(font_response(FONT_GEIST))
339 });
340 let router = router.get(
341 "/static/fonts/GeistMono-Variable.woff2",
342 |_req| async move { Ok(font_response(FONT_GEIST_MONO)) },
343 );
344 let router = router.get("/static/fonts/Tajawal-Regular.woff2", |_req| async move {
345 Ok(font_response(FONT_TAJAWAL_REG))
346 });
347 let router = router.get("/static/fonts/Tajawal-Medium.woff2", |_req| async move {
348 Ok(font_response(FONT_TAJAWAL_MED))
349 });
350 let router = router.get("/static/fonts/Tajawal-Bold.woff2", |_req| async move {
351 Ok(font_response(FONT_TAJAWAL_BOLD))
352 });
353 let router = router.get(
354 "/static/fonts/NotoNaskhArabic-Variable.woff2",
355 |_req| async move { Ok(font_response(FONT_NOTO_NASKH_AR)) },
356 );
357
358 let c = ctx.clone();
360 let router = router.get("/admin/login", move |req| {
361 let c = c.clone();
362 async move { handlers::show_login(&c, req).await }
363 });
364
365 let c = ctx.clone();
366 let router = router.post("/admin/login", move |req| {
367 let c = c.clone();
368 async move { handlers::do_login(&c, req).await }
369 });
370
371 let c = ctx.clone();
372 let router = router.post("/admin/logout", move |req| {
373 let c = c.clone();
374 async move { handlers::do_logout(&c, req).await }
375 });
376
377 let recovery_state = Arc::new(super::recovery_handlers::RecoveryState::from_admin(
394 &ctx.admin,
395 ));
396
397 let c = ctx.clone();
398 let router = router.get("/admin/forgot-password", move |req| {
399 let c = c.clone();
400 async move { super::recovery_handlers::show_forgot_password(&c, &req).await }
401 });
402
403 let c = ctx.clone();
404 let rs = recovery_state.clone();
405 let router = router.post("/admin/forgot-password", move |req| {
406 let c = c.clone();
407 let rs = rs.clone();
408 async move { super::recovery_handlers::do_forgot_password(&c, &rs, req).await }
409 });
410
411 let c = ctx.clone();
412 let router = router.get("/admin/forgot-password/sent", move |req| {
413 let c = c.clone();
414 async move { super::recovery_handlers::show_forgot_password_sent(&c, &req).await }
415 });
416
417 let c = ctx.clone();
418 let router = router.get("/admin/reset-password/:token", move |req| {
419 let c = c.clone();
420 async move {
421 let token = req
422 .param("token")
423 .ok_or_else(|| Error::BadRequest("missing token".into()))?
424 .to_string();
425 super::recovery_handlers::show_reset_password(&c, &req, &token).await
426 }
427 });
428
429 let c = ctx.clone();
430 let rs = recovery_state.clone();
431 let router = router.post("/admin/reset-password/:token", move |req| {
432 let c = c.clone();
433 let rs = rs.clone();
434 async move {
435 let token = req
436 .param("token")
437 .ok_or_else(|| Error::BadRequest("missing token".into()))?
438 .to_string();
439 super::recovery_handlers::do_reset_password(&c, &rs, req, &token).await
440 }
441 });
442
443 let c = ctx.clone();
445 let router = router.get("/admin", move |req| {
446 let c = c.clone();
447 async move {
448 match role_guard(&c, &req, Role::Staff).await? {
449 Guard::Redirect(r) => Ok(r),
450 Guard::Allow(ident) => handlers::dashboard(&c, ident, &req).await,
451 }
452 }
453 });
454
455 let c = ctx.clone();
457 let router = router.get("/admin/history", move |req| {
458 let c = c.clone();
459 async move {
460 match role_guard(&c, &req, Role::Administrator).await? {
461 Guard::Redirect(r) => Ok(r),
462 Guard::Allow(ident) => handlers::show_log_entries(&c, ident, &req).await,
463 }
464 }
465 });
466
467 let c = ctx.clone();
470 let router = router.get("/admin/account/sessions", move |req| {
471 let c = c.clone();
472 async move {
473 match role_guard(&c, &req, Role::User).await? {
474 Guard::Redirect(r) => Ok(r),
475 Guard::Allow(ident) => handlers::show_account_sessions(&c, ident, &req).await,
476 }
477 }
478 });
479
480 let c = ctx.clone();
488 let router = router.post("/admin/account/sessions/revoke-others", move |req| {
489 let c = c.clone();
490 async move {
491 match role_guard(&c, &req, Role::User).await? {
492 Guard::Redirect(r) => Ok(r),
493 Guard::Allow(ident) => handlers::do_revoke_other_sessions(&c, ident, req).await,
494 }
495 }
496 });
497
498 let c = ctx.clone();
499 let router = router.post("/admin/account/sessions/revoke-all", move |req| {
500 let c = c.clone();
501 async move {
502 match role_guard(&c, &req, Role::User).await? {
503 Guard::Redirect(r) => Ok(r),
504 Guard::Allow(ident) => handlers::do_revoke_all_sessions(&c, ident, req).await,
505 }
506 }
507 });
508
509 let c = ctx.clone();
510 let router = router.post("/admin/account/sessions/:id/revoke", move |req| {
511 let c = c.clone();
512 async move {
513 match role_guard(&c, &req, Role::User).await? {
514 Guard::Redirect(r) => Ok(r),
515 Guard::Allow(ident) => {
516 let id = parse_id(req.param("id"))?;
517 handlers::do_revoke_session(&c, ident, req, id).await
518 }
519 }
520 }
521 });
522
523 let c = ctx.clone();
527 let router = router.get("/admin/password_change", move |req| {
528 let c = c.clone();
529 async move {
530 match role_guard(&c, &req, Role::User).await? {
531 Guard::Redirect(r) => Ok(r),
532 Guard::Allow(ident) => handlers::show_password_change(&c, ident, &req).await,
533 }
534 }
535 });
536 let c = ctx.clone();
537 let router = router.post("/admin/password_change", move |req| {
538 let c = c.clone();
539 async move {
540 match role_guard(&c, &req, Role::User).await? {
541 Guard::Redirect(r) => Ok(r),
542 Guard::Allow(ident) => handlers::do_password_change(&c, ident, req).await,
543 }
544 }
545 });
546
547 let c = ctx.clone();
556 let router = router.get("/admin/reauth", move |req| {
557 let c = c.clone();
558 async move {
559 match role_guard(&c, &req, Role::User).await? {
560 Guard::Redirect(r) => Ok(r),
561 Guard::Allow(ident) => {
562 super::admin_recovery_handlers::show_reauth(&c, ident, &req).await
563 }
564 }
565 }
566 });
567
568 let c = ctx.clone();
569 let router = router.post("/admin/reauth", move |req| {
570 let c = c.clone();
571 async move {
572 match role_guard(&c, &req, Role::User).await? {
573 Guard::Redirect(r) => Ok(r),
574 Guard::Allow(ident) => {
575 super::admin_recovery_handlers::do_reauth(&c, ident, req).await
576 }
577 }
578 }
579 });
580
581 let c = ctx.clone();
591 let router = router.get("/admin/must-change-password", 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) => {
597 super::admin_recovery_handlers::show_must_change_password(&c, ident, &req).await
598 }
599 }
600 }
601 });
602
603 let c = ctx.clone();
604 let router = router.post("/admin/must-change-password", move |req| {
605 let c = c.clone();
606 async move {
607 match role_guard(&c, &req, Role::User).await? {
608 Guard::Redirect(r) => Ok(r),
609 Guard::Allow(ident) => {
610 super::admin_recovery_handlers::do_must_change_password(&c, ident, req).await
611 }
612 }
613 }
614 });
615
616 let c = ctx.clone();
618 let ac = auth_ctx.clone();
619 let router = router.get("/admin/users", move |req| {
620 let c = c.clone();
621 let ac = ac.clone();
622 async move {
623 match role_guard(&c, &req, Role::Administrator).await? {
624 Guard::Redirect(r) => Ok(r),
625 Guard::Allow(ident) => {
626 super::builtin::list_users(&ac, ident, handlers::csrf_token(&req)).await
627 }
628 }
629 }
630 });
631
632 let c = ctx.clone();
633 let ac = auth_ctx.clone();
634 let router = router.get("/admin/users/new", move |req| {
635 let c = c.clone();
636 let ac = ac.clone();
637 async move {
638 match role_guard(&c, &req, Role::Administrator).await? {
639 Guard::Redirect(r) => Ok(r),
640 Guard::Allow(ident) => {
641 super::builtin::show_new_user(&ac, ident, handlers::csrf_token(&req)).await
642 }
643 }
644 }
645 });
646
647 let c = ctx.clone();
648 let ac = auth_ctx.clone();
649 let router = router.post("/admin/users/new", move |req| {
650 let c = c.clone();
651 let ac = ac.clone();
652 async move {
653 match role_guard(&c, &req, Role::Administrator).await? {
654 Guard::Redirect(r) => Ok(r),
655 Guard::Allow(ident) => super::builtin::do_new_user(&ac, ident, req).await,
656 }
657 }
658 });
659
660 let c = ctx.clone();
661 let ac = auth_ctx.clone();
662 let router = router.get("/admin/users/:id/edit", move |req| {
663 let c = c.clone();
664 let ac = ac.clone();
665 async move {
666 match role_guard(&c, &req, Role::Administrator).await? {
667 Guard::Redirect(r) => Ok(r),
668 Guard::Allow(ident) => {
669 let id = parse_id(req.param("id"))?;
670 super::builtin::show_user_edit(&ac, ident, id, handlers::csrf_token(&req)).await
671 }
672 }
673 }
674 });
675
676 let c = ctx.clone();
677 let ac = auth_ctx.clone();
678 let router = router.post("/admin/users/:id/edit", move |req| {
679 let c = c.clone();
680 let ac = ac.clone();
681 async move {
682 match role_guard(&c, &req, Role::Administrator).await? {
683 Guard::Redirect(r) => Ok(r),
684 Guard::Allow(ident) => {
685 let id = parse_id(req.param("id"))?;
686 super::builtin::do_user_edit(&ac, ident, id, req).await
687 }
688 }
689 }
690 });
691
692 let c = ctx.clone();
693 let ac = auth_ctx.clone();
694 let router = router.get("/admin/users/:id/delete", move |req| {
695 let c = c.clone();
696 let ac = ac.clone();
697 async move {
698 match role_guard(&c, &req, Role::Administrator).await? {
699 Guard::Redirect(r) => Ok(r),
700 Guard::Allow(ident) => {
701 let id = parse_id(req.param("id"))?;
702 super::builtin::show_user_delete(&ac, ident, id, handlers::csrf_token(&req))
703 .await
704 }
705 }
706 }
707 });
708
709 let c = ctx.clone();
710 let ac = auth_ctx.clone();
711 let router = router.post("/admin/users/:id/delete", move |req| {
712 let c = c.clone();
713 let ac = ac.clone();
714 async move {
715 match role_guard(&c, &req, Role::Administrator).await? {
716 Guard::Redirect(r) => Ok(r),
717 Guard::Allow(ident) => {
718 let id = parse_id(req.param("id"))?;
719 super::builtin::do_user_delete(&ac, ident, id, req).await
720 }
721 }
722 }
723 });
724
725 let c = ctx.clone();
741 let router = router.get("/admin/users/:id/reset-password", move |req| {
742 let c = c.clone();
743 async move {
744 match role_guard(&c, &req, Role::Administrator).await? {
745 Guard::Redirect(r) => Ok(r),
746 Guard::Allow(ident) => {
747 let id = parse_id(req.param("id"))?;
748 super::admin_recovery_handlers::show_admin_reset_password(&c, ident, id, &req)
749 .await
750 }
751 }
752 }
753 });
754
755 let c = ctx.clone();
757 let router = router.post("/admin/users/:id/reset-password", move |req| {
758 let c = c.clone();
759 async move {
760 match role_guard(&c, &req, Role::Administrator).await? {
761 Guard::Redirect(r) => Ok(r),
762 Guard::Allow(ident) => {
763 let id = parse_id(req.param("id"))?;
764 super::admin_recovery_handlers::do_admin_reset_password(&c, ident, id, req)
765 .await
766 }
767 }
768 }
769 });
770
771 let c = ctx.clone();
773 let router = router.get("/admin/users/:id/lock", move |req| {
774 let c = c.clone();
775 async move {
776 match role_guard(&c, &req, Role::Administrator).await? {
777 Guard::Redirect(r) => Ok(r),
778 Guard::Allow(ident) => {
779 let id = parse_id(req.param("id"))?;
780 super::admin_recovery_handlers::show_lock_user(&c, ident, id, &req).await
781 }
782 }
783 }
784 });
785
786 let c = ctx.clone();
788 let router = router.post("/admin/users/:id/lock", move |req| {
789 let c = c.clone();
790 async move {
791 match role_guard(&c, &req, Role::Administrator).await? {
792 Guard::Redirect(r) => Ok(r),
793 Guard::Allow(ident) => {
794 let id = parse_id(req.param("id"))?;
795 super::admin_recovery_handlers::do_lock_user(&c, ident, id, req).await
796 }
797 }
798 }
799 });
800
801 let c = ctx.clone();
803 let router = router.get("/admin/users/:id/unlock", move |req| {
804 let c = c.clone();
805 async move {
806 match role_guard(&c, &req, Role::Administrator).await? {
807 Guard::Redirect(r) => Ok(r),
808 Guard::Allow(ident) => {
809 let id = parse_id(req.param("id"))?;
810 super::admin_recovery_handlers::show_unlock_user(&c, ident, id, &req).await
811 }
812 }
813 }
814 });
815
816 let c = ctx.clone();
818 let router = router.post("/admin/users/:id/unlock", move |req| {
819 let c = c.clone();
820 async move {
821 match role_guard(&c, &req, Role::Administrator).await? {
822 Guard::Redirect(r) => Ok(r),
823 Guard::Allow(ident) => {
824 let id = parse_id(req.param("id"))?;
825 super::admin_recovery_handlers::do_unlock_user(&c, ident, id, req).await
826 }
827 }
828 }
829 });
830
831 let c = ctx.clone();
834 let router = router.get("/admin/users/:id/revoke-sessions", move |req| {
835 let c = c.clone();
836 async move {
837 match role_guard(&c, &req, Role::Administrator).await? {
838 Guard::Redirect(r) => Ok(r),
839 Guard::Allow(ident) => {
840 let id = parse_id(req.param("id"))?;
841 super::admin_recovery_handlers::show_admin_revoke_sessions(&c, ident, id, &req)
842 .await
843 }
844 }
845 }
846 });
847
848 let c = ctx.clone();
850 let router = router.post("/admin/users/:id/revoke-sessions", move |req| {
851 let c = c.clone();
852 async move {
853 match role_guard(&c, &req, Role::Administrator).await? {
854 Guard::Redirect(r) => Ok(r),
855 Guard::Allow(ident) => {
856 let id = parse_id(req.param("id"))?;
857 super::admin_recovery_handlers::do_admin_revoke_sessions(&c, ident, id, req)
858 .await
859 }
860 }
861 }
862 });
863
864 let c = ctx.clone();
871 let ac = auth_ctx.clone();
872 let router = router.get("/admin/users/:id", move |req| {
873 let c = c.clone();
874 let ac = ac.clone();
875 async move {
876 match role_guard(&c, &req, Role::Administrator).await? {
877 Guard::Redirect(r) => Ok(r),
878 Guard::Allow(ident) => {
879 let id = parse_id(req.param("id"))?;
880 let q = req.query();
881 let tab = q.get("tab").map(|s| s.to_string());
882 let page: i64 = q.get("page").and_then(|s| s.parse().ok()).unwrap_or(1);
883 super::builtin::show_user_view(
884 &ac,
885 ident,
886 id,
887 handlers::csrf_token(&req),
888 tab,
889 page,
890 )
891 .await
892 }
893 }
894 }
895 });
896
897 let c = ctx.clone();
899 let ac = auth_ctx.clone();
900 let router = router.get("/admin/groups", move |req| {
901 let c = c.clone();
902 let ac = ac.clone();
903 async move {
904 match role_guard(&c, &req, Role::Administrator).await? {
905 Guard::Redirect(r) => Ok(r),
906 Guard::Allow(ident) => {
907 super::builtin::list_groups(&ac, ident, handlers::csrf_token(&req)).await
908 }
909 }
910 }
911 });
912
913 let c = ctx.clone();
914 let ac = auth_ctx.clone();
915 let router = router.get("/admin/groups/new", move |req| {
916 let c = c.clone();
917 let ac = ac.clone();
918 async move {
919 match role_guard(&c, &req, Role::Administrator).await? {
920 Guard::Redirect(r) => Ok(r),
921 Guard::Allow(ident) => {
922 super::builtin::show_new_group(&ac, ident, handlers::csrf_token(&req)).await
923 }
924 }
925 }
926 });
927
928 let c = ctx.clone();
929 let ac = auth_ctx.clone();
930 let router = router.post("/admin/groups/new", move |req| {
931 let c = c.clone();
932 let ac = ac.clone();
933 async move {
934 match role_guard(&c, &req, Role::Administrator).await? {
935 Guard::Redirect(r) => Ok(r),
936 Guard::Allow(ident) => super::builtin::do_new_group(&ac, ident, req).await,
937 }
938 }
939 });
940
941 let c = ctx.clone();
942 let ac = auth_ctx.clone();
943 let router = router.get("/admin/groups/:id/edit", move |req| {
944 let c = c.clone();
945 let ac = ac.clone();
946 async move {
947 match role_guard(&c, &req, Role::Administrator).await? {
948 Guard::Redirect(r) => Ok(r),
949 Guard::Allow(ident) => {
950 let id = parse_id(req.param("id"))?;
951 super::builtin::show_group_edit(&ac, ident, id, handlers::csrf_token(&req))
952 .await
953 }
954 }
955 }
956 });
957
958 let c = ctx.clone();
959 let ac = auth_ctx.clone();
960 let router = router.post("/admin/groups/:id/edit", move |req| {
961 let c = c.clone();
962 let ac = ac.clone();
963 async move {
964 match role_guard(&c, &req, Role::Administrator).await? {
965 Guard::Redirect(r) => Ok(r),
966 Guard::Allow(ident) => {
967 let id = parse_id(req.param("id"))?;
968 super::builtin::do_group_edit(&ac, ident, id, req).await
969 }
970 }
971 }
972 });
973
974 let c = ctx.clone();
975 let ac = auth_ctx.clone();
976 let router = router.get("/admin/groups/:id/delete", move |req| {
977 let c = c.clone();
978 let ac = ac.clone();
979 async move {
980 match role_guard(&c, &req, Role::Administrator).await? {
981 Guard::Redirect(r) => Ok(r),
982 Guard::Allow(ident) => {
983 let id = parse_id(req.param("id"))?;
984 super::builtin::show_group_delete(&ac, ident, id, handlers::csrf_token(&req))
985 .await
986 }
987 }
988 }
989 });
990
991 let c = ctx.clone();
992 let ac = auth_ctx.clone();
993 let router = router.post("/admin/groups/:id/delete", move |req| {
994 let c = c.clone();
995 let ac = ac.clone();
996 async move {
997 match role_guard(&c, &req, Role::Administrator).await? {
998 Guard::Redirect(r) => Ok(r),
999 Guard::Allow(ident) => {
1000 let id = parse_id(req.param("id"))?;
1001 super::builtin::do_group_delete(&ac, ident, id, req).await
1002 }
1003 }
1004 }
1005 });
1006
1007 let c = ctx.clone();
1009 let router = router.get("/admin/:admin_name", move |req| {
1010 let c = c.clone();
1011 async move {
1012 let name = model_name_from_req(&req)?;
1013 let perm = perm_for(&c, &name, "view")?;
1014 match perm_guard(&c, &req, &perm).await? {
1015 Guard::Redirect(r) => Ok(r),
1016 Guard::Allow(ident) => handlers::list_model(&c, ident, &name, &req).await,
1017 }
1018 }
1019 });
1020
1021 let c = ctx.clone();
1023 let router = router.get("/admin/:admin_name/new", move |req| {
1024 let c = c.clone();
1025 async move {
1026 let name = model_name_from_req(&req)?;
1027 let perm = perm_for(&c, &name, "add")?;
1028 match perm_guard(&c, &req, &perm).await? {
1029 Guard::Redirect(r) => Ok(r),
1030 Guard::Allow(ident) => handlers::show_new_form(&c, ident, &name, &req).await,
1031 }
1032 }
1033 });
1034 let c = ctx.clone();
1035 let router = router.post("/admin/:admin_name/new", move |req| {
1036 let c = c.clone();
1037 async move {
1038 let name = model_name_from_req(&req)?;
1039 let perm = perm_for(&c, &name, "add")?;
1040 match perm_guard(&c, &req, &perm).await? {
1041 Guard::Redirect(r) => Ok(r),
1042 Guard::Allow(ident) => handlers::do_create(&c, ident, &name, req).await,
1043 }
1044 }
1045 });
1046
1047 let c = ctx.clone();
1049 let router = router.get("/admin/:admin_name/:id/edit", move |req| {
1050 let c = c.clone();
1051 async move {
1052 let name = model_name_from_req(&req)?;
1053 let perm = perm_for(&c, &name, "change")?;
1054 match perm_guard(&c, &req, &perm).await? {
1055 Guard::Redirect(r) => Ok(r),
1056 Guard::Allow(ident) => {
1057 let id = parse_id(req.param("id"))?;
1058 handlers::show_edit_form(&c, ident, &name, id, &req).await
1059 }
1060 }
1061 }
1062 });
1063 let c = ctx.clone();
1064 let router = router.post("/admin/:admin_name/:id/edit", move |req| {
1065 let c = c.clone();
1066 async move {
1067 let name = model_name_from_req(&req)?;
1068 let perm = perm_for(&c, &name, "change")?;
1069 match perm_guard(&c, &req, &perm).await? {
1070 Guard::Redirect(r) => Ok(r),
1071 Guard::Allow(ident) => {
1072 let id = parse_id(req.param("id"))?;
1073 handlers::do_update(&c, ident, &name, id, req).await
1074 }
1075 }
1076 }
1077 });
1078
1079 let c = ctx.clone();
1082 let router = router.get("/admin/:admin_name/:id/history", move |req| {
1083 let c = c.clone();
1084 async move {
1085 let name = model_name_from_req(&req)?;
1086 let perm = perm_for(&c, &name, "view")?;
1087 match perm_guard(&c, &req, &perm).await? {
1088 Guard::Redirect(r) => Ok(r),
1089 Guard::Allow(ident) => {
1090 let id = parse_id(req.param("id"))?;
1091 handlers::show_object_history(&c, ident, &name, id, &req).await
1092 }
1093 }
1094 }
1095 });
1096
1097 let c = ctx.clone();
1099 let router = router.get("/admin/:admin_name/:id/delete", move |req| {
1100 let c = c.clone();
1101 async move {
1102 let name = model_name_from_req(&req)?;
1103 let perm = perm_for(&c, &name, "delete")?;
1104 match perm_guard(&c, &req, &perm).await? {
1105 Guard::Redirect(r) => Ok(r),
1106 Guard::Allow(ident) => {
1107 let id = parse_id(req.param("id"))?;
1108 handlers::show_delete_confirm(&c, ident, &name, id, &req).await
1109 }
1110 }
1111 }
1112 });
1113 let c = ctx.clone();
1114 let router = router.post("/admin/:admin_name/:id/delete", move |req| {
1115 let c = c.clone();
1116 async move {
1117 let name = model_name_from_req(&req)?;
1118 let perm = perm_for(&c, &name, "delete")?;
1119 match perm_guard(&c, &req, &perm).await? {
1120 Guard::Redirect(r) => Ok(r),
1121 Guard::Allow(ident) => {
1122 let id = parse_id(req.param("id"))?;
1123 handlers::do_delete(&c, ident, &name, id).await
1124 }
1125 }
1126 }
1127 });
1128
1129 let c = ctx.clone();
1134 let router = router.post("/admin/:admin_name/bulk_delete", move |req| {
1135 let c = c.clone();
1136 async move {
1137 let name = model_name_from_req(&req)?;
1138 let perm = perm_for(&c, &name, "delete")?;
1139 match perm_guard(&c, &req, &perm).await? {
1140 Guard::Redirect(r) => Ok(r),
1141 Guard::Allow(ident) => handlers::handle_bulk_delete(&c, ident, &name, &req).await,
1142 }
1143 }
1144 });
1145
1146 let c = ctx.clone();
1151 router.post("/admin/:admin_name/bulk/:action", move |req| {
1152 let c = c.clone();
1153 async move {
1154 let name = model_name_from_req(&req)?;
1155 let action = req
1156 .param("action")
1157 .ok_or_else(|| Error::BadRequest("missing bulk action name".into()))?
1158 .to_string();
1159 let perm = perm_for(&c, &name, "change")?;
1160 match perm_guard(&c, &req, &perm).await? {
1161 Guard::Redirect(r) => Ok(r),
1162 Guard::Allow(ident) => {
1163 handlers::handle_bulk_action(&c, ident, &name, &action, &req).await
1164 }
1165 }
1166 }
1167 })
1168}
1169
1170#[cfg(test)]
1171mod tests {
1172 use super::*;
1173
1174 fn make_identity(role: Role, is_active: bool) -> Identity {
1175 Identity {
1176 user_id: 42,
1177 email: "test@example.com".into(),
1178 role,
1179 is_active,
1180 is_demo: false,
1181 demo_label: None,
1182 must_change_password: false,
1183 }
1184 }
1185
1186 #[test]
1191 fn role_guard_decision_admin_meets_staff_floor() {
1192 let id = make_identity(Role::Administrator, true);
1193 assert!(id.role.includes(Role::Staff));
1194 }
1195
1196 #[test]
1197 fn role_guard_decision_user_does_not_meet_staff() {
1198 let id = make_identity(Role::User, true);
1199 assert!(!id.role.includes(Role::Staff));
1200 }
1201
1202 #[test]
1203 fn role_guard_decision_administrator_does_not_meet_developer() {
1204 let id = make_identity(Role::Administrator, true);
1205 assert!(!id.role.includes(Role::Developer));
1206 }
1207
1208 #[test]
1209 fn role_guard_decision_developer_meets_everything() {
1210 let id = make_identity(Role::Developer, true);
1211 for &min in &[
1212 Role::User,
1213 Role::Staff,
1214 Role::Supervisor,
1215 Role::Administrator,
1216 Role::Developer,
1217 ] {
1218 assert!(id.role.includes(min), "Developer should meet {min:?}");
1219 }
1220 }
1221
1222 #[test]
1225 fn perm_guard_admin_short_circuits_without_perm() {
1226 let id = make_identity(Role::Administrator, true);
1227 assert!(perm_guard_verdict(&id, false));
1228 }
1229
1230 #[test]
1231 fn perm_guard_developer_short_circuits_without_perm() {
1232 let id = make_identity(Role::Developer, true);
1233 assert!(perm_guard_verdict(&id, false));
1234 }
1235
1236 #[test]
1237 fn perm_guard_staff_with_perm_passes() {
1238 let id = make_identity(Role::Staff, true);
1239 assert!(perm_guard_verdict(&id, true));
1240 }
1241
1242 #[test]
1243 fn perm_guard_staff_without_perm_denies() {
1244 let id = make_identity(Role::Staff, true);
1245 assert!(!perm_guard_verdict(&id, false));
1246 }
1247
1248 #[test]
1249 fn perm_guard_inactive_admin_denies_even_with_bypass() {
1250 let id = make_identity(Role::Administrator, false);
1252 assert!(!perm_guard_verdict(&id, true));
1253 }
1254
1255 #[test]
1256 fn perm_guard_supervisor_without_perm_denies() {
1257 let id = make_identity(Role::Supervisor, true);
1259 assert!(!perm_guard_verdict(&id, false));
1260 }
1261
1262 #[test]
1267 fn strict_mailer_guard_passes_for_default_admin() {
1268 let admin = super::super::types::Admin::new();
1269 assert!(strict_mailer_guard_check(&admin).is_ok());
1270 }
1271
1272 #[test]
1275 fn strict_mailer_guard_fails_when_required_but_default_mailer() {
1276 use crate::auth::DefaultRecoveryPolicy;
1277 let admin = super::super::types::Admin::new().recovery_policy(std::sync::Arc::new(
1278 DefaultRecoveryPolicy::new().with_strict_mailer_required(true),
1279 ));
1280 let err = strict_mailer_guard_check(&admin).expect_err("guard should fail");
1281 assert!(
1282 err.contains("strict_mailer_required"),
1283 "error message must name the policy method: {err}"
1284 );
1285 assert!(
1286 err.contains("Admin::mailer"),
1287 "error message must direct the operator to the fix: {err}"
1288 );
1289 }
1290
1291 #[test]
1296 fn strict_mailer_guard_passes_when_mailer_was_explicitly_overridden() {
1297 use crate::auth::DefaultRecoveryPolicy;
1298 use crate::email::LogMailer;
1299 let admin = super::super::types::Admin::new()
1300 .recovery_policy(std::sync::Arc::new(
1301 DefaultRecoveryPolicy::new().with_strict_mailer_required(true),
1302 ))
1303 .mailer(std::sync::Arc::new(LogMailer));
1304 assert!(strict_mailer_guard_check(&admin).is_ok());
1305 }
1306
1307 #[test]
1310 fn strict_mailer_guard_passes_when_strict_mode_disabled() {
1311 let admin = super::super::types::Admin::new();
1312 assert!(strict_mailer_guard_check(&admin).is_ok());
1313 }
1314
1315 #[test]
1318 fn whitelist_accepts_the_three_locked_paths() {
1319 assert!(super::is_must_change_whitelisted_path(
1321 "/admin/must-change-password"
1322 ));
1323 assert!(super::is_must_change_whitelisted_path("/admin/logout"));
1324 assert!(super::is_must_change_whitelisted_path(
1325 "/admin/account/sessions"
1326 ));
1327 }
1328
1329 #[test]
1330 fn whitelist_rejects_subpaths_of_account_sessions() {
1331 assert!(!super::is_must_change_whitelisted_path(
1336 "/admin/account/sessions/revoke"
1337 ));
1338 assert!(!super::is_must_change_whitelisted_path(
1339 "/admin/account/sessions/revoke-others"
1340 ));
1341 assert!(!super::is_must_change_whitelisted_path(
1342 "/admin/account/sessions/"
1343 ));
1344 }
1345
1346 #[test]
1347 fn whitelist_rejects_other_admin_paths() {
1348 for path in [
1349 "/admin",
1350 "/admin/",
1351 "/admin/users",
1352 "/admin/users/42",
1353 "/admin/login",
1354 "/admin/password_change",
1355 "/admin/forgot-password",
1356 "/admin/reauth",
1357 "/admin/must-change-password/", ] {
1359 assert!(
1360 !super::is_must_change_whitelisted_path(path),
1361 "expected reject for {path:?}"
1362 );
1363 }
1364 }
1365
1366 #[test]
1367 fn whitelist_rejects_paths_outside_admin_surface() {
1368 for path in ["/", "/login", "/static/admin.css", "/api"] {
1369 assert!(
1370 !super::is_must_change_whitelisted_path(path),
1371 "expected reject for {path:?}"
1372 );
1373 }
1374 }
1375}