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