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
43/// Self-hosted fonts (SIL OFL-1.1, see assets/static/fonts/LICENSE.txt).
44/// Bundling them as bytes keeps the single-binary deploy story intact
45/// and avoids the FOUT/CDN round-trip every consuming app would
46/// otherwise inherit from a Google Fonts <link>.
47///
48/// Latin: Geist (variable wght 100..900) + Geist Mono (variable wght
49/// 100..900). Arabic: Tajawal (UI surfaces — buttons, sidebar, tables)
50/// in 400/500/700, plus Noto Naskh Arabic (paragraph body, variable
51/// wght 400..700).
52const 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
64/// Either an identity + a permission check passed, or any non-Allow
65/// response the route closure should return as-is (a 303 redirect to
66/// /admin/login, a 403 forbidden body, etc.).
67enum Guard {
68    Allow(Identity),
69    Redirect(Response),
70}
71
72async fn login_guard(ctx: &AdminCtx, req: &Request) -> Result<Guard> {
73    let cookie = match req.header("cookie") {
74        Some(c) => c,
75        None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
76    };
77    let token = match auth::session_token_from_cookie(cookie) {
78        Some(t) => t,
79        None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
80    };
81    let ident = match auth::identity_from_session(&ctx.db, &token).await? {
82        Some(i) => i,
83        None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
84    };
85    if !ident.is_active {
86        return Ok(Guard::Redirect(Response::redirect("/admin/login")));
87    }
88    Ok(Guard::Allow(ident))
89}
90
91async fn role_guard(ctx: &AdminCtx, req: &Request, min: Role) -> Result<Guard> {
92    match login_guard(ctx, req).await? {
93        Guard::Redirect(r) => Ok(Guard::Redirect(r)),
94        Guard::Allow(ident) => {
95            if ident.role.includes(min) {
96                Ok(Guard::Allow(ident))
97            } else {
98                let body = render::render_forbidden_body(
99                    &ctx.admin,
100                    &ctx.templates,
101                    &ident,
102                    handlers::csrf_token(req),
103                    None,
104                    Some(min.label()),
105                )?;
106                Ok(Guard::Redirect(
107                    Response::html(body).with_status(hyper::StatusCode::FORBIDDEN),
108                ))
109            }
110        }
111    }
112}
113
114async fn perm_guard(ctx: &AdminCtx, req: &Request, perm: &str) -> Result<Guard> {
115    match role_guard(ctx, req, Role::Staff).await? {
116        Guard::Redirect(r) => Ok(Guard::Redirect(r)),
117        Guard::Allow(ident) => {
118            if ident.role.bypasses_group_checks() {
119                return Ok(Guard::Allow(ident));
120            }
121            if auth::check_permission(&ctx.db, &ident, perm).await? {
122                Ok(Guard::Allow(ident))
123            } else {
124                let body = render::render_forbidden_body(
125                    &ctx.admin,
126                    &ctx.templates,
127                    &ident,
128                    handlers::csrf_token(req),
129                    Some(perm.to_string()),
130                    None,
131                )?;
132                Ok(Guard::Redirect(
133                    Response::html(body).with_status(hyper::StatusCode::FORBIDDEN),
134                ))
135            }
136        }
137    }
138}
139
140/// Pure decision logic for `perm_guard`, factored out so it can be
141/// unit-tested without a `Db`.
142#[cfg(test)]
143fn perm_guard_verdict(ident: &Identity, perm_held: bool) -> bool {
144    if !ident.is_active {
145        return false;
146    }
147    if ident.role.bypasses_group_checks() {
148        return true;
149    }
150    perm_held
151}
152
153fn parse_id(raw: Option<&str>) -> Result<i64> {
154    raw.and_then(|s| s.parse().ok())
155        .ok_or_else(|| Error::BadRequest("invalid id".into()))
156}
157
158fn model_name_from_req(req: &Request) -> Result<String> {
159    req.param("admin_name")
160        .map(|s| s.to_string())
161        .ok_or_else(|| Error::BadRequest("missing model".into()))
162}
163
164fn perm_for(ctx: &AdminCtx, admin_name: &str, action: &str) -> Result<String> {
165    let entry = ctx
166        .admin
167        .find(admin_name)
168        .ok_or_else(|| Error::NotFound(format!("no admin model: {admin_name}")))?;
169    let singular = entry.singular_name.to_ascii_lowercase();
170    Ok(format!("{admin_name}.{action}_{singular}"))
171}
172
173pub fn register_admin_routes(
174    router: Router,
175    admin: Admin,
176    db: Db,
177    templates: Arc<Templates>,
178) -> Router {
179    let ctx = Arc::new(AdminCtx::new(
180        Arc::new(admin),
181        db.clone(),
182        templates.clone(),
183    ));
184
185    // Bespoke user/group pages share the same DB / templates / Admin
186    // arc but live in their own ctx type with the same shape.
187    let auth_ctx = Arc::new(super::builtin::AuthAdminCtx {
188        admin: ctx.admin.clone(),
189        db,
190        templates,
191    });
192
193    // Render `Err(_)` from /admin/* handlers as styled HTML instead of
194    // the framework default `text/plain`. Non-admin paths bubble
195    // through unchanged so JSON / curl consumers still get the text
196    // body. `Error::Forbidden` (handled by `role_guard` via
197    // `admin/forbidden.html`) and login-required redirects come
198    // through as `Ok` responses and bypass this branch.
199    let err_admin = ctx.admin.clone();
200    let err_templates = ctx.templates.clone();
201    let router = router.middleware(move |req, next| {
202        let admin = err_admin.clone();
203        let templates = err_templates.clone();
204        Box::pin(async move {
205            let is_admin_path = req.path().starts_with("/admin");
206            let result = next.run(req).await;
207            match result {
208                Ok(resp) => Ok(resp),
209                Err(err) if is_admin_path => Ok(render::render_admin_error_response(
210                    &admin,
211                    &templates,
212                    None,
213                    err.status(),
214                    err.client_message().to_string(),
215                )),
216                Err(err) => Err(err),
217            }
218        })
219    });
220
221    // Embedded stylesheet + JS. The bytes are baked into the binary
222    // so single-binary deploy is preserved. CSS/JS use `no-cache`
223    // (revalidate every request) so theme + design tweaks roll out the
224    // moment the binary restarts; fonts (next block) keep their long
225    // immutable cache because their bytes never change per release.
226    let router = router.get("/static/admin.css", |_req| async move {
227        Ok(Response::new(
228            hyper::StatusCode::OK,
229            bytes::Bytes::from_static(ADMIN_CSS.as_bytes()),
230        )
231        .with_header("content-type", "text/css; charset=utf-8")
232        .with_header("cache-control", "no-cache, must-revalidate"))
233    });
234    let router = router.get("/static/admin.js", |_req| async move {
235        Ok(Response::new(
236            hyper::StatusCode::OK,
237            bytes::Bytes::from_static(ADMIN_JS.as_bytes()),
238        )
239        .with_header("content-type", "application/javascript; charset=utf-8")
240        .with_header("cache-control", "no-cache, must-revalidate"))
241    });
242
243    // Self-hosted fonts. Cache aggressively: file contents are
244    // immutable per build, so a 1-year cache is safe — the binary
245    // ships a fresh copy on the next release.
246    fn font_response(bytes: &'static [u8]) -> Response {
247        Response::new(hyper::StatusCode::OK, bytes::Bytes::from_static(bytes))
248            .with_header("content-type", "font/woff2")
249            .with_header("cache-control", "public, max-age=31536000, immutable")
250    }
251    let router = router.get("/static/fonts/Geist-Variable.woff2", |_req| async move {
252        Ok(font_response(FONT_GEIST))
253    });
254    let router = router.get(
255        "/static/fonts/GeistMono-Variable.woff2",
256        |_req| async move { Ok(font_response(FONT_GEIST_MONO)) },
257    );
258    let router = router.get("/static/fonts/Tajawal-Regular.woff2", |_req| async move {
259        Ok(font_response(FONT_TAJAWAL_REG))
260    });
261    let router = router.get("/static/fonts/Tajawal-Medium.woff2", |_req| async move {
262        Ok(font_response(FONT_TAJAWAL_MED))
263    });
264    let router = router.get("/static/fonts/Tajawal-Bold.woff2", |_req| async move {
265        Ok(font_response(FONT_TAJAWAL_BOLD))
266    });
267    let router = router.get(
268        "/static/fonts/NotoNaskhArabic-Variable.woff2",
269        |_req| async move { Ok(font_response(FONT_NOTO_NASKH_AR)) },
270    );
271
272    // Public: login/logout.
273    let c = ctx.clone();
274    let router = router.get("/admin/login", move |req| {
275        let c = c.clone();
276        async move { handlers::show_login(&c, req).await }
277    });
278
279    let c = ctx.clone();
280    let router = router.post("/admin/login", move |req| {
281        let c = c.clone();
282        async move { handlers::do_login(&c, req).await }
283    });
284
285    let c = ctx.clone();
286    let router = router.post("/admin/logout", move |req| {
287        let c = c.clone();
288        async move { handlers::do_logout(&c, req).await }
289    });
290
291    // Dashboard — Staff floor. User-tier sees the forbidden page.
292    let c = ctx.clone();
293    let router = router.get("/admin", move |req| {
294        let c = c.clone();
295        async move {
296            match role_guard(&c, &req, Role::Staff).await? {
297                Guard::Redirect(r) => Ok(r),
298                Guard::Allow(ident) => handlers::dashboard(&c, ident, &req).await,
299            }
300        }
301    });
302
303    // Global history log (admin-only; high-signal page).
304    let c = ctx.clone();
305    let router = router.get("/admin/history", move |req| {
306        let c = c.clone();
307        async move {
308            match role_guard(&c, &req, Role::Administrator).await? {
309                Guard::Redirect(r) => Ok(r),
310                Guard::Allow(ident) => handlers::show_log_entries(&c, ident, &req).await,
311            }
312        }
313    });
314
315    // Self-service password change. Any logged-in user (User-tier and
316    // above). User-tier can change their own password even though
317    // they can't access the dashboard.
318    let c = ctx.clone();
319    let router = router.get("/admin/password_change", move |req| {
320        let c = c.clone();
321        async move {
322            match role_guard(&c, &req, Role::User).await? {
323                Guard::Redirect(r) => Ok(r),
324                Guard::Allow(ident) => handlers::show_password_change(&c, ident, &req).await,
325            }
326        }
327    });
328    let c = ctx.clone();
329    let router = router.post("/admin/password_change", move |req| {
330        let c = c.clone();
331        async move {
332            match role_guard(&c, &req, Role::User).await? {
333                Guard::Redirect(r) => Ok(r),
334                Guard::Allow(ident) => handlers::do_password_change(&c, ident, req).await,
335            }
336        }
337    });
338
339    // --- Built-in users admin (admin-only) ---
340    let c = ctx.clone();
341    let ac = auth_ctx.clone();
342    let router = router.get("/admin/users", move |req| {
343        let c = c.clone();
344        let ac = ac.clone();
345        async move {
346            match role_guard(&c, &req, Role::Administrator).await? {
347                Guard::Redirect(r) => Ok(r),
348                Guard::Allow(ident) => {
349                    super::builtin::list_users(&ac, ident, handlers::csrf_token(&req)).await
350                }
351            }
352        }
353    });
354
355    let c = ctx.clone();
356    let ac = auth_ctx.clone();
357    let router = router.get("/admin/users/new", move |req| {
358        let c = c.clone();
359        let ac = ac.clone();
360        async move {
361            match role_guard(&c, &req, Role::Administrator).await? {
362                Guard::Redirect(r) => Ok(r),
363                Guard::Allow(ident) => {
364                    super::builtin::show_new_user(&ac, ident, handlers::csrf_token(&req)).await
365                }
366            }
367        }
368    });
369
370    let c = ctx.clone();
371    let ac = auth_ctx.clone();
372    let router = router.post("/admin/users/new", move |req| {
373        let c = c.clone();
374        let ac = ac.clone();
375        async move {
376            match role_guard(&c, &req, Role::Administrator).await? {
377                Guard::Redirect(r) => Ok(r),
378                Guard::Allow(ident) => super::builtin::do_new_user(&ac, ident, req).await,
379            }
380        }
381    });
382
383    let c = ctx.clone();
384    let ac = auth_ctx.clone();
385    let router = router.get("/admin/users/:id/edit", move |req| {
386        let c = c.clone();
387        let ac = ac.clone();
388        async move {
389            match role_guard(&c, &req, Role::Administrator).await? {
390                Guard::Redirect(r) => Ok(r),
391                Guard::Allow(ident) => {
392                    let id = parse_id(req.param("id"))?;
393                    super::builtin::show_user_edit(&ac, ident, id, handlers::csrf_token(&req)).await
394                }
395            }
396        }
397    });
398
399    let c = ctx.clone();
400    let ac = auth_ctx.clone();
401    let router = router.post("/admin/users/:id/edit", move |req| {
402        let c = c.clone();
403        let ac = ac.clone();
404        async move {
405            match role_guard(&c, &req, Role::Administrator).await? {
406                Guard::Redirect(r) => Ok(r),
407                Guard::Allow(ident) => {
408                    let id = parse_id(req.param("id"))?;
409                    super::builtin::do_user_edit(&ac, ident, id, req).await
410                }
411            }
412        }
413    });
414
415    let c = ctx.clone();
416    let ac = auth_ctx.clone();
417    let router = router.get("/admin/users/:id/delete", move |req| {
418        let c = c.clone();
419        let ac = ac.clone();
420        async move {
421            match role_guard(&c, &req, Role::Administrator).await? {
422                Guard::Redirect(r) => Ok(r),
423                Guard::Allow(ident) => {
424                    let id = parse_id(req.param("id"))?;
425                    super::builtin::show_user_delete(&ac, ident, id, handlers::csrf_token(&req))
426                        .await
427                }
428            }
429        }
430    });
431
432    let c = ctx.clone();
433    let ac = auth_ctx.clone();
434    let router = router.post("/admin/users/:id/delete", move |req| {
435        let c = c.clone();
436        let ac = ac.clone();
437        async move {
438            match role_guard(&c, &req, Role::Administrator).await? {
439                Guard::Redirect(r) => Ok(r),
440                Guard::Allow(ident) => {
441                    let id = parse_id(req.param("id"))?;
442                    super::builtin::do_user_delete(&ac, ident, id, req).await
443                }
444            }
445        }
446    });
447
448    // Read-only user profile view. MUST be registered AFTER
449    // `/admin/users/new` and the `:id/edit` + `:id/delete` routes
450    // above: the router matches in insertion order, and `:id` is a
451    // wildcard that would happily swallow "new" or extra path
452    // segments. Putting this last preserves the more-specific routes'
453    // priority.
454    let c = ctx.clone();
455    let ac = auth_ctx.clone();
456    let router = router.get("/admin/users/:id", move |req| {
457        let c = c.clone();
458        let ac = ac.clone();
459        async move {
460            match role_guard(&c, &req, Role::Administrator).await? {
461                Guard::Redirect(r) => Ok(r),
462                Guard::Allow(ident) => {
463                    let id = parse_id(req.param("id"))?;
464                    let q = req.query();
465                    let tab = q.get("tab").map(|s| s.to_string());
466                    let page: i64 = q.get("page").and_then(|s| s.parse().ok()).unwrap_or(1);
467                    super::builtin::show_user_view(
468                        &ac,
469                        ident,
470                        id,
471                        handlers::csrf_token(&req),
472                        tab,
473                        page,
474                    )
475                    .await
476                }
477            }
478        }
479    });
480
481    // --- Built-in groups admin (admin-only) ---
482    let c = ctx.clone();
483    let ac = auth_ctx.clone();
484    let router = router.get("/admin/groups", move |req| {
485        let c = c.clone();
486        let ac = ac.clone();
487        async move {
488            match role_guard(&c, &req, Role::Administrator).await? {
489                Guard::Redirect(r) => Ok(r),
490                Guard::Allow(ident) => {
491                    super::builtin::list_groups(&ac, ident, handlers::csrf_token(&req)).await
492                }
493            }
494        }
495    });
496
497    let c = ctx.clone();
498    let ac = auth_ctx.clone();
499    let router = router.get("/admin/groups/new", move |req| {
500        let c = c.clone();
501        let ac = ac.clone();
502        async move {
503            match role_guard(&c, &req, Role::Administrator).await? {
504                Guard::Redirect(r) => Ok(r),
505                Guard::Allow(ident) => {
506                    super::builtin::show_new_group(&ac, ident, handlers::csrf_token(&req)).await
507                }
508            }
509        }
510    });
511
512    let c = ctx.clone();
513    let ac = auth_ctx.clone();
514    let router = router.post("/admin/groups/new", move |req| {
515        let c = c.clone();
516        let ac = ac.clone();
517        async move {
518            match role_guard(&c, &req, Role::Administrator).await? {
519                Guard::Redirect(r) => Ok(r),
520                Guard::Allow(ident) => super::builtin::do_new_group(&ac, ident, req).await,
521            }
522        }
523    });
524
525    let c = ctx.clone();
526    let ac = auth_ctx.clone();
527    let router = router.get("/admin/groups/:id/edit", move |req| {
528        let c = c.clone();
529        let ac = ac.clone();
530        async move {
531            match role_guard(&c, &req, Role::Administrator).await? {
532                Guard::Redirect(r) => Ok(r),
533                Guard::Allow(ident) => {
534                    let id = parse_id(req.param("id"))?;
535                    super::builtin::show_group_edit(&ac, ident, id, handlers::csrf_token(&req))
536                        .await
537                }
538            }
539        }
540    });
541
542    let c = ctx.clone();
543    let ac = auth_ctx.clone();
544    let router = router.post("/admin/groups/:id/edit", move |req| {
545        let c = c.clone();
546        let ac = ac.clone();
547        async move {
548            match role_guard(&c, &req, Role::Administrator).await? {
549                Guard::Redirect(r) => Ok(r),
550                Guard::Allow(ident) => {
551                    let id = parse_id(req.param("id"))?;
552                    super::builtin::do_group_edit(&ac, ident, id, req).await
553                }
554            }
555        }
556    });
557
558    let c = ctx.clone();
559    let ac = auth_ctx.clone();
560    let router = router.get("/admin/groups/:id/delete", move |req| {
561        let c = c.clone();
562        let ac = ac.clone();
563        async move {
564            match role_guard(&c, &req, Role::Administrator).await? {
565                Guard::Redirect(r) => Ok(r),
566                Guard::Allow(ident) => {
567                    let id = parse_id(req.param("id"))?;
568                    super::builtin::show_group_delete(&ac, ident, id, handlers::csrf_token(&req))
569                        .await
570                }
571            }
572        }
573    });
574
575    let c = ctx.clone();
576    let ac = auth_ctx.clone();
577    let router = router.post("/admin/groups/:id/delete", move |req| {
578        let c = c.clone();
579        let ac = ac.clone();
580        async move {
581            match role_guard(&c, &req, Role::Administrator).await? {
582                Guard::Redirect(r) => Ok(r),
583                Guard::Allow(ident) => {
584                    let id = parse_id(req.param("id"))?;
585                    super::builtin::do_group_delete(&ac, ident, id, req).await
586                }
587            }
588        }
589    });
590
591    // Per-model list — needs `view` permission.
592    let c = ctx.clone();
593    let router = router.get("/admin/:admin_name", move |req| {
594        let c = c.clone();
595        async move {
596            let name = model_name_from_req(&req)?;
597            let perm = perm_for(&c, &name, "view")?;
598            match perm_guard(&c, &req, &perm).await? {
599                Guard::Redirect(r) => Ok(r),
600                Guard::Allow(ident) => handlers::list_model(&c, ident, &name, &req).await,
601            }
602        }
603    });
604
605    // Create.
606    let c = ctx.clone();
607    let router = router.get("/admin/:admin_name/new", move |req| {
608        let c = c.clone();
609        async move {
610            let name = model_name_from_req(&req)?;
611            let perm = perm_for(&c, &name, "add")?;
612            match perm_guard(&c, &req, &perm).await? {
613                Guard::Redirect(r) => Ok(r),
614                Guard::Allow(ident) => handlers::show_new_form(&c, ident, &name, &req).await,
615            }
616        }
617    });
618    let c = ctx.clone();
619    let router = router.post("/admin/:admin_name/new", move |req| {
620        let c = c.clone();
621        async move {
622            let name = model_name_from_req(&req)?;
623            let perm = perm_for(&c, &name, "add")?;
624            match perm_guard(&c, &req, &perm).await? {
625                Guard::Redirect(r) => Ok(r),
626                Guard::Allow(ident) => handlers::do_create(&c, ident, &name, req).await,
627            }
628        }
629    });
630
631    // Edit.
632    let c = ctx.clone();
633    let router = router.get("/admin/:admin_name/:id/edit", move |req| {
634        let c = c.clone();
635        async move {
636            let name = model_name_from_req(&req)?;
637            let perm = perm_for(&c, &name, "change")?;
638            match perm_guard(&c, &req, &perm).await? {
639                Guard::Redirect(r) => Ok(r),
640                Guard::Allow(ident) => {
641                    let id = parse_id(req.param("id"))?;
642                    handlers::show_edit_form(&c, ident, &name, id, &req).await
643                }
644            }
645        }
646    });
647    let c = ctx.clone();
648    let router = router.post("/admin/:admin_name/:id/edit", move |req| {
649        let c = c.clone();
650        async move {
651            let name = model_name_from_req(&req)?;
652            let perm = perm_for(&c, &name, "change")?;
653            match perm_guard(&c, &req, &perm).await? {
654                Guard::Redirect(r) => Ok(r),
655                Guard::Allow(ident) => {
656                    let id = parse_id(req.param("id"))?;
657                    handlers::do_update(&c, ident, &name, id, req).await
658                }
659            }
660        }
661    });
662
663    // Per-object history. Read-only; same `view` permission as the
664    // changelist (if you can list, you can read the audit trail).
665    let c = ctx.clone();
666    let router = router.get("/admin/:admin_name/:id/history", move |req| {
667        let c = c.clone();
668        async move {
669            let name = model_name_from_req(&req)?;
670            let perm = perm_for(&c, &name, "view")?;
671            match perm_guard(&c, &req, &perm).await? {
672                Guard::Redirect(r) => Ok(r),
673                Guard::Allow(ident) => {
674                    let id = parse_id(req.param("id"))?;
675                    handlers::show_object_history(&c, ident, &name, id, &req).await
676                }
677            }
678        }
679    });
680
681    // Delete.
682    let c = ctx.clone();
683    let router = router.get("/admin/:admin_name/:id/delete", move |req| {
684        let c = c.clone();
685        async move {
686            let name = model_name_from_req(&req)?;
687            let perm = perm_for(&c, &name, "delete")?;
688            match perm_guard(&c, &req, &perm).await? {
689                Guard::Redirect(r) => Ok(r),
690                Guard::Allow(ident) => {
691                    let id = parse_id(req.param("id"))?;
692                    handlers::show_delete_confirm(&c, ident, &name, id, &req).await
693                }
694            }
695        }
696    });
697    let c = ctx.clone();
698    let router = router.post("/admin/:admin_name/:id/delete", move |req| {
699        let c = c.clone();
700        async move {
701            let name = model_name_from_req(&req)?;
702            let perm = perm_for(&c, &name, "delete")?;
703            match perm_guard(&c, &req, &perm).await? {
704                Guard::Redirect(r) => Ok(r),
705                Guard::Allow(ident) => {
706                    let id = parse_id(req.param("id"))?;
707                    handlers::do_delete(&c, ident, &name, id).await
708                }
709            }
710        }
711    });
712
713    // Bulk delete — same permission gate as the per-row delete.
714    // Two-step flow: first POST renders the confirm page, second POST
715    // (with `_confirmed=1`) executes. See `handlers::handle_bulk_delete`
716    // for the full contract.
717    let c = ctx.clone();
718    let router = router.post("/admin/:admin_name/bulk_delete", move |req| {
719        let c = c.clone();
720        async move {
721            let name = model_name_from_req(&req)?;
722            let perm = perm_for(&c, &name, "delete")?;
723            match perm_guard(&c, &req, &perm).await? {
724                Guard::Redirect(r) => Ok(r),
725                Guard::Allow(ident) => handlers::handle_bulk_delete(&c, ident, &name, &req).await,
726            }
727        }
728    });
729
730    // Project-defined bulk actions. Permission gated on `change` —
731    // bulk actions modify rows but don't delete them (delete has its
732    // own route). Project-side guard against further write-vs-read
733    // distinctions belongs inside `execute_bulk_action`.
734    let c = ctx.clone();
735    router.post("/admin/:admin_name/bulk/:action", move |req| {
736        let c = c.clone();
737        async move {
738            let name = model_name_from_req(&req)?;
739            let action = req
740                .param("action")
741                .ok_or_else(|| Error::BadRequest("missing bulk action name".into()))?
742                .to_string();
743            let perm = perm_for(&c, &name, "change")?;
744            match perm_guard(&c, &req, &perm).await? {
745                Guard::Redirect(r) => Ok(r),
746                Guard::Allow(ident) => {
747                    handlers::handle_bulk_action(&c, ident, &name, &action, &req).await
748                }
749            }
750        }
751    })
752}
753
754#[cfg(test)]
755mod tests {
756    use super::*;
757
758    fn make_identity(role: Role, is_active: bool) -> Identity {
759        Identity {
760            user_id: 42,
761            email: "test@example.com".into(),
762            role,
763            is_active,
764            is_demo: false,
765            demo_label: None,
766        }
767    }
768
769    // role_guard's decision is `Role::includes(min)`. The 25-case
770    // matrix lives in `auth::role::tests::includes_matrix_…`; the
771    // cases below pin the most operator-relevant pairings.
772
773    #[test]
774    fn role_guard_decision_admin_meets_staff_floor() {
775        let id = make_identity(Role::Administrator, true);
776        assert!(id.role.includes(Role::Staff));
777    }
778
779    #[test]
780    fn role_guard_decision_user_does_not_meet_staff() {
781        let id = make_identity(Role::User, true);
782        assert!(!id.role.includes(Role::Staff));
783    }
784
785    #[test]
786    fn role_guard_decision_administrator_does_not_meet_developer() {
787        let id = make_identity(Role::Administrator, true);
788        assert!(!id.role.includes(Role::Developer));
789    }
790
791    #[test]
792    fn role_guard_decision_developer_meets_everything() {
793        let id = make_identity(Role::Developer, true);
794        for &min in &[
795            Role::User,
796            Role::Staff,
797            Role::Supervisor,
798            Role::Administrator,
799            Role::Developer,
800        ] {
801            assert!(id.role.includes(min), "Developer should meet {min:?}");
802        }
803    }
804
805    // ---- perm_guard_verdict matrix --------------------------------------
806
807    #[test]
808    fn perm_guard_admin_short_circuits_without_perm() {
809        let id = make_identity(Role::Administrator, true);
810        assert!(perm_guard_verdict(&id, false));
811    }
812
813    #[test]
814    fn perm_guard_developer_short_circuits_without_perm() {
815        let id = make_identity(Role::Developer, true);
816        assert!(perm_guard_verdict(&id, false));
817    }
818
819    #[test]
820    fn perm_guard_staff_with_perm_passes() {
821        let id = make_identity(Role::Staff, true);
822        assert!(perm_guard_verdict(&id, true));
823    }
824
825    #[test]
826    fn perm_guard_staff_without_perm_denies() {
827        let id = make_identity(Role::Staff, true);
828        assert!(!perm_guard_verdict(&id, false));
829    }
830
831    #[test]
832    fn perm_guard_inactive_admin_denies_even_with_bypass() {
833        // Defense-in-depth invariant.
834        let id = make_identity(Role::Administrator, false);
835        assert!(!perm_guard_verdict(&id, true));
836    }
837
838    #[test]
839    fn perm_guard_supervisor_without_perm_denies() {
840        // Supervisor doesn't bypass; needs the per-model perm.
841        let id = make_identity(Role::Supervisor, true);
842        assert!(!perm_guard_verdict(&id, false));
843    }
844}