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