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 → 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    // ---- base -------------------------------------------------
63    include_str!("../../assets/static/admin/base/reset.css"),
64    "\n",
65    include_str!("../../assets/static/admin/base/base.css"),
66    "\n",
67    include_str!("../../assets/static/admin/base/typography.css"),
68    "\n",
69    include_str!("../../assets/static/admin/base/typography-i18n.css"),
70    "\n",
71    include_str!("../../assets/static/admin/base/utilities.css"),
72    "\n",
73    // ---- layout -----------------------------------------------
74    include_str!("../../assets/static/admin/layout/shell.css"),
75    "\n",
76    include_str!("../../assets/static/admin/layout/topbar.css"),
77    "\n",
78    include_str!("../../assets/static/admin/layout/sidebar.css"),
79    "\n",
80    include_str!("../../assets/static/admin/layout/footer.css"),
81    "\n",
82    // ---- components -------------------------------------------
83    include_str!("../../assets/static/admin/components/cards.css"),
84    "\n",
85    include_str!("../../assets/static/admin/components/buttons.css"),
86    "\n",
87    include_str!("../../assets/static/admin/components/forms.css"),
88    "\n",
89    include_str!("../../assets/static/admin/components/tables.css"),
90    "\n",
91    include_str!("../../assets/static/admin/components/filters.css"),
92    "\n",
93    include_str!("../../assets/static/admin/components/dropdowns.css"),
94    "\n",
95    include_str!("../../assets/static/admin/components/search_palette.css"),
96    "\n",
97    include_str!("../../assets/static/admin/components/pagination.css"),
98    "\n",
99    include_str!("../../assets/static/admin/components/pills.css"),
100    "\n",
101    include_str!("../../assets/static/admin/components/flashes.css"),
102    "\n",
103    include_str!("../../assets/static/admin/components/timeline.css"),
104    "\n",
105    include_str!("../../assets/static/admin/components/tabs.css"),
106    "\n",
107    // ---- pages ------------------------------------------------
108    include_str!("../../assets/static/admin/pages/auth.css"),
109    "\n",
110    include_str!("../../assets/static/admin/pages/dashboard.css"),
111    "\n",
112    include_str!("../../assets/static/admin/pages/db_browser.css"),
113    "\n",
114    include_str!("../../assets/static/admin/pages/permissions.css"),
115    "\n",
116    include_str!("../../assets/static/admin/pages/sessions.css"),
117    "\n",
118    include_str!("../../assets/static/admin/pages/errors.css"),
119    "\n",
120    include_str!("../../assets/static/admin/pages/list.css"),
121    "\n",
122    // ---- responsive — mobile-first overrides, last so they win.
123    include_str!("../../assets/static/admin/layout/responsive.css"),
124    "\n",
125    // ---- print ------------------------------------------------
126    include_str!("../../assets/static/admin/print/print.css"),
127);
128
129/// Embedded admin JS (sidebar drawer + dropdowns + bulk select +
130/// FK autocomplete). ≤200 LOC, no build step.
131const ADMIN_JS: &str = include_str!("../../assets/static/admin.js");
132
133/// Self-hosted fonts (SIL OFL-1.1, see assets/static/fonts/LICENSE.txt).
134/// Bundling them as bytes keeps the single-binary deploy story intact
135/// and avoids the FOUT/CDN round-trip every consuming app would
136/// otherwise inherit from a Google Fonts <link>.
137///
138/// Latin (identity): Geist Variable wght 100..900. Latin fallback:
139/// Inter Variable (Latin-ext + Cyrillic + Greek + Vietnamese under
140/// one variable file). Mono: Geist Mono Variable. Arabic: Noto
141/// Naskh Arabic Variable wght 400..700 (default reading face) +
142/// Tajawal 400/500/700 (selective geometric accent). International
143/// scripts: Noto Sans Thai + Devanagari (variable, auto-loaded via
144/// unicode-range) + Noto Sans JP/KR/SC (static Regular, lang-gated
145/// to avoid Han Unification shape collisions).
146const FONT_GEIST: &[u8] = include_bytes!("../../assets/static/fonts/Geist-Variable.woff2");
147const FONT_GEIST_MONO: &[u8] = include_bytes!("../../assets/static/fonts/GeistMono-Variable.woff2");
148const FONT_INTER: &[u8] = include_bytes!("../../assets/static/fonts/InterVariable.woff2");
149const FONT_TAJAWAL_REG: &[u8] = include_bytes!("../../assets/static/fonts/Tajawal-Regular.woff2");
150const FONT_TAJAWAL_MED: &[u8] = include_bytes!("../../assets/static/fonts/Tajawal-Medium.woff2");
151const FONT_TAJAWAL_BOLD: &[u8] = include_bytes!("../../assets/static/fonts/Tajawal-Bold.woff2");
152const FONT_NOTO_NASKH_AR: &[u8] =
153    include_bytes!("../../assets/static/fonts/NotoNaskhArabic-Variable.woff2");
154const FONT_NOTO_THAI: &[u8] =
155    include_bytes!("../../assets/static/fonts/NotoSansThai-Variable.woff2");
156const FONT_NOTO_DEVA: &[u8] =
157    include_bytes!("../../assets/static/fonts/NotoSansDevanagari-Variable.woff2");
158const FONT_NOTO_JP: &[u8] = include_bytes!("../../assets/static/fonts/NotoSansJP-Regular.woff2");
159const FONT_NOTO_KR: &[u8] = include_bytes!("../../assets/static/fonts/NotoSansKR-Regular.woff2");
160const FONT_NOTO_SC: &[u8] = include_bytes!("../../assets/static/fonts/NotoSansSC-Regular.woff2");
161
162use super::handlers::{self, AdminCtx};
163use super::render;
164use super::types::Admin;
165
166/// Either an identity + a permission check passed, or any non-Allow
167/// response the route closure should return as-is (a 303 redirect to
168/// /admin/login, a 403 forbidden body, etc.).
169enum Guard {
170    Allow(Identity),
171    Redirect(Response),
172}
173
174/// Paths a user with `must_change_password = TRUE` is allowed to
175/// reach without first completing the forced rotation.
176/// Locked-decision per `DESIGN_R2_ORGANISATIONAL.md` §12.
177///
178/// Exact-path match (no prefix matching). Sub-paths of
179/// `/admin/account/sessions` (e.g. `/admin/account/sessions/revoke`)
180/// are intentionally NOT whitelisted — a user being forced to
181/// rotate may view their active sessions but must finish the
182/// rotation before revoking siblings.
183const MUST_CHANGE_WHITELIST: &[&str] = &[
184    "/admin/must-change-password",
185    "/admin/logout",
186    "/admin/account/sessions",
187];
188
189/// Whether `path` is on the must-change-password whitelist.
190/// Pulled out as a free fn so the rule is unit-testable without a
191/// `Request`. See [`MUST_CHANGE_WHITELIST`] for the contract.
192fn is_must_change_whitelisted_path(path: &str) -> bool {
193    MUST_CHANGE_WHITELIST.contains(&path)
194}
195
196/// Exact-path read-only-mode allowlist for mutating verbs
197/// (POST / PUT / DELETE). When [`Admin::read_only`] is on, every
198/// other mutating request under `/admin/*` is rejected with 403;
199/// the entries here keep auth-flow round-trips working so an
200/// operator can still sign in and out of a frozen admin.
201///
202/// Sub-paths under `/admin/account/sessions/`, `/admin/account/mfa/`,
203/// and `/admin/reset-password/` need prefix matching (variable
204/// `:id` / `:token` segments); [`is_read_only_writable_path`]
205/// handles those separately.
206const READ_ONLY_EXACT_ALLOW: &[&str] = &[
207    "/admin/login",
208    "/admin/logout",
209    "/admin/reauth",
210    "/admin/forgot-password",
211    "/admin/mfa/verify",
212    "/admin/must-change-password",
213    "/admin/password_change",
214];
215
216const READ_ONLY_PREFIX_ALLOW: &[&str] = &[
217    // Token-bearing self-recovery URL — operators must be able to
218    // consume a reset link even when project mutations are frozen.
219    "/admin/reset-password/",
220    // Own-session management — sign-out-everywhere, revoke this
221    // device, etc. Identity self-service is not "data mutation."
222    "/admin/account/sessions/",
223    // Own MFA management — enrolment, regeneration, disable. Same
224    // identity-self-service reasoning.
225    "/admin/account/mfa/",
226];
227
228/// `/admin/<model>/saved_filters` and its
229/// `/admin/<model>/saved_filters/<id>/delete` sibling are
230/// per-operator UI state (bookmarks for the list page), not
231/// project-data mutations. Allowlist by suffix-match so they
232/// remain usable even in read-only mode.
233fn is_saved_filter_path(path: &str) -> bool {
234    if let Some(rest) = path.strip_prefix("/admin/") {
235        // Path shape: `<admin_name>/saved_filters` or
236        // `<admin_name>/saved_filters/<id>/delete`. Find the
237        // first `/saved_filters` segment after the model slug.
238        if let Some((_, after)) = rest.split_once('/') {
239            return after == "saved_filters" || after.starts_with("saved_filters/");
240        }
241    }
242    false
243}
244
245/// `true` when the HTTP method writes to state — POST, PUT,
246/// PATCH, DELETE. GET / HEAD / OPTIONS pass through the read-only
247/// guard untouched. Idempotency isn't the discriminator (PUT/PATCH
248/// are mutating despite being idempotent) — the question is "does
249/// this typically mutate server state?"
250pub(crate) fn is_mutating_method(method: &hyper::Method) -> bool {
251    matches!(
252        *method,
253        hyper::Method::POST | hyper::Method::PUT | hyper::Method::PATCH | hyper::Method::DELETE
254    )
255}
256
257/// Returns `true` when a mutating request to `path` should be
258/// allowed despite [`Admin::read_only`] being on. The narrow set
259/// of exceptions covers identity / auth flows so a read-only
260/// admin is still usable as a sign-in surface.
261///
262/// Pulled out as a free fn so the policy is unit-testable
263/// without a `Request`.
264pub(crate) fn is_read_only_writable_path(path: &str) -> bool {
265    if READ_ONLY_EXACT_ALLOW.contains(&path) {
266        return true;
267    }
268    if READ_ONLY_PREFIX_ALLOW
269        .iter()
270        .any(|prefix| path.starts_with(prefix))
271    {
272        return true;
273    }
274    is_saved_filter_path(path)
275}
276
277/// Extract the `:admin_name` URL segment from a request path of the
278/// shape `/admin/<admin_name>[/...]`. Returns `None` for paths that
279/// don't match (root `/admin/`, framework reserved `/admin/_*`, or
280/// non-admin paths). Used by the per-model read-only gate so the
281/// middleware can check `Admin::is_model_read_only` without parsing
282/// the router's `:admin_name` capture.
283pub(crate) fn extract_admin_name(path: &str) -> Option<&str> {
284    let rest = path.strip_prefix("/admin/")?;
285    let slug = rest.split('/').next()?;
286    if slug.is_empty() || slug.starts_with('_') {
287        return None;
288    }
289    Some(slug)
290}
291
292/// Paths reachable when `MfaPolicy::Required` is active and the
293/// user has not yet enrolled (R3 commit #18). Forward-only
294/// enforcement per `DESIGN_R3_MFA.md` D6: existing sessions
295/// continue to work, but every non-whitelisted request from a
296/// not-yet-enrolled user redirects to the enrolment form.
297/// Mirrors [`MUST_CHANGE_WHITELIST`]'s shape so the two
298/// interstitial flows compose identically when both gates fire.
299///
300/// Exact-path match. Sub-paths of `/admin/account/sessions`
301/// (e.g. `/admin/account/sessions/revoke`) are NOT whitelisted
302/// — a user being forced to enrol may view their active
303/// sessions but must finish enrolment before revoking siblings.
304const MFA_ENROLL_WHITELIST: &[&str] = &[
305    "/admin/account/mfa/enroll",
306    "/admin/logout",
307    "/admin/account/sessions",
308];
309
310fn is_mfa_enroll_whitelisted_path(path: &str) -> bool {
311    MFA_ENROLL_WHITELIST.contains(&path)
312}
313
314/// Paths reachable when the user has MFA enrolled but the
315/// current session has not yet been promoted to `mfa_verified`
316/// (the post-password, pre-MFA-verify window from R3 commit
317/// #16's `do_login`). The user can complete the second-factor
318/// verify, log out, or inspect their active sessions — nothing
319/// else.
320///
321/// Exact-path match. See [`MFA_ENROLL_WHITELIST`] for the
322/// rationale around the sessions page.
323const MFA_VERIFY_WHITELIST: &[&str] = &[
324    "/admin/mfa/verify",
325    "/admin/logout",
326    "/admin/account/sessions",
327];
328
329fn is_mfa_verify_whitelisted_path(path: &str) -> bool {
330    MFA_VERIFY_WHITELIST.contains(&path)
331}
332
333/// Whether the active `MfaPolicy` requires MFA for a given
334/// role. Pulled out as a free fn so the rule is unit-testable
335/// without an `Admin` context.
336///
337/// `MfaPolicy::Disabled` / `Optional` → never required.
338/// `MfaPolicy::Required` → required for every role.
339/// `MfaPolicy::RequiredForRoles(roles)` → required iff the
340///   user's role appears in the slice. An empty slice reads
341///   as "no role requires MFA" — equivalent to `Optional`.
342fn mfa_required_for_role(policy: crate::auth::MfaPolicy, role: Role) -> bool {
343    use crate::auth::MfaPolicy;
344    match policy {
345        MfaPolicy::Disabled | MfaPolicy::Optional => false,
346        MfaPolicy::Required => true,
347        MfaPolicy::RequiredForRoles(roles) => roles.contains(&role),
348    }
349}
350
351async fn login_guard(ctx: &AdminCtx, req: &Request) -> Result<Guard> {
352    let cookie = match req.header("cookie") {
353        Some(c) => c,
354        None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
355    };
356    let token = match auth::session_token_from_cookie(cookie) {
357        Some(t) => t,
358        None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
359    };
360    let ident = match auth::identity_from_session(&ctx.db, &token).await? {
361        Some(i) => i,
362        None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
363    };
364    if !ident.is_active {
365        return Ok(Guard::Redirect(Response::redirect("/admin/login")));
366    }
367
368    // R2 forced-rotation gate (`DESIGN_R2_ORGANISATIONAL.md` §3.4 +
369    // §9.2). When the flag is set, every authenticated request EXCEPT
370    // the whitelist redirects to `/admin/must-change-password`. The
371    // check sits BEFORE any role gate so even Administrators /
372    // Developers with the flag set are funnelled through.
373    if ident.must_change_password && !is_must_change_whitelisted_path(req.path()) {
374        return Ok(Guard::Redirect(Response::redirect(
375            "/admin/must-change-password",
376        )));
377    }
378
379    // R3 MFA-required gate — forward-only per D6
380    // (`DESIGN_R3_MFA.md` §12.3). When the active MfaPolicy
381    // requires MFA for this user's role AND they have not
382    // enrolled, every non-whitelisted request redirects to the
383    // enrolment form. Existing sessions continue to work; the
384    // redirect kicks in at the NEXT request, not at the moment
385    // the policy flips. This matches R2's must-change-password
386    // shape — see MFA_ENROLL_WHITELIST for the reachable paths.
387    let policy = ctx.admin.active_mfa_policy();
388    if mfa_required_for_role(policy, ident.role)
389        && !ident.mfa_enabled
390        && !is_mfa_enroll_whitelisted_path(req.path())
391    {
392        return Ok(Guard::Redirect(Response::redirect(
393            "/admin/account/mfa/enroll",
394        )));
395    }
396
397    // R3 pending-MFA-verify gate (`DESIGN_R3_MFA.md` §4.2 +
398    // §12.3). When the user has MFA enrolled but the current
399    // session has not yet been promoted to mfa_verified (the
400    // post-password, pre-MFA-verify window from commit #16's
401    // do_login), restrict access to the MFA verify whitelist.
402    // The verify POST handler rotates the session via
403    // promote_session_to_mfa_verified once both factors land.
404    use crate::auth::SessionTrust;
405    if ident.mfa_enabled
406        && ident.trust_level != SessionTrust::MfaVerified
407        && !is_mfa_verify_whitelisted_path(req.path())
408    {
409        return Ok(Guard::Redirect(Response::redirect("/admin/mfa/verify")));
410    }
411
412    Ok(Guard::Allow(ident))
413}
414
415async fn role_guard(ctx: &AdminCtx, req: &Request, min: Role) -> Result<Guard> {
416    match login_guard(ctx, req).await? {
417        Guard::Redirect(r) => Ok(Guard::Redirect(r)),
418        Guard::Allow(ident) => {
419            if ident.role.includes(min) {
420                Ok(Guard::Allow(ident))
421            } else {
422                let body = render::render_forbidden_body(
423                    &ctx.admin,
424                    &ctx.templates,
425                    &ident,
426                    handlers::csrf_token(req),
427                    None,
428                    Some(min.label()),
429                )?;
430                Ok(Guard::Redirect(
431                    Response::html(body).with_status(hyper::StatusCode::FORBIDDEN),
432                ))
433            }
434        }
435    }
436}
437
438async fn perm_guard(ctx: &AdminCtx, req: &Request, perm: &str) -> Result<Guard> {
439    match role_guard(ctx, req, Role::Staff).await? {
440        Guard::Redirect(r) => Ok(Guard::Redirect(r)),
441        Guard::Allow(ident) => {
442            if ident.role.bypasses_group_checks() {
443                return Ok(Guard::Allow(ident));
444            }
445            if auth::check_permission(&ctx.db, &ident, perm).await? {
446                Ok(Guard::Allow(ident))
447            } else {
448                let body = render::render_forbidden_body(
449                    &ctx.admin,
450                    &ctx.templates,
451                    &ident,
452                    handlers::csrf_token(req),
453                    Some(perm.to_string()),
454                    None,
455                )?;
456                Ok(Guard::Redirect(
457                    Response::html(body).with_status(hyper::StatusCode::FORBIDDEN),
458                ))
459            }
460        }
461    }
462}
463
464/// Pure decision logic for `perm_guard`, factored out so it can be
465/// unit-tested without a `Db`.
466#[cfg(test)]
467fn perm_guard_verdict(ident: &Identity, perm_held: bool) -> bool {
468    if !ident.is_active {
469        return false;
470    }
471    if ident.role.bypasses_group_checks() {
472        return true;
473    }
474    perm_held
475}
476
477fn parse_id(raw: Option<&str>) -> Result<i64> {
478    raw.and_then(|s| s.parse().ok())
479        .ok_or_else(|| Error::BadRequest("invalid id".into()))
480}
481
482fn model_name_from_req(req: &Request) -> Result<String> {
483    req.param("admin_name")
484        .map(|s| s.to_string())
485        .ok_or_else(|| Error::BadRequest("missing model".into()))
486}
487
488fn perm_for(ctx: &AdminCtx, admin_name: &str, action: &str) -> Result<String> {
489    let entry = ctx
490        .admin
491        .find(admin_name)
492        .ok_or_else(|| Error::NotFound(format!("no admin model: {admin_name}")))?;
493    let singular = entry.singular_name.to_ascii_lowercase();
494    Ok(format!("{admin_name}.{action}_{singular}"))
495}
496
497/// Pure verdict for the R1 strict-mailer boot guard
498/// (`DESIGN_RECOVERY.md` §12.1). Returns an operator-facing error
499/// string when the policy demands a real mailer but `Admin::new()`'s
500/// default `LogMailer` is still in place; returns `Ok(())`
501/// otherwise.
502///
503/// Detection is deterministic and structural: it reads
504/// [`Admin::has_custom_mailer`] (set whenever
505/// [`Admin::mailer`] has been called). No `Arc::ptr_eq` against a
506/// freshly-constructed `LogMailer`; no environment heuristics; no
507/// hostname checks; no "production mode" guessing — the operator
508/// declares intent by calling `Admin::mailer(...)` (and opts the
509/// policy in via `RecoveryPolicy::strict_mailer_required(true)`).
510///
511/// The framework treats an explicit `Admin::mailer(...)` call as
512/// satisfying the guard even when the supplied mailer is itself a
513/// `LogMailer` — this is the documented escape hatch for projects
514/// that want to silence the guard during a migration window
515/// without yet wiring a real transport.
516/// Best-effort identity resolution for the chrome of an admin
517/// error page. Mirrors what `auth::routes::login_guard` does on
518/// the success path, but is non-fatal: any failure returns `None`
519/// and the error page falls back to the unauthenticated chrome
520/// (just like the original behaviour).
521///
522/// `VISIBILITY_AUDIT.md` A2: without this lookup, every 4xx/5xx
523/// page rendered as if the operator were logged out, so the
524/// operator lost their sidebar + top-bar account links the
525/// moment they hit an erroring route — a navigational dead-end.
526async fn resolve_identity_for_error_page(db: &Db, cookie_header: &str) -> Option<Identity> {
527    let token = auth::session_token_from_cookie(cookie_header)?;
528    let identity = auth::identity_from_session(db, token.as_str())
529        .await
530        .ok()
531        .flatten()?;
532    if !identity.is_active {
533        return None;
534    }
535    Some(identity)
536}
537
538fn strict_mailer_guard_check(admin: &Admin) -> std::result::Result<(), String> {
539    if admin.active_recovery_policy().strict_mailer_required() && !admin.has_custom_mailer() {
540        Err(
541            "rustio-admin: RecoveryPolicy::strict_mailer_required() = true but no mailer \
542             was registered via Admin::mailer(...).\n\n\
543             The framework's default LogMailer writes recovery emails to log::info! instead \
544             of sending them, which is unsuitable for production. Recovery routes are NOT \
545             registered with this configuration.\n\n\
546             To resolve, choose one:\n\
547              (a) register a real mailer before calling register_admin_routes:\n\
548                  Admin::mailer(Arc::new(MyProjectMailer::new(...)))\n\
549              (b) opt the policy out of strict mode (the framework default — dev / CI / \
550                  testing baseline):\n\
551                  RecoveryPolicy::strict_mailer_required(false)\n\n\
552             See DESIGN_RECOVERY.md §12.1 for the contract."
553                .to_string(),
554        )
555    } else {
556        Ok(())
557    }
558}
559
560// public:
561pub fn register_admin_routes(
562    router: Router,
563    admin: Admin,
564    db: Db,
565    templates: Arc<Templates>,
566) -> Router {
567    // R1 commit #9 — strict-mailer boot guard. Runs BEFORE any
568    // route registration so a misconfigured deployment fails
569    // loudly at startup rather than registering recovery routes
570    // against a production-unsafe default mailer
571    // (`DESIGN_RECOVERY.md` §12.1). The check is structural: see
572    // [`strict_mailer_guard_check`] for why we don't do
573    // pointer-equality tricks against the default LogMailer.
574    if let Err(msg) = strict_mailer_guard_check(&admin) {
575        panic!("{msg}");
576    }
577
578    let ctx = Arc::new(AdminCtx::new(
579        Arc::new(admin),
580        db.clone(),
581        templates.clone(),
582    ));
583
584    // Bespoke user/group pages share the same DB / templates / Admin
585    // arc but live in their own ctx type with the same shape.
586    let auth_ctx = Arc::new(super::builtin::AuthAdminCtx {
587        admin: ctx.admin.clone(),
588        db,
589        templates,
590    });
591
592    // Render `Err(_)` from /admin/* handlers as styled HTML instead of
593    // the framework default `text/plain`. Non-admin paths bubble
594    // through unchanged so JSON / curl consumers still get the text
595    // body. `Error::Forbidden` (handled by `role_guard` via
596    // `admin/forbidden.html`) and login-required redirects come
597    // through as `Ok` responses and bypass this branch.
598    //
599    // The middleware also resolves the operator's identity from the
600    // session cookie BEFORE handing off, so the error page renders
601    // with the same chrome (sidebar, top-bar actor, "Log out") the
602    // operator was using when they hit the failing route. Without
603    // this lookup the 404 page rendered as if the operator were
604    // unauthenticated — a navigational dead-end documented as
605    // `VISIBILITY_AUDIT.md` finding A2.
606    let err_admin = ctx.admin.clone();
607    let err_templates = ctx.templates.clone();
608    let err_db = ctx.db.clone();
609    let router = router.middleware(move |req, next| {
610        let admin = err_admin.clone();
611        let templates = err_templates.clone();
612        let db = err_db.clone();
613        Box::pin(async move {
614            let is_admin_path = req.path().starts_with("/admin");
615            // Capture the cookie header BEFORE moving `req` into
616            // `next.run` so the error branch can re-resolve the
617            // operator's identity. The auth middleware sits inside
618            // this one; the request is consumed by `next.run` before
619            // we get to the `Err` branch.
620            let cookie_header = if is_admin_path {
621                req.header("cookie").map(|s| s.to_string())
622            } else {
623                None
624            };
625            let result = next.run(req).await;
626            match result {
627                Ok(resp) => Ok(resp),
628                Err(err) if is_admin_path => {
629                    let identity = match cookie_header.as_deref() {
630                        Some(cookie) => resolve_identity_for_error_page(&db, cookie).await,
631                        None => None,
632                    };
633                    Ok(render::render_admin_error_response(
634                        &admin,
635                        &templates,
636                        identity.as_ref(),
637                        err.status(),
638                        err.client_message().to_string(),
639                    ))
640                }
641                Err(err) => Err(err),
642            }
643        })
644    });
645
646    // Read-only guard. When [`Admin::read_only`] is on, every
647    // mutating verb (POST / PUT / PATCH / DELETE) under `/admin/*`
648    // returns 403 — except a small allowlist for auth flows
649    // (login / logout / reauth / MFA verify / password recovery /
650    // own-session management). Mounted AFTER the error-renderer
651    // above so the 403 gets the styled HTML page treatment instead
652    // of plain text. Mounted BEFORE every route registration below
653    // so the chain runs early and the actual handler never sees a
654    // blocked mutation. Pass-through (`next.run(req)`) when the
655    // admin isn't read-only — zero overhead for non-frozen
656    // deployments.
657    let ro_flag = ctx.admin.is_read_only();
658    let ro_models = std::sync::Arc::new(ctx.admin.read_only_models.clone());
659    let router = router.middleware(move |req, next| {
660        let ro_models = ro_models.clone();
661        Box::pin(async move {
662            if req.path().starts_with("/admin")
663                && is_mutating_method(req.method())
664                && !is_read_only_writable_path(req.path())
665            {
666                // Whole-admin read-only takes precedence: every
667                // project-data mutation is rejected.
668                if ro_flag {
669                    return Err(Error::Forbidden(
670                        "This admin is currently in read-only mode. \
671                         Project-data mutations are disabled until the operator \
672                         turns read-only off."
673                            .into(),
674                    ));
675                }
676                // Per-model read-only: extract the `:admin_name`
677                // segment and check the frozen set. A frozen slug
678                // stays frozen even if the rest of the admin is
679                // writable.
680                if !ro_models.is_empty() {
681                    if let Some(slug) = extract_admin_name(req.path()) {
682                        if ro_models.contains(slug) {
683                            return Err(Error::Forbidden(format!(
684                                "Model `{slug}` is frozen (read-only). \
685                                 Mutations on this model are disabled."
686                            )));
687                        }
688                    }
689                }
690            }
691            next.run(req).await
692        })
693    });
694
695    // Embedded stylesheet + JS. The bytes are baked into the binary
696    // so single-binary deploy is preserved. CSS/JS use `no-cache`
697    // (revalidate every request) so theme + design tweaks roll out the
698    // moment the binary restarts; fonts (next block) keep their long
699    // immutable cache because their bytes never change per release.
700    let router = router.get("/static/admin.css", |_req| async move {
701        Ok(Response::new(
702            hyper::StatusCode::OK,
703            bytes::Bytes::from_static(ADMIN_CSS.as_bytes()),
704        )
705        .with_header("content-type", "text/css; charset=utf-8")
706        .with_header("cache-control", "no-cache, must-revalidate"))
707    });
708    let router = router.get("/static/admin.js", |_req| async move {
709        Ok(Response::new(
710            hyper::StatusCode::OK,
711            bytes::Bytes::from_static(ADMIN_JS.as_bytes()),
712        )
713        .with_header("content-type", "application/javascript; charset=utf-8")
714        .with_header("cache-control", "no-cache, must-revalidate"))
715    });
716
717    // Self-hosted fonts. Cache aggressively: file contents are
718    // immutable per build, so a 1-year cache is safe — the binary
719    // ships a fresh copy on the next release.
720    fn font_response(bytes: &'static [u8]) -> Response {
721        Response::new(hyper::StatusCode::OK, bytes::Bytes::from_static(bytes))
722            .with_header("content-type", "font/woff2")
723            .with_header("cache-control", "public, max-age=31536000, immutable")
724    }
725    let router = router.get("/static/fonts/Geist-Variable.woff2", |_req| async move {
726        Ok(font_response(FONT_GEIST))
727    });
728    let router = router.get(
729        "/static/fonts/GeistMono-Variable.woff2",
730        |_req| async move { Ok(font_response(FONT_GEIST_MONO)) },
731    );
732    let router = router.get("/static/fonts/Tajawal-Regular.woff2", |_req| async move {
733        Ok(font_response(FONT_TAJAWAL_REG))
734    });
735    let router = router.get("/static/fonts/Tajawal-Medium.woff2", |_req| async move {
736        Ok(font_response(FONT_TAJAWAL_MED))
737    });
738    let router = router.get("/static/fonts/Tajawal-Bold.woff2", |_req| async move {
739        Ok(font_response(FONT_TAJAWAL_BOLD))
740    });
741    let router = router.get(
742        "/static/fonts/NotoNaskhArabic-Variable.woff2",
743        |_req| async move { Ok(font_response(FONT_NOTO_NASKH_AR)) },
744    );
745    let router = router.get("/static/fonts/InterVariable.woff2", |_req| async move {
746        Ok(font_response(FONT_INTER))
747    });
748    let router = router.get(
749        "/static/fonts/NotoSansThai-Variable.woff2",
750        |_req| async move { Ok(font_response(FONT_NOTO_THAI)) },
751    );
752    let router = router.get(
753        "/static/fonts/NotoSansDevanagari-Variable.woff2",
754        |_req| async move { Ok(font_response(FONT_NOTO_DEVA)) },
755    );
756    let router = router.get(
757        "/static/fonts/NotoSansJP-Regular.woff2",
758        |_req| async move { Ok(font_response(FONT_NOTO_JP)) },
759    );
760    let router = router.get(
761        "/static/fonts/NotoSansKR-Regular.woff2",
762        |_req| async move { Ok(font_response(FONT_NOTO_KR)) },
763    );
764    let router = router.get(
765        "/static/fonts/NotoSansSC-Regular.woff2",
766        |_req| async move { Ok(font_response(FONT_NOTO_SC)) },
767    );
768
769    // Public: liveness / readiness probe. No auth — load
770    // balancers and k8s probes don't carry session cookies.
771    // Registered ahead of `/admin/:admin_name` so the route
772    // can't be shadowed by a model literally named `healthz`.
773    let c = ctx.clone();
774    let router = router.get("/admin/healthz", move |_req| {
775        let c = c.clone();
776        async move { super::healthz::healthz(&c.db).await }
777    });
778
779    // Public: login/logout.
780    let c = ctx.clone();
781    let router = router.get("/admin/login", move |req| {
782        let c = c.clone();
783        async move { handlers::show_login(&c, req).await }
784    });
785
786    let c = ctx.clone();
787    let router = router.post("/admin/login", move |req| {
788        let c = c.clone();
789        async move { handlers::do_login(&c, req).await }
790    });
791
792    let c = ctx.clone();
793    let router = router.post("/admin/logout", move |req| {
794        let c = c.clone();
795        async move { handlers::do_logout(&c, req).await }
796    });
797
798    // === R1 recovery routes ====================================
799    //
800    // MUST be registered BEFORE the `/admin/:admin_name` model
801    // wildcards lower down — without that ordering, a request to
802    // `/admin/forgot-password` would match `:admin_name =
803    // "forgot-password"` and route into the model CRUD handler.
804    //
805    // Recovery state (the rate-limit buckets) is built once here
806    // and cloned into each route closure so the buckets persist
807    // for the process lifetime. No global / static / OnceLock —
808    // the Arc lives in the closures.
809    //
810    // Strict-mailer boot guard already ran at the top of this fn
811    // (would have panicked if misconfigured); reaching this block
812    // means we have the operator's blessing to wire recovery.
813
814    let recovery_state = Arc::new(super::recovery_handlers::RecoveryState::from_admin(
815        &ctx.admin,
816    ));
817
818    let c = ctx.clone();
819    let router = router.get("/admin/forgot-password", move |req| {
820        let c = c.clone();
821        async move { super::recovery_handlers::show_forgot_password(&c, &req).await }
822    });
823
824    let c = ctx.clone();
825    let rs = recovery_state.clone();
826    let router = router.post("/admin/forgot-password", move |req| {
827        let c = c.clone();
828        let rs = rs.clone();
829        async move { super::recovery_handlers::do_forgot_password(&c, &rs, req).await }
830    });
831
832    let c = ctx.clone();
833    let router = router.get("/admin/forgot-password/sent", move |req| {
834        let c = c.clone();
835        async move { super::recovery_handlers::show_forgot_password_sent(&c, &req).await }
836    });
837
838    let c = ctx.clone();
839    let router = router.get("/admin/reset-password/:token", move |req| {
840        let c = c.clone();
841        async move {
842            let token = req
843                .param("token")
844                .ok_or_else(|| Error::BadRequest("missing token".into()))?
845                .to_string();
846            super::recovery_handlers::show_reset_password(&c, &req, &token).await
847        }
848    });
849
850    let c = ctx.clone();
851    let rs = recovery_state.clone();
852    let router = router.post("/admin/reset-password/:token", move |req| {
853        let c = c.clone();
854        let rs = rs.clone();
855        async move {
856            let token = req
857                .param("token")
858                .ok_or_else(|| Error::BadRequest("missing token".into()))?
859                .to_string();
860            super::recovery_handlers::do_reset_password(&c, &rs, req, &token).await
861        }
862    });
863
864    // Dashboard — Staff floor. User-tier sees the forbidden page.
865    let c = ctx.clone();
866    let router = router.get("/admin", move |req| {
867        let c = c.clone();
868        async move {
869            match role_guard(&c, &req, Role::Staff).await? {
870                Guard::Redirect(r) => Ok(r),
871                Guard::Allow(ident) => handlers::dashboard(&c, ident, &req).await,
872            }
873        }
874    });
875
876    // Database browser — Developer-only schema explorer at
877    // `/admin/db`. Read-only `information_schema` / `pg_catalog`
878    // queries; no DDL, no row sampling. Registered before the
879    // generic `/admin/:admin_name` so a model coincidentally named
880    // `db` can't shadow it (also the static literal wins on tie).
881    let c = ctx.clone();
882    let router = router.get("/admin/db", move |req| {
883        let c = c.clone();
884        async move {
885            match role_guard(&c, &req, Role::Developer).await? {
886                Guard::Redirect(r) => Ok(r),
887                Guard::Allow(ident) => super::db_browser::show_db_browser(&c, ident, &req).await,
888            }
889        }
890    });
891
892    // Notifications — per-operator list page. Any signed-in
893    // operator sees their own notifications (filtered by user_id
894    // in the handler), so the gate is just Staff. The topbar
895    // bell links here from every authenticated page.
896    let c = ctx.clone();
897    let router = router.get("/admin/notifications", move |req| {
898        let c = c.clone();
899        async move {
900            match role_guard(&c, &req, Role::Staff).await? {
901                Guard::Redirect(r) => Ok(r),
902                Guard::Allow(ident) => handlers::show_notifications(&c, ident, &req).await,
903            }
904        }
905    });
906    let c = ctx.clone();
907    let router = router.post("/admin/notifications/mark_all_read", move |req| {
908        let c = c.clone();
909        async move {
910            match role_guard(&c, &req, Role::Staff).await? {
911                Guard::Redirect(r) => Ok(r),
912                Guard::Allow(ident) => {
913                    handlers::do_mark_all_notifications_read(&c, ident, req).await
914                }
915            }
916        }
917    });
918
919    // Feature flags — Administrator-only management page.
920    // Lists existing flags with toggle buttons + a "create"
921    // form. Reads in project code via
922    // `rustio_admin::feature_enabled(db, key)`.
923    let c = ctx.clone();
924    let router = router.get("/admin/feature_flags", move |req| {
925        let c = c.clone();
926        async move {
927            match role_guard(&c, &req, Role::Administrator).await? {
928                Guard::Redirect(r) => Ok(r),
929                Guard::Allow(ident) => handlers::show_feature_flags(&c, ident, &req).await,
930            }
931        }
932    });
933    let c = ctx.clone();
934    let router = router.post("/admin/feature_flags", move |req| {
935        let c = c.clone();
936        async move {
937            match role_guard(&c, &req, Role::Administrator).await? {
938                Guard::Redirect(r) => Ok(r),
939                Guard::Allow(ident) => handlers::do_create_feature_flag(&c, ident, req).await,
940            }
941        }
942    });
943    let c = ctx.clone();
944    let router = router.post("/admin/feature_flags/:key/toggle", move |req| {
945        let c = c.clone();
946        async move {
947            let key = req
948                .param("key")
949                .ok_or_else(|| Error::BadRequest("missing flag key".into()))?
950                .to_string();
951            match role_guard(&c, &req, Role::Administrator).await? {
952                Guard::Redirect(r) => Ok(r),
953                Guard::Allow(ident) => handlers::do_toggle_feature_flag(&c, ident, &key, req).await,
954            }
955        }
956    });
957
958    // Health dashboard — Administrator-only web counterpart to
959    // `rustio doctor`. Runs the same DB probes the CLI does
960    // (Postgres reachable, auth tables present, ≥1 active admin,
961    // RUSTIO_SECRET_KEY shape). Distinct from `/admin/healthz`
962    // which is the public liveness probe.
963    let c = ctx.clone();
964    let router = router.get("/admin/health", move |req| {
965        let c = c.clone();
966        async move {
967            match role_guard(&c, &req, Role::Administrator).await? {
968                Guard::Redirect(r) => Ok(r),
969                Guard::Allow(ident) => handlers::show_health(&c, ident, &req).await,
970            }
971        }
972    });
973
974    // Global history log (admin-only; high-signal page).
975    let c = ctx.clone();
976    let router = router.get("/admin/history", move |req| {
977        let c = c.clone();
978        async move {
979            match role_guard(&c, &req, Role::Administrator).await? {
980                Guard::Redirect(r) => Ok(r),
981                Guard::Allow(ident) => handlers::show_log_entries(&c, ident, &req).await,
982            }
983        }
984    });
985
986    // Self-service active-sessions listing (R0). Any logged-in user
987    // (User-tier and above) can see their own active sessions.
988    let c = ctx.clone();
989    let router = router.get("/admin/account/sessions", move |req| {
990        let c = c.clone();
991        async move {
992            match role_guard(&c, &req, Role::User).await? {
993                Guard::Redirect(r) => Ok(r),
994                Guard::Allow(ident) => handlers::show_account_sessions(&c, ident, &req).await,
995            }
996        }
997    });
998
999    // R1 commit #10 — active-sessions revoke buttons. All three
1000    // POST routes go through `auth::invalidate_sessions` (Doctrine
1001    // 22) and write `AuditEvent::SessionsRevokedSelf` per revoked
1002    // id. The `/revoke-others` and `/revoke-all` literal segments
1003    // sit at depth-4 while `:id/revoke` sits at depth-5, so segment
1004    // count alone disambiguates them — no explicit ordering
1005    // constraint between the three.
1006    let c = ctx.clone();
1007    let router = router.post("/admin/account/sessions/revoke-others", move |req| {
1008        let c = c.clone();
1009        async move {
1010            match role_guard(&c, &req, Role::User).await? {
1011                Guard::Redirect(r) => Ok(r),
1012                Guard::Allow(ident) => handlers::do_revoke_other_sessions(&c, ident, req).await,
1013            }
1014        }
1015    });
1016
1017    let c = ctx.clone();
1018    let router = router.post("/admin/account/sessions/revoke-all", move |req| {
1019        let c = c.clone();
1020        async move {
1021            match role_guard(&c, &req, Role::User).await? {
1022                Guard::Redirect(r) => Ok(r),
1023                Guard::Allow(ident) => handlers::do_revoke_all_sessions(&c, ident, req).await,
1024            }
1025        }
1026    });
1027
1028    let c = ctx.clone();
1029    let router = router.post("/admin/account/sessions/:id/revoke", move |req| {
1030        let c = c.clone();
1031        async move {
1032            match role_guard(&c, &req, Role::User).await? {
1033                Guard::Redirect(r) => Ok(r),
1034                Guard::Allow(ident) => {
1035                    let id = parse_id(req.param("id"))?;
1036                    handlers::do_revoke_session(&c, ident, req, id).await
1037                }
1038            }
1039        }
1040    });
1041
1042    // Self-service password change. Any logged-in user (User-tier and
1043    // above). User-tier can change their own password even though
1044    // they can't access the dashboard.
1045    let c = ctx.clone();
1046    let router = router.get("/admin/password_change", move |req| {
1047        let c = c.clone();
1048        async move {
1049            match role_guard(&c, &req, Role::User).await? {
1050                Guard::Redirect(r) => Ok(r),
1051                Guard::Allow(ident) => handlers::show_password_change(&c, ident, &req).await,
1052            }
1053        }
1054    });
1055    let c = ctx.clone();
1056    let router = router.post("/admin/password_change", move |req| {
1057        let c = c.clone();
1058        async move {
1059            match role_guard(&c, &req, Role::User).await? {
1060                Guard::Redirect(r) => Ok(r),
1061                Guard::Allow(ident) => handlers::do_password_change(&c, ident, req).await,
1062            }
1063        }
1064    });
1065
1066    // === R2 re-auth wall (R2 commit #11) ====================================
1067    //
1068    // Standalone wall: any authenticated user can promote their own
1069    // session into the elevated band by re-entering their password.
1070    // The handler validates `return_to` strictly (only `/admin*`
1071    // paths; see `admin_recovery_handlers::validate_return_to`).
1072    // Any role from User-tier upward.
1073
1074    let c = ctx.clone();
1075    let router = router.get("/admin/reauth", move |req| {
1076        let c = c.clone();
1077        async move {
1078            match role_guard(&c, &req, Role::User).await? {
1079                Guard::Redirect(r) => Ok(r),
1080                Guard::Allow(ident) => {
1081                    super::admin_recovery_handlers::show_reauth(&c, ident, &req).await
1082                }
1083            }
1084        }
1085    });
1086
1087    let c = ctx.clone();
1088    let router = router.post("/admin/reauth", move |req| {
1089        let c = c.clone();
1090        async move {
1091            match role_guard(&c, &req, Role::User).await? {
1092                Guard::Redirect(r) => Ok(r),
1093                Guard::Allow(ident) => {
1094                    super::admin_recovery_handlers::do_reauth(&c, ident, req).await
1095                }
1096            }
1097        }
1098    });
1099
1100    // === R2 forced password rotation (R2 commit #12) ========================
1101    //
1102    // The `must_change_password` interstitial is the only writeable
1103    // surface a user can reach while their flag is TRUE. The path is
1104    // on `MUST_CHANGE_WHITELIST`; the `login_guard` redirect therefore
1105    // skips it (otherwise the rotation would be unreachable). Role::User
1106    // matches: any authenticated user can be forced to rotate, even a
1107    // User-tier account that can't access the dashboard.
1108
1109    let c = ctx.clone();
1110    let router = router.get("/admin/must-change-password", move |req| {
1111        let c = c.clone();
1112        async move {
1113            match role_guard(&c, &req, Role::User).await? {
1114                Guard::Redirect(r) => Ok(r),
1115                Guard::Allow(ident) => {
1116                    super::admin_recovery_handlers::show_must_change_password(&c, ident, &req).await
1117                }
1118            }
1119        }
1120    });
1121
1122    let c = ctx.clone();
1123    let router = router.post("/admin/must-change-password", move |req| {
1124        let c = c.clone();
1125        async move {
1126            match role_guard(&c, &req, Role::User).await? {
1127                Guard::Redirect(r) => Ok(r),
1128                Guard::Allow(ident) => {
1129                    super::admin_recovery_handlers::do_must_change_password(&c, ident, req).await
1130                }
1131            }
1132        }
1133    });
1134
1135    // === R3 MFA surface (R3 commits #12-#15) ================================
1136    //
1137    // Eight routes:
1138    //   /admin/mfa/verify                        — login second factor (#12)
1139    //   /admin/account/mfa/enroll                — provision + confirm (#13)
1140    //   /admin/account/mfa/regenerate-codes      — atomic batch swap   (#14)
1141    //   /admin/account/mfa/disable               — self-disable        (#15)
1142    //
1143    // All gated by `Role::User` — every authenticated user can manage
1144    // their own MFA. The /admin/mfa/verify path is on
1145    // `MFA_VERIFY_WHITELIST`; the enrol path is on
1146    // `MFA_ENROLL_WHITELIST` — so `login_guard` does NOT redirect
1147    // away from these routes even when the user is in the pending-
1148    // verify or required-enrol state. Otherwise the interstitial
1149    // pages would be unreachable.
1150
1151    // --- /admin/mfa/verify (R3 commit #12) ---
1152    let c = ctx.clone();
1153    let router = router.get("/admin/mfa/verify", move |req| {
1154        let c = c.clone();
1155        async move {
1156            match role_guard(&c, &req, Role::User).await? {
1157                Guard::Redirect(r) => Ok(r),
1158                Guard::Allow(ident) => super::mfa_handlers::show_verify(&c, ident, &req).await,
1159            }
1160        }
1161    });
1162
1163    let c = ctx.clone();
1164    let router = router.post("/admin/mfa/verify", move |req| {
1165        let c = c.clone();
1166        async move {
1167            match role_guard(&c, &req, Role::User).await? {
1168                Guard::Redirect(r) => Ok(r),
1169                Guard::Allow(ident) => super::mfa_handlers::do_verify(&c, ident, req).await,
1170            }
1171        }
1172    });
1173
1174    // --- /admin/account/mfa/enroll (R3 commit #13) ---
1175    let c = ctx.clone();
1176    let router = router.get("/admin/account/mfa/enroll", move |req| {
1177        let c = c.clone();
1178        async move {
1179            match role_guard(&c, &req, Role::User).await? {
1180                Guard::Redirect(r) => Ok(r),
1181                Guard::Allow(ident) => super::mfa_handlers::show_enroll(&c, ident, &req).await,
1182            }
1183        }
1184    });
1185
1186    let c = ctx.clone();
1187    let router = router.post("/admin/account/mfa/enroll", move |req| {
1188        let c = c.clone();
1189        async move {
1190            match role_guard(&c, &req, Role::User).await? {
1191                Guard::Redirect(r) => Ok(r),
1192                Guard::Allow(ident) => super::mfa_handlers::do_enroll(&c, ident, req).await,
1193            }
1194        }
1195    });
1196
1197    // --- /admin/account/mfa/regenerate-codes (R3 commit #14) ---
1198    let c = ctx.clone();
1199    let router = router.get("/admin/account/mfa/regenerate-codes", move |req| {
1200        let c = c.clone();
1201        async move {
1202            match role_guard(&c, &req, Role::User).await? {
1203                Guard::Redirect(r) => Ok(r),
1204                Guard::Allow(ident) => super::mfa_handlers::show_regenerate(&c, ident, &req).await,
1205            }
1206        }
1207    });
1208
1209    let c = ctx.clone();
1210    let router = router.post("/admin/account/mfa/regenerate-codes", move |req| {
1211        let c = c.clone();
1212        async move {
1213            match role_guard(&c, &req, Role::User).await? {
1214                Guard::Redirect(r) => Ok(r),
1215                Guard::Allow(ident) => super::mfa_handlers::do_regenerate(&c, ident, req).await,
1216            }
1217        }
1218    });
1219
1220    // --- /admin/account/mfa/disable (R3 commit #15) ---
1221    let c = ctx.clone();
1222    let router = router.get("/admin/account/mfa/disable", move |req| {
1223        let c = c.clone();
1224        async move {
1225            match role_guard(&c, &req, Role::User).await? {
1226                Guard::Redirect(r) => Ok(r),
1227                Guard::Allow(ident) => super::mfa_handlers::show_disable(&c, ident, &req).await,
1228            }
1229        }
1230    });
1231
1232    let c = ctx.clone();
1233    let router = router.post("/admin/account/mfa/disable", move |req| {
1234        let c = c.clone();
1235        async move {
1236            match role_guard(&c, &req, Role::User).await? {
1237                Guard::Redirect(r) => Ok(r),
1238                Guard::Allow(ident) => super::mfa_handlers::do_disable(&c, ident, req).await,
1239            }
1240        }
1241    });
1242
1243    // --- Built-in users admin (admin-only) ---
1244    let c = ctx.clone();
1245    let ac = auth_ctx.clone();
1246    let router = router.get("/admin/users", move |req| {
1247        let c = c.clone();
1248        let ac = ac.clone();
1249        async move {
1250            match role_guard(&c, &req, Role::Administrator).await? {
1251                Guard::Redirect(r) => Ok(r),
1252                Guard::Allow(ident) => {
1253                    super::builtin::list_users(&ac, ident, handlers::csrf_token(&req)).await
1254                }
1255            }
1256        }
1257    });
1258
1259    let c = ctx.clone();
1260    let ac = auth_ctx.clone();
1261    let router = router.get("/admin/users/new", move |req| {
1262        let c = c.clone();
1263        let ac = ac.clone();
1264        async move {
1265            match role_guard(&c, &req, Role::Administrator).await? {
1266                Guard::Redirect(r) => Ok(r),
1267                Guard::Allow(ident) => {
1268                    super::builtin::show_new_user(&ac, ident, handlers::csrf_token(&req)).await
1269                }
1270            }
1271        }
1272    });
1273
1274    let c = ctx.clone();
1275    let ac = auth_ctx.clone();
1276    let router = router.post("/admin/users/new", move |req| {
1277        let c = c.clone();
1278        let ac = ac.clone();
1279        async move {
1280            match role_guard(&c, &req, Role::Administrator).await? {
1281                Guard::Redirect(r) => Ok(r),
1282                Guard::Allow(ident) => super::builtin::do_new_user(&ac, ident, req).await,
1283            }
1284        }
1285    });
1286
1287    let c = ctx.clone();
1288    let ac = auth_ctx.clone();
1289    let router = router.get("/admin/users/:id/edit", move |req| {
1290        let c = c.clone();
1291        let ac = ac.clone();
1292        async move {
1293            match role_guard(&c, &req, Role::Administrator).await? {
1294                Guard::Redirect(r) => Ok(r),
1295                Guard::Allow(ident) => {
1296                    let id = parse_id(req.param("id"))?;
1297                    super::builtin::show_user_edit(&ac, ident, id, handlers::csrf_token(&req)).await
1298                }
1299            }
1300        }
1301    });
1302
1303    let c = ctx.clone();
1304    let ac = auth_ctx.clone();
1305    let router = router.post("/admin/users/:id/edit", move |req| {
1306        let c = c.clone();
1307        let ac = ac.clone();
1308        async move {
1309            match role_guard(&c, &req, Role::Administrator).await? {
1310                Guard::Redirect(r) => Ok(r),
1311                Guard::Allow(ident) => {
1312                    let id = parse_id(req.param("id"))?;
1313                    super::builtin::do_user_edit(&ac, ident, id, req).await
1314                }
1315            }
1316        }
1317    });
1318
1319    let c = ctx.clone();
1320    let ac = auth_ctx.clone();
1321    let router = router.get("/admin/users/:id/delete", move |req| {
1322        let c = c.clone();
1323        let ac = ac.clone();
1324        async move {
1325            match role_guard(&c, &req, Role::Administrator).await? {
1326                Guard::Redirect(r) => Ok(r),
1327                Guard::Allow(ident) => {
1328                    let id = parse_id(req.param("id"))?;
1329                    super::builtin::show_user_delete(&ac, ident, id, handlers::csrf_token(&req))
1330                        .await
1331                }
1332            }
1333        }
1334    });
1335
1336    let c = ctx.clone();
1337    let ac = auth_ctx.clone();
1338    let router = router.post("/admin/users/:id/delete", move |req| {
1339        let c = c.clone();
1340        let ac = ac.clone();
1341        async move {
1342            match role_guard(&c, &req, Role::Administrator).await? {
1343                Guard::Redirect(r) => Ok(r),
1344                Guard::Allow(ident) => {
1345                    let id = parse_id(req.param("id"))?;
1346                    super::builtin::do_user_delete(&ac, ident, id, req).await
1347                }
1348            }
1349        }
1350    });
1351
1352    // === R2 admin-driven recovery routes ====================================
1353    //
1354    // Registered alongside the existing `/admin/users/:id/...` cluster
1355    // (per `DESIGN_R2_ORGANISATIONAL.md` §7.2 — user-related cluster
1356    // contiguous). All gated `Role::Administrator`; the cross-rank
1357    // safety check + the re-auth wall are enforced INSIDE the
1358    // handlers (commits #15 / #16) so a Supervisor probe doesn't even
1359    // reach the form.
1360    //
1361    // Insertion-order note: these are 4-segment routes, so the
1362    // 3-segment `/admin/users/:id` read-only view further down doesn't
1363    // conflict regardless of order. Placing them before the 3-segment
1364    // view keeps the user routes lexically clustered.
1365
1366    // GET /admin/users/:id/reset-password — admin reset form (R2 #15).
1367    let c = ctx.clone();
1368    let router = router.get("/admin/users/:id/reset-password", move |req| {
1369        let c = c.clone();
1370        async move {
1371            match role_guard(&c, &req, Role::Administrator).await? {
1372                Guard::Redirect(r) => Ok(r),
1373                Guard::Allow(ident) => {
1374                    let id = parse_id(req.param("id"))?;
1375                    super::admin_recovery_handlers::show_admin_reset_password(&c, ident, id, &req)
1376                        .await
1377                }
1378            }
1379        }
1380    });
1381
1382    // POST /admin/users/:id/reset-password — apply admin reset (R2 #15).
1383    let c = ctx.clone();
1384    let router = router.post("/admin/users/:id/reset-password", move |req| {
1385        let c = c.clone();
1386        async move {
1387            match role_guard(&c, &req, Role::Administrator).await? {
1388                Guard::Redirect(r) => Ok(r),
1389                Guard::Allow(ident) => {
1390                    let id = parse_id(req.param("id"))?;
1391                    super::admin_recovery_handlers::do_admin_reset_password(&c, ident, id, req)
1392                        .await
1393                }
1394            }
1395        }
1396    });
1397
1398    // GET /admin/users/:id/lock — lock confirmation form (R2 #16).
1399    let c = ctx.clone();
1400    let router = router.get("/admin/users/:id/lock", move |req| {
1401        let c = c.clone();
1402        async move {
1403            match role_guard(&c, &req, Role::Administrator).await? {
1404                Guard::Redirect(r) => Ok(r),
1405                Guard::Allow(ident) => {
1406                    let id = parse_id(req.param("id"))?;
1407                    super::admin_recovery_handlers::show_lock_user(&c, ident, id, &req).await
1408                }
1409            }
1410        }
1411    });
1412
1413    // POST /admin/users/:id/lock — apply manual lock (R2 #16).
1414    let c = ctx.clone();
1415    let router = router.post("/admin/users/:id/lock", move |req| {
1416        let c = c.clone();
1417        async move {
1418            match role_guard(&c, &req, Role::Administrator).await? {
1419                Guard::Redirect(r) => Ok(r),
1420                Guard::Allow(ident) => {
1421                    let id = parse_id(req.param("id"))?;
1422                    super::admin_recovery_handlers::do_lock_user(&c, ident, id, req).await
1423                }
1424            }
1425        }
1426    });
1427
1428    // GET /admin/users/:id/unlock — unlock confirmation form (R2 #16).
1429    let c = ctx.clone();
1430    let router = router.get("/admin/users/:id/unlock", move |req| {
1431        let c = c.clone();
1432        async move {
1433            match role_guard(&c, &req, Role::Administrator).await? {
1434                Guard::Redirect(r) => Ok(r),
1435                Guard::Allow(ident) => {
1436                    let id = parse_id(req.param("id"))?;
1437                    super::admin_recovery_handlers::show_unlock_user(&c, ident, id, &req).await
1438                }
1439            }
1440        }
1441    });
1442
1443    // POST /admin/users/:id/unlock — clear lock (R2 #16).
1444    let c = ctx.clone();
1445    let router = router.post("/admin/users/:id/unlock", move |req| {
1446        let c = c.clone();
1447        async move {
1448            match role_guard(&c, &req, Role::Administrator).await? {
1449                Guard::Redirect(r) => Ok(r),
1450                Guard::Allow(ident) => {
1451                    let id = parse_id(req.param("id"))?;
1452                    super::admin_recovery_handlers::do_unlock_user(&c, ident, id, req).await
1453                }
1454            }
1455        }
1456    });
1457
1458    // GET /admin/users/:id/revoke-sessions — revoke confirmation form
1459    // (R2 #16).
1460    let c = ctx.clone();
1461    let router = router.get("/admin/users/:id/revoke-sessions", move |req| {
1462        let c = c.clone();
1463        async move {
1464            match role_guard(&c, &req, Role::Administrator).await? {
1465                Guard::Redirect(r) => Ok(r),
1466                Guard::Allow(ident) => {
1467                    let id = parse_id(req.param("id"))?;
1468                    super::admin_recovery_handlers::show_admin_revoke_sessions(&c, ident, id, &req)
1469                        .await
1470                }
1471            }
1472        }
1473    });
1474
1475    // POST /admin/users/:id/revoke-sessions — revoke all sessions (R2 #16).
1476    let c = ctx.clone();
1477    let router = router.post("/admin/users/:id/revoke-sessions", move |req| {
1478        let c = c.clone();
1479        async move {
1480            match role_guard(&c, &req, Role::Administrator).await? {
1481                Guard::Redirect(r) => Ok(r),
1482                Guard::Allow(ident) => {
1483                    let id = parse_id(req.param("id"))?;
1484                    super::admin_recovery_handlers::do_admin_revoke_sessions(&c, ident, id, req)
1485                        .await
1486                }
1487            }
1488        }
1489    });
1490
1491    // POST /admin/users/:id/sessions/:session_id/revoke — revoke
1492    // ONE specific session (per-row affordance on the user_view
1493    // sessions tab). Narrower than `revoke-sessions` above: no
1494    // confirmation page, no reason field, no re-auth wall. Same
1495    // Administrator role gate; the handler enforces cross-rank +
1496    // session-ownership + same-actor-session refusal in-line.
1497    let c = ctx.clone();
1498    let router = router.post("/admin/users/:id/sessions/:session_id/revoke", move |req| {
1499        let c = c.clone();
1500        async move {
1501            match role_guard(&c, &req, Role::Administrator).await? {
1502                Guard::Redirect(r) => Ok(r),
1503                Guard::Allow(ident) => {
1504                    let user_id = parse_id(req.param("id"))?;
1505                    let session_id = parse_id(req.param("session_id"))?;
1506                    super::admin_recovery_handlers::do_admin_revoke_one_session(
1507                        &c, ident, user_id, session_id, req,
1508                    )
1509                    .await
1510                }
1511            }
1512        }
1513    });
1514
1515    // Read-only user profile view. MUST be registered AFTER
1516    // `/admin/users/new` and the `:id/edit` + `:id/delete` routes
1517    // above: the router matches in insertion order, and `:id` is a
1518    // wildcard that would happily swallow "new" or extra path
1519    // segments. Putting this last preserves the more-specific routes'
1520    // priority.
1521    let c = ctx.clone();
1522    let ac = auth_ctx.clone();
1523    let router = router.get("/admin/users/:id", move |req| {
1524        let c = c.clone();
1525        let ac = ac.clone();
1526        async move {
1527            match role_guard(&c, &req, Role::Administrator).await? {
1528                Guard::Redirect(r) => Ok(r),
1529                Guard::Allow(ident) => {
1530                    let id = parse_id(req.param("id"))?;
1531                    let q = req.query();
1532                    let tab = q.get("tab").map(|s| s.to_string());
1533                    let page: i64 = q.get("page").and_then(|s| s.parse().ok()).unwrap_or(1);
1534                    let viewing_session_id = match req
1535                        .header("cookie")
1536                        .and_then(crate::auth::session_token_from_cookie)
1537                    {
1538                        Some(token) => crate::auth::current_session_id(&ac.db, &token)
1539                            .await
1540                            .ok()
1541                            .flatten(),
1542                        None => None,
1543                    };
1544                    super::builtin::show_user_view(
1545                        &ac,
1546                        ident,
1547                        id,
1548                        handlers::csrf_token(&req),
1549                        tab,
1550                        page,
1551                        viewing_session_id,
1552                    )
1553                    .await
1554                }
1555            }
1556        }
1557    });
1558
1559    // --- Built-in groups admin (admin-only) ---
1560    let c = ctx.clone();
1561    let ac = auth_ctx.clone();
1562    let router = router.get("/admin/groups", move |req| {
1563        let c = c.clone();
1564        let ac = ac.clone();
1565        async move {
1566            match role_guard(&c, &req, Role::Administrator).await? {
1567                Guard::Redirect(r) => Ok(r),
1568                Guard::Allow(ident) => {
1569                    super::builtin::list_groups(&ac, ident, handlers::csrf_token(&req)).await
1570                }
1571            }
1572        }
1573    });
1574
1575    let c = ctx.clone();
1576    let ac = auth_ctx.clone();
1577    let router = router.get("/admin/groups/new", move |req| {
1578        let c = c.clone();
1579        let ac = ac.clone();
1580        async move {
1581            match role_guard(&c, &req, Role::Administrator).await? {
1582                Guard::Redirect(r) => Ok(r),
1583                Guard::Allow(ident) => {
1584                    super::builtin::show_new_group(&ac, ident, handlers::csrf_token(&req)).await
1585                }
1586            }
1587        }
1588    });
1589
1590    let c = ctx.clone();
1591    let ac = auth_ctx.clone();
1592    let router = router.post("/admin/groups/new", move |req| {
1593        let c = c.clone();
1594        let ac = ac.clone();
1595        async move {
1596            match role_guard(&c, &req, Role::Administrator).await? {
1597                Guard::Redirect(r) => Ok(r),
1598                Guard::Allow(ident) => super::builtin::do_new_group(&ac, ident, req).await,
1599            }
1600        }
1601    });
1602
1603    let c = ctx.clone();
1604    let ac = auth_ctx.clone();
1605    let router = router.get("/admin/groups/:id/edit", move |req| {
1606        let c = c.clone();
1607        let ac = ac.clone();
1608        async move {
1609            match role_guard(&c, &req, Role::Administrator).await? {
1610                Guard::Redirect(r) => Ok(r),
1611                Guard::Allow(ident) => {
1612                    let id = parse_id(req.param("id"))?;
1613                    super::builtin::show_group_edit(&ac, ident, id, handlers::csrf_token(&req))
1614                        .await
1615                }
1616            }
1617        }
1618    });
1619
1620    let c = ctx.clone();
1621    let ac = auth_ctx.clone();
1622    let router = router.post("/admin/groups/:id/edit", move |req| {
1623        let c = c.clone();
1624        let ac = ac.clone();
1625        async move {
1626            match role_guard(&c, &req, Role::Administrator).await? {
1627                Guard::Redirect(r) => Ok(r),
1628                Guard::Allow(ident) => {
1629                    let id = parse_id(req.param("id"))?;
1630                    super::builtin::do_group_edit(&ac, ident, id, req).await
1631                }
1632            }
1633        }
1634    });
1635
1636    let c = ctx.clone();
1637    let ac = auth_ctx.clone();
1638    let router = router.get("/admin/groups/:id/delete", move |req| {
1639        let c = c.clone();
1640        let ac = ac.clone();
1641        async move {
1642            match role_guard(&c, &req, Role::Administrator).await? {
1643                Guard::Redirect(r) => Ok(r),
1644                Guard::Allow(ident) => {
1645                    let id = parse_id(req.param("id"))?;
1646                    super::builtin::show_group_delete(&ac, ident, id, handlers::csrf_token(&req))
1647                        .await
1648                }
1649            }
1650        }
1651    });
1652
1653    let c = ctx.clone();
1654    let ac = auth_ctx.clone();
1655    let router = router.post("/admin/groups/:id/delete", move |req| {
1656        let c = c.clone();
1657        let ac = ac.clone();
1658        async move {
1659            match role_guard(&c, &req, Role::Administrator).await? {
1660                Guard::Redirect(r) => Ok(r),
1661                Guard::Allow(ident) => {
1662                    let id = parse_id(req.param("id"))?;
1663                    super::builtin::do_group_delete(&ac, ident, id, req).await
1664                }
1665            }
1666        }
1667    });
1668
1669    // Uploaded-file serve. `Admin::uploads_dir` is the storage
1670    // root; the route resolves `<rel>` under it with a canonical-
1671    // path guard so a hand-edited URL can't reach files outside.
1672    // Identity-gated at Staff (anyone with admin access can see
1673    // uploaded files in v1; per-row visibility checks are a
1674    // future iteration). Registered before any generic
1675    // `/admin/:admin_name/…` route so the literal `uploads`
1676    // segment can't be shadowed by a model named `uploads`.
1677    let c = ctx.clone();
1678    let router = router.get("/admin/uploads/:filename", move |req| {
1679        let c = c.clone();
1680        async move {
1681            match role_guard(&c, &req, Role::Staff).await? {
1682                Guard::Redirect(r) => Ok(r),
1683                Guard::Allow(ident) => {
1684                    let filename = req
1685                        .param("filename")
1686                        .map(str::to_string)
1687                        .unwrap_or_default();
1688                    handlers::serve_upload(&c, ident, &filename, req).await
1689                }
1690            }
1691        }
1692    });
1693
1694    // FK autocomplete lookup. Registered before any generic
1695    // `/admin/:admin_name/…` route so a literal `_lookup` segment
1696    // can't be shadowed by a model named `_lookup`. The endpoint
1697    // is gated on `view` permission for the target model — same
1698    // surface as the user reading the target's list page.
1699    let c = ctx.clone();
1700    let router = router.get("/admin/_lookup/:admin_name", move |req| {
1701        let c = c.clone();
1702        async move {
1703            let name = model_name_from_req(&req)?;
1704            let perm = perm_for(&c, &name, "view")?;
1705            match perm_guard(&c, &req, &perm).await? {
1706                Guard::Redirect(r) => Ok(r),
1707                Guard::Allow(ident) => handlers::lookup_model(&c, ident, &name, req).await,
1708            }
1709        }
1710    });
1711
1712    // Global cross-model search (`⌘K` palette). Registered before
1713    // any generic `/admin/:admin_name/…` route so a literal `_search`
1714    // segment can't be shadowed by a model named `_search`. Role
1715    // gate is `Staff` — anyone who can reach the admin can open the
1716    // palette; the handler itself filters results to models the
1717    // operator can `view`, so each operator sees only what their
1718    // sidebar already lets them reach.
1719    let c = ctx.clone();
1720    let router = router.get("/admin/_search", move |req| {
1721        let c = c.clone();
1722        async move {
1723            match role_guard(&c, &req, Role::Staff).await? {
1724                Guard::Redirect(r) => Ok(r),
1725                Guard::Allow(ident) => handlers::search_models(&c, ident, req).await,
1726            }
1727        }
1728    });
1729
1730    // Built-in framework docs — Staff-gated read-only pages
1731    // rendering the embedded markdown sources from `docs/*.md`.
1732    // Mounted before the generic `/admin/:admin_name` pattern so
1733    // a literal `docs` segment can't be shadowed by a model
1734    // named `docs`. The per-doc route uses `:slug` which is
1735    // matched against the static `EMBEDDED_DOCS` list — unknown
1736    // slugs 404 cleanly.
1737    let c = ctx.clone();
1738    let router = router.get("/admin/docs", move |req| {
1739        let c = c.clone();
1740        async move {
1741            match role_guard(&c, &req, Role::Staff).await? {
1742                Guard::Redirect(r) => Ok(r),
1743                Guard::Allow(ident) => handlers::show_docs_index(&c, ident, &req).await,
1744            }
1745        }
1746    });
1747    let c = ctx.clone();
1748    let router = router.get("/admin/docs/:slug", move |req| {
1749        let c = c.clone();
1750        async move {
1751            let slug = req
1752                .param("slug")
1753                .ok_or_else(|| Error::BadRequest("missing doc slug".into()))?
1754                .to_string();
1755            match role_guard(&c, &req, Role::Staff).await? {
1756                Guard::Redirect(r) => Ok(r),
1757                Guard::Allow(ident) => handlers::show_doc_page(&c, ident, &slug, &req).await,
1758            }
1759        }
1760    });
1761
1762    // Auto-generated OpenAPI 3.0 spec. Registered before the
1763    // generic `/admin/:admin_name/…` patterns so a literal `apis`
1764    // / `openapi.json` segment can't be shadowed by a model named
1765    // `apis`. Role gate: Staff. The doc itself only lists endpoint
1766    // shapes — clients still need per-model `view` to call the
1767    // endpoints it describes.
1768    let c = ctx.clone();
1769    let router = router.get("/admin/apis/openapi.json", move |req| {
1770        let c = c.clone();
1771        async move {
1772            match role_guard(&c, &req, Role::Staff).await? {
1773                Guard::Redirect(r) => Ok(r),
1774                Guard::Allow(_) => {
1775                    let spec = super::openapi::build_spec(&c.admin);
1776                    super::json_api::json_response(spec)
1777                }
1778            }
1779        }
1780    });
1781
1782    // Auto-generated TypeScript SDK skeleton. Sibling of the
1783    // OpenAPI spec endpoint above; same Staff gate. Emits one
1784    // `export interface` per registered project model with field
1785    // types projected from `FieldType`. Operators wrap their own
1786    // fetch around the documented JSON envelopes.
1787    let c = ctx.clone();
1788    let router = router.get("/admin/apis/sdk.ts", move |req| {
1789        let c = c.clone();
1790        async move {
1791            match role_guard(&c, &req, Role::Staff).await? {
1792                Guard::Redirect(r) => Ok(r),
1793                Guard::Allow(_) => {
1794                    let body = super::sdk_gen::build_typescript(&c.admin);
1795                    Ok(crate::http::Response::ok(body)
1796                        .with_header("content-type", "text/typescript; charset=utf-8")
1797                        .with_header(
1798                            "content-disposition",
1799                            "attachment; filename=\"rustio-sdk.ts\"",
1800                        ))
1801                }
1802            }
1803        }
1804    });
1805
1806    // Human-readable HTML index for the API surface. Sibling of
1807    // the openapi.json endpoint above; same Staff gate. Lists
1808    // every registered model's endpoint table + field shapes so
1809    // operators can read the surface without opening a JSON
1810    // viewer.
1811    let c = ctx.clone();
1812    let router = router.get("/admin/apis", move |req| {
1813        let c = c.clone();
1814        async move {
1815            match role_guard(&c, &req, Role::Staff).await? {
1816                Guard::Redirect(r) => Ok(r),
1817                Guard::Allow(ident) => handlers::show_apis_index(&c, ident, &req).await,
1818            }
1819        }
1820    });
1821
1822    // Interactive playground (read-only preview): pick a model,
1823    // build a list-page query, fetch the JSON envelope in-page.
1824    // Mounted before the generic /admin/:admin_name pattern so
1825    // `apis` / `playground` segments don't collide with a model
1826    // named `playground`. Same Staff gate as the API index;
1827    // operators still need per-model `view` to receive non-empty
1828    // results.
1829    let c = ctx.clone();
1830    let router = router.get("/admin/apis/playground", move |req| {
1831        let c = c.clone();
1832        async move {
1833            match role_guard(&c, &req, Role::Staff).await? {
1834                Guard::Redirect(r) => Ok(r),
1835                Guard::Allow(ident) => handlers::show_apis_playground(&c, ident, &req).await,
1836            }
1837        }
1838    });
1839
1840    // Per-model list — needs `view` permission.
1841    let c = ctx.clone();
1842    let router = router.get("/admin/:admin_name", move |req| {
1843        let c = c.clone();
1844        async move {
1845            let name = model_name_from_req(&req)?;
1846            let perm = perm_for(&c, &name, "view")?;
1847            match perm_guard(&c, &req, &perm).await? {
1848                Guard::Redirect(r) => Ok(r),
1849                Guard::Allow(ident) => handlers::list_model(&c, ident, &name, &req).await,
1850            }
1851        }
1852    });
1853
1854    // CSV export — must be registered before `/admin/:admin_name/:id/…`
1855    // patterns so the literal `export.csv` second segment isn't
1856    // shadowed by a numeric `:id` route. Same `view` permission gate
1857    // as the list page; `find_project_entry` blocks core entries.
1858    let c = ctx.clone();
1859    let router = router.get("/admin/:admin_name/export.csv", move |req| {
1860        let c = c.clone();
1861        async move {
1862            let name = model_name_from_req(&req)?;
1863            let perm = perm_for(&c, &name, "view")?;
1864            match perm_guard(&c, &req, &perm).await? {
1865                Guard::Redirect(r) => Ok(r),
1866                Guard::Allow(ident) => handlers::export_model_csv(&c, ident, &name, req).await,
1867            }
1868        }
1869    });
1870
1871    // CSV import — sibling of export.csv. Requires the model's
1872    // `change` permission (it inserts rows). Each CSV row goes
1873    // through `AdminOps::create` so framework validation runs
1874    // unchanged. Per-row failures surface on a result page;
1875    // partial imports are explicit, not silent.
1876    let c = ctx.clone();
1877    let router = router.post("/admin/:admin_name/import.csv", move |req| {
1878        let c = c.clone();
1879        async move {
1880            let name = model_name_from_req(&req)?;
1881            let perm = perm_for(&c, &name, "change")?;
1882            match perm_guard(&c, &req, &perm).await? {
1883                Guard::Redirect(r) => Ok(r),
1884                Guard::Allow(ident) => handlers::import_model_csv(&c, ident, &name, req).await,
1885            }
1886        }
1887    });
1888
1889    // Saved-filter create — POST /admin/:admin_name/saved_filters.
1890    // Same `view` gate as the list page: any operator who can
1891    // reach the list can bookmark its state. Registered before the
1892    // generic /:admin_name/:id/edit route so the literal
1893    // `saved_filters` segment isn't shadowed by a numeric `:id`.
1894    let c = ctx.clone();
1895    let router = router.post("/admin/:admin_name/saved_filters", move |req| {
1896        let c = c.clone();
1897        async move {
1898            let name = model_name_from_req(&req)?;
1899            let perm = perm_for(&c, &name, "view")?;
1900            match perm_guard(&c, &req, &perm).await? {
1901                Guard::Redirect(r) => Ok(r),
1902                Guard::Allow(ident) => handlers::do_save_filter(&c, ident, &name, req).await,
1903            }
1904        }
1905    });
1906
1907    // Saved-filter delete — POST /admin/:admin_name/saved_filters/:id/delete.
1908    // SQL scope-locks to identity.user_id so the route gate can
1909    // stay `view`; an operator can only delete their own bookmarks.
1910    let c = ctx.clone();
1911    let router = router.post("/admin/:admin_name/saved_filters/:id/delete", move |req| {
1912        let c = c.clone();
1913        async move {
1914            let name = model_name_from_req(&req)?;
1915            let id = parse_id(req.param("id"))?;
1916            let perm = perm_for(&c, &name, "view")?;
1917            match perm_guard(&c, &req, &perm).await? {
1918                Guard::Redirect(r) => Ok(r),
1919                Guard::Allow(ident) => handlers::do_delete_filter(&c, ident, &name, id, req).await,
1920            }
1921        }
1922    });
1923
1924    // Create.
1925    let c = ctx.clone();
1926    let router = router.get("/admin/:admin_name/new", move |req| {
1927        let c = c.clone();
1928        async move {
1929            let name = model_name_from_req(&req)?;
1930            let perm = perm_for(&c, &name, "add")?;
1931            match perm_guard(&c, &req, &perm).await? {
1932                Guard::Redirect(r) => Ok(r),
1933                Guard::Allow(ident) => handlers::show_new_form(&c, ident, &name, &req).await,
1934            }
1935        }
1936    });
1937    let c = ctx.clone();
1938    let router = router.post("/admin/:admin_name/new", move |req| {
1939        let c = c.clone();
1940        async move {
1941            let name = model_name_from_req(&req)?;
1942            let perm = perm_for(&c, &name, "add")?;
1943            match perm_guard(&c, &req, &perm).await? {
1944                Guard::Redirect(r) => Ok(r),
1945                Guard::Allow(ident) => handlers::do_create(&c, ident, &name, req).await,
1946            }
1947        }
1948    });
1949
1950    // JSON detail. `GET /admin/:admin_name/:id` returns the row
1951    // as JSON when the client asked for it (Accept header or
1952    // `?format=json`); otherwise redirects to the HTML edit
1953    // form so a browser landing on the bare URL ends up where
1954    // it expects. View permission, same as the list endpoint
1955    // which JSON-list is already gated on.
1956    let c = ctx.clone();
1957    let router = router.get("/admin/:admin_name/:id", move |req| {
1958        let c = c.clone();
1959        async move {
1960            let name = model_name_from_req(&req)?;
1961            let perm = perm_for(&c, &name, "view")?;
1962            match perm_guard(&c, &req, &perm).await? {
1963                Guard::Redirect(r) => Ok(r),
1964                Guard::Allow(ident) => {
1965                    let id = parse_id(req.param("id"))?;
1966                    handlers::show_object_json(&c, ident, &name, id, &req).await
1967                }
1968            }
1969        }
1970    });
1971
1972    // Edit.
1973    let c = ctx.clone();
1974    let router = router.get("/admin/:admin_name/:id/edit", move |req| {
1975        let c = c.clone();
1976        async move {
1977            let name = model_name_from_req(&req)?;
1978            let perm = perm_for(&c, &name, "change")?;
1979            match perm_guard(&c, &req, &perm).await? {
1980                Guard::Redirect(r) => Ok(r),
1981                Guard::Allow(ident) => {
1982                    let id = parse_id(req.param("id"))?;
1983                    handlers::show_edit_form(&c, ident, &name, id, &req).await
1984                }
1985            }
1986        }
1987    });
1988    let c = ctx.clone();
1989    let router = router.post("/admin/:admin_name/:id/edit", move |req| {
1990        let c = c.clone();
1991        async move {
1992            let name = model_name_from_req(&req)?;
1993            let perm = perm_for(&c, &name, "change")?;
1994            match perm_guard(&c, &req, &perm).await? {
1995                Guard::Redirect(r) => Ok(r),
1996                Guard::Allow(ident) => {
1997                    let id = parse_id(req.param("id"))?;
1998                    handlers::do_update(&c, ident, &name, id, req).await
1999                }
2000            }
2001        }
2002    });
2003
2004    // Per-object history. Read-only; same `view` permission as the
2005    // changelist (if you can list, you can read the audit trail).
2006    let c = ctx.clone();
2007    let router = router.get("/admin/:admin_name/:id/history", move |req| {
2008        let c = c.clone();
2009        async move {
2010            let name = model_name_from_req(&req)?;
2011            let perm = perm_for(&c, &name, "view")?;
2012            match perm_guard(&c, &req, &perm).await? {
2013                Guard::Redirect(r) => Ok(r),
2014                Guard::Allow(ident) => {
2015                    let id = parse_id(req.param("id"))?;
2016                    handlers::show_object_history(&c, ident, &name, id, &req).await
2017                }
2018            }
2019        }
2020    });
2021
2022    // Delete.
2023    let c = ctx.clone();
2024    let router = router.get("/admin/:admin_name/:id/delete", move |req| {
2025        let c = c.clone();
2026        async move {
2027            let name = model_name_from_req(&req)?;
2028            let perm = perm_for(&c, &name, "delete")?;
2029            match perm_guard(&c, &req, &perm).await? {
2030                Guard::Redirect(r) => Ok(r),
2031                Guard::Allow(ident) => {
2032                    let id = parse_id(req.param("id"))?;
2033                    handlers::show_delete_confirm(&c, ident, &name, id, &req).await
2034                }
2035            }
2036        }
2037    });
2038    let c = ctx.clone();
2039    let router = router.post("/admin/:admin_name/:id/delete", move |req| {
2040        let c = c.clone();
2041        async move {
2042            let name = model_name_from_req(&req)?;
2043            let perm = perm_for(&c, &name, "delete")?;
2044            match perm_guard(&c, &req, &perm).await? {
2045                Guard::Redirect(r) => Ok(r),
2046                Guard::Allow(ident) => {
2047                    let id = parse_id(req.param("id"))?;
2048                    handlers::do_delete(&c, ident, &name, req, id).await
2049                }
2050            }
2051        }
2052    });
2053
2054    // Bulk delete — same permission gate as the per-row delete.
2055    // Two-step flow: first POST renders the confirm page, second POST
2056    // (with `_confirmed=1`) executes. See `handlers::handle_bulk_delete`
2057    // for the full contract.
2058    let c = ctx.clone();
2059    let router = router.post("/admin/:admin_name/bulk_delete", move |req| {
2060        let c = c.clone();
2061        async move {
2062            let name = model_name_from_req(&req)?;
2063            let perm = perm_for(&c, &name, "delete")?;
2064            match perm_guard(&c, &req, &perm).await? {
2065                Guard::Redirect(r) => Ok(r),
2066                Guard::Allow(ident) => handlers::handle_bulk_delete(&c, ident, &name, &req).await,
2067            }
2068        }
2069    });
2070
2071    // Project-defined bulk actions. Permission gated on `change` —
2072    // bulk actions modify rows but don't delete them (delete has its
2073    // own route). Project-side guard against further write-vs-read
2074    // distinctions belongs inside `execute_bulk_action`.
2075    let c = ctx.clone();
2076    router.post("/admin/:admin_name/bulk/:action", move |req| {
2077        let c = c.clone();
2078        async move {
2079            let name = model_name_from_req(&req)?;
2080            let action = req
2081                .param("action")
2082                .ok_or_else(|| Error::BadRequest("missing bulk action name".into()))?
2083                .to_string();
2084            let perm = perm_for(&c, &name, "change")?;
2085            match perm_guard(&c, &req, &perm).await? {
2086                Guard::Redirect(r) => Ok(r),
2087                Guard::Allow(ident) => {
2088                    handlers::handle_bulk_action(&c, ident, &name, &action, &req).await
2089                }
2090            }
2091        }
2092    })
2093}
2094
2095#[cfg(test)]
2096mod tests {
2097    use super::*;
2098
2099    fn make_identity(role: Role, is_active: bool) -> Identity {
2100        Identity {
2101            user_id: 42,
2102            email: "test@example.com".into(),
2103            role,
2104            is_active,
2105            is_demo: false,
2106            demo_label: None,
2107            must_change_password: false,
2108            mfa_enabled: false,
2109            trust_level: crate::auth::SessionTrust::Authenticated,
2110        }
2111    }
2112
2113    // role_guard's decision is `Role::includes(min)`. The 25-case
2114    // matrix lives in `auth::role::tests::includes_matrix_…`; the
2115    // cases below pin the most operator-relevant pairings.
2116
2117    #[test]
2118    fn role_guard_decision_admin_meets_staff_floor() {
2119        let id = make_identity(Role::Administrator, true);
2120        assert!(id.role.includes(Role::Staff));
2121    }
2122
2123    #[test]
2124    fn role_guard_decision_user_does_not_meet_staff() {
2125        let id = make_identity(Role::User, true);
2126        assert!(!id.role.includes(Role::Staff));
2127    }
2128
2129    #[test]
2130    fn role_guard_decision_administrator_does_not_meet_developer() {
2131        let id = make_identity(Role::Administrator, true);
2132        assert!(!id.role.includes(Role::Developer));
2133    }
2134
2135    #[test]
2136    fn role_guard_decision_developer_meets_everything() {
2137        let id = make_identity(Role::Developer, true);
2138        for &min in &[
2139            Role::User,
2140            Role::Staff,
2141            Role::Supervisor,
2142            Role::Administrator,
2143            Role::Developer,
2144        ] {
2145            assert!(id.role.includes(min), "Developer should meet {min:?}");
2146        }
2147    }
2148
2149    // ---- perm_guard_verdict matrix --------------------------------------
2150
2151    #[test]
2152    fn perm_guard_admin_short_circuits_without_perm() {
2153        let id = make_identity(Role::Administrator, true);
2154        assert!(perm_guard_verdict(&id, false));
2155    }
2156
2157    #[test]
2158    fn perm_guard_developer_short_circuits_without_perm() {
2159        let id = make_identity(Role::Developer, true);
2160        assert!(perm_guard_verdict(&id, false));
2161    }
2162
2163    #[test]
2164    fn perm_guard_staff_with_perm_passes() {
2165        let id = make_identity(Role::Staff, true);
2166        assert!(perm_guard_verdict(&id, true));
2167    }
2168
2169    #[test]
2170    fn perm_guard_staff_without_perm_denies() {
2171        let id = make_identity(Role::Staff, true);
2172        assert!(!perm_guard_verdict(&id, false));
2173    }
2174
2175    #[test]
2176    fn perm_guard_inactive_admin_denies_even_with_bypass() {
2177        // Defense-in-depth invariant.
2178        let id = make_identity(Role::Administrator, false);
2179        assert!(!perm_guard_verdict(&id, true));
2180    }
2181
2182    #[test]
2183    fn perm_guard_supervisor_without_perm_denies() {
2184        // Supervisor doesn't bypass; needs the per-model perm.
2185        let id = make_identity(Role::Supervisor, true);
2186        assert!(!perm_guard_verdict(&id, false));
2187    }
2188
2189    // ---- strict_mailer_guard_check ----------------------------------------
2190
2191    /// Default `Admin::new()` doesn't override the mailer AND
2192    /// doesn't enable strict mode — the guard passes.
2193    #[test]
2194    fn strict_mailer_guard_passes_for_default_admin() {
2195        let admin = super::super::types::Admin::new();
2196        assert!(strict_mailer_guard_check(&admin).is_ok());
2197    }
2198
2199    /// Strict-mailer mode + default LogMailer = boot guard fires.
2200    /// The error message is operator-actionable.
2201    #[test]
2202    fn strict_mailer_guard_fails_when_required_but_default_mailer() {
2203        use crate::auth::DefaultRecoveryPolicy;
2204        let admin = super::super::types::Admin::new().recovery_policy(std::sync::Arc::new(
2205            DefaultRecoveryPolicy::new().with_strict_mailer_required(true),
2206        ));
2207        let err = strict_mailer_guard_check(&admin).expect_err("guard should fail");
2208        assert!(
2209            err.contains("strict_mailer_required"),
2210            "error message must name the policy method: {err}"
2211        );
2212        assert!(
2213            err.contains("Admin::mailer"),
2214            "error message must direct the operator to the fix: {err}"
2215        );
2216    }
2217
2218    /// Strict-mailer mode + project-supplied mailer = guard passes.
2219    /// Note: the explicit override flips the flag even when the
2220    /// supplied value happens to be another LogMailer — the
2221    /// operator's intent is what matters, not the concrete type.
2222    #[test]
2223    fn strict_mailer_guard_passes_when_mailer_was_explicitly_overridden() {
2224        use crate::auth::DefaultRecoveryPolicy;
2225        use crate::email::LogMailer;
2226        let admin = super::super::types::Admin::new()
2227            .recovery_policy(std::sync::Arc::new(
2228                DefaultRecoveryPolicy::new().with_strict_mailer_required(true),
2229            ))
2230            .mailer(std::sync::Arc::new(LogMailer));
2231        assert!(strict_mailer_guard_check(&admin).is_ok());
2232    }
2233
2234    /// Project NOT in strict mode + default LogMailer = passes
2235    /// (dev / CI / testing baseline).
2236    #[test]
2237    fn strict_mailer_guard_passes_when_strict_mode_disabled() {
2238        let admin = super::super::types::Admin::new();
2239        assert!(strict_mailer_guard_check(&admin).is_ok());
2240    }
2241
2242    // ---- must-change-password whitelist (R2 commit #13) --------------------
2243
2244    #[test]
2245    fn whitelist_accepts_the_three_locked_paths() {
2246        // Locked-decision per DESIGN_R2_ORGANISATIONAL.md §12.
2247        assert!(super::is_must_change_whitelisted_path(
2248            "/admin/must-change-password"
2249        ));
2250        assert!(super::is_must_change_whitelisted_path("/admin/logout"));
2251        assert!(super::is_must_change_whitelisted_path(
2252            "/admin/account/sessions"
2253        ));
2254    }
2255
2256    #[test]
2257    fn whitelist_rejects_subpaths_of_account_sessions() {
2258        // Sub-paths of /admin/account/sessions (revoke buttons) are
2259        // intentionally NOT whitelisted — a user being forced to
2260        // rotate may VIEW their sessions but must finish the
2261        // rotation before revoking siblings.
2262        assert!(!super::is_must_change_whitelisted_path(
2263            "/admin/account/sessions/revoke"
2264        ));
2265        assert!(!super::is_must_change_whitelisted_path(
2266            "/admin/account/sessions/revoke-others"
2267        ));
2268        assert!(!super::is_must_change_whitelisted_path(
2269            "/admin/account/sessions/"
2270        ));
2271    }
2272
2273    #[test]
2274    fn whitelist_rejects_other_admin_paths() {
2275        for path in [
2276            "/admin",
2277            "/admin/",
2278            "/admin/users",
2279            "/admin/users/42",
2280            "/admin/login",
2281            "/admin/password_change",
2282            "/admin/forgot-password",
2283            "/admin/reauth",
2284            "/admin/must-change-password/", // trailing slash → not exact
2285        ] {
2286            assert!(
2287                !super::is_must_change_whitelisted_path(path),
2288                "expected reject for {path:?}"
2289            );
2290        }
2291    }
2292
2293    #[test]
2294    fn whitelist_rejects_paths_outside_admin_surface() {
2295        for path in ["/", "/login", "/static/admin.css", "/api"] {
2296            assert!(
2297                !super::is_must_change_whitelisted_path(path),
2298                "expected reject for {path:?}"
2299            );
2300        }
2301    }
2302
2303    // ---- Read-only writable-path allowlist ----------------------
2304
2305    #[test]
2306    fn read_only_allows_auth_flow_exact_paths() {
2307        for path in [
2308            "/admin/login",
2309            "/admin/logout",
2310            "/admin/reauth",
2311            "/admin/forgot-password",
2312            "/admin/mfa/verify",
2313            "/admin/must-change-password",
2314            "/admin/password_change",
2315        ] {
2316            assert!(
2317                super::is_read_only_writable_path(path),
2318                "auth path {path:?} must be writable in read-only mode"
2319            );
2320        }
2321    }
2322
2323    #[test]
2324    fn read_only_allows_prefix_paths() {
2325        // Token-bearing recovery URL, own-session and own-MFA
2326        // management — all carry dynamic segments, allowlisted by
2327        // prefix not by exact match.
2328        for path in [
2329            "/admin/reset-password/abc123",
2330            "/admin/reset-password/abc123/whatever",
2331            "/admin/account/sessions/42/revoke",
2332            "/admin/account/sessions/revoke-all",
2333            "/admin/account/mfa/enroll",
2334            "/admin/account/mfa/disable",
2335        ] {
2336            assert!(
2337                super::is_read_only_writable_path(path),
2338                "prefix-allowlisted path {path:?} must be writable"
2339            );
2340        }
2341    }
2342
2343    #[test]
2344    fn read_only_blocks_project_data_mutations() {
2345        // Project CRUD, bulk actions, admin-driven user lifecycle
2346        // — none of these should slip through.
2347        for path in [
2348            "/admin/posts/new",
2349            "/admin/posts/42/edit",
2350            "/admin/posts/42/delete",
2351            "/admin/posts/bulk_delete",
2352            "/admin/posts/bulk/archive",
2353            "/admin/users/new",
2354            "/admin/users/42/edit",
2355            "/admin/users/42/reset-password",
2356            "/admin/users/42/lock",
2357            "/admin/users/42/sessions/99/revoke",
2358            "/admin/groups/new",
2359            "/admin/groups/42/delete",
2360        ] {
2361            assert!(
2362                !super::is_read_only_writable_path(path),
2363                "data-mutation path {path:?} must be blocked in read-only mode"
2364            );
2365        }
2366    }
2367
2368    #[test]
2369    fn read_only_blocks_random_paths_outside_admin_surface() {
2370        // Paths outside /admin/* don't reach this helper in
2371        // production (the middleware checks `starts_with("/admin")`
2372        // first), but the helper itself must still say "not
2373        // writable" so the policy is self-consistent.
2374        for path in ["/", "/login", "/static/admin.css", "/api/v1/posts"] {
2375            assert!(
2376                !super::is_read_only_writable_path(path),
2377                "non-admin path {path:?} must not be writable"
2378            );
2379        }
2380    }
2381
2382    #[test]
2383    fn extract_admin_name_parses_slug_segment() {
2384        assert_eq!(super::extract_admin_name("/admin/posts"), Some("posts"));
2385        assert_eq!(super::extract_admin_name("/admin/posts/"), Some("posts"));
2386        assert_eq!(
2387            super::extract_admin_name("/admin/posts/42/edit"),
2388            Some("posts")
2389        );
2390        assert_eq!(
2391            super::extract_admin_name("/admin/users/42/sessions/99/revoke"),
2392            Some("users")
2393        );
2394    }
2395
2396    #[test]
2397    fn extract_admin_name_rejects_root_reserved_and_non_admin() {
2398        // Root /admin and trailing slash → no model slug.
2399        assert_eq!(super::extract_admin_name("/admin/"), None);
2400        assert_eq!(super::extract_admin_name("/admin"), None);
2401        // Underscore-prefixed slugs are framework-reserved (`_search`,
2402        // `_lookup`, `healthz` etc.) — never project models.
2403        assert_eq!(super::extract_admin_name("/admin/_search"), None);
2404        assert_eq!(super::extract_admin_name("/admin/_lookup/posts"), None);
2405        // Paths outside /admin.
2406        assert_eq!(super::extract_admin_name("/login"), None);
2407        assert_eq!(super::extract_admin_name("/static/admin.css"), None);
2408    }
2409
2410    #[test]
2411    fn read_only_model_builder_and_accessor_round_trip() {
2412        let admin = super::super::types::Admin::new()
2413            .read_only_model("archive_posts")
2414            .read_only_model("legacy_invoices");
2415        assert!(admin.is_model_read_only("archive_posts"));
2416        assert!(admin.is_model_read_only("legacy_invoices"));
2417        assert!(!admin.is_model_read_only("posts"));
2418        // Whole-admin flag stays independent.
2419        assert!(!admin.is_read_only());
2420    }
2421
2422    #[test]
2423    fn is_mutating_method_recognises_write_verbs() {
2424        use hyper::Method;
2425        for m in [Method::POST, Method::PUT, Method::PATCH, Method::DELETE] {
2426            assert!(super::is_mutating_method(&m), "{m} must be mutating");
2427        }
2428        for m in [Method::GET, Method::HEAD, Method::OPTIONS] {
2429            assert!(!super::is_mutating_method(&m), "{m} must not be mutating");
2430        }
2431    }
2432}