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