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
72/// Paths a user with `must_change_password = TRUE` is allowed to
73/// reach without first completing the forced rotation.
74/// Locked-decision per `DESIGN_R2_ORGANISATIONAL.md` §12.
75///
76/// Exact-path match (no prefix matching). Sub-paths of
77/// `/admin/account/sessions` (e.g. `/admin/account/sessions/revoke`)
78/// are intentionally NOT whitelisted — a user being forced to
79/// rotate may view their active sessions but must finish the
80/// rotation before revoking siblings.
81const MUST_CHANGE_WHITELIST: &[&str] = &[
82    "/admin/must-change-password",
83    "/admin/logout",
84    "/admin/account/sessions",
85];
86
87/// Whether `path` is on the must-change-password whitelist.
88/// Pulled out as a free fn so the rule is unit-testable without a
89/// `Request`. See [`MUST_CHANGE_WHITELIST`] for the contract.
90fn is_must_change_whitelisted_path(path: &str) -> bool {
91    MUST_CHANGE_WHITELIST.contains(&path)
92}
93
94/// Paths reachable when `MfaPolicy::Required` is active and the
95/// user has not yet enrolled (R3 commit #18). Forward-only
96/// enforcement per `DESIGN_R3_MFA.md` D6: existing sessions
97/// continue to work, but every non-whitelisted request from a
98/// not-yet-enrolled user redirects to the enrolment form.
99/// Mirrors [`MUST_CHANGE_WHITELIST`]'s shape so the two
100/// interstitial flows compose identically when both gates fire.
101///
102/// Exact-path match. Sub-paths of `/admin/account/sessions`
103/// (e.g. `/admin/account/sessions/revoke`) are NOT whitelisted
104/// — a user being forced to enrol may view their active
105/// sessions but must finish enrolment before revoking siblings.
106const MFA_ENROLL_WHITELIST: &[&str] = &[
107    "/admin/account/mfa/enroll",
108    "/admin/logout",
109    "/admin/account/sessions",
110];
111
112fn is_mfa_enroll_whitelisted_path(path: &str) -> bool {
113    MFA_ENROLL_WHITELIST.contains(&path)
114}
115
116/// Paths reachable when the user has MFA enrolled but the
117/// current session has not yet been promoted to `mfa_verified`
118/// (the post-password, pre-MFA-verify window from R3 commit
119/// #16's `do_login`). The user can complete the second-factor
120/// verify, log out, or inspect their active sessions — nothing
121/// else.
122///
123/// Exact-path match. See [`MFA_ENROLL_WHITELIST`] for the
124/// rationale around the sessions page.
125const MFA_VERIFY_WHITELIST: &[&str] = &[
126    "/admin/mfa/verify",
127    "/admin/logout",
128    "/admin/account/sessions",
129];
130
131fn is_mfa_verify_whitelisted_path(path: &str) -> bool {
132    MFA_VERIFY_WHITELIST.contains(&path)
133}
134
135/// Whether the active `MfaPolicy` requires MFA for a given
136/// role. Pulled out as a free fn so the rule is unit-testable
137/// without an `Admin` context.
138///
139/// `MfaPolicy::Disabled` / `Optional` → never required.
140/// `MfaPolicy::Required` → required for every role.
141/// `MfaPolicy::RequiredForRoles(roles)` → required iff the
142///   user's role appears in the slice. An empty slice reads
143///   as "no role requires MFA" — equivalent to `Optional`.
144fn mfa_required_for_role(policy: crate::auth::MfaPolicy, role: Role) -> bool {
145    use crate::auth::MfaPolicy;
146    match policy {
147        MfaPolicy::Disabled | MfaPolicy::Optional => false,
148        MfaPolicy::Required => true,
149        MfaPolicy::RequiredForRoles(roles) => roles.contains(&role),
150    }
151}
152
153async fn login_guard(ctx: &AdminCtx, req: &Request) -> Result<Guard> {
154    let cookie = match req.header("cookie") {
155        Some(c) => c,
156        None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
157    };
158    let token = match auth::session_token_from_cookie(cookie) {
159        Some(t) => t,
160        None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
161    };
162    let ident = match auth::identity_from_session(&ctx.db, &token).await? {
163        Some(i) => i,
164        None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
165    };
166    if !ident.is_active {
167        return Ok(Guard::Redirect(Response::redirect("/admin/login")));
168    }
169
170    // R2 forced-rotation gate (`DESIGN_R2_ORGANISATIONAL.md` §3.4 +
171    // §9.2). When the flag is set, every authenticated request EXCEPT
172    // the whitelist redirects to `/admin/must-change-password`. The
173    // check sits BEFORE any role gate so even Administrators /
174    // Developers with the flag set are funnelled through.
175    if ident.must_change_password && !is_must_change_whitelisted_path(req.path()) {
176        return Ok(Guard::Redirect(Response::redirect(
177            "/admin/must-change-password",
178        )));
179    }
180
181    // R3 MFA-required gate — forward-only per D6
182    // (`DESIGN_R3_MFA.md` §12.3). When the active MfaPolicy
183    // requires MFA for this user's role AND they have not
184    // enrolled, every non-whitelisted request redirects to the
185    // enrolment form. Existing sessions continue to work; the
186    // redirect kicks in at the NEXT request, not at the moment
187    // the policy flips. This matches R2's must-change-password
188    // shape — see MFA_ENROLL_WHITELIST for the reachable paths.
189    let policy = ctx.admin.active_mfa_policy();
190    if mfa_required_for_role(policy, ident.role)
191        && !ident.mfa_enabled
192        && !is_mfa_enroll_whitelisted_path(req.path())
193    {
194        return Ok(Guard::Redirect(Response::redirect(
195            "/admin/account/mfa/enroll",
196        )));
197    }
198
199    // R3 pending-MFA-verify gate (`DESIGN_R3_MFA.md` §4.2 +
200    // §12.3). When the user has MFA enrolled but the current
201    // session has not yet been promoted to mfa_verified (the
202    // post-password, pre-MFA-verify window from commit #16's
203    // do_login), restrict access to the MFA verify whitelist.
204    // The verify POST handler rotates the session via
205    // promote_session_to_mfa_verified once both factors land.
206    use crate::auth::SessionTrust;
207    if ident.mfa_enabled
208        && ident.trust_level != SessionTrust::MfaVerified
209        && !is_mfa_verify_whitelisted_path(req.path())
210    {
211        return Ok(Guard::Redirect(Response::redirect("/admin/mfa/verify")));
212    }
213
214    Ok(Guard::Allow(ident))
215}
216
217async fn role_guard(ctx: &AdminCtx, req: &Request, min: Role) -> Result<Guard> {
218    match login_guard(ctx, req).await? {
219        Guard::Redirect(r) => Ok(Guard::Redirect(r)),
220        Guard::Allow(ident) => {
221            if ident.role.includes(min) {
222                Ok(Guard::Allow(ident))
223            } else {
224                let body = render::render_forbidden_body(
225                    &ctx.admin,
226                    &ctx.templates,
227                    &ident,
228                    handlers::csrf_token(req),
229                    None,
230                    Some(min.label()),
231                )?;
232                Ok(Guard::Redirect(
233                    Response::html(body).with_status(hyper::StatusCode::FORBIDDEN),
234                ))
235            }
236        }
237    }
238}
239
240async fn perm_guard(ctx: &AdminCtx, req: &Request, perm: &str) -> Result<Guard> {
241    match role_guard(ctx, req, Role::Staff).await? {
242        Guard::Redirect(r) => Ok(Guard::Redirect(r)),
243        Guard::Allow(ident) => {
244            if ident.role.bypasses_group_checks() {
245                return Ok(Guard::Allow(ident));
246            }
247            if auth::check_permission(&ctx.db, &ident, perm).await? {
248                Ok(Guard::Allow(ident))
249            } else {
250                let body = render::render_forbidden_body(
251                    &ctx.admin,
252                    &ctx.templates,
253                    &ident,
254                    handlers::csrf_token(req),
255                    Some(perm.to_string()),
256                    None,
257                )?;
258                Ok(Guard::Redirect(
259                    Response::html(body).with_status(hyper::StatusCode::FORBIDDEN),
260                ))
261            }
262        }
263    }
264}
265
266/// Pure decision logic for `perm_guard`, factored out so it can be
267/// unit-tested without a `Db`.
268#[cfg(test)]
269fn perm_guard_verdict(ident: &Identity, perm_held: bool) -> bool {
270    if !ident.is_active {
271        return false;
272    }
273    if ident.role.bypasses_group_checks() {
274        return true;
275    }
276    perm_held
277}
278
279fn parse_id(raw: Option<&str>) -> Result<i64> {
280    raw.and_then(|s| s.parse().ok())
281        .ok_or_else(|| Error::BadRequest("invalid id".into()))
282}
283
284fn model_name_from_req(req: &Request) -> Result<String> {
285    req.param("admin_name")
286        .map(|s| s.to_string())
287        .ok_or_else(|| Error::BadRequest("missing model".into()))
288}
289
290fn perm_for(ctx: &AdminCtx, admin_name: &str, action: &str) -> Result<String> {
291    let entry = ctx
292        .admin
293        .find(admin_name)
294        .ok_or_else(|| Error::NotFound(format!("no admin model: {admin_name}")))?;
295    let singular = entry.singular_name.to_ascii_lowercase();
296    Ok(format!("{admin_name}.{action}_{singular}"))
297}
298
299/// Pure verdict for the R1 strict-mailer boot guard
300/// (`DESIGN_RECOVERY.md` §12.1). Returns an operator-facing error
301/// string when the policy demands a real mailer but `Admin::new()`'s
302/// default `LogMailer` is still in place; returns `Ok(())`
303/// otherwise.
304///
305/// Detection is deterministic and structural: it reads
306/// [`Admin::has_custom_mailer`] (set whenever
307/// [`Admin::mailer`] has been called). No `Arc::ptr_eq` against a
308/// freshly-constructed `LogMailer`; no environment heuristics; no
309/// hostname checks; no "production mode" guessing — the operator
310/// declares intent by calling `Admin::mailer(...)` (and opts the
311/// policy in via `RecoveryPolicy::strict_mailer_required(true)`).
312///
313/// The framework treats an explicit `Admin::mailer(...)` call as
314/// satisfying the guard even when the supplied mailer is itself a
315/// `LogMailer` — this is the documented escape hatch for projects
316/// that want to silence the guard during a migration window
317/// without yet wiring a real transport.
318/// Best-effort identity resolution for the chrome of an admin
319/// error page. Mirrors what `auth::routes::login_guard` does on
320/// the success path, but is non-fatal: any failure returns `None`
321/// and the error page falls back to the unauthenticated chrome
322/// (just like the original behaviour).
323///
324/// `VISIBILITY_AUDIT.md` A2: without this lookup, every 4xx/5xx
325/// page rendered as if the operator were logged out, so the
326/// operator lost their sidebar + top-bar account links the
327/// moment they hit an erroring route — a navigational dead-end.
328async fn resolve_identity_for_error_page(db: &Db, cookie_header: &str) -> Option<Identity> {
329    let token = auth::session_token_from_cookie(cookie_header)?;
330    let identity = auth::identity_from_session(db, token.as_str())
331        .await
332        .ok()
333        .flatten()?;
334    if !identity.is_active {
335        return None;
336    }
337    Some(identity)
338}
339
340fn strict_mailer_guard_check(admin: &Admin) -> std::result::Result<(), String> {
341    if admin.active_recovery_policy().strict_mailer_required() && !admin.has_custom_mailer() {
342        Err(
343            "rustio-admin: RecoveryPolicy::strict_mailer_required() = true but no mailer \
344             was registered via Admin::mailer(...).\n\n\
345             The framework's default LogMailer writes recovery emails to log::info! instead \
346             of sending them, which is unsuitable for production. Recovery routes are NOT \
347             registered with this configuration.\n\n\
348             To resolve, choose one:\n\
349              (a) register a real mailer before calling register_admin_routes:\n\
350                  Admin::mailer(Arc::new(MyProjectMailer::new(...)))\n\
351              (b) opt the policy out of strict mode (the framework default — dev / CI / \
352                  testing baseline):\n\
353                  RecoveryPolicy::strict_mailer_required(false)\n\n\
354             See DESIGN_RECOVERY.md §12.1 for the contract."
355                .to_string(),
356        )
357    } else {
358        Ok(())
359    }
360}
361
362pub fn register_admin_routes(
363    router: Router,
364    admin: Admin,
365    db: Db,
366    templates: Arc<Templates>,
367) -> Router {
368    // R1 commit #9 — strict-mailer boot guard. Runs BEFORE any
369    // route registration so a misconfigured deployment fails
370    // loudly at startup rather than registering recovery routes
371    // against a production-unsafe default mailer
372    // (`DESIGN_RECOVERY.md` §12.1). The check is structural: see
373    // [`strict_mailer_guard_check`] for why we don't do
374    // pointer-equality tricks against the default LogMailer.
375    if let Err(msg) = strict_mailer_guard_check(&admin) {
376        panic!("{msg}");
377    }
378
379    let ctx = Arc::new(AdminCtx::new(
380        Arc::new(admin),
381        db.clone(),
382        templates.clone(),
383    ));
384
385    // Bespoke user/group pages share the same DB / templates / Admin
386    // arc but live in their own ctx type with the same shape.
387    let auth_ctx = Arc::new(super::builtin::AuthAdminCtx {
388        admin: ctx.admin.clone(),
389        db,
390        templates,
391    });
392
393    // Render `Err(_)` from /admin/* handlers as styled HTML instead of
394    // the framework default `text/plain`. Non-admin paths bubble
395    // through unchanged so JSON / curl consumers still get the text
396    // body. `Error::Forbidden` (handled by `role_guard` via
397    // `admin/forbidden.html`) and login-required redirects come
398    // through as `Ok` responses and bypass this branch.
399    //
400    // The middleware also resolves the operator's identity from the
401    // session cookie BEFORE handing off, so the error page renders
402    // with the same chrome (sidebar, top-bar actor, "Log out") the
403    // operator was using when they hit the failing route. Without
404    // this lookup the 404 page rendered as if the operator were
405    // unauthenticated — a navigational dead-end documented as
406    // `VISIBILITY_AUDIT.md` finding A2.
407    let err_admin = ctx.admin.clone();
408    let err_templates = ctx.templates.clone();
409    let err_db = ctx.db.clone();
410    let router = router.middleware(move |req, next| {
411        let admin = err_admin.clone();
412        let templates = err_templates.clone();
413        let db = err_db.clone();
414        Box::pin(async move {
415            let is_admin_path = req.path().starts_with("/admin");
416            // Capture the cookie header BEFORE moving `req` into
417            // `next.run` so the error branch can re-resolve the
418            // operator's identity. The auth middleware sits inside
419            // this one; the request is consumed by `next.run` before
420            // we get to the `Err` branch.
421            let cookie_header = if is_admin_path {
422                req.header("cookie").map(|s| s.to_string())
423            } else {
424                None
425            };
426            let result = next.run(req).await;
427            match result {
428                Ok(resp) => Ok(resp),
429                Err(err) if is_admin_path => {
430                    let identity = match cookie_header.as_deref() {
431                        Some(cookie) => resolve_identity_for_error_page(&db, cookie).await,
432                        None => None,
433                    };
434                    Ok(render::render_admin_error_response(
435                        &admin,
436                        &templates,
437                        identity.as_ref(),
438                        err.status(),
439                        err.client_message().to_string(),
440                    ))
441                }
442                Err(err) => Err(err),
443            }
444        })
445    });
446
447    // Embedded stylesheet + JS. The bytes are baked into the binary
448    // so single-binary deploy is preserved. CSS/JS use `no-cache`
449    // (revalidate every request) so theme + design tweaks roll out the
450    // moment the binary restarts; fonts (next block) keep their long
451    // immutable cache because their bytes never change per release.
452    let router = router.get("/static/admin.css", |_req| async move {
453        Ok(Response::new(
454            hyper::StatusCode::OK,
455            bytes::Bytes::from_static(ADMIN_CSS.as_bytes()),
456        )
457        .with_header("content-type", "text/css; charset=utf-8")
458        .with_header("cache-control", "no-cache, must-revalidate"))
459    });
460    let router = router.get("/static/admin.js", |_req| async move {
461        Ok(Response::new(
462            hyper::StatusCode::OK,
463            bytes::Bytes::from_static(ADMIN_JS.as_bytes()),
464        )
465        .with_header("content-type", "application/javascript; charset=utf-8")
466        .with_header("cache-control", "no-cache, must-revalidate"))
467    });
468
469    // Self-hosted fonts. Cache aggressively: file contents are
470    // immutable per build, so a 1-year cache is safe — the binary
471    // ships a fresh copy on the next release.
472    fn font_response(bytes: &'static [u8]) -> Response {
473        Response::new(hyper::StatusCode::OK, bytes::Bytes::from_static(bytes))
474            .with_header("content-type", "font/woff2")
475            .with_header("cache-control", "public, max-age=31536000, immutable")
476    }
477    let router = router.get("/static/fonts/Geist-Variable.woff2", |_req| async move {
478        Ok(font_response(FONT_GEIST))
479    });
480    let router = router.get(
481        "/static/fonts/GeistMono-Variable.woff2",
482        |_req| async move { Ok(font_response(FONT_GEIST_MONO)) },
483    );
484    let router = router.get("/static/fonts/Tajawal-Regular.woff2", |_req| async move {
485        Ok(font_response(FONT_TAJAWAL_REG))
486    });
487    let router = router.get("/static/fonts/Tajawal-Medium.woff2", |_req| async move {
488        Ok(font_response(FONT_TAJAWAL_MED))
489    });
490    let router = router.get("/static/fonts/Tajawal-Bold.woff2", |_req| async move {
491        Ok(font_response(FONT_TAJAWAL_BOLD))
492    });
493    let router = router.get(
494        "/static/fonts/NotoNaskhArabic-Variable.woff2",
495        |_req| async move { Ok(font_response(FONT_NOTO_NASKH_AR)) },
496    );
497
498    // Public: login/logout.
499    let c = ctx.clone();
500    let router = router.get("/admin/login", move |req| {
501        let c = c.clone();
502        async move { handlers::show_login(&c, req).await }
503    });
504
505    let c = ctx.clone();
506    let router = router.post("/admin/login", move |req| {
507        let c = c.clone();
508        async move { handlers::do_login(&c, req).await }
509    });
510
511    let c = ctx.clone();
512    let router = router.post("/admin/logout", move |req| {
513        let c = c.clone();
514        async move { handlers::do_logout(&c, req).await }
515    });
516
517    // === R1 recovery routes ====================================
518    //
519    // MUST be registered BEFORE the `/admin/:admin_name` model
520    // wildcards lower down — without that ordering, a request to
521    // `/admin/forgot-password` would match `:admin_name =
522    // "forgot-password"` and route into the model CRUD handler.
523    //
524    // Recovery state (the rate-limit buckets) is built once here
525    // and cloned into each route closure so the buckets persist
526    // for the process lifetime. No global / static / OnceLock —
527    // the Arc lives in the closures.
528    //
529    // Strict-mailer boot guard already ran at the top of this fn
530    // (would have panicked if misconfigured); reaching this block
531    // means we have the operator's blessing to wire recovery.
532
533    let recovery_state = Arc::new(super::recovery_handlers::RecoveryState::from_admin(
534        &ctx.admin,
535    ));
536
537    let c = ctx.clone();
538    let router = router.get("/admin/forgot-password", move |req| {
539        let c = c.clone();
540        async move { super::recovery_handlers::show_forgot_password(&c, &req).await }
541    });
542
543    let c = ctx.clone();
544    let rs = recovery_state.clone();
545    let router = router.post("/admin/forgot-password", move |req| {
546        let c = c.clone();
547        let rs = rs.clone();
548        async move { super::recovery_handlers::do_forgot_password(&c, &rs, req).await }
549    });
550
551    let c = ctx.clone();
552    let router = router.get("/admin/forgot-password/sent", move |req| {
553        let c = c.clone();
554        async move { super::recovery_handlers::show_forgot_password_sent(&c, &req).await }
555    });
556
557    let c = ctx.clone();
558    let router = router.get("/admin/reset-password/:token", move |req| {
559        let c = c.clone();
560        async move {
561            let token = req
562                .param("token")
563                .ok_or_else(|| Error::BadRequest("missing token".into()))?
564                .to_string();
565            super::recovery_handlers::show_reset_password(&c, &req, &token).await
566        }
567    });
568
569    let c = ctx.clone();
570    let rs = recovery_state.clone();
571    let router = router.post("/admin/reset-password/:token", move |req| {
572        let c = c.clone();
573        let rs = rs.clone();
574        async move {
575            let token = req
576                .param("token")
577                .ok_or_else(|| Error::BadRequest("missing token".into()))?
578                .to_string();
579            super::recovery_handlers::do_reset_password(&c, &rs, req, &token).await
580        }
581    });
582
583    // Dashboard — Staff floor. User-tier sees the forbidden page.
584    let c = ctx.clone();
585    let router = router.get("/admin", move |req| {
586        let c = c.clone();
587        async move {
588            match role_guard(&c, &req, Role::Staff).await? {
589                Guard::Redirect(r) => Ok(r),
590                Guard::Allow(ident) => handlers::dashboard(&c, ident, &req).await,
591            }
592        }
593    });
594
595    // Global history log (admin-only; high-signal page).
596    let c = ctx.clone();
597    let router = router.get("/admin/history", move |req| {
598        let c = c.clone();
599        async move {
600            match role_guard(&c, &req, Role::Administrator).await? {
601                Guard::Redirect(r) => Ok(r),
602                Guard::Allow(ident) => handlers::show_log_entries(&c, ident, &req).await,
603            }
604        }
605    });
606
607    // Self-service active-sessions listing (R0). Any logged-in user
608    // (User-tier and above) can see their own active sessions.
609    let c = ctx.clone();
610    let router = router.get("/admin/account/sessions", move |req| {
611        let c = c.clone();
612        async move {
613            match role_guard(&c, &req, Role::User).await? {
614                Guard::Redirect(r) => Ok(r),
615                Guard::Allow(ident) => handlers::show_account_sessions(&c, ident, &req).await,
616            }
617        }
618    });
619
620    // R1 commit #10 — active-sessions revoke buttons. All three
621    // POST routes go through `auth::invalidate_sessions` (Doctrine
622    // 22) and write `AuditEvent::SessionsRevokedSelf` per revoked
623    // id. The `/revoke-others` and `/revoke-all` literal segments
624    // sit at depth-4 while `:id/revoke` sits at depth-5, so segment
625    // count alone disambiguates them — no explicit ordering
626    // constraint between the three.
627    let c = ctx.clone();
628    let router = router.post("/admin/account/sessions/revoke-others", move |req| {
629        let c = c.clone();
630        async move {
631            match role_guard(&c, &req, Role::User).await? {
632                Guard::Redirect(r) => Ok(r),
633                Guard::Allow(ident) => handlers::do_revoke_other_sessions(&c, ident, req).await,
634            }
635        }
636    });
637
638    let c = ctx.clone();
639    let router = router.post("/admin/account/sessions/revoke-all", move |req| {
640        let c = c.clone();
641        async move {
642            match role_guard(&c, &req, Role::User).await? {
643                Guard::Redirect(r) => Ok(r),
644                Guard::Allow(ident) => handlers::do_revoke_all_sessions(&c, ident, req).await,
645            }
646        }
647    });
648
649    let c = ctx.clone();
650    let router = router.post("/admin/account/sessions/:id/revoke", move |req| {
651        let c = c.clone();
652        async move {
653            match role_guard(&c, &req, Role::User).await? {
654                Guard::Redirect(r) => Ok(r),
655                Guard::Allow(ident) => {
656                    let id = parse_id(req.param("id"))?;
657                    handlers::do_revoke_session(&c, ident, req, id).await
658                }
659            }
660        }
661    });
662
663    // Self-service password change. Any logged-in user (User-tier and
664    // above). User-tier can change their own password even though
665    // they can't access the dashboard.
666    let c = ctx.clone();
667    let router = router.get("/admin/password_change", move |req| {
668        let c = c.clone();
669        async move {
670            match role_guard(&c, &req, Role::User).await? {
671                Guard::Redirect(r) => Ok(r),
672                Guard::Allow(ident) => handlers::show_password_change(&c, ident, &req).await,
673            }
674        }
675    });
676    let c = ctx.clone();
677    let router = router.post("/admin/password_change", move |req| {
678        let c = c.clone();
679        async move {
680            match role_guard(&c, &req, Role::User).await? {
681                Guard::Redirect(r) => Ok(r),
682                Guard::Allow(ident) => handlers::do_password_change(&c, ident, req).await,
683            }
684        }
685    });
686
687    // === R2 re-auth wall (R2 commit #11) ====================================
688    //
689    // Standalone wall: any authenticated user can promote their own
690    // session into the elevated band by re-entering their password.
691    // The handler validates `return_to` strictly (only `/admin*`
692    // paths; see `admin_recovery_handlers::validate_return_to`).
693    // Any role from User-tier upward.
694
695    let c = ctx.clone();
696    let router = router.get("/admin/reauth", move |req| {
697        let c = c.clone();
698        async move {
699            match role_guard(&c, &req, Role::User).await? {
700                Guard::Redirect(r) => Ok(r),
701                Guard::Allow(ident) => {
702                    super::admin_recovery_handlers::show_reauth(&c, ident, &req).await
703                }
704            }
705        }
706    });
707
708    let c = ctx.clone();
709    let router = router.post("/admin/reauth", move |req| {
710        let c = c.clone();
711        async move {
712            match role_guard(&c, &req, Role::User).await? {
713                Guard::Redirect(r) => Ok(r),
714                Guard::Allow(ident) => {
715                    super::admin_recovery_handlers::do_reauth(&c, ident, req).await
716                }
717            }
718        }
719    });
720
721    // === R2 forced password rotation (R2 commit #12) ========================
722    //
723    // The `must_change_password` interstitial is the only writeable
724    // surface a user can reach while their flag is TRUE. The path is
725    // on `MUST_CHANGE_WHITELIST`; the `login_guard` redirect therefore
726    // skips it (otherwise the rotation would be unreachable). Role::User
727    // matches: any authenticated user can be forced to rotate, even a
728    // User-tier account that can't access the dashboard.
729
730    let c = ctx.clone();
731    let router = router.get("/admin/must-change-password", move |req| {
732        let c = c.clone();
733        async move {
734            match role_guard(&c, &req, Role::User).await? {
735                Guard::Redirect(r) => Ok(r),
736                Guard::Allow(ident) => {
737                    super::admin_recovery_handlers::show_must_change_password(&c, ident, &req).await
738                }
739            }
740        }
741    });
742
743    let c = ctx.clone();
744    let router = router.post("/admin/must-change-password", move |req| {
745        let c = c.clone();
746        async move {
747            match role_guard(&c, &req, Role::User).await? {
748                Guard::Redirect(r) => Ok(r),
749                Guard::Allow(ident) => {
750                    super::admin_recovery_handlers::do_must_change_password(&c, ident, req).await
751                }
752            }
753        }
754    });
755
756    // === R3 MFA surface (R3 commits #12-#15) ================================
757    //
758    // Eight routes:
759    //   /admin/mfa/verify                        — login second factor (#12)
760    //   /admin/account/mfa/enroll                — provision + confirm (#13)
761    //   /admin/account/mfa/regenerate-codes      — atomic batch swap   (#14)
762    //   /admin/account/mfa/disable               — self-disable        (#15)
763    //
764    // All gated by `Role::User` — every authenticated user can manage
765    // their own MFA. The /admin/mfa/verify path is on
766    // `MFA_VERIFY_WHITELIST`; the enrol path is on
767    // `MFA_ENROLL_WHITELIST` — so `login_guard` does NOT redirect
768    // away from these routes even when the user is in the pending-
769    // verify or required-enrol state. Otherwise the interstitial
770    // pages would be unreachable.
771
772    // --- /admin/mfa/verify (R3 commit #12) ---
773    let c = ctx.clone();
774    let router = router.get("/admin/mfa/verify", move |req| {
775        let c = c.clone();
776        async move {
777            match role_guard(&c, &req, Role::User).await? {
778                Guard::Redirect(r) => Ok(r),
779                Guard::Allow(ident) => super::mfa_handlers::show_verify(&c, ident, &req).await,
780            }
781        }
782    });
783
784    let c = ctx.clone();
785    let router = router.post("/admin/mfa/verify", move |req| {
786        let c = c.clone();
787        async move {
788            match role_guard(&c, &req, Role::User).await? {
789                Guard::Redirect(r) => Ok(r),
790                Guard::Allow(ident) => super::mfa_handlers::do_verify(&c, ident, req).await,
791            }
792        }
793    });
794
795    // --- /admin/account/mfa/enroll (R3 commit #13) ---
796    let c = ctx.clone();
797    let router = router.get("/admin/account/mfa/enroll", move |req| {
798        let c = c.clone();
799        async move {
800            match role_guard(&c, &req, Role::User).await? {
801                Guard::Redirect(r) => Ok(r),
802                Guard::Allow(ident) => super::mfa_handlers::show_enroll(&c, ident, &req).await,
803            }
804        }
805    });
806
807    let c = ctx.clone();
808    let router = router.post("/admin/account/mfa/enroll", move |req| {
809        let c = c.clone();
810        async move {
811            match role_guard(&c, &req, Role::User).await? {
812                Guard::Redirect(r) => Ok(r),
813                Guard::Allow(ident) => super::mfa_handlers::do_enroll(&c, ident, req).await,
814            }
815        }
816    });
817
818    // --- /admin/account/mfa/regenerate-codes (R3 commit #14) ---
819    let c = ctx.clone();
820    let router = router.get("/admin/account/mfa/regenerate-codes", move |req| {
821        let c = c.clone();
822        async move {
823            match role_guard(&c, &req, Role::User).await? {
824                Guard::Redirect(r) => Ok(r),
825                Guard::Allow(ident) => super::mfa_handlers::show_regenerate(&c, ident, &req).await,
826            }
827        }
828    });
829
830    let c = ctx.clone();
831    let router = router.post("/admin/account/mfa/regenerate-codes", move |req| {
832        let c = c.clone();
833        async move {
834            match role_guard(&c, &req, Role::User).await? {
835                Guard::Redirect(r) => Ok(r),
836                Guard::Allow(ident) => super::mfa_handlers::do_regenerate(&c, ident, req).await,
837            }
838        }
839    });
840
841    // --- /admin/account/mfa/disable (R3 commit #15) ---
842    let c = ctx.clone();
843    let router = router.get("/admin/account/mfa/disable", move |req| {
844        let c = c.clone();
845        async move {
846            match role_guard(&c, &req, Role::User).await? {
847                Guard::Redirect(r) => Ok(r),
848                Guard::Allow(ident) => super::mfa_handlers::show_disable(&c, ident, &req).await,
849            }
850        }
851    });
852
853    let c = ctx.clone();
854    let router = router.post("/admin/account/mfa/disable", move |req| {
855        let c = c.clone();
856        async move {
857            match role_guard(&c, &req, Role::User).await? {
858                Guard::Redirect(r) => Ok(r),
859                Guard::Allow(ident) => super::mfa_handlers::do_disable(&c, ident, req).await,
860            }
861        }
862    });
863
864    // --- Built-in users admin (admin-only) ---
865    let c = ctx.clone();
866    let ac = auth_ctx.clone();
867    let router = router.get("/admin/users", move |req| {
868        let c = c.clone();
869        let ac = ac.clone();
870        async move {
871            match role_guard(&c, &req, Role::Administrator).await? {
872                Guard::Redirect(r) => Ok(r),
873                Guard::Allow(ident) => {
874                    super::builtin::list_users(&ac, ident, handlers::csrf_token(&req)).await
875                }
876            }
877        }
878    });
879
880    let c = ctx.clone();
881    let ac = auth_ctx.clone();
882    let router = router.get("/admin/users/new", move |req| {
883        let c = c.clone();
884        let ac = ac.clone();
885        async move {
886            match role_guard(&c, &req, Role::Administrator).await? {
887                Guard::Redirect(r) => Ok(r),
888                Guard::Allow(ident) => {
889                    super::builtin::show_new_user(&ac, ident, handlers::csrf_token(&req)).await
890                }
891            }
892        }
893    });
894
895    let c = ctx.clone();
896    let ac = auth_ctx.clone();
897    let router = router.post("/admin/users/new", move |req| {
898        let c = c.clone();
899        let ac = ac.clone();
900        async move {
901            match role_guard(&c, &req, Role::Administrator).await? {
902                Guard::Redirect(r) => Ok(r),
903                Guard::Allow(ident) => super::builtin::do_new_user(&ac, ident, req).await,
904            }
905        }
906    });
907
908    let c = ctx.clone();
909    let ac = auth_ctx.clone();
910    let router = router.get("/admin/users/:id/edit", move |req| {
911        let c = c.clone();
912        let ac = ac.clone();
913        async move {
914            match role_guard(&c, &req, Role::Administrator).await? {
915                Guard::Redirect(r) => Ok(r),
916                Guard::Allow(ident) => {
917                    let id = parse_id(req.param("id"))?;
918                    super::builtin::show_user_edit(&ac, ident, id, handlers::csrf_token(&req)).await
919                }
920            }
921        }
922    });
923
924    let c = ctx.clone();
925    let ac = auth_ctx.clone();
926    let router = router.post("/admin/users/:id/edit", move |req| {
927        let c = c.clone();
928        let ac = ac.clone();
929        async move {
930            match role_guard(&c, &req, Role::Administrator).await? {
931                Guard::Redirect(r) => Ok(r),
932                Guard::Allow(ident) => {
933                    let id = parse_id(req.param("id"))?;
934                    super::builtin::do_user_edit(&ac, ident, id, req).await
935                }
936            }
937        }
938    });
939
940    let c = ctx.clone();
941    let ac = auth_ctx.clone();
942    let router = router.get("/admin/users/:id/delete", move |req| {
943        let c = c.clone();
944        let ac = ac.clone();
945        async move {
946            match role_guard(&c, &req, Role::Administrator).await? {
947                Guard::Redirect(r) => Ok(r),
948                Guard::Allow(ident) => {
949                    let id = parse_id(req.param("id"))?;
950                    super::builtin::show_user_delete(&ac, ident, id, handlers::csrf_token(&req))
951                        .await
952                }
953            }
954        }
955    });
956
957    let c = ctx.clone();
958    let ac = auth_ctx.clone();
959    let router = router.post("/admin/users/:id/delete", move |req| {
960        let c = c.clone();
961        let ac = ac.clone();
962        async move {
963            match role_guard(&c, &req, Role::Administrator).await? {
964                Guard::Redirect(r) => Ok(r),
965                Guard::Allow(ident) => {
966                    let id = parse_id(req.param("id"))?;
967                    super::builtin::do_user_delete(&ac, ident, id, req).await
968                }
969            }
970        }
971    });
972
973    // === R2 admin-driven recovery routes ====================================
974    //
975    // Registered alongside the existing `/admin/users/:id/...` cluster
976    // (per `DESIGN_R2_ORGANISATIONAL.md` §7.2 — user-related cluster
977    // contiguous). All gated `Role::Administrator`; the cross-rank
978    // safety check + the re-auth wall are enforced INSIDE the
979    // handlers (commits #15 / #16) so a Supervisor probe doesn't even
980    // reach the form.
981    //
982    // Insertion-order note: these are 4-segment routes, so the
983    // 3-segment `/admin/users/:id` read-only view further down doesn't
984    // conflict regardless of order. Placing them before the 3-segment
985    // view keeps the user routes lexically clustered.
986
987    // GET /admin/users/:id/reset-password — admin reset form (R2 #15).
988    let c = ctx.clone();
989    let router = router.get("/admin/users/:id/reset-password", move |req| {
990        let c = c.clone();
991        async move {
992            match role_guard(&c, &req, Role::Administrator).await? {
993                Guard::Redirect(r) => Ok(r),
994                Guard::Allow(ident) => {
995                    let id = parse_id(req.param("id"))?;
996                    super::admin_recovery_handlers::show_admin_reset_password(&c, ident, id, &req)
997                        .await
998                }
999            }
1000        }
1001    });
1002
1003    // POST /admin/users/:id/reset-password — apply admin reset (R2 #15).
1004    let c = ctx.clone();
1005    let router = router.post("/admin/users/:id/reset-password", move |req| {
1006        let c = c.clone();
1007        async move {
1008            match role_guard(&c, &req, Role::Administrator).await? {
1009                Guard::Redirect(r) => Ok(r),
1010                Guard::Allow(ident) => {
1011                    let id = parse_id(req.param("id"))?;
1012                    super::admin_recovery_handlers::do_admin_reset_password(&c, ident, id, req)
1013                        .await
1014                }
1015            }
1016        }
1017    });
1018
1019    // GET /admin/users/:id/lock — lock confirmation form (R2 #16).
1020    let c = ctx.clone();
1021    let router = router.get("/admin/users/:id/lock", move |req| {
1022        let c = c.clone();
1023        async move {
1024            match role_guard(&c, &req, Role::Administrator).await? {
1025                Guard::Redirect(r) => Ok(r),
1026                Guard::Allow(ident) => {
1027                    let id = parse_id(req.param("id"))?;
1028                    super::admin_recovery_handlers::show_lock_user(&c, ident, id, &req).await
1029                }
1030            }
1031        }
1032    });
1033
1034    // POST /admin/users/:id/lock — apply manual lock (R2 #16).
1035    let c = ctx.clone();
1036    let router = router.post("/admin/users/:id/lock", move |req| {
1037        let c = c.clone();
1038        async move {
1039            match role_guard(&c, &req, Role::Administrator).await? {
1040                Guard::Redirect(r) => Ok(r),
1041                Guard::Allow(ident) => {
1042                    let id = parse_id(req.param("id"))?;
1043                    super::admin_recovery_handlers::do_lock_user(&c, ident, id, req).await
1044                }
1045            }
1046        }
1047    });
1048
1049    // GET /admin/users/:id/unlock — unlock confirmation form (R2 #16).
1050    let c = ctx.clone();
1051    let router = router.get("/admin/users/:id/unlock", move |req| {
1052        let c = c.clone();
1053        async move {
1054            match role_guard(&c, &req, Role::Administrator).await? {
1055                Guard::Redirect(r) => Ok(r),
1056                Guard::Allow(ident) => {
1057                    let id = parse_id(req.param("id"))?;
1058                    super::admin_recovery_handlers::show_unlock_user(&c, ident, id, &req).await
1059                }
1060            }
1061        }
1062    });
1063
1064    // POST /admin/users/:id/unlock — clear lock (R2 #16).
1065    let c = ctx.clone();
1066    let router = router.post("/admin/users/:id/unlock", move |req| {
1067        let c = c.clone();
1068        async move {
1069            match role_guard(&c, &req, Role::Administrator).await? {
1070                Guard::Redirect(r) => Ok(r),
1071                Guard::Allow(ident) => {
1072                    let id = parse_id(req.param("id"))?;
1073                    super::admin_recovery_handlers::do_unlock_user(&c, ident, id, req).await
1074                }
1075            }
1076        }
1077    });
1078
1079    // GET /admin/users/:id/revoke-sessions — revoke confirmation form
1080    // (R2 #16).
1081    let c = ctx.clone();
1082    let router = router.get("/admin/users/:id/revoke-sessions", move |req| {
1083        let c = c.clone();
1084        async move {
1085            match role_guard(&c, &req, Role::Administrator).await? {
1086                Guard::Redirect(r) => Ok(r),
1087                Guard::Allow(ident) => {
1088                    let id = parse_id(req.param("id"))?;
1089                    super::admin_recovery_handlers::show_admin_revoke_sessions(&c, ident, id, &req)
1090                        .await
1091                }
1092            }
1093        }
1094    });
1095
1096    // POST /admin/users/:id/revoke-sessions — revoke all sessions (R2 #16).
1097    let c = ctx.clone();
1098    let router = router.post("/admin/users/:id/revoke-sessions", move |req| {
1099        let c = c.clone();
1100        async move {
1101            match role_guard(&c, &req, Role::Administrator).await? {
1102                Guard::Redirect(r) => Ok(r),
1103                Guard::Allow(ident) => {
1104                    let id = parse_id(req.param("id"))?;
1105                    super::admin_recovery_handlers::do_admin_revoke_sessions(&c, ident, id, req)
1106                        .await
1107                }
1108            }
1109        }
1110    });
1111
1112    // Read-only user profile view. MUST be registered AFTER
1113    // `/admin/users/new` and the `:id/edit` + `:id/delete` routes
1114    // above: the router matches in insertion order, and `:id` is a
1115    // wildcard that would happily swallow "new" or extra path
1116    // segments. Putting this last preserves the more-specific routes'
1117    // priority.
1118    let c = ctx.clone();
1119    let ac = auth_ctx.clone();
1120    let router = router.get("/admin/users/:id", move |req| {
1121        let c = c.clone();
1122        let ac = ac.clone();
1123        async move {
1124            match role_guard(&c, &req, Role::Administrator).await? {
1125                Guard::Redirect(r) => Ok(r),
1126                Guard::Allow(ident) => {
1127                    let id = parse_id(req.param("id"))?;
1128                    let q = req.query();
1129                    let tab = q.get("tab").map(|s| s.to_string());
1130                    let page: i64 = q.get("page").and_then(|s| s.parse().ok()).unwrap_or(1);
1131                    super::builtin::show_user_view(
1132                        &ac,
1133                        ident,
1134                        id,
1135                        handlers::csrf_token(&req),
1136                        tab,
1137                        page,
1138                    )
1139                    .await
1140                }
1141            }
1142        }
1143    });
1144
1145    // --- Built-in groups admin (admin-only) ---
1146    let c = ctx.clone();
1147    let ac = auth_ctx.clone();
1148    let router = router.get("/admin/groups", move |req| {
1149        let c = c.clone();
1150        let ac = ac.clone();
1151        async move {
1152            match role_guard(&c, &req, Role::Administrator).await? {
1153                Guard::Redirect(r) => Ok(r),
1154                Guard::Allow(ident) => {
1155                    super::builtin::list_groups(&ac, ident, handlers::csrf_token(&req)).await
1156                }
1157            }
1158        }
1159    });
1160
1161    let c = ctx.clone();
1162    let ac = auth_ctx.clone();
1163    let router = router.get("/admin/groups/new", move |req| {
1164        let c = c.clone();
1165        let ac = ac.clone();
1166        async move {
1167            match role_guard(&c, &req, Role::Administrator).await? {
1168                Guard::Redirect(r) => Ok(r),
1169                Guard::Allow(ident) => {
1170                    super::builtin::show_new_group(&ac, ident, handlers::csrf_token(&req)).await
1171                }
1172            }
1173        }
1174    });
1175
1176    let c = ctx.clone();
1177    let ac = auth_ctx.clone();
1178    let router = router.post("/admin/groups/new", move |req| {
1179        let c = c.clone();
1180        let ac = ac.clone();
1181        async move {
1182            match role_guard(&c, &req, Role::Administrator).await? {
1183                Guard::Redirect(r) => Ok(r),
1184                Guard::Allow(ident) => super::builtin::do_new_group(&ac, ident, req).await,
1185            }
1186        }
1187    });
1188
1189    let c = ctx.clone();
1190    let ac = auth_ctx.clone();
1191    let router = router.get("/admin/groups/:id/edit", move |req| {
1192        let c = c.clone();
1193        let ac = ac.clone();
1194        async move {
1195            match role_guard(&c, &req, Role::Administrator).await? {
1196                Guard::Redirect(r) => Ok(r),
1197                Guard::Allow(ident) => {
1198                    let id = parse_id(req.param("id"))?;
1199                    super::builtin::show_group_edit(&ac, ident, id, handlers::csrf_token(&req))
1200                        .await
1201                }
1202            }
1203        }
1204    });
1205
1206    let c = ctx.clone();
1207    let ac = auth_ctx.clone();
1208    let router = router.post("/admin/groups/:id/edit", move |req| {
1209        let c = c.clone();
1210        let ac = ac.clone();
1211        async move {
1212            match role_guard(&c, &req, Role::Administrator).await? {
1213                Guard::Redirect(r) => Ok(r),
1214                Guard::Allow(ident) => {
1215                    let id = parse_id(req.param("id"))?;
1216                    super::builtin::do_group_edit(&ac, ident, id, req).await
1217                }
1218            }
1219        }
1220    });
1221
1222    let c = ctx.clone();
1223    let ac = auth_ctx.clone();
1224    let router = router.get("/admin/groups/:id/delete", move |req| {
1225        let c = c.clone();
1226        let ac = ac.clone();
1227        async move {
1228            match role_guard(&c, &req, Role::Administrator).await? {
1229                Guard::Redirect(r) => Ok(r),
1230                Guard::Allow(ident) => {
1231                    let id = parse_id(req.param("id"))?;
1232                    super::builtin::show_group_delete(&ac, ident, id, handlers::csrf_token(&req))
1233                        .await
1234                }
1235            }
1236        }
1237    });
1238
1239    let c = ctx.clone();
1240    let ac = auth_ctx.clone();
1241    let router = router.post("/admin/groups/:id/delete", move |req| {
1242        let c = c.clone();
1243        let ac = ac.clone();
1244        async move {
1245            match role_guard(&c, &req, Role::Administrator).await? {
1246                Guard::Redirect(r) => Ok(r),
1247                Guard::Allow(ident) => {
1248                    let id = parse_id(req.param("id"))?;
1249                    super::builtin::do_group_delete(&ac, ident, id, req).await
1250                }
1251            }
1252        }
1253    });
1254
1255    // Per-model list — needs `view` permission.
1256    let c = ctx.clone();
1257    let router = router.get("/admin/:admin_name", move |req| {
1258        let c = c.clone();
1259        async move {
1260            let name = model_name_from_req(&req)?;
1261            let perm = perm_for(&c, &name, "view")?;
1262            match perm_guard(&c, &req, &perm).await? {
1263                Guard::Redirect(r) => Ok(r),
1264                Guard::Allow(ident) => handlers::list_model(&c, ident, &name, &req).await,
1265            }
1266        }
1267    });
1268
1269    // Create.
1270    let c = ctx.clone();
1271    let router = router.get("/admin/:admin_name/new", move |req| {
1272        let c = c.clone();
1273        async move {
1274            let name = model_name_from_req(&req)?;
1275            let perm = perm_for(&c, &name, "add")?;
1276            match perm_guard(&c, &req, &perm).await? {
1277                Guard::Redirect(r) => Ok(r),
1278                Guard::Allow(ident) => handlers::show_new_form(&c, ident, &name, &req).await,
1279            }
1280        }
1281    });
1282    let c = ctx.clone();
1283    let router = router.post("/admin/:admin_name/new", move |req| {
1284        let c = c.clone();
1285        async move {
1286            let name = model_name_from_req(&req)?;
1287            let perm = perm_for(&c, &name, "add")?;
1288            match perm_guard(&c, &req, &perm).await? {
1289                Guard::Redirect(r) => Ok(r),
1290                Guard::Allow(ident) => handlers::do_create(&c, ident, &name, req).await,
1291            }
1292        }
1293    });
1294
1295    // Edit.
1296    let c = ctx.clone();
1297    let router = router.get("/admin/:admin_name/:id/edit", move |req| {
1298        let c = c.clone();
1299        async move {
1300            let name = model_name_from_req(&req)?;
1301            let perm = perm_for(&c, &name, "change")?;
1302            match perm_guard(&c, &req, &perm).await? {
1303                Guard::Redirect(r) => Ok(r),
1304                Guard::Allow(ident) => {
1305                    let id = parse_id(req.param("id"))?;
1306                    handlers::show_edit_form(&c, ident, &name, id, &req).await
1307                }
1308            }
1309        }
1310    });
1311    let c = ctx.clone();
1312    let router = router.post("/admin/:admin_name/:id/edit", move |req| {
1313        let c = c.clone();
1314        async move {
1315            let name = model_name_from_req(&req)?;
1316            let perm = perm_for(&c, &name, "change")?;
1317            match perm_guard(&c, &req, &perm).await? {
1318                Guard::Redirect(r) => Ok(r),
1319                Guard::Allow(ident) => {
1320                    let id = parse_id(req.param("id"))?;
1321                    handlers::do_update(&c, ident, &name, id, req).await
1322                }
1323            }
1324        }
1325    });
1326
1327    // Per-object history. Read-only; same `view` permission as the
1328    // changelist (if you can list, you can read the audit trail).
1329    let c = ctx.clone();
1330    let router = router.get("/admin/:admin_name/:id/history", move |req| {
1331        let c = c.clone();
1332        async move {
1333            let name = model_name_from_req(&req)?;
1334            let perm = perm_for(&c, &name, "view")?;
1335            match perm_guard(&c, &req, &perm).await? {
1336                Guard::Redirect(r) => Ok(r),
1337                Guard::Allow(ident) => {
1338                    let id = parse_id(req.param("id"))?;
1339                    handlers::show_object_history(&c, ident, &name, id, &req).await
1340                }
1341            }
1342        }
1343    });
1344
1345    // Delete.
1346    let c = ctx.clone();
1347    let router = router.get("/admin/:admin_name/:id/delete", move |req| {
1348        let c = c.clone();
1349        async move {
1350            let name = model_name_from_req(&req)?;
1351            let perm = perm_for(&c, &name, "delete")?;
1352            match perm_guard(&c, &req, &perm).await? {
1353                Guard::Redirect(r) => Ok(r),
1354                Guard::Allow(ident) => {
1355                    let id = parse_id(req.param("id"))?;
1356                    handlers::show_delete_confirm(&c, ident, &name, id, &req).await
1357                }
1358            }
1359        }
1360    });
1361    let c = ctx.clone();
1362    let router = router.post("/admin/:admin_name/:id/delete", move |req| {
1363        let c = c.clone();
1364        async move {
1365            let name = model_name_from_req(&req)?;
1366            let perm = perm_for(&c, &name, "delete")?;
1367            match perm_guard(&c, &req, &perm).await? {
1368                Guard::Redirect(r) => Ok(r),
1369                Guard::Allow(ident) => {
1370                    let id = parse_id(req.param("id"))?;
1371                    handlers::do_delete(&c, ident, &name, id).await
1372                }
1373            }
1374        }
1375    });
1376
1377    // Bulk delete — same permission gate as the per-row delete.
1378    // Two-step flow: first POST renders the confirm page, second POST
1379    // (with `_confirmed=1`) executes. See `handlers::handle_bulk_delete`
1380    // for the full contract.
1381    let c = ctx.clone();
1382    let router = router.post("/admin/:admin_name/bulk_delete", move |req| {
1383        let c = c.clone();
1384        async move {
1385            let name = model_name_from_req(&req)?;
1386            let perm = perm_for(&c, &name, "delete")?;
1387            match perm_guard(&c, &req, &perm).await? {
1388                Guard::Redirect(r) => Ok(r),
1389                Guard::Allow(ident) => handlers::handle_bulk_delete(&c, ident, &name, &req).await,
1390            }
1391        }
1392    });
1393
1394    // Project-defined bulk actions. Permission gated on `change` —
1395    // bulk actions modify rows but don't delete them (delete has its
1396    // own route). Project-side guard against further write-vs-read
1397    // distinctions belongs inside `execute_bulk_action`.
1398    let c = ctx.clone();
1399    router.post("/admin/:admin_name/bulk/:action", move |req| {
1400        let c = c.clone();
1401        async move {
1402            let name = model_name_from_req(&req)?;
1403            let action = req
1404                .param("action")
1405                .ok_or_else(|| Error::BadRequest("missing bulk action name".into()))?
1406                .to_string();
1407            let perm = perm_for(&c, &name, "change")?;
1408            match perm_guard(&c, &req, &perm).await? {
1409                Guard::Redirect(r) => Ok(r),
1410                Guard::Allow(ident) => {
1411                    handlers::handle_bulk_action(&c, ident, &name, &action, &req).await
1412                }
1413            }
1414        }
1415    })
1416}
1417
1418#[cfg(test)]
1419mod tests {
1420    use super::*;
1421
1422    fn make_identity(role: Role, is_active: bool) -> Identity {
1423        Identity {
1424            user_id: 42,
1425            email: "test@example.com".into(),
1426            role,
1427            is_active,
1428            is_demo: false,
1429            demo_label: None,
1430            must_change_password: false,
1431            mfa_enabled: false,
1432            trust_level: crate::auth::SessionTrust::Authenticated,
1433        }
1434    }
1435
1436    // role_guard's decision is `Role::includes(min)`. The 25-case
1437    // matrix lives in `auth::role::tests::includes_matrix_…`; the
1438    // cases below pin the most operator-relevant pairings.
1439
1440    #[test]
1441    fn role_guard_decision_admin_meets_staff_floor() {
1442        let id = make_identity(Role::Administrator, true);
1443        assert!(id.role.includes(Role::Staff));
1444    }
1445
1446    #[test]
1447    fn role_guard_decision_user_does_not_meet_staff() {
1448        let id = make_identity(Role::User, true);
1449        assert!(!id.role.includes(Role::Staff));
1450    }
1451
1452    #[test]
1453    fn role_guard_decision_administrator_does_not_meet_developer() {
1454        let id = make_identity(Role::Administrator, true);
1455        assert!(!id.role.includes(Role::Developer));
1456    }
1457
1458    #[test]
1459    fn role_guard_decision_developer_meets_everything() {
1460        let id = make_identity(Role::Developer, true);
1461        for &min in &[
1462            Role::User,
1463            Role::Staff,
1464            Role::Supervisor,
1465            Role::Administrator,
1466            Role::Developer,
1467        ] {
1468            assert!(id.role.includes(min), "Developer should meet {min:?}");
1469        }
1470    }
1471
1472    // ---- perm_guard_verdict matrix --------------------------------------
1473
1474    #[test]
1475    fn perm_guard_admin_short_circuits_without_perm() {
1476        let id = make_identity(Role::Administrator, true);
1477        assert!(perm_guard_verdict(&id, false));
1478    }
1479
1480    #[test]
1481    fn perm_guard_developer_short_circuits_without_perm() {
1482        let id = make_identity(Role::Developer, true);
1483        assert!(perm_guard_verdict(&id, false));
1484    }
1485
1486    #[test]
1487    fn perm_guard_staff_with_perm_passes() {
1488        let id = make_identity(Role::Staff, true);
1489        assert!(perm_guard_verdict(&id, true));
1490    }
1491
1492    #[test]
1493    fn perm_guard_staff_without_perm_denies() {
1494        let id = make_identity(Role::Staff, true);
1495        assert!(!perm_guard_verdict(&id, false));
1496    }
1497
1498    #[test]
1499    fn perm_guard_inactive_admin_denies_even_with_bypass() {
1500        // Defense-in-depth invariant.
1501        let id = make_identity(Role::Administrator, false);
1502        assert!(!perm_guard_verdict(&id, true));
1503    }
1504
1505    #[test]
1506    fn perm_guard_supervisor_without_perm_denies() {
1507        // Supervisor doesn't bypass; needs the per-model perm.
1508        let id = make_identity(Role::Supervisor, true);
1509        assert!(!perm_guard_verdict(&id, false));
1510    }
1511
1512    // ---- strict_mailer_guard_check ----------------------------------------
1513
1514    /// Default `Admin::new()` doesn't override the mailer AND
1515    /// doesn't enable strict mode — the guard passes.
1516    #[test]
1517    fn strict_mailer_guard_passes_for_default_admin() {
1518        let admin = super::super::types::Admin::new();
1519        assert!(strict_mailer_guard_check(&admin).is_ok());
1520    }
1521
1522    /// Strict-mailer mode + default LogMailer = boot guard fires.
1523    /// The error message is operator-actionable.
1524    #[test]
1525    fn strict_mailer_guard_fails_when_required_but_default_mailer() {
1526        use crate::auth::DefaultRecoveryPolicy;
1527        let admin = super::super::types::Admin::new().recovery_policy(std::sync::Arc::new(
1528            DefaultRecoveryPolicy::new().with_strict_mailer_required(true),
1529        ));
1530        let err = strict_mailer_guard_check(&admin).expect_err("guard should fail");
1531        assert!(
1532            err.contains("strict_mailer_required"),
1533            "error message must name the policy method: {err}"
1534        );
1535        assert!(
1536            err.contains("Admin::mailer"),
1537            "error message must direct the operator to the fix: {err}"
1538        );
1539    }
1540
1541    /// Strict-mailer mode + project-supplied mailer = guard passes.
1542    /// Note: the explicit override flips the flag even when the
1543    /// supplied value happens to be another LogMailer — the
1544    /// operator's intent is what matters, not the concrete type.
1545    #[test]
1546    fn strict_mailer_guard_passes_when_mailer_was_explicitly_overridden() {
1547        use crate::auth::DefaultRecoveryPolicy;
1548        use crate::email::LogMailer;
1549        let admin = super::super::types::Admin::new()
1550            .recovery_policy(std::sync::Arc::new(
1551                DefaultRecoveryPolicy::new().with_strict_mailer_required(true),
1552            ))
1553            .mailer(std::sync::Arc::new(LogMailer));
1554        assert!(strict_mailer_guard_check(&admin).is_ok());
1555    }
1556
1557    /// Project NOT in strict mode + default LogMailer = passes
1558    /// (dev / CI / testing baseline).
1559    #[test]
1560    fn strict_mailer_guard_passes_when_strict_mode_disabled() {
1561        let admin = super::super::types::Admin::new();
1562        assert!(strict_mailer_guard_check(&admin).is_ok());
1563    }
1564
1565    // ---- must-change-password whitelist (R2 commit #13) --------------------
1566
1567    #[test]
1568    fn whitelist_accepts_the_three_locked_paths() {
1569        // Locked-decision per DESIGN_R2_ORGANISATIONAL.md §12.
1570        assert!(super::is_must_change_whitelisted_path(
1571            "/admin/must-change-password"
1572        ));
1573        assert!(super::is_must_change_whitelisted_path("/admin/logout"));
1574        assert!(super::is_must_change_whitelisted_path(
1575            "/admin/account/sessions"
1576        ));
1577    }
1578
1579    #[test]
1580    fn whitelist_rejects_subpaths_of_account_sessions() {
1581        // Sub-paths of /admin/account/sessions (revoke buttons) are
1582        // intentionally NOT whitelisted — a user being forced to
1583        // rotate may VIEW their sessions but must finish the
1584        // rotation before revoking siblings.
1585        assert!(!super::is_must_change_whitelisted_path(
1586            "/admin/account/sessions/revoke"
1587        ));
1588        assert!(!super::is_must_change_whitelisted_path(
1589            "/admin/account/sessions/revoke-others"
1590        ));
1591        assert!(!super::is_must_change_whitelisted_path(
1592            "/admin/account/sessions/"
1593        ));
1594    }
1595
1596    #[test]
1597    fn whitelist_rejects_other_admin_paths() {
1598        for path in [
1599            "/admin",
1600            "/admin/",
1601            "/admin/users",
1602            "/admin/users/42",
1603            "/admin/login",
1604            "/admin/password_change",
1605            "/admin/forgot-password",
1606            "/admin/reauth",
1607            "/admin/must-change-password/", // trailing slash → not exact
1608        ] {
1609            assert!(
1610                !super::is_must_change_whitelisted_path(path),
1611                "expected reject for {path:?}"
1612            );
1613        }
1614    }
1615
1616    #[test]
1617    fn whitelist_rejects_paths_outside_admin_surface() {
1618        for path in ["/", "/login", "/static/admin.css", "/api"] {
1619            assert!(
1620                !super::is_must_change_whitelisted_path(path),
1621                "expected reject for {path:?}"
1622            );
1623        }
1624    }
1625}