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 active-sessions listing (R0; read-only). Any
316    // logged-in user (User-tier and above) can see their own active
317    // sessions. Revoke buttons land in 0.5.x once the centralized
318    // invalidate_sessions API is fully exercised by R1 password reset.
319    let c = ctx.clone();
320    let router = router.get("/admin/account/sessions", 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_account_sessions(&c, ident, &req).await,
326            }
327        }
328    });
329
330    // Self-service password change. Any logged-in user (User-tier and
331    // above). User-tier can change their own password even though
332    // they can't access the dashboard.
333    let c = ctx.clone();
334    let router = router.get("/admin/password_change", move |req| {
335        let c = c.clone();
336        async move {
337            match role_guard(&c, &req, Role::User).await? {
338                Guard::Redirect(r) => Ok(r),
339                Guard::Allow(ident) => handlers::show_password_change(&c, ident, &req).await,
340            }
341        }
342    });
343    let c = ctx.clone();
344    let router = router.post("/admin/password_change", move |req| {
345        let c = c.clone();
346        async move {
347            match role_guard(&c, &req, Role::User).await? {
348                Guard::Redirect(r) => Ok(r),
349                Guard::Allow(ident) => handlers::do_password_change(&c, ident, req).await,
350            }
351        }
352    });
353
354    // --- Built-in users admin (admin-only) ---
355    let c = ctx.clone();
356    let ac = auth_ctx.clone();
357    let router = router.get("/admin/users", 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::list_users(&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.get("/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) => {
379                    super::builtin::show_new_user(&ac, ident, handlers::csrf_token(&req)).await
380                }
381            }
382        }
383    });
384
385    let c = ctx.clone();
386    let ac = auth_ctx.clone();
387    let router = router.post("/admin/users/new", move |req| {
388        let c = c.clone();
389        let ac = ac.clone();
390        async move {
391            match role_guard(&c, &req, Role::Administrator).await? {
392                Guard::Redirect(r) => Ok(r),
393                Guard::Allow(ident) => super::builtin::do_new_user(&ac, ident, req).await,
394            }
395        }
396    });
397
398    let c = ctx.clone();
399    let ac = auth_ctx.clone();
400    let router = router.get("/admin/users/:id/edit", move |req| {
401        let c = c.clone();
402        let ac = ac.clone();
403        async move {
404            match role_guard(&c, &req, Role::Administrator).await? {
405                Guard::Redirect(r) => Ok(r),
406                Guard::Allow(ident) => {
407                    let id = parse_id(req.param("id"))?;
408                    super::builtin::show_user_edit(&ac, ident, id, handlers::csrf_token(&req)).await
409                }
410            }
411        }
412    });
413
414    let c = ctx.clone();
415    let ac = auth_ctx.clone();
416    let router = router.post("/admin/users/:id/edit", move |req| {
417        let c = c.clone();
418        let ac = ac.clone();
419        async move {
420            match role_guard(&c, &req, Role::Administrator).await? {
421                Guard::Redirect(r) => Ok(r),
422                Guard::Allow(ident) => {
423                    let id = parse_id(req.param("id"))?;
424                    super::builtin::do_user_edit(&ac, ident, id, req).await
425                }
426            }
427        }
428    });
429
430    let c = ctx.clone();
431    let ac = auth_ctx.clone();
432    let router = router.get("/admin/users/:id/delete", move |req| {
433        let c = c.clone();
434        let ac = ac.clone();
435        async move {
436            match role_guard(&c, &req, Role::Administrator).await? {
437                Guard::Redirect(r) => Ok(r),
438                Guard::Allow(ident) => {
439                    let id = parse_id(req.param("id"))?;
440                    super::builtin::show_user_delete(&ac, ident, id, handlers::csrf_token(&req))
441                        .await
442                }
443            }
444        }
445    });
446
447    let c = ctx.clone();
448    let ac = auth_ctx.clone();
449    let router = router.post("/admin/users/:id/delete", move |req| {
450        let c = c.clone();
451        let ac = ac.clone();
452        async move {
453            match role_guard(&c, &req, Role::Administrator).await? {
454                Guard::Redirect(r) => Ok(r),
455                Guard::Allow(ident) => {
456                    let id = parse_id(req.param("id"))?;
457                    super::builtin::do_user_delete(&ac, ident, id, req).await
458                }
459            }
460        }
461    });
462
463    // Read-only user profile view. MUST be registered AFTER
464    // `/admin/users/new` and the `:id/edit` + `:id/delete` routes
465    // above: the router matches in insertion order, and `:id` is a
466    // wildcard that would happily swallow "new" or extra path
467    // segments. Putting this last preserves the more-specific routes'
468    // priority.
469    let c = ctx.clone();
470    let ac = auth_ctx.clone();
471    let router = router.get("/admin/users/:id", move |req| {
472        let c = c.clone();
473        let ac = ac.clone();
474        async move {
475            match role_guard(&c, &req, Role::Administrator).await? {
476                Guard::Redirect(r) => Ok(r),
477                Guard::Allow(ident) => {
478                    let id = parse_id(req.param("id"))?;
479                    let q = req.query();
480                    let tab = q.get("tab").map(|s| s.to_string());
481                    let page: i64 = q.get("page").and_then(|s| s.parse().ok()).unwrap_or(1);
482                    super::builtin::show_user_view(
483                        &ac,
484                        ident,
485                        id,
486                        handlers::csrf_token(&req),
487                        tab,
488                        page,
489                    )
490                    .await
491                }
492            }
493        }
494    });
495
496    // --- Built-in groups admin (admin-only) ---
497    let c = ctx.clone();
498    let ac = auth_ctx.clone();
499    let router = router.get("/admin/groups", 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::list_groups(&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.get("/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) => {
521                    super::builtin::show_new_group(&ac, ident, handlers::csrf_token(&req)).await
522                }
523            }
524        }
525    });
526
527    let c = ctx.clone();
528    let ac = auth_ctx.clone();
529    let router = router.post("/admin/groups/new", 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) => super::builtin::do_new_group(&ac, ident, req).await,
536            }
537        }
538    });
539
540    let c = ctx.clone();
541    let ac = auth_ctx.clone();
542    let router = router.get("/admin/groups/:id/edit", move |req| {
543        let c = c.clone();
544        let ac = ac.clone();
545        async move {
546            match role_guard(&c, &req, Role::Administrator).await? {
547                Guard::Redirect(r) => Ok(r),
548                Guard::Allow(ident) => {
549                    let id = parse_id(req.param("id"))?;
550                    super::builtin::show_group_edit(&ac, ident, id, handlers::csrf_token(&req))
551                        .await
552                }
553            }
554        }
555    });
556
557    let c = ctx.clone();
558    let ac = auth_ctx.clone();
559    let router = router.post("/admin/groups/:id/edit", move |req| {
560        let c = c.clone();
561        let ac = ac.clone();
562        async move {
563            match role_guard(&c, &req, Role::Administrator).await? {
564                Guard::Redirect(r) => Ok(r),
565                Guard::Allow(ident) => {
566                    let id = parse_id(req.param("id"))?;
567                    super::builtin::do_group_edit(&ac, ident, id, req).await
568                }
569            }
570        }
571    });
572
573    let c = ctx.clone();
574    let ac = auth_ctx.clone();
575    let router = router.get("/admin/groups/:id/delete", move |req| {
576        let c = c.clone();
577        let ac = ac.clone();
578        async move {
579            match role_guard(&c, &req, Role::Administrator).await? {
580                Guard::Redirect(r) => Ok(r),
581                Guard::Allow(ident) => {
582                    let id = parse_id(req.param("id"))?;
583                    super::builtin::show_group_delete(&ac, ident, id, handlers::csrf_token(&req))
584                        .await
585                }
586            }
587        }
588    });
589
590    let c = ctx.clone();
591    let ac = auth_ctx.clone();
592    let router = router.post("/admin/groups/:id/delete", move |req| {
593        let c = c.clone();
594        let ac = ac.clone();
595        async move {
596            match role_guard(&c, &req, Role::Administrator).await? {
597                Guard::Redirect(r) => Ok(r),
598                Guard::Allow(ident) => {
599                    let id = parse_id(req.param("id"))?;
600                    super::builtin::do_group_delete(&ac, ident, id, req).await
601                }
602            }
603        }
604    });
605
606    // Per-model list — needs `view` permission.
607    let c = ctx.clone();
608    let router = router.get("/admin/:admin_name", 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, "view")?;
613            match perm_guard(&c, &req, &perm).await? {
614                Guard::Redirect(r) => Ok(r),
615                Guard::Allow(ident) => handlers::list_model(&c, ident, &name, &req).await,
616            }
617        }
618    });
619
620    // Create.
621    let c = ctx.clone();
622    let router = router.get("/admin/:admin_name/new", move |req| {
623        let c = c.clone();
624        async move {
625            let name = model_name_from_req(&req)?;
626            let perm = perm_for(&c, &name, "add")?;
627            match perm_guard(&c, &req, &perm).await? {
628                Guard::Redirect(r) => Ok(r),
629                Guard::Allow(ident) => handlers::show_new_form(&c, ident, &name, &req).await,
630            }
631        }
632    });
633    let c = ctx.clone();
634    let router = router.post("/admin/:admin_name/new", 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, "add")?;
639            match perm_guard(&c, &req, &perm).await? {
640                Guard::Redirect(r) => Ok(r),
641                Guard::Allow(ident) => handlers::do_create(&c, ident, &name, req).await,
642            }
643        }
644    });
645
646    // Edit.
647    let c = ctx.clone();
648    let router = router.get("/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::show_edit_form(&c, ident, &name, id, &req).await
658                }
659            }
660        }
661    });
662    let c = ctx.clone();
663    let router = router.post("/admin/:admin_name/:id/edit", move |req| {
664        let c = c.clone();
665        async move {
666            let name = model_name_from_req(&req)?;
667            let perm = perm_for(&c, &name, "change")?;
668            match perm_guard(&c, &req, &perm).await? {
669                Guard::Redirect(r) => Ok(r),
670                Guard::Allow(ident) => {
671                    let id = parse_id(req.param("id"))?;
672                    handlers::do_update(&c, ident, &name, id, req).await
673                }
674            }
675        }
676    });
677
678    // Per-object history. Read-only; same `view` permission as the
679    // changelist (if you can list, you can read the audit trail).
680    let c = ctx.clone();
681    let router = router.get("/admin/:admin_name/:id/history", move |req| {
682        let c = c.clone();
683        async move {
684            let name = model_name_from_req(&req)?;
685            let perm = perm_for(&c, &name, "view")?;
686            match perm_guard(&c, &req, &perm).await? {
687                Guard::Redirect(r) => Ok(r),
688                Guard::Allow(ident) => {
689                    let id = parse_id(req.param("id"))?;
690                    handlers::show_object_history(&c, ident, &name, id, &req).await
691                }
692            }
693        }
694    });
695
696    // Delete.
697    let c = ctx.clone();
698    let router = router.get("/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::show_delete_confirm(&c, ident, &name, id, &req).await
708                }
709            }
710        }
711    });
712    let c = ctx.clone();
713    let router = router.post("/admin/:admin_name/:id/delete", move |req| {
714        let c = c.clone();
715        async move {
716            let name = model_name_from_req(&req)?;
717            let perm = perm_for(&c, &name, "delete")?;
718            match perm_guard(&c, &req, &perm).await? {
719                Guard::Redirect(r) => Ok(r),
720                Guard::Allow(ident) => {
721                    let id = parse_id(req.param("id"))?;
722                    handlers::do_delete(&c, ident, &name, id).await
723                }
724            }
725        }
726    });
727
728    // Bulk delete — same permission gate as the per-row delete.
729    // Two-step flow: first POST renders the confirm page, second POST
730    // (with `_confirmed=1`) executes. See `handlers::handle_bulk_delete`
731    // for the full contract.
732    let c = ctx.clone();
733    let router = router.post("/admin/:admin_name/bulk_delete", move |req| {
734        let c = c.clone();
735        async move {
736            let name = model_name_from_req(&req)?;
737            let perm = perm_for(&c, &name, "delete")?;
738            match perm_guard(&c, &req, &perm).await? {
739                Guard::Redirect(r) => Ok(r),
740                Guard::Allow(ident) => handlers::handle_bulk_delete(&c, ident, &name, &req).await,
741            }
742        }
743    });
744
745    // Project-defined bulk actions. Permission gated on `change` —
746    // bulk actions modify rows but don't delete them (delete has its
747    // own route). Project-side guard against further write-vs-read
748    // distinctions belongs inside `execute_bulk_action`.
749    let c = ctx.clone();
750    router.post("/admin/:admin_name/bulk/:action", move |req| {
751        let c = c.clone();
752        async move {
753            let name = model_name_from_req(&req)?;
754            let action = req
755                .param("action")
756                .ok_or_else(|| Error::BadRequest("missing bulk action name".into()))?
757                .to_string();
758            let perm = perm_for(&c, &name, "change")?;
759            match perm_guard(&c, &req, &perm).await? {
760                Guard::Redirect(r) => Ok(r),
761                Guard::Allow(ident) => {
762                    handlers::handle_bulk_action(&c, ident, &name, &action, &req).await
763                }
764            }
765        }
766    })
767}
768
769#[cfg(test)]
770mod tests {
771    use super::*;
772
773    fn make_identity(role: Role, is_active: bool) -> Identity {
774        Identity {
775            user_id: 42,
776            email: "test@example.com".into(),
777            role,
778            is_active,
779            is_demo: false,
780            demo_label: None,
781        }
782    }
783
784    // role_guard's decision is `Role::includes(min)`. The 25-case
785    // matrix lives in `auth::role::tests::includes_matrix_…`; the
786    // cases below pin the most operator-relevant pairings.
787
788    #[test]
789    fn role_guard_decision_admin_meets_staff_floor() {
790        let id = make_identity(Role::Administrator, true);
791        assert!(id.role.includes(Role::Staff));
792    }
793
794    #[test]
795    fn role_guard_decision_user_does_not_meet_staff() {
796        let id = make_identity(Role::User, true);
797        assert!(!id.role.includes(Role::Staff));
798    }
799
800    #[test]
801    fn role_guard_decision_administrator_does_not_meet_developer() {
802        let id = make_identity(Role::Administrator, true);
803        assert!(!id.role.includes(Role::Developer));
804    }
805
806    #[test]
807    fn role_guard_decision_developer_meets_everything() {
808        let id = make_identity(Role::Developer, true);
809        for &min in &[
810            Role::User,
811            Role::Staff,
812            Role::Supervisor,
813            Role::Administrator,
814            Role::Developer,
815        ] {
816            assert!(id.role.includes(min), "Developer should meet {min:?}");
817        }
818    }
819
820    // ---- perm_guard_verdict matrix --------------------------------------
821
822    #[test]
823    fn perm_guard_admin_short_circuits_without_perm() {
824        let id = make_identity(Role::Administrator, true);
825        assert!(perm_guard_verdict(&id, false));
826    }
827
828    #[test]
829    fn perm_guard_developer_short_circuits_without_perm() {
830        let id = make_identity(Role::Developer, true);
831        assert!(perm_guard_verdict(&id, false));
832    }
833
834    #[test]
835    fn perm_guard_staff_with_perm_passes() {
836        let id = make_identity(Role::Staff, true);
837        assert!(perm_guard_verdict(&id, true));
838    }
839
840    #[test]
841    fn perm_guard_staff_without_perm_denies() {
842        let id = make_identity(Role::Staff, true);
843        assert!(!perm_guard_verdict(&id, false));
844    }
845
846    #[test]
847    fn perm_guard_inactive_admin_denies_even_with_bypass() {
848        // Defense-in-depth invariant.
849        let id = make_identity(Role::Administrator, false);
850        assert!(!perm_guard_verdict(&id, true));
851    }
852
853    #[test]
854    fn perm_guard_supervisor_without_perm_denies() {
855        // Supervisor doesn't bypass; needs the per-model perm.
856        let id = make_identity(Role::Supervisor, true);
857        assert!(!perm_guard_verdict(&id, false));
858    }
859}