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] =
54 include_bytes!("../../assets/static/fonts/GeistMono-Variable.woff2");
55const FONT_TAJAWAL_REG: &[u8] = include_bytes!("../../assets/static/fonts/Tajawal-Regular.woff2");
56const FONT_TAJAWAL_MED: &[u8] = include_bytes!("../../assets/static/fonts/Tajawal-Medium.woff2");
57const FONT_TAJAWAL_BOLD: &[u8] = include_bytes!("../../assets/static/fonts/Tajawal-Bold.woff2");
58const FONT_NOTO_NASKH_AR: &[u8] =
59 include_bytes!("../../assets/static/fonts/NotoNaskhArabic-Variable.woff2");
60
61use super::handlers::{self, AdminCtx};
62use super::render;
63use super::types::Admin;
64
65enum Guard {
69 Allow(Identity),
70 Redirect(Response),
71}
72
73async fn login_guard(ctx: &AdminCtx, req: &Request) -> Result<Guard> {
74 let cookie = match req.header("cookie") {
75 Some(c) => c,
76 None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
77 };
78 let token = match auth::session_token_from_cookie(cookie) {
79 Some(t) => t,
80 None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
81 };
82 let ident = match auth::identity_from_session(&ctx.db, &token).await? {
83 Some(i) => i,
84 None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
85 };
86 if !ident.is_active {
87 return Ok(Guard::Redirect(Response::redirect("/admin/login")));
88 }
89 Ok(Guard::Allow(ident))
90}
91
92async fn role_guard(ctx: &AdminCtx, req: &Request, min: Role) -> Result<Guard> {
93 match login_guard(ctx, req).await? {
94 Guard::Redirect(r) => Ok(Guard::Redirect(r)),
95 Guard::Allow(ident) => {
96 if ident.role.includes(min) {
97 Ok(Guard::Allow(ident))
98 } else {
99 let body = render::render_forbidden_body(
100 &ctx.admin,
101 &ctx.templates,
102 &ident,
103 handlers::csrf_token(req),
104 None,
105 Some(min.label()),
106 )?;
107 Ok(Guard::Redirect(
108 Response::html(body).with_status(hyper::StatusCode::FORBIDDEN),
109 ))
110 }
111 }
112 }
113}
114
115async fn perm_guard(ctx: &AdminCtx, req: &Request, perm: &str) -> Result<Guard> {
116 match role_guard(ctx, req, Role::Staff).await? {
117 Guard::Redirect(r) => Ok(Guard::Redirect(r)),
118 Guard::Allow(ident) => {
119 if ident.role.bypasses_group_checks() {
120 return Ok(Guard::Allow(ident));
121 }
122 if auth::check_permission(&ctx.db, &ident, perm).await? {
123 Ok(Guard::Allow(ident))
124 } else {
125 let body = render::render_forbidden_body(
126 &ctx.admin,
127 &ctx.templates,
128 &ident,
129 handlers::csrf_token(req),
130 Some(perm.to_string()),
131 None,
132 )?;
133 Ok(Guard::Redirect(
134 Response::html(body).with_status(hyper::StatusCode::FORBIDDEN),
135 ))
136 }
137 }
138 }
139}
140
141#[cfg(test)]
144fn perm_guard_verdict(ident: &Identity, perm_held: bool) -> bool {
145 if !ident.is_active {
146 return false;
147 }
148 if ident.role.bypasses_group_checks() {
149 return true;
150 }
151 perm_held
152}
153
154fn parse_id(raw: Option<&str>) -> Result<i64> {
155 raw.and_then(|s| s.parse().ok())
156 .ok_or_else(|| Error::BadRequest("invalid id".into()))
157}
158
159fn model_name_from_req(req: &Request) -> Result<String> {
160 req.param("admin_name")
161 .map(|s| s.to_string())
162 .ok_or_else(|| Error::BadRequest("missing model".into()))
163}
164
165fn perm_for(ctx: &AdminCtx, admin_name: &str, action: &str) -> Result<String> {
166 let entry = ctx
167 .admin
168 .find(admin_name)
169 .ok_or_else(|| Error::NotFound(format!("no admin model: {admin_name}")))?;
170 let singular = entry.singular_name.to_ascii_lowercase();
171 Ok(format!("{admin_name}.{action}_{singular}"))
172}
173
174pub fn register_admin_routes(
175 router: Router,
176 admin: Admin,
177 db: Db,
178 templates: Arc<Templates>,
179) -> Router {
180 let ctx = Arc::new(AdminCtx::new(
181 Arc::new(admin),
182 db.clone(),
183 templates.clone(),
184 ));
185
186 let auth_ctx = Arc::new(super::builtin::AuthAdminCtx {
189 admin: ctx.admin.clone(),
190 db,
191 templates,
192 });
193
194 let err_admin = ctx.admin.clone();
201 let err_templates = ctx.templates.clone();
202 let router = router.middleware(move |req, next| {
203 let admin = err_admin.clone();
204 let templates = err_templates.clone();
205 Box::pin(async move {
206 let is_admin_path = req.path().starts_with("/admin");
207 let result = next.run(req).await;
208 match result {
209 Ok(resp) => Ok(resp),
210 Err(err) if is_admin_path => Ok(render::render_admin_error_response(
211 &admin,
212 &templates,
213 None,
214 err.status(),
215 err.client_message().to_string(),
216 )),
217 Err(err) => Err(err),
218 }
219 })
220 });
221
222 let router = router.get("/static/admin.css", |_req| async move {
228 Ok(Response::new(
229 hyper::StatusCode::OK,
230 bytes::Bytes::from_static(ADMIN_CSS.as_bytes()),
231 )
232 .with_header("content-type", "text/css; charset=utf-8")
233 .with_header("cache-control", "no-cache, must-revalidate"))
234 });
235 let router = router.get("/static/admin.js", |_req| async move {
236 Ok(Response::new(
237 hyper::StatusCode::OK,
238 bytes::Bytes::from_static(ADMIN_JS.as_bytes()),
239 )
240 .with_header("content-type", "application/javascript; charset=utf-8")
241 .with_header("cache-control", "no-cache, must-revalidate"))
242 });
243
244 fn font_response(bytes: &'static [u8]) -> Response {
248 Response::new(hyper::StatusCode::OK, bytes::Bytes::from_static(bytes))
249 .with_header("content-type", "font/woff2")
250 .with_header("cache-control", "public, max-age=31536000, immutable")
251 }
252 let router = router.get("/static/fonts/Geist-Variable.woff2", |_req| async move {
253 Ok(font_response(FONT_GEIST))
254 });
255 let router = router.get(
256 "/static/fonts/GeistMono-Variable.woff2",
257 |_req| async move { Ok(font_response(FONT_GEIST_MONO)) },
258 );
259 let router = router.get("/static/fonts/Tajawal-Regular.woff2", |_req| async move {
260 Ok(font_response(FONT_TAJAWAL_REG))
261 });
262 let router = router.get("/static/fonts/Tajawal-Medium.woff2", |_req| async move {
263 Ok(font_response(FONT_TAJAWAL_MED))
264 });
265 let router = router.get("/static/fonts/Tajawal-Bold.woff2", |_req| async move {
266 Ok(font_response(FONT_TAJAWAL_BOLD))
267 });
268 let router = router.get(
269 "/static/fonts/NotoNaskhArabic-Variable.woff2",
270 |_req| async move { Ok(font_response(FONT_NOTO_NASKH_AR)) },
271 );
272
273 let c = ctx.clone();
275 let router = router.get("/admin/login", move |req| {
276 let c = c.clone();
277 async move { handlers::show_login(&c, req).await }
278 });
279
280 let c = ctx.clone();
281 let router = router.post("/admin/login", move |req| {
282 let c = c.clone();
283 async move { handlers::do_login(&c, req).await }
284 });
285
286 let c = ctx.clone();
287 let router = router.post("/admin/logout", move |req| {
288 let c = c.clone();
289 async move { handlers::do_logout(&c, req).await }
290 });
291
292 let c = ctx.clone();
294 let router = router.get("/admin", move |req| {
295 let c = c.clone();
296 async move {
297 match role_guard(&c, &req, Role::Staff).await? {
298 Guard::Redirect(r) => Ok(r),
299 Guard::Allow(ident) => handlers::dashboard(&c, ident, &req).await,
300 }
301 }
302 });
303
304 let c = ctx.clone();
306 let router = router.get("/admin/history", move |req| {
307 let c = c.clone();
308 async move {
309 match role_guard(&c, &req, Role::Administrator).await? {
310 Guard::Redirect(r) => Ok(r),
311 Guard::Allow(ident) => handlers::show_log_entries(&c, ident, &req).await,
312 }
313 }
314 });
315
316 let c = ctx.clone();
320 let router = router.get("/admin/password_change", move |req| {
321 let c = c.clone();
322 async move {
323 match role_guard(&c, &req, Role::User).await? {
324 Guard::Redirect(r) => Ok(r),
325 Guard::Allow(ident) => handlers::show_password_change(&c, ident, &req).await,
326 }
327 }
328 });
329 let c = ctx.clone();
330 let router = router.post("/admin/password_change", move |req| {
331 let c = c.clone();
332 async move {
333 match role_guard(&c, &req, Role::User).await? {
334 Guard::Redirect(r) => Ok(r),
335 Guard::Allow(ident) => handlers::do_password_change(&c, ident, req).await,
336 }
337 }
338 });
339
340 let c = ctx.clone();
342 let ac = auth_ctx.clone();
343 let router = router.get("/admin/users", move |req| {
344 let c = c.clone();
345 let ac = ac.clone();
346 async move {
347 match role_guard(&c, &req, Role::Administrator).await? {
348 Guard::Redirect(r) => Ok(r),
349 Guard::Allow(ident) => {
350 super::builtin::list_users(&ac, ident, handlers::csrf_token(&req)).await
351 }
352 }
353 }
354 });
355
356 let c = ctx.clone();
357 let ac = auth_ctx.clone();
358 let router = router.get("/admin/users/new", move |req| {
359 let c = c.clone();
360 let ac = ac.clone();
361 async move {
362 match role_guard(&c, &req, Role::Administrator).await? {
363 Guard::Redirect(r) => Ok(r),
364 Guard::Allow(ident) => {
365 super::builtin::show_new_user(&ac, ident, handlers::csrf_token(&req)).await
366 }
367 }
368 }
369 });
370
371 let c = ctx.clone();
372 let ac = auth_ctx.clone();
373 let router = router.post("/admin/users/new", move |req| {
374 let c = c.clone();
375 let ac = ac.clone();
376 async move {
377 match role_guard(&c, &req, Role::Administrator).await? {
378 Guard::Redirect(r) => Ok(r),
379 Guard::Allow(ident) => super::builtin::do_new_user(&ac, ident, req).await,
380 }
381 }
382 });
383
384 let c = ctx.clone();
385 let ac = auth_ctx.clone();
386 let router = router.get("/admin/users/:id/edit", move |req| {
387 let c = c.clone();
388 let ac = ac.clone();
389 async move {
390 match role_guard(&c, &req, Role::Administrator).await? {
391 Guard::Redirect(r) => Ok(r),
392 Guard::Allow(ident) => {
393 let id = parse_id(req.param("id"))?;
394 super::builtin::show_user_edit(&ac, ident, id, handlers::csrf_token(&req)).await
395 }
396 }
397 }
398 });
399
400 let c = ctx.clone();
401 let ac = auth_ctx.clone();
402 let router = router.post("/admin/users/:id/edit", move |req| {
403 let c = c.clone();
404 let ac = ac.clone();
405 async move {
406 match role_guard(&c, &req, Role::Administrator).await? {
407 Guard::Redirect(r) => Ok(r),
408 Guard::Allow(ident) => {
409 let id = parse_id(req.param("id"))?;
410 super::builtin::do_user_edit(&ac, ident, id, req).await
411 }
412 }
413 }
414 });
415
416 let c = ctx.clone();
417 let ac = auth_ctx.clone();
418 let router = router.get("/admin/users/:id/delete", move |req| {
419 let c = c.clone();
420 let ac = ac.clone();
421 async move {
422 match role_guard(&c, &req, Role::Administrator).await? {
423 Guard::Redirect(r) => Ok(r),
424 Guard::Allow(ident) => {
425 let id = parse_id(req.param("id"))?;
426 super::builtin::show_user_delete(&ac, ident, id, handlers::csrf_token(&req))
427 .await
428 }
429 }
430 }
431 });
432
433 let c = ctx.clone();
434 let ac = auth_ctx.clone();
435 let router = router.post("/admin/users/:id/delete", move |req| {
436 let c = c.clone();
437 let ac = ac.clone();
438 async move {
439 match role_guard(&c, &req, Role::Administrator).await? {
440 Guard::Redirect(r) => Ok(r),
441 Guard::Allow(ident) => {
442 let id = parse_id(req.param("id"))?;
443 super::builtin::do_user_delete(&ac, ident, id, req).await
444 }
445 }
446 }
447 });
448
449 let c = ctx.clone();
456 let ac = auth_ctx.clone();
457 let router = router.get("/admin/users/:id", move |req| {
458 let c = c.clone();
459 let ac = ac.clone();
460 async move {
461 match role_guard(&c, &req, Role::Administrator).await? {
462 Guard::Redirect(r) => Ok(r),
463 Guard::Allow(ident) => {
464 let id = parse_id(req.param("id"))?;
465 let q = req.query();
466 let tab = q.get("tab").map(|s| s.to_string());
467 let page: i64 = q.get("page").and_then(|s| s.parse().ok()).unwrap_or(1);
468 super::builtin::show_user_view(
469 &ac,
470 ident,
471 id,
472 handlers::csrf_token(&req),
473 tab,
474 page,
475 )
476 .await
477 }
478 }
479 }
480 });
481
482 let c = ctx.clone();
484 let ac = auth_ctx.clone();
485 let router = router.get("/admin/groups", move |req| {
486 let c = c.clone();
487 let ac = ac.clone();
488 async move {
489 match role_guard(&c, &req, Role::Administrator).await? {
490 Guard::Redirect(r) => Ok(r),
491 Guard::Allow(ident) => {
492 super::builtin::list_groups(&ac, ident, handlers::csrf_token(&req)).await
493 }
494 }
495 }
496 });
497
498 let c = ctx.clone();
499 let ac = auth_ctx.clone();
500 let router = router.get("/admin/groups/new", move |req| {
501 let c = c.clone();
502 let ac = ac.clone();
503 async move {
504 match role_guard(&c, &req, Role::Administrator).await? {
505 Guard::Redirect(r) => Ok(r),
506 Guard::Allow(ident) => {
507 super::builtin::show_new_group(&ac, ident, handlers::csrf_token(&req)).await
508 }
509 }
510 }
511 });
512
513 let c = ctx.clone();
514 let ac = auth_ctx.clone();
515 let router = router.post("/admin/groups/new", move |req| {
516 let c = c.clone();
517 let ac = ac.clone();
518 async move {
519 match role_guard(&c, &req, Role::Administrator).await? {
520 Guard::Redirect(r) => Ok(r),
521 Guard::Allow(ident) => super::builtin::do_new_group(&ac, ident, req).await,
522 }
523 }
524 });
525
526 let c = ctx.clone();
527 let ac = auth_ctx.clone();
528 let router = router.get("/admin/groups/:id/edit", move |req| {
529 let c = c.clone();
530 let ac = ac.clone();
531 async move {
532 match role_guard(&c, &req, Role::Administrator).await? {
533 Guard::Redirect(r) => Ok(r),
534 Guard::Allow(ident) => {
535 let id = parse_id(req.param("id"))?;
536 super::builtin::show_group_edit(&ac, ident, id, handlers::csrf_token(&req))
537 .await
538 }
539 }
540 }
541 });
542
543 let c = ctx.clone();
544 let ac = auth_ctx.clone();
545 let router = router.post("/admin/groups/:id/edit", move |req| {
546 let c = c.clone();
547 let ac = ac.clone();
548 async move {
549 match role_guard(&c, &req, Role::Administrator).await? {
550 Guard::Redirect(r) => Ok(r),
551 Guard::Allow(ident) => {
552 let id = parse_id(req.param("id"))?;
553 super::builtin::do_group_edit(&ac, ident, id, req).await
554 }
555 }
556 }
557 });
558
559 let c = ctx.clone();
560 let ac = auth_ctx.clone();
561 let router = router.get("/admin/groups/:id/delete", move |req| {
562 let c = c.clone();
563 let ac = ac.clone();
564 async move {
565 match role_guard(&c, &req, Role::Administrator).await? {
566 Guard::Redirect(r) => Ok(r),
567 Guard::Allow(ident) => {
568 let id = parse_id(req.param("id"))?;
569 super::builtin::show_group_delete(&ac, ident, id, handlers::csrf_token(&req))
570 .await
571 }
572 }
573 }
574 });
575
576 let c = ctx.clone();
577 let ac = auth_ctx.clone();
578 let router = router.post("/admin/groups/:id/delete", move |req| {
579 let c = c.clone();
580 let ac = ac.clone();
581 async move {
582 match role_guard(&c, &req, Role::Administrator).await? {
583 Guard::Redirect(r) => Ok(r),
584 Guard::Allow(ident) => {
585 let id = parse_id(req.param("id"))?;
586 super::builtin::do_group_delete(&ac, ident, id, req).await
587 }
588 }
589 }
590 });
591
592 let c = ctx.clone();
594 let router = router.get("/admin/:admin_name", move |req| {
595 let c = c.clone();
596 async move {
597 let name = model_name_from_req(&req)?;
598 let perm = perm_for(&c, &name, "view")?;
599 match perm_guard(&c, &req, &perm).await? {
600 Guard::Redirect(r) => Ok(r),
601 Guard::Allow(ident) => handlers::list_model(&c, ident, &name, &req).await,
602 }
603 }
604 });
605
606 let c = ctx.clone();
608 let router = router.get("/admin/:admin_name/new", move |req| {
609 let c = c.clone();
610 async move {
611 let name = model_name_from_req(&req)?;
612 let perm = perm_for(&c, &name, "add")?;
613 match perm_guard(&c, &req, &perm).await? {
614 Guard::Redirect(r) => Ok(r),
615 Guard::Allow(ident) => handlers::show_new_form(&c, ident, &name, &req).await,
616 }
617 }
618 });
619 let c = ctx.clone();
620 let router = router.post("/admin/:admin_name/new", move |req| {
621 let c = c.clone();
622 async move {
623 let name = model_name_from_req(&req)?;
624 let perm = perm_for(&c, &name, "add")?;
625 match perm_guard(&c, &req, &perm).await? {
626 Guard::Redirect(r) => Ok(r),
627 Guard::Allow(ident) => handlers::do_create(&c, ident, &name, req).await,
628 }
629 }
630 });
631
632 let c = ctx.clone();
634 let router = router.get("/admin/:admin_name/:id/edit", move |req| {
635 let c = c.clone();
636 async move {
637 let name = model_name_from_req(&req)?;
638 let perm = perm_for(&c, &name, "change")?;
639 match perm_guard(&c, &req, &perm).await? {
640 Guard::Redirect(r) => Ok(r),
641 Guard::Allow(ident) => {
642 let id = parse_id(req.param("id"))?;
643 handlers::show_edit_form(&c, ident, &name, id, &req).await
644 }
645 }
646 }
647 });
648 let c = ctx.clone();
649 let router = router.post("/admin/:admin_name/:id/edit", move |req| {
650 let c = c.clone();
651 async move {
652 let name = model_name_from_req(&req)?;
653 let perm = perm_for(&c, &name, "change")?;
654 match perm_guard(&c, &req, &perm).await? {
655 Guard::Redirect(r) => Ok(r),
656 Guard::Allow(ident) => {
657 let id = parse_id(req.param("id"))?;
658 handlers::do_update(&c, ident, &name, id, req).await
659 }
660 }
661 }
662 });
663
664 let c = ctx.clone();
667 let router = router.get("/admin/:admin_name/:id/history", move |req| {
668 let c = c.clone();
669 async move {
670 let name = model_name_from_req(&req)?;
671 let perm = perm_for(&c, &name, "view")?;
672 match perm_guard(&c, &req, &perm).await? {
673 Guard::Redirect(r) => Ok(r),
674 Guard::Allow(ident) => {
675 let id = parse_id(req.param("id"))?;
676 handlers::show_object_history(&c, ident, &name, id, &req).await
677 }
678 }
679 }
680 });
681
682 let c = ctx.clone();
684 let router = router.get("/admin/:admin_name/:id/delete", move |req| {
685 let c = c.clone();
686 async move {
687 let name = model_name_from_req(&req)?;
688 let perm = perm_for(&c, &name, "delete")?;
689 match perm_guard(&c, &req, &perm).await? {
690 Guard::Redirect(r) => Ok(r),
691 Guard::Allow(ident) => {
692 let id = parse_id(req.param("id"))?;
693 handlers::show_delete_confirm(&c, ident, &name, id, &req).await
694 }
695 }
696 }
697 });
698 let c = ctx.clone();
699 let router = router.post("/admin/:admin_name/:id/delete", move |req| {
700 let c = c.clone();
701 async move {
702 let name = model_name_from_req(&req)?;
703 let perm = perm_for(&c, &name, "delete")?;
704 match perm_guard(&c, &req, &perm).await? {
705 Guard::Redirect(r) => Ok(r),
706 Guard::Allow(ident) => {
707 let id = parse_id(req.param("id"))?;
708 handlers::do_delete(&c, ident, &name, id).await
709 }
710 }
711 }
712 });
713
714 let c = ctx.clone();
719 let router = router.post("/admin/:admin_name/bulk_delete", move |req| {
720 let c = c.clone();
721 async move {
722 let name = model_name_from_req(&req)?;
723 let perm = perm_for(&c, &name, "delete")?;
724 match perm_guard(&c, &req, &perm).await? {
725 Guard::Redirect(r) => Ok(r),
726 Guard::Allow(ident) => {
727 handlers::handle_bulk_delete(&c, ident, &name, &req).await
728 }
729 }
730 }
731 });
732
733 let c = ctx.clone();
738 router.post("/admin/:admin_name/bulk/:action", move |req| {
739 let c = c.clone();
740 async move {
741 let name = model_name_from_req(&req)?;
742 let action = req
743 .param("action")
744 .ok_or_else(|| Error::BadRequest("missing bulk action name".into()))?
745 .to_string();
746 let perm = perm_for(&c, &name, "change")?;
747 match perm_guard(&c, &req, &perm).await? {
748 Guard::Redirect(r) => Ok(r),
749 Guard::Allow(ident) => {
750 handlers::handle_bulk_action(&c, ident, &name, &action, &req).await
751 }
752 }
753 }
754 })
755}
756
757#[cfg(test)]
758mod tests {
759 use super::*;
760
761 fn make_identity(role: Role, is_active: bool) -> Identity {
762 Identity {
763 user_id: 42,
764 email: "test@example.com".into(),
765 role,
766 is_active,
767 is_demo: false,
768 demo_label: None,
769 }
770 }
771
772 #[test]
777 fn role_guard_decision_admin_meets_staff_floor() {
778 let id = make_identity(Role::Administrator, true);
779 assert!(id.role.includes(Role::Staff));
780 }
781
782 #[test]
783 fn role_guard_decision_user_does_not_meet_staff() {
784 let id = make_identity(Role::User, true);
785 assert!(!id.role.includes(Role::Staff));
786 }
787
788 #[test]
789 fn role_guard_decision_administrator_does_not_meet_developer() {
790 let id = make_identity(Role::Administrator, true);
791 assert!(!id.role.includes(Role::Developer));
792 }
793
794 #[test]
795 fn role_guard_decision_developer_meets_everything() {
796 let id = make_identity(Role::Developer, true);
797 for &min in &[
798 Role::User,
799 Role::Staff,
800 Role::Supervisor,
801 Role::Administrator,
802 Role::Developer,
803 ] {
804 assert!(id.role.includes(min), "Developer should meet {min:?}");
805 }
806 }
807
808 #[test]
811 fn perm_guard_admin_short_circuits_without_perm() {
812 let id = make_identity(Role::Administrator, true);
813 assert!(perm_guard_verdict(&id, false));
814 }
815
816 #[test]
817 fn perm_guard_developer_short_circuits_without_perm() {
818 let id = make_identity(Role::Developer, true);
819 assert!(perm_guard_verdict(&id, false));
820 }
821
822 #[test]
823 fn perm_guard_staff_with_perm_passes() {
824 let id = make_identity(Role::Staff, true);
825 assert!(perm_guard_verdict(&id, true));
826 }
827
828 #[test]
829 fn perm_guard_staff_without_perm_denies() {
830 let id = make_identity(Role::Staff, true);
831 assert!(!perm_guard_verdict(&id, false));
832 }
833
834 #[test]
835 fn perm_guard_inactive_admin_denies_even_with_bypass() {
836 let id = make_identity(Role::Administrator, false);
838 assert!(!perm_guard_verdict(&id, true));
839 }
840
841 #[test]
842 fn perm_guard_supervisor_without_perm_denies() {
843 let id = make_identity(Role::Supervisor, true);
845 assert!(!perm_guard_verdict(&id, false));
846 }
847}