Skip to main content

rustio_admin/admin/
routes.rs

1//! Admin route registration with permission checks.
2//!
3//! Every admin URL is gated by a specific permission:
4//!   GET  /admin/:model            → posts.view_post
5//!   GET  /admin/:model/new        → posts.add_post
6//!   POST /admin/:model/new        → posts.add_post
7//!   GET  /admin/:model/:id/edit   → posts.change_post
8//!   POST /admin/:model/:id/edit   → posts.change_post
9//!   GET  /admin/:model/:id/delete → posts.delete_post
10//!   POST /admin/:model/:id/delete → posts.delete_post
11//!
12//! Administrator + Developer bypass every check (see
13//! `Role::bypasses_group_checks`). Staff and Supervisor need the
14//! specific permission granted either directly or via a group.
15//!
16//! Slimmed for Tier 1: the legacy file's developer stub routes
17//! (`__schema__`, `__logs__`, `__sql_console__`) and the FK remote-
18//! search endpoint have been dropped. Everything else — `/static/admin.css`
19//! and `/static/admin.js` (P8), login/logout, dashboard,
20//! /admin/users/*, /admin/groups/*, /admin/history,
21//! /admin/password_change, /admin/:model/* CRUD,
22//! /admin/:model/:id/history — is wired below.
23
24use 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
33/// Embedded stylesheet baked into the binary. P8 ships a single
34/// hand-written CSS file; project overrides happen via
35/// `Admin::theme(...)` (CSS custom properties) rather than an asset
36/// override, so we don't expose a disk path here.
37const ADMIN_CSS: &str = include_str!("../../assets/static/admin.css");
38
39/// Embedded admin JS (theme toggle + sidebar drawer). ≤200 LOC, no
40/// build step.
41const ADMIN_JS: &str = include_str!("../../assets/static/admin.js");
42
43use super::handlers::{self, AdminCtx};
44use super::render;
45use super::types::Admin;
46
47/// Either an identity + a permission check passed, or any non-Allow
48/// response the route closure should return as-is (a 303 redirect to
49/// /admin/login, a 403 forbidden body, etc.).
50enum Guard {
51    Allow(Identity),
52    Redirect(Response),
53}
54
55async fn login_guard(ctx: &AdminCtx, req: &Request) -> Result<Guard> {
56    let cookie = match req.header("cookie") {
57        Some(c) => c,
58        None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
59    };
60    let token = match auth::session_token_from_cookie(cookie) {
61        Some(t) => t,
62        None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
63    };
64    let ident = match auth::identity_from_session(&ctx.db, &token).await? {
65        Some(i) => i,
66        None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
67    };
68    if !ident.is_active {
69        return Ok(Guard::Redirect(Response::redirect("/admin/login")));
70    }
71    Ok(Guard::Allow(ident))
72}
73
74async fn role_guard(ctx: &AdminCtx, req: &Request, min: Role) -> Result<Guard> {
75    match login_guard(ctx, req).await? {
76        Guard::Redirect(r) => Ok(Guard::Redirect(r)),
77        Guard::Allow(ident) => {
78            if ident.role.includes(min) {
79                Ok(Guard::Allow(ident))
80            } else {
81                let body = render::render_forbidden_body(
82                    &ctx.admin,
83                    &ctx.templates,
84                    &ident,
85                    handlers::csrf_token(req),
86                    None,
87                    Some(min.label()),
88                )?;
89                Ok(Guard::Redirect(
90                    Response::html(body).with_status(hyper::StatusCode::FORBIDDEN),
91                ))
92            }
93        }
94    }
95}
96
97async fn perm_guard(ctx: &AdminCtx, req: &Request, perm: &str) -> Result<Guard> {
98    match role_guard(ctx, req, Role::Staff).await? {
99        Guard::Redirect(r) => Ok(Guard::Redirect(r)),
100        Guard::Allow(ident) => {
101            if ident.role.bypasses_group_checks() {
102                return Ok(Guard::Allow(ident));
103            }
104            if auth::check_permission(&ctx.db, &ident, perm).await? {
105                Ok(Guard::Allow(ident))
106            } else {
107                let body = render::render_forbidden_body(
108                    &ctx.admin,
109                    &ctx.templates,
110                    &ident,
111                    handlers::csrf_token(req),
112                    Some(perm.to_string()),
113                    None,
114                )?;
115                Ok(Guard::Redirect(
116                    Response::html(body).with_status(hyper::StatusCode::FORBIDDEN),
117                ))
118            }
119        }
120    }
121}
122
123/// Pure decision logic for `perm_guard`, factored out so it can be
124/// unit-tested without a `Db`.
125#[cfg(test)]
126fn perm_guard_verdict(ident: &Identity, perm_held: bool) -> bool {
127    if !ident.is_active {
128        return false;
129    }
130    if ident.role.bypasses_group_checks() {
131        return true;
132    }
133    perm_held
134}
135
136fn parse_id(raw: Option<&str>) -> Result<i64> {
137    raw.and_then(|s| s.parse().ok())
138        .ok_or_else(|| Error::BadRequest("invalid id".into()))
139}
140
141fn model_name_from_req(req: &Request) -> Result<String> {
142    req.param("admin_name")
143        .map(|s| s.to_string())
144        .ok_or_else(|| Error::BadRequest("missing model".into()))
145}
146
147fn perm_for(ctx: &AdminCtx, admin_name: &str, action: &str) -> Result<String> {
148    let entry = ctx
149        .admin
150        .find(admin_name)
151        .ok_or_else(|| Error::NotFound(format!("no admin model: {admin_name}")))?;
152    let singular = entry.singular_name.to_ascii_lowercase();
153    Ok(format!("{admin_name}.{action}_{singular}"))
154}
155
156pub fn register_admin_routes(
157    router: Router,
158    admin: Admin,
159    db: Db,
160    templates: Arc<Templates>,
161) -> Router {
162    let ctx = Arc::new(AdminCtx::new(
163        Arc::new(admin),
164        db.clone(),
165        templates.clone(),
166    ));
167
168    // Bespoke user/group pages share the same DB / templates / Admin
169    // arc but live in their own ctx type with the same shape.
170    let auth_ctx = Arc::new(super::builtin::AuthAdminCtx {
171        admin: ctx.admin.clone(),
172        db,
173        templates,
174    });
175
176    // Render `Err(_)` from /admin/* handlers as styled HTML instead of
177    // the framework default `text/plain`. Non-admin paths bubble
178    // through unchanged so JSON / curl consumers still get the text
179    // body. `Error::Forbidden` (handled by `role_guard` via
180    // `admin/forbidden.html`) and login-required redirects come
181    // through as `Ok` responses and bypass this branch.
182    let err_admin = ctx.admin.clone();
183    let err_templates = ctx.templates.clone();
184    let router = router.middleware(move |req, next| {
185        let admin = err_admin.clone();
186        let templates = err_templates.clone();
187        Box::pin(async move {
188            let is_admin_path = req.path().starts_with("/admin");
189            let result = next.run(req).await;
190            match result {
191                Ok(resp) => Ok(resp),
192                Err(err) if is_admin_path => Ok(render::render_admin_error_response(
193                    &admin,
194                    &templates,
195                    None,
196                    err.status(),
197                    err.client_message().to_string(),
198                )),
199                Err(err) => Err(err),
200            }
201        })
202    });
203
204    // Embedded stylesheet + JS. The bytes are baked into the binary
205    // so single-binary deploy is preserved; the long cache lifetime
206    // keeps repeat-loads cheap.
207    let router = router.get("/static/admin.css", |_req| async move {
208        Ok(Response::new(
209            hyper::StatusCode::OK,
210            bytes::Bytes::from_static(ADMIN_CSS.as_bytes()),
211        )
212        .with_header("content-type", "text/css; charset=utf-8")
213        .with_header("cache-control", "public, max-age=3600"))
214    });
215    let router = router.get("/static/admin.js", |_req| async move {
216        Ok(Response::new(
217            hyper::StatusCode::OK,
218            bytes::Bytes::from_static(ADMIN_JS.as_bytes()),
219        )
220        .with_header("content-type", "application/javascript; charset=utf-8")
221        .with_header("cache-control", "public, max-age=3600"))
222    });
223
224    // Public: login/logout.
225    let c = ctx.clone();
226    let router = router.get("/admin/login", move |req| {
227        let c = c.clone();
228        async move { handlers::show_login(&c, req).await }
229    });
230
231    let c = ctx.clone();
232    let router = router.post("/admin/login", move |req| {
233        let c = c.clone();
234        async move { handlers::do_login(&c, req).await }
235    });
236
237    let c = ctx.clone();
238    let router = router.post("/admin/logout", move |req| {
239        let c = c.clone();
240        async move { handlers::do_logout(&c, req).await }
241    });
242
243    // Dashboard — Staff floor. User-tier sees the forbidden page.
244    let c = ctx.clone();
245    let router = router.get("/admin", move |req| {
246        let c = c.clone();
247        async move {
248            match role_guard(&c, &req, Role::Staff).await? {
249                Guard::Redirect(r) => Ok(r),
250                Guard::Allow(ident) => handlers::dashboard(&c, ident, &req).await,
251            }
252        }
253    });
254
255    // Global history log (admin-only; high-signal page).
256    let c = ctx.clone();
257    let router = router.get("/admin/history", move |req| {
258        let c = c.clone();
259        async move {
260            match role_guard(&c, &req, Role::Administrator).await? {
261                Guard::Redirect(r) => Ok(r),
262                Guard::Allow(ident) => handlers::show_log_entries(&c, ident, &req).await,
263            }
264        }
265    });
266
267    // Self-service password change. Any logged-in user (User-tier and
268    // above). User-tier can change their own password even though
269    // they can't access the dashboard.
270    let c = ctx.clone();
271    let router = router.get("/admin/password_change", move |req| {
272        let c = c.clone();
273        async move {
274            match role_guard(&c, &req, Role::User).await? {
275                Guard::Redirect(r) => Ok(r),
276                Guard::Allow(ident) => handlers::show_password_change(&c, ident, &req).await,
277            }
278        }
279    });
280    let c = ctx.clone();
281    let router = router.post("/admin/password_change", move |req| {
282        let c = c.clone();
283        async move {
284            match role_guard(&c, &req, Role::User).await? {
285                Guard::Redirect(r) => Ok(r),
286                Guard::Allow(ident) => handlers::do_password_change(&c, ident, req).await,
287            }
288        }
289    });
290
291    // --- Built-in users admin (admin-only) ---
292    let c = ctx.clone();
293    let ac = auth_ctx.clone();
294    let router = router.get("/admin/users", move |req| {
295        let c = c.clone();
296        let ac = ac.clone();
297        async move {
298            match role_guard(&c, &req, Role::Administrator).await? {
299                Guard::Redirect(r) => Ok(r),
300                Guard::Allow(ident) => {
301                    super::builtin::list_users(&ac, ident, handlers::csrf_token(&req)).await
302                }
303            }
304        }
305    });
306
307    let c = ctx.clone();
308    let ac = auth_ctx.clone();
309    let router = router.get("/admin/users/new", move |req| {
310        let c = c.clone();
311        let ac = ac.clone();
312        async move {
313            match role_guard(&c, &req, Role::Administrator).await? {
314                Guard::Redirect(r) => Ok(r),
315                Guard::Allow(ident) => {
316                    super::builtin::show_new_user(&ac, ident, handlers::csrf_token(&req)).await
317                }
318            }
319        }
320    });
321
322    let c = ctx.clone();
323    let ac = auth_ctx.clone();
324    let router = router.post("/admin/users/new", move |req| {
325        let c = c.clone();
326        let ac = ac.clone();
327        async move {
328            match role_guard(&c, &req, Role::Administrator).await? {
329                Guard::Redirect(r) => Ok(r),
330                Guard::Allow(ident) => super::builtin::do_new_user(&ac, ident, req).await,
331            }
332        }
333    });
334
335    let c = ctx.clone();
336    let ac = auth_ctx.clone();
337    let router = router.get("/admin/users/:id/edit", move |req| {
338        let c = c.clone();
339        let ac = ac.clone();
340        async move {
341            match role_guard(&c, &req, Role::Administrator).await? {
342                Guard::Redirect(r) => Ok(r),
343                Guard::Allow(ident) => {
344                    let id = parse_id(req.param("id"))?;
345                    super::builtin::show_user_edit(&ac, ident, id, handlers::csrf_token(&req)).await
346                }
347            }
348        }
349    });
350
351    let c = ctx.clone();
352    let ac = auth_ctx.clone();
353    let router = router.post("/admin/users/:id/edit", move |req| {
354        let c = c.clone();
355        let ac = ac.clone();
356        async move {
357            match role_guard(&c, &req, Role::Administrator).await? {
358                Guard::Redirect(r) => Ok(r),
359                Guard::Allow(ident) => {
360                    let id = parse_id(req.param("id"))?;
361                    super::builtin::do_user_edit(&ac, ident, id, req).await
362                }
363            }
364        }
365    });
366
367    let c = ctx.clone();
368    let ac = auth_ctx.clone();
369    let router = router.get("/admin/users/:id/delete", move |req| {
370        let c = c.clone();
371        let ac = ac.clone();
372        async move {
373            match role_guard(&c, &req, Role::Administrator).await? {
374                Guard::Redirect(r) => Ok(r),
375                Guard::Allow(ident) => {
376                    let id = parse_id(req.param("id"))?;
377                    super::builtin::show_user_delete(&ac, ident, id, handlers::csrf_token(&req))
378                        .await
379                }
380            }
381        }
382    });
383
384    let c = ctx.clone();
385    let ac = auth_ctx.clone();
386    let router = router.post("/admin/users/:id/delete", 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::do_user_delete(&ac, ident, id, req).await
395                }
396            }
397        }
398    });
399
400    // Read-only user profile view. MUST be registered AFTER
401    // `/admin/users/new` and the `:id/edit` + `:id/delete` routes
402    // above: the router matches in insertion order, and `:id` is a
403    // wildcard that would happily swallow "new" or extra path
404    // segments. Putting this last preserves the more-specific routes'
405    // priority.
406    let c = ctx.clone();
407    let ac = auth_ctx.clone();
408    let router = router.get("/admin/users/:id", move |req| {
409        let c = c.clone();
410        let ac = ac.clone();
411        async move {
412            match role_guard(&c, &req, Role::Administrator).await? {
413                Guard::Redirect(r) => Ok(r),
414                Guard::Allow(ident) => {
415                    let id = parse_id(req.param("id"))?;
416                    let q = req.query();
417                    let tab = q.get("tab").map(|s| s.to_string());
418                    let page: i64 = q.get("page").and_then(|s| s.parse().ok()).unwrap_or(1);
419                    super::builtin::show_user_view(
420                        &ac,
421                        ident,
422                        id,
423                        handlers::csrf_token(&req),
424                        tab,
425                        page,
426                    )
427                    .await
428                }
429            }
430        }
431    });
432
433    // --- Built-in groups admin (admin-only) ---
434    let c = ctx.clone();
435    let ac = auth_ctx.clone();
436    let router = router.get("/admin/groups", move |req| {
437        let c = c.clone();
438        let ac = ac.clone();
439        async move {
440            match role_guard(&c, &req, Role::Administrator).await? {
441                Guard::Redirect(r) => Ok(r),
442                Guard::Allow(ident) => {
443                    super::builtin::list_groups(&ac, ident, handlers::csrf_token(&req)).await
444                }
445            }
446        }
447    });
448
449    let c = ctx.clone();
450    let ac = auth_ctx.clone();
451    let router = router.get("/admin/groups/new", move |req| {
452        let c = c.clone();
453        let ac = ac.clone();
454        async move {
455            match role_guard(&c, &req, Role::Administrator).await? {
456                Guard::Redirect(r) => Ok(r),
457                Guard::Allow(ident) => {
458                    super::builtin::show_new_group(&ac, ident, handlers::csrf_token(&req)).await
459                }
460            }
461        }
462    });
463
464    let c = ctx.clone();
465    let ac = auth_ctx.clone();
466    let router = router.post("/admin/groups/new", move |req| {
467        let c = c.clone();
468        let ac = ac.clone();
469        async move {
470            match role_guard(&c, &req, Role::Administrator).await? {
471                Guard::Redirect(r) => Ok(r),
472                Guard::Allow(ident) => super::builtin::do_new_group(&ac, ident, req).await,
473            }
474        }
475    });
476
477    let c = ctx.clone();
478    let ac = auth_ctx.clone();
479    let router = router.get("/admin/groups/:id/edit", move |req| {
480        let c = c.clone();
481        let ac = ac.clone();
482        async move {
483            match role_guard(&c, &req, Role::Administrator).await? {
484                Guard::Redirect(r) => Ok(r),
485                Guard::Allow(ident) => {
486                    let id = parse_id(req.param("id"))?;
487                    super::builtin::show_group_edit(&ac, ident, id, handlers::csrf_token(&req))
488                        .await
489                }
490            }
491        }
492    });
493
494    let c = ctx.clone();
495    let ac = auth_ctx.clone();
496    let router = router.post("/admin/groups/:id/edit", move |req| {
497        let c = c.clone();
498        let ac = ac.clone();
499        async move {
500            match role_guard(&c, &req, Role::Administrator).await? {
501                Guard::Redirect(r) => Ok(r),
502                Guard::Allow(ident) => {
503                    let id = parse_id(req.param("id"))?;
504                    super::builtin::do_group_edit(&ac, ident, id, req).await
505                }
506            }
507        }
508    });
509
510    let c = ctx.clone();
511    let ac = auth_ctx.clone();
512    let router = router.get("/admin/groups/:id/delete", move |req| {
513        let c = c.clone();
514        let ac = ac.clone();
515        async move {
516            match role_guard(&c, &req, Role::Administrator).await? {
517                Guard::Redirect(r) => Ok(r),
518                Guard::Allow(ident) => {
519                    let id = parse_id(req.param("id"))?;
520                    super::builtin::show_group_delete(&ac, ident, id, handlers::csrf_token(&req))
521                        .await
522                }
523            }
524        }
525    });
526
527    let c = ctx.clone();
528    let ac = auth_ctx.clone();
529    let router = router.post("/admin/groups/:id/delete", move |req| {
530        let c = c.clone();
531        let ac = ac.clone();
532        async move {
533            match role_guard(&c, &req, Role::Administrator).await? {
534                Guard::Redirect(r) => Ok(r),
535                Guard::Allow(ident) => {
536                    let id = parse_id(req.param("id"))?;
537                    super::builtin::do_group_delete(&ac, ident, id, req).await
538                }
539            }
540        }
541    });
542
543    // Per-model list — needs `view` permission.
544    let c = ctx.clone();
545    let router = router.get("/admin/:admin_name", move |req| {
546        let c = c.clone();
547        async move {
548            let name = model_name_from_req(&req)?;
549            let perm = perm_for(&c, &name, "view")?;
550            match perm_guard(&c, &req, &perm).await? {
551                Guard::Redirect(r) => Ok(r),
552                Guard::Allow(ident) => handlers::list_model(&c, ident, &name, &req).await,
553            }
554        }
555    });
556
557    // Create.
558    let c = ctx.clone();
559    let router = router.get("/admin/:admin_name/new", move |req| {
560        let c = c.clone();
561        async move {
562            let name = model_name_from_req(&req)?;
563            let perm = perm_for(&c, &name, "add")?;
564            match perm_guard(&c, &req, &perm).await? {
565                Guard::Redirect(r) => Ok(r),
566                Guard::Allow(ident) => handlers::show_new_form(&c, ident, &name, &req).await,
567            }
568        }
569    });
570    let c = ctx.clone();
571    let router = router.post("/admin/:admin_name/new", move |req| {
572        let c = c.clone();
573        async move {
574            let name = model_name_from_req(&req)?;
575            let perm = perm_for(&c, &name, "add")?;
576            match perm_guard(&c, &req, &perm).await? {
577                Guard::Redirect(r) => Ok(r),
578                Guard::Allow(ident) => handlers::do_create(&c, ident, &name, req).await,
579            }
580        }
581    });
582
583    // Edit.
584    let c = ctx.clone();
585    let router = router.get("/admin/:admin_name/:id/edit", move |req| {
586        let c = c.clone();
587        async move {
588            let name = model_name_from_req(&req)?;
589            let perm = perm_for(&c, &name, "change")?;
590            match perm_guard(&c, &req, &perm).await? {
591                Guard::Redirect(r) => Ok(r),
592                Guard::Allow(ident) => {
593                    let id = parse_id(req.param("id"))?;
594                    handlers::show_edit_form(&c, ident, &name, id, &req).await
595                }
596            }
597        }
598    });
599    let c = ctx.clone();
600    let router = router.post("/admin/:admin_name/:id/edit", move |req| {
601        let c = c.clone();
602        async move {
603            let name = model_name_from_req(&req)?;
604            let perm = perm_for(&c, &name, "change")?;
605            match perm_guard(&c, &req, &perm).await? {
606                Guard::Redirect(r) => Ok(r),
607                Guard::Allow(ident) => {
608                    let id = parse_id(req.param("id"))?;
609                    handlers::do_update(&c, ident, &name, id, req).await
610                }
611            }
612        }
613    });
614
615    // Per-object history. Read-only; same `view` permission as the
616    // changelist (if you can list, you can read the audit trail).
617    let c = ctx.clone();
618    let router = router.get("/admin/:admin_name/:id/history", move |req| {
619        let c = c.clone();
620        async move {
621            let name = model_name_from_req(&req)?;
622            let perm = perm_for(&c, &name, "view")?;
623            match perm_guard(&c, &req, &perm).await? {
624                Guard::Redirect(r) => Ok(r),
625                Guard::Allow(ident) => {
626                    let id = parse_id(req.param("id"))?;
627                    handlers::show_object_history(&c, ident, &name, id, &req).await
628                }
629            }
630        }
631    });
632
633    // Delete.
634    let c = ctx.clone();
635    let router = router.get("/admin/:admin_name/:id/delete", move |req| {
636        let c = c.clone();
637        async move {
638            let name = model_name_from_req(&req)?;
639            let perm = perm_for(&c, &name, "delete")?;
640            match perm_guard(&c, &req, &perm).await? {
641                Guard::Redirect(r) => Ok(r),
642                Guard::Allow(ident) => {
643                    let id = parse_id(req.param("id"))?;
644                    handlers::show_delete_confirm(&c, ident, &name, id, &req).await
645                }
646            }
647        }
648    });
649    let c = ctx.clone();
650    router.post("/admin/:admin_name/:id/delete", move |req| {
651        let c = c.clone();
652        async move {
653            let name = model_name_from_req(&req)?;
654            let perm = perm_for(&c, &name, "delete")?;
655            match perm_guard(&c, &req, &perm).await? {
656                Guard::Redirect(r) => Ok(r),
657                Guard::Allow(ident) => {
658                    let id = parse_id(req.param("id"))?;
659                    handlers::do_delete(&c, ident, &name, id).await
660                }
661            }
662        }
663    })
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669
670    fn make_identity(role: Role, is_active: bool) -> Identity {
671        Identity {
672            user_id: 42,
673            email: "test@example.com".into(),
674            role,
675            is_active,
676            is_demo: false,
677            demo_label: None,
678        }
679    }
680
681    // role_guard's decision is `Role::includes(min)`. The 25-case
682    // matrix lives in `auth::role::tests::includes_matrix_…`; the
683    // cases below pin the most operator-relevant pairings.
684
685    #[test]
686    fn role_guard_decision_admin_meets_staff_floor() {
687        let id = make_identity(Role::Administrator, true);
688        assert!(id.role.includes(Role::Staff));
689    }
690
691    #[test]
692    fn role_guard_decision_user_does_not_meet_staff() {
693        let id = make_identity(Role::User, true);
694        assert!(!id.role.includes(Role::Staff));
695    }
696
697    #[test]
698    fn role_guard_decision_administrator_does_not_meet_developer() {
699        let id = make_identity(Role::Administrator, true);
700        assert!(!id.role.includes(Role::Developer));
701    }
702
703    #[test]
704    fn role_guard_decision_developer_meets_everything() {
705        let id = make_identity(Role::Developer, true);
706        for &min in &[
707            Role::User,
708            Role::Staff,
709            Role::Supervisor,
710            Role::Administrator,
711            Role::Developer,
712        ] {
713            assert!(id.role.includes(min), "Developer should meet {min:?}");
714        }
715    }
716
717    // ---- perm_guard_verdict matrix --------------------------------------
718
719    #[test]
720    fn perm_guard_admin_short_circuits_without_perm() {
721        let id = make_identity(Role::Administrator, true);
722        assert!(perm_guard_verdict(&id, false));
723    }
724
725    #[test]
726    fn perm_guard_developer_short_circuits_without_perm() {
727        let id = make_identity(Role::Developer, true);
728        assert!(perm_guard_verdict(&id, false));
729    }
730
731    #[test]
732    fn perm_guard_staff_with_perm_passes() {
733        let id = make_identity(Role::Staff, true);
734        assert!(perm_guard_verdict(&id, true));
735    }
736
737    #[test]
738    fn perm_guard_staff_without_perm_denies() {
739        let id = make_identity(Role::Staff, true);
740        assert!(!perm_guard_verdict(&id, false));
741    }
742
743    #[test]
744    fn perm_guard_inactive_admin_denies_even_with_bypass() {
745        // Defense-in-depth invariant.
746        let id = make_identity(Role::Administrator, false);
747        assert!(!perm_guard_verdict(&id, true));
748    }
749
750    #[test]
751    fn perm_guard_supervisor_without_perm_denies() {
752        // Supervisor doesn't bypass; needs the per-model perm.
753        let id = make_identity(Role::Supervisor, true);
754        assert!(!perm_guard_verdict(&id, false));
755    }
756}