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