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] =
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
65/// Either an identity + a permission check passed, or any non-Allow
66/// response the route closure should return as-is (a 303 redirect to
67/// /admin/login, a 403 forbidden body, etc.).
68enum 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/// Pure decision logic for `perm_guard`, factored out so it can be
142/// unit-tested without a `Db`.
143#[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    // Bespoke user/group pages share the same DB / templates / Admin
187    // arc but live in their own ctx type with the same shape.
188    let auth_ctx = Arc::new(super::builtin::AuthAdminCtx {
189        admin: ctx.admin.clone(),
190        db,
191        templates,
192    });
193
194    // Render `Err(_)` from /admin/* handlers as styled HTML instead of
195    // the framework default `text/plain`. Non-admin paths bubble
196    // through unchanged so JSON / curl consumers still get the text
197    // body. `Error::Forbidden` (handled by `role_guard` via
198    // `admin/forbidden.html`) and login-required redirects come
199    // through as `Ok` responses and bypass this branch.
200    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    // Embedded stylesheet + JS. The bytes are baked into the binary
223    // so single-binary deploy is preserved. CSS/JS use `no-cache`
224    // (revalidate every request) so theme + design tweaks roll out the
225    // moment the binary restarts; fonts (next block) keep their long
226    // immutable cache because their bytes never change per release.
227    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    // Self-hosted fonts. Cache aggressively: file contents are
245    // immutable per build, so a 1-year cache is safe — the binary
246    // ships a fresh copy on the next release.
247    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    // Public: login/logout.
274    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    // Dashboard — Staff floor. User-tier sees the forbidden page.
293    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    // Global history log (admin-only; high-signal page).
305    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    // Self-service password change. Any logged-in user (User-tier and
317    // above). User-tier can change their own password even though
318    // they can't access the dashboard.
319    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    // --- Built-in users admin (admin-only) ---
341    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    // Read-only user profile view. MUST be registered AFTER
450    // `/admin/users/new` and the `:id/edit` + `:id/delete` routes
451    // above: the router matches in insertion order, and `:id` is a
452    // wildcard that would happily swallow "new" or extra path
453    // segments. Putting this last preserves the more-specific routes'
454    // priority.
455    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    // --- Built-in groups admin (admin-only) ---
483    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    // Per-model list — needs `view` permission.
593    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    // Create.
607    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    // Edit.
633    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    // Per-object history. Read-only; same `view` permission as the
665    // changelist (if you can list, you can read the audit trail).
666    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    // Delete.
683    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    // Bulk delete — same permission gate as the per-row delete.
715    // Two-step flow: first POST renders the confirm page, second POST
716    // (with `_confirmed=1`) executes. See `handlers::handle_bulk_delete`
717    // for the full contract.
718    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    // Project-defined bulk actions. Permission gated on `change` —
734    // bulk actions modify rows but don't delete them (delete has its
735    // own route). Project-side guard against further write-vs-read
736    // distinctions belongs inside `execute_bulk_action`.
737    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    // role_guard's decision is `Role::includes(min)`. The 25-case
773    // matrix lives in `auth::role::tests::includes_matrix_…`; the
774    // cases below pin the most operator-relevant pairings.
775
776    #[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    // ---- perm_guard_verdict matrix --------------------------------------
809
810    #[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        // Defense-in-depth invariant.
837        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        // Supervisor doesn't bypass; needs the per-model perm.
844        let id = make_identity(Role::Supervisor, true);
845        assert!(!perm_guard_verdict(&id, false));
846    }
847}