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
173/// Pure verdict for the R1 strict-mailer boot guard
174/// (`DESIGN_RECOVERY.md` §12.1). Returns an operator-facing error
175/// string when the policy demands a real mailer but `Admin::new()`'s
176/// default `LogMailer` is still in place; returns `Ok(())`
177/// otherwise.
178///
179/// Detection is deterministic and structural: it reads
180/// [`Admin::has_custom_mailer`] (set whenever
181/// [`Admin::mailer`] has been called). No `Arc::ptr_eq` against a
182/// freshly-constructed `LogMailer`; no environment heuristics; no
183/// hostname checks; no "production mode" guessing — the operator
184/// declares intent by calling `Admin::mailer(...)` (and opts the
185/// policy in via `RecoveryPolicy::strict_mailer_required(true)`).
186///
187/// The framework treats an explicit `Admin::mailer(...)` call as
188/// satisfying the guard even when the supplied mailer is itself a
189/// `LogMailer` — this is the documented escape hatch for projects
190/// that want to silence the guard during a migration window
191/// without yet wiring a real transport.
192fn strict_mailer_guard_check(admin: &Admin) -> std::result::Result<(), String> {
193    if admin.active_recovery_policy().strict_mailer_required() && !admin.has_custom_mailer() {
194        Err(
195            "rustio-admin: RecoveryPolicy::strict_mailer_required() = true but no mailer \
196             was registered via Admin::mailer(...).\n\n\
197             The framework's default LogMailer writes recovery emails to log::info! instead \
198             of sending them, which is unsuitable for production. Recovery routes are NOT \
199             registered with this configuration.\n\n\
200             To resolve, choose one:\n\
201              (a) register a real mailer before calling register_admin_routes:\n\
202                  Admin::mailer(Arc::new(MyProjectMailer::new(...)))\n\
203              (b) opt the policy out of strict mode (the framework default — dev / CI / \
204                  testing baseline):\n\
205                  RecoveryPolicy::strict_mailer_required(false)\n\n\
206             See DESIGN_RECOVERY.md §12.1 for the contract."
207                .to_string(),
208        )
209    } else {
210        Ok(())
211    }
212}
213
214pub fn register_admin_routes(
215    router: Router,
216    admin: Admin,
217    db: Db,
218    templates: Arc<Templates>,
219) -> Router {
220    // R1 commit #9 — strict-mailer boot guard. Runs BEFORE any
221    // route registration so a misconfigured deployment fails
222    // loudly at startup rather than registering recovery routes
223    // against a production-unsafe default mailer
224    // (`DESIGN_RECOVERY.md` §12.1). The check is structural: see
225    // [`strict_mailer_guard_check`] for why we don't do
226    // pointer-equality tricks against the default LogMailer.
227    if let Err(msg) = strict_mailer_guard_check(&admin) {
228        panic!("{msg}");
229    }
230
231    let ctx = Arc::new(AdminCtx::new(
232        Arc::new(admin),
233        db.clone(),
234        templates.clone(),
235    ));
236
237    // Bespoke user/group pages share the same DB / templates / Admin
238    // arc but live in their own ctx type with the same shape.
239    let auth_ctx = Arc::new(super::builtin::AuthAdminCtx {
240        admin: ctx.admin.clone(),
241        db,
242        templates,
243    });
244
245    // Render `Err(_)` from /admin/* handlers as styled HTML instead of
246    // the framework default `text/plain`. Non-admin paths bubble
247    // through unchanged so JSON / curl consumers still get the text
248    // body. `Error::Forbidden` (handled by `role_guard` via
249    // `admin/forbidden.html`) and login-required redirects come
250    // through as `Ok` responses and bypass this branch.
251    let err_admin = ctx.admin.clone();
252    let err_templates = ctx.templates.clone();
253    let router = router.middleware(move |req, next| {
254        let admin = err_admin.clone();
255        let templates = err_templates.clone();
256        Box::pin(async move {
257            let is_admin_path = req.path().starts_with("/admin");
258            let result = next.run(req).await;
259            match result {
260                Ok(resp) => Ok(resp),
261                Err(err) if is_admin_path => Ok(render::render_admin_error_response(
262                    &admin,
263                    &templates,
264                    None,
265                    err.status(),
266                    err.client_message().to_string(),
267                )),
268                Err(err) => Err(err),
269            }
270        })
271    });
272
273    // Embedded stylesheet + JS. The bytes are baked into the binary
274    // so single-binary deploy is preserved. CSS/JS use `no-cache`
275    // (revalidate every request) so theme + design tweaks roll out the
276    // moment the binary restarts; fonts (next block) keep their long
277    // immutable cache because their bytes never change per release.
278    let router = router.get("/static/admin.css", |_req| async move {
279        Ok(Response::new(
280            hyper::StatusCode::OK,
281            bytes::Bytes::from_static(ADMIN_CSS.as_bytes()),
282        )
283        .with_header("content-type", "text/css; charset=utf-8")
284        .with_header("cache-control", "no-cache, must-revalidate"))
285    });
286    let router = router.get("/static/admin.js", |_req| async move {
287        Ok(Response::new(
288            hyper::StatusCode::OK,
289            bytes::Bytes::from_static(ADMIN_JS.as_bytes()),
290        )
291        .with_header("content-type", "application/javascript; charset=utf-8")
292        .with_header("cache-control", "no-cache, must-revalidate"))
293    });
294
295    // Self-hosted fonts. Cache aggressively: file contents are
296    // immutable per build, so a 1-year cache is safe — the binary
297    // ships a fresh copy on the next release.
298    fn font_response(bytes: &'static [u8]) -> Response {
299        Response::new(hyper::StatusCode::OK, bytes::Bytes::from_static(bytes))
300            .with_header("content-type", "font/woff2")
301            .with_header("cache-control", "public, max-age=31536000, immutable")
302    }
303    let router = router.get("/static/fonts/Geist-Variable.woff2", |_req| async move {
304        Ok(font_response(FONT_GEIST))
305    });
306    let router = router.get(
307        "/static/fonts/GeistMono-Variable.woff2",
308        |_req| async move { Ok(font_response(FONT_GEIST_MONO)) },
309    );
310    let router = router.get("/static/fonts/Tajawal-Regular.woff2", |_req| async move {
311        Ok(font_response(FONT_TAJAWAL_REG))
312    });
313    let router = router.get("/static/fonts/Tajawal-Medium.woff2", |_req| async move {
314        Ok(font_response(FONT_TAJAWAL_MED))
315    });
316    let router = router.get("/static/fonts/Tajawal-Bold.woff2", |_req| async move {
317        Ok(font_response(FONT_TAJAWAL_BOLD))
318    });
319    let router = router.get(
320        "/static/fonts/NotoNaskhArabic-Variable.woff2",
321        |_req| async move { Ok(font_response(FONT_NOTO_NASKH_AR)) },
322    );
323
324    // Public: login/logout.
325    let c = ctx.clone();
326    let router = router.get("/admin/login", move |req| {
327        let c = c.clone();
328        async move { handlers::show_login(&c, req).await }
329    });
330
331    let c = ctx.clone();
332    let router = router.post("/admin/login", move |req| {
333        let c = c.clone();
334        async move { handlers::do_login(&c, req).await }
335    });
336
337    let c = ctx.clone();
338    let router = router.post("/admin/logout", move |req| {
339        let c = c.clone();
340        async move { handlers::do_logout(&c, req).await }
341    });
342
343    // === R1 recovery routes ====================================
344    //
345    // MUST be registered BEFORE the `/admin/:admin_name` model
346    // wildcards lower down — without that ordering, a request to
347    // `/admin/forgot-password` would match `:admin_name =
348    // "forgot-password"` and route into the model CRUD handler.
349    //
350    // Recovery state (the rate-limit buckets) is built once here
351    // and cloned into each route closure so the buckets persist
352    // for the process lifetime. No global / static / OnceLock —
353    // the Arc lives in the closures.
354    //
355    // Strict-mailer boot guard already ran at the top of this fn
356    // (would have panicked if misconfigured); reaching this block
357    // means we have the operator's blessing to wire recovery.
358
359    let recovery_state = Arc::new(super::recovery_handlers::RecoveryState::from_admin(
360        &ctx.admin,
361    ));
362
363    let c = ctx.clone();
364    let router = router.get("/admin/forgot-password", move |req| {
365        let c = c.clone();
366        async move { super::recovery_handlers::show_forgot_password(&c, &req).await }
367    });
368
369    let c = ctx.clone();
370    let rs = recovery_state.clone();
371    let router = router.post("/admin/forgot-password", move |req| {
372        let c = c.clone();
373        let rs = rs.clone();
374        async move { super::recovery_handlers::do_forgot_password(&c, &rs, req).await }
375    });
376
377    let c = ctx.clone();
378    let router = router.get("/admin/forgot-password/sent", move |req| {
379        let c = c.clone();
380        async move { super::recovery_handlers::show_forgot_password_sent(&c, &req).await }
381    });
382
383    let c = ctx.clone();
384    let router = router.get("/admin/reset-password/:token", move |req| {
385        let c = c.clone();
386        async move {
387            let token = req
388                .param("token")
389                .ok_or_else(|| Error::BadRequest("missing token".into()))?
390                .to_string();
391            super::recovery_handlers::show_reset_password(&c, &req, &token).await
392        }
393    });
394
395    let c = ctx.clone();
396    let rs = recovery_state.clone();
397    let router = router.post("/admin/reset-password/:token", move |req| {
398        let c = c.clone();
399        let rs = rs.clone();
400        async move {
401            let token = req
402                .param("token")
403                .ok_or_else(|| Error::BadRequest("missing token".into()))?
404                .to_string();
405            super::recovery_handlers::do_reset_password(&c, &rs, req, &token).await
406        }
407    });
408
409    // Dashboard — Staff floor. User-tier sees the forbidden page.
410    let c = ctx.clone();
411    let router = router.get("/admin", move |req| {
412        let c = c.clone();
413        async move {
414            match role_guard(&c, &req, Role::Staff).await? {
415                Guard::Redirect(r) => Ok(r),
416                Guard::Allow(ident) => handlers::dashboard(&c, ident, &req).await,
417            }
418        }
419    });
420
421    // Global history log (admin-only; high-signal page).
422    let c = ctx.clone();
423    let router = router.get("/admin/history", move |req| {
424        let c = c.clone();
425        async move {
426            match role_guard(&c, &req, Role::Administrator).await? {
427                Guard::Redirect(r) => Ok(r),
428                Guard::Allow(ident) => handlers::show_log_entries(&c, ident, &req).await,
429            }
430        }
431    });
432
433    // Self-service active-sessions listing (R0). Any logged-in user
434    // (User-tier and above) can see their own active sessions.
435    let c = ctx.clone();
436    let router = router.get("/admin/account/sessions", move |req| {
437        let c = c.clone();
438        async move {
439            match role_guard(&c, &req, Role::User).await? {
440                Guard::Redirect(r) => Ok(r),
441                Guard::Allow(ident) => handlers::show_account_sessions(&c, ident, &req).await,
442            }
443        }
444    });
445
446    // R1 commit #10 — active-sessions revoke buttons. All three
447    // POST routes go through `auth::invalidate_sessions` (Doctrine
448    // 22) and write `AuditEvent::SessionsRevokedSelf` per revoked
449    // id. The `/revoke-others` and `/revoke-all` literal segments
450    // sit at depth-4 while `:id/revoke` sits at depth-5, so segment
451    // count alone disambiguates them — no explicit ordering
452    // constraint between the three.
453    let c = ctx.clone();
454    let router = router.post("/admin/account/sessions/revoke-others", move |req| {
455        let c = c.clone();
456        async move {
457            match role_guard(&c, &req, Role::User).await? {
458                Guard::Redirect(r) => Ok(r),
459                Guard::Allow(ident) => handlers::do_revoke_other_sessions(&c, ident, req).await,
460            }
461        }
462    });
463
464    let c = ctx.clone();
465    let router = router.post("/admin/account/sessions/revoke-all", move |req| {
466        let c = c.clone();
467        async move {
468            match role_guard(&c, &req, Role::User).await? {
469                Guard::Redirect(r) => Ok(r),
470                Guard::Allow(ident) => handlers::do_revoke_all_sessions(&c, ident, req).await,
471            }
472        }
473    });
474
475    let c = ctx.clone();
476    let router = router.post("/admin/account/sessions/:id/revoke", move |req| {
477        let c = c.clone();
478        async move {
479            match role_guard(&c, &req, Role::User).await? {
480                Guard::Redirect(r) => Ok(r),
481                Guard::Allow(ident) => {
482                    let id = parse_id(req.param("id"))?;
483                    handlers::do_revoke_session(&c, ident, req, id).await
484                }
485            }
486        }
487    });
488
489    // Self-service password change. Any logged-in user (User-tier and
490    // above). User-tier can change their own password even though
491    // they can't access the dashboard.
492    let c = ctx.clone();
493    let router = router.get("/admin/password_change", move |req| {
494        let c = c.clone();
495        async move {
496            match role_guard(&c, &req, Role::User).await? {
497                Guard::Redirect(r) => Ok(r),
498                Guard::Allow(ident) => handlers::show_password_change(&c, ident, &req).await,
499            }
500        }
501    });
502    let c = ctx.clone();
503    let router = router.post("/admin/password_change", move |req| {
504        let c = c.clone();
505        async move {
506            match role_guard(&c, &req, Role::User).await? {
507                Guard::Redirect(r) => Ok(r),
508                Guard::Allow(ident) => handlers::do_password_change(&c, ident, req).await,
509            }
510        }
511    });
512
513    // --- Built-in users admin (admin-only) ---
514    let c = ctx.clone();
515    let ac = auth_ctx.clone();
516    let router = router.get("/admin/users", move |req| {
517        let c = c.clone();
518        let ac = ac.clone();
519        async move {
520            match role_guard(&c, &req, Role::Administrator).await? {
521                Guard::Redirect(r) => Ok(r),
522                Guard::Allow(ident) => {
523                    super::builtin::list_users(&ac, ident, handlers::csrf_token(&req)).await
524                }
525            }
526        }
527    });
528
529    let c = ctx.clone();
530    let ac = auth_ctx.clone();
531    let router = router.get("/admin/users/new", move |req| {
532        let c = c.clone();
533        let ac = ac.clone();
534        async move {
535            match role_guard(&c, &req, Role::Administrator).await? {
536                Guard::Redirect(r) => Ok(r),
537                Guard::Allow(ident) => {
538                    super::builtin::show_new_user(&ac, ident, handlers::csrf_token(&req)).await
539                }
540            }
541        }
542    });
543
544    let c = ctx.clone();
545    let ac = auth_ctx.clone();
546    let router = router.post("/admin/users/new", move |req| {
547        let c = c.clone();
548        let ac = ac.clone();
549        async move {
550            match role_guard(&c, &req, Role::Administrator).await? {
551                Guard::Redirect(r) => Ok(r),
552                Guard::Allow(ident) => super::builtin::do_new_user(&ac, ident, req).await,
553            }
554        }
555    });
556
557    let c = ctx.clone();
558    let ac = auth_ctx.clone();
559    let router = router.get("/admin/users/: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::show_user_edit(&ac, ident, id, handlers::csrf_token(&req)).await
568                }
569            }
570        }
571    });
572
573    let c = ctx.clone();
574    let ac = auth_ctx.clone();
575    let router = router.post("/admin/users/:id/edit", 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::do_user_edit(&ac, ident, id, req).await
584                }
585            }
586        }
587    });
588
589    let c = ctx.clone();
590    let ac = auth_ctx.clone();
591    let router = router.get("/admin/users/:id/delete", move |req| {
592        let c = c.clone();
593        let ac = ac.clone();
594        async move {
595            match role_guard(&c, &req, Role::Administrator).await? {
596                Guard::Redirect(r) => Ok(r),
597                Guard::Allow(ident) => {
598                    let id = parse_id(req.param("id"))?;
599                    super::builtin::show_user_delete(&ac, ident, id, handlers::csrf_token(&req))
600                        .await
601                }
602            }
603        }
604    });
605
606    let c = ctx.clone();
607    let ac = auth_ctx.clone();
608    let router = router.post("/admin/users/:id/delete", move |req| {
609        let c = c.clone();
610        let ac = ac.clone();
611        async move {
612            match role_guard(&c, &req, Role::Administrator).await? {
613                Guard::Redirect(r) => Ok(r),
614                Guard::Allow(ident) => {
615                    let id = parse_id(req.param("id"))?;
616                    super::builtin::do_user_delete(&ac, ident, id, req).await
617                }
618            }
619        }
620    });
621
622    // Read-only user profile view. MUST be registered AFTER
623    // `/admin/users/new` and the `:id/edit` + `:id/delete` routes
624    // above: the router matches in insertion order, and `:id` is a
625    // wildcard that would happily swallow "new" or extra path
626    // segments. Putting this last preserves the more-specific routes'
627    // priority.
628    let c = ctx.clone();
629    let ac = auth_ctx.clone();
630    let router = router.get("/admin/users/:id", move |req| {
631        let c = c.clone();
632        let ac = ac.clone();
633        async move {
634            match role_guard(&c, &req, Role::Administrator).await? {
635                Guard::Redirect(r) => Ok(r),
636                Guard::Allow(ident) => {
637                    let id = parse_id(req.param("id"))?;
638                    let q = req.query();
639                    let tab = q.get("tab").map(|s| s.to_string());
640                    let page: i64 = q.get("page").and_then(|s| s.parse().ok()).unwrap_or(1);
641                    super::builtin::show_user_view(
642                        &ac,
643                        ident,
644                        id,
645                        handlers::csrf_token(&req),
646                        tab,
647                        page,
648                    )
649                    .await
650                }
651            }
652        }
653    });
654
655    // --- Built-in groups admin (admin-only) ---
656    let c = ctx.clone();
657    let ac = auth_ctx.clone();
658    let router = router.get("/admin/groups", move |req| {
659        let c = c.clone();
660        let ac = ac.clone();
661        async move {
662            match role_guard(&c, &req, Role::Administrator).await? {
663                Guard::Redirect(r) => Ok(r),
664                Guard::Allow(ident) => {
665                    super::builtin::list_groups(&ac, ident, handlers::csrf_token(&req)).await
666                }
667            }
668        }
669    });
670
671    let c = ctx.clone();
672    let ac = auth_ctx.clone();
673    let router = router.get("/admin/groups/new", move |req| {
674        let c = c.clone();
675        let ac = ac.clone();
676        async move {
677            match role_guard(&c, &req, Role::Administrator).await? {
678                Guard::Redirect(r) => Ok(r),
679                Guard::Allow(ident) => {
680                    super::builtin::show_new_group(&ac, ident, handlers::csrf_token(&req)).await
681                }
682            }
683        }
684    });
685
686    let c = ctx.clone();
687    let ac = auth_ctx.clone();
688    let router = router.post("/admin/groups/new", move |req| {
689        let c = c.clone();
690        let ac = ac.clone();
691        async move {
692            match role_guard(&c, &req, Role::Administrator).await? {
693                Guard::Redirect(r) => Ok(r),
694                Guard::Allow(ident) => super::builtin::do_new_group(&ac, ident, req).await,
695            }
696        }
697    });
698
699    let c = ctx.clone();
700    let ac = auth_ctx.clone();
701    let router = router.get("/admin/groups/:id/edit", move |req| {
702        let c = c.clone();
703        let ac = ac.clone();
704        async move {
705            match role_guard(&c, &req, Role::Administrator).await? {
706                Guard::Redirect(r) => Ok(r),
707                Guard::Allow(ident) => {
708                    let id = parse_id(req.param("id"))?;
709                    super::builtin::show_group_edit(&ac, ident, id, handlers::csrf_token(&req))
710                        .await
711                }
712            }
713        }
714    });
715
716    let c = ctx.clone();
717    let ac = auth_ctx.clone();
718    let router = router.post("/admin/groups/:id/edit", move |req| {
719        let c = c.clone();
720        let ac = ac.clone();
721        async move {
722            match role_guard(&c, &req, Role::Administrator).await? {
723                Guard::Redirect(r) => Ok(r),
724                Guard::Allow(ident) => {
725                    let id = parse_id(req.param("id"))?;
726                    super::builtin::do_group_edit(&ac, ident, id, req).await
727                }
728            }
729        }
730    });
731
732    let c = ctx.clone();
733    let ac = auth_ctx.clone();
734    let router = router.get("/admin/groups/:id/delete", move |req| {
735        let c = c.clone();
736        let ac = ac.clone();
737        async move {
738            match role_guard(&c, &req, Role::Administrator).await? {
739                Guard::Redirect(r) => Ok(r),
740                Guard::Allow(ident) => {
741                    let id = parse_id(req.param("id"))?;
742                    super::builtin::show_group_delete(&ac, ident, id, handlers::csrf_token(&req))
743                        .await
744                }
745            }
746        }
747    });
748
749    let c = ctx.clone();
750    let ac = auth_ctx.clone();
751    let router = router.post("/admin/groups/:id/delete", move |req| {
752        let c = c.clone();
753        let ac = ac.clone();
754        async move {
755            match role_guard(&c, &req, Role::Administrator).await? {
756                Guard::Redirect(r) => Ok(r),
757                Guard::Allow(ident) => {
758                    let id = parse_id(req.param("id"))?;
759                    super::builtin::do_group_delete(&ac, ident, id, req).await
760                }
761            }
762        }
763    });
764
765    // Per-model list — needs `view` permission.
766    let c = ctx.clone();
767    let router = router.get("/admin/:admin_name", move |req| {
768        let c = c.clone();
769        async move {
770            let name = model_name_from_req(&req)?;
771            let perm = perm_for(&c, &name, "view")?;
772            match perm_guard(&c, &req, &perm).await? {
773                Guard::Redirect(r) => Ok(r),
774                Guard::Allow(ident) => handlers::list_model(&c, ident, &name, &req).await,
775            }
776        }
777    });
778
779    // Create.
780    let c = ctx.clone();
781    let router = router.get("/admin/:admin_name/new", move |req| {
782        let c = c.clone();
783        async move {
784            let name = model_name_from_req(&req)?;
785            let perm = perm_for(&c, &name, "add")?;
786            match perm_guard(&c, &req, &perm).await? {
787                Guard::Redirect(r) => Ok(r),
788                Guard::Allow(ident) => handlers::show_new_form(&c, ident, &name, &req).await,
789            }
790        }
791    });
792    let c = ctx.clone();
793    let router = router.post("/admin/:admin_name/new", move |req| {
794        let c = c.clone();
795        async move {
796            let name = model_name_from_req(&req)?;
797            let perm = perm_for(&c, &name, "add")?;
798            match perm_guard(&c, &req, &perm).await? {
799                Guard::Redirect(r) => Ok(r),
800                Guard::Allow(ident) => handlers::do_create(&c, ident, &name, req).await,
801            }
802        }
803    });
804
805    // Edit.
806    let c = ctx.clone();
807    let router = router.get("/admin/:admin_name/:id/edit", move |req| {
808        let c = c.clone();
809        async move {
810            let name = model_name_from_req(&req)?;
811            let perm = perm_for(&c, &name, "change")?;
812            match perm_guard(&c, &req, &perm).await? {
813                Guard::Redirect(r) => Ok(r),
814                Guard::Allow(ident) => {
815                    let id = parse_id(req.param("id"))?;
816                    handlers::show_edit_form(&c, ident, &name, id, &req).await
817                }
818            }
819        }
820    });
821    let c = ctx.clone();
822    let router = router.post("/admin/:admin_name/:id/edit", move |req| {
823        let c = c.clone();
824        async move {
825            let name = model_name_from_req(&req)?;
826            let perm = perm_for(&c, &name, "change")?;
827            match perm_guard(&c, &req, &perm).await? {
828                Guard::Redirect(r) => Ok(r),
829                Guard::Allow(ident) => {
830                    let id = parse_id(req.param("id"))?;
831                    handlers::do_update(&c, ident, &name, id, req).await
832                }
833            }
834        }
835    });
836
837    // Per-object history. Read-only; same `view` permission as the
838    // changelist (if you can list, you can read the audit trail).
839    let c = ctx.clone();
840    let router = router.get("/admin/:admin_name/:id/history", move |req| {
841        let c = c.clone();
842        async move {
843            let name = model_name_from_req(&req)?;
844            let perm = perm_for(&c, &name, "view")?;
845            match perm_guard(&c, &req, &perm).await? {
846                Guard::Redirect(r) => Ok(r),
847                Guard::Allow(ident) => {
848                    let id = parse_id(req.param("id"))?;
849                    handlers::show_object_history(&c, ident, &name, id, &req).await
850                }
851            }
852        }
853    });
854
855    // Delete.
856    let c = ctx.clone();
857    let router = router.get("/admin/:admin_name/:id/delete", move |req| {
858        let c = c.clone();
859        async move {
860            let name = model_name_from_req(&req)?;
861            let perm = perm_for(&c, &name, "delete")?;
862            match perm_guard(&c, &req, &perm).await? {
863                Guard::Redirect(r) => Ok(r),
864                Guard::Allow(ident) => {
865                    let id = parse_id(req.param("id"))?;
866                    handlers::show_delete_confirm(&c, ident, &name, id, &req).await
867                }
868            }
869        }
870    });
871    let c = ctx.clone();
872    let router = router.post("/admin/:admin_name/:id/delete", move |req| {
873        let c = c.clone();
874        async move {
875            let name = model_name_from_req(&req)?;
876            let perm = perm_for(&c, &name, "delete")?;
877            match perm_guard(&c, &req, &perm).await? {
878                Guard::Redirect(r) => Ok(r),
879                Guard::Allow(ident) => {
880                    let id = parse_id(req.param("id"))?;
881                    handlers::do_delete(&c, ident, &name, id).await
882                }
883            }
884        }
885    });
886
887    // Bulk delete — same permission gate as the per-row delete.
888    // Two-step flow: first POST renders the confirm page, second POST
889    // (with `_confirmed=1`) executes. See `handlers::handle_bulk_delete`
890    // for the full contract.
891    let c = ctx.clone();
892    let router = router.post("/admin/:admin_name/bulk_delete", move |req| {
893        let c = c.clone();
894        async move {
895            let name = model_name_from_req(&req)?;
896            let perm = perm_for(&c, &name, "delete")?;
897            match perm_guard(&c, &req, &perm).await? {
898                Guard::Redirect(r) => Ok(r),
899                Guard::Allow(ident) => handlers::handle_bulk_delete(&c, ident, &name, &req).await,
900            }
901        }
902    });
903
904    // Project-defined bulk actions. Permission gated on `change` —
905    // bulk actions modify rows but don't delete them (delete has its
906    // own route). Project-side guard against further write-vs-read
907    // distinctions belongs inside `execute_bulk_action`.
908    let c = ctx.clone();
909    router.post("/admin/:admin_name/bulk/:action", move |req| {
910        let c = c.clone();
911        async move {
912            let name = model_name_from_req(&req)?;
913            let action = req
914                .param("action")
915                .ok_or_else(|| Error::BadRequest("missing bulk action name".into()))?
916                .to_string();
917            let perm = perm_for(&c, &name, "change")?;
918            match perm_guard(&c, &req, &perm).await? {
919                Guard::Redirect(r) => Ok(r),
920                Guard::Allow(ident) => {
921                    handlers::handle_bulk_action(&c, ident, &name, &action, &req).await
922                }
923            }
924        }
925    })
926}
927
928#[cfg(test)]
929mod tests {
930    use super::*;
931
932    fn make_identity(role: Role, is_active: bool) -> Identity {
933        Identity {
934            user_id: 42,
935            email: "test@example.com".into(),
936            role,
937            is_active,
938            is_demo: false,
939            demo_label: None,
940        }
941    }
942
943    // role_guard's decision is `Role::includes(min)`. The 25-case
944    // matrix lives in `auth::role::tests::includes_matrix_…`; the
945    // cases below pin the most operator-relevant pairings.
946
947    #[test]
948    fn role_guard_decision_admin_meets_staff_floor() {
949        let id = make_identity(Role::Administrator, true);
950        assert!(id.role.includes(Role::Staff));
951    }
952
953    #[test]
954    fn role_guard_decision_user_does_not_meet_staff() {
955        let id = make_identity(Role::User, true);
956        assert!(!id.role.includes(Role::Staff));
957    }
958
959    #[test]
960    fn role_guard_decision_administrator_does_not_meet_developer() {
961        let id = make_identity(Role::Administrator, true);
962        assert!(!id.role.includes(Role::Developer));
963    }
964
965    #[test]
966    fn role_guard_decision_developer_meets_everything() {
967        let id = make_identity(Role::Developer, true);
968        for &min in &[
969            Role::User,
970            Role::Staff,
971            Role::Supervisor,
972            Role::Administrator,
973            Role::Developer,
974        ] {
975            assert!(id.role.includes(min), "Developer should meet {min:?}");
976        }
977    }
978
979    // ---- perm_guard_verdict matrix --------------------------------------
980
981    #[test]
982    fn perm_guard_admin_short_circuits_without_perm() {
983        let id = make_identity(Role::Administrator, true);
984        assert!(perm_guard_verdict(&id, false));
985    }
986
987    #[test]
988    fn perm_guard_developer_short_circuits_without_perm() {
989        let id = make_identity(Role::Developer, true);
990        assert!(perm_guard_verdict(&id, false));
991    }
992
993    #[test]
994    fn perm_guard_staff_with_perm_passes() {
995        let id = make_identity(Role::Staff, true);
996        assert!(perm_guard_verdict(&id, true));
997    }
998
999    #[test]
1000    fn perm_guard_staff_without_perm_denies() {
1001        let id = make_identity(Role::Staff, true);
1002        assert!(!perm_guard_verdict(&id, false));
1003    }
1004
1005    #[test]
1006    fn perm_guard_inactive_admin_denies_even_with_bypass() {
1007        // Defense-in-depth invariant.
1008        let id = make_identity(Role::Administrator, false);
1009        assert!(!perm_guard_verdict(&id, true));
1010    }
1011
1012    #[test]
1013    fn perm_guard_supervisor_without_perm_denies() {
1014        // Supervisor doesn't bypass; needs the per-model perm.
1015        let id = make_identity(Role::Supervisor, true);
1016        assert!(!perm_guard_verdict(&id, false));
1017    }
1018
1019    // ---- strict_mailer_guard_check ----------------------------------------
1020
1021    /// Default `Admin::new()` doesn't override the mailer AND
1022    /// doesn't enable strict mode — the guard passes.
1023    #[test]
1024    fn strict_mailer_guard_passes_for_default_admin() {
1025        let admin = super::super::types::Admin::new();
1026        assert!(strict_mailer_guard_check(&admin).is_ok());
1027    }
1028
1029    /// Strict-mailer mode + default LogMailer = boot guard fires.
1030    /// The error message is operator-actionable.
1031    #[test]
1032    fn strict_mailer_guard_fails_when_required_but_default_mailer() {
1033        use crate::auth::DefaultRecoveryPolicy;
1034        let admin = super::super::types::Admin::new().recovery_policy(std::sync::Arc::new(
1035            DefaultRecoveryPolicy::new().with_strict_mailer_required(true),
1036        ));
1037        let err = strict_mailer_guard_check(&admin).expect_err("guard should fail");
1038        assert!(
1039            err.contains("strict_mailer_required"),
1040            "error message must name the policy method: {err}"
1041        );
1042        assert!(
1043            err.contains("Admin::mailer"),
1044            "error message must direct the operator to the fix: {err}"
1045        );
1046    }
1047
1048    /// Strict-mailer mode + project-supplied mailer = guard passes.
1049    /// Note: the explicit override flips the flag even when the
1050    /// supplied value happens to be another LogMailer — the
1051    /// operator's intent is what matters, not the concrete type.
1052    #[test]
1053    fn strict_mailer_guard_passes_when_mailer_was_explicitly_overridden() {
1054        use crate::auth::DefaultRecoveryPolicy;
1055        use crate::email::LogMailer;
1056        let admin = super::super::types::Admin::new()
1057            .recovery_policy(std::sync::Arc::new(
1058                DefaultRecoveryPolicy::new().with_strict_mailer_required(true),
1059            ))
1060            .mailer(std::sync::Arc::new(LogMailer));
1061        assert!(strict_mailer_guard_check(&admin).is_ok());
1062    }
1063
1064    /// Project NOT in strict mode + default LogMailer = passes
1065    /// (dev / CI / testing baseline).
1066    #[test]
1067    fn strict_mailer_guard_passes_when_strict_mode_disabled() {
1068        let admin = super::super::types::Admin::new();
1069        assert!(strict_mailer_guard_check(&admin).is_ok());
1070    }
1071}