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