Skip to main content

autumn_admin_plugin/
lib.rs

1//! # autumn-admin-plugin
2//!
3//! Out-of-the-box admin panel plugin for autumn-web applications.
4//!
5//! Provides auto-generated CRUD views, search, filtering, and audit trails
6//! for any model registered via the [`AdminPlugin`] builder. The UI is
7//! server-rendered with Maud + HTMX โ€” no JS build step required.
8//!
9//! # Quick start
10//!
11//! ```rust,ignore
12//! use autumn_admin_plugin::{prelude::*, AdminPlugin};
13//!
14//! autumn_web::app()
15//!     .plugin(
16//!         AdminPlugin::new()
17//!             .register(ProjectAdmin::default())
18//!             .register(TicketAdmin::default()),
19//!     )
20//!     .routes(routes![...])
21//!     .run()
22//!     .await;
23//! ```
24//!
25//! # Security
26//!
27//! The plugin requires the `"admin"` role in the session by default. Override
28//! with [`AdminPlugin::require_role`] (pass `None` to disable; not recommended
29//! for production).
30//!
31//! # Naming convention
32//!
33//! First-party plugin: `autumn-<name>-plugin`.
34
35mod auth;
36pub mod experiments;
37pub mod feature_flags;
38mod registry;
39mod routes;
40mod templates;
41mod traits;
42
43pub use registry::AdminRegistry;
44pub use traits::{
45    AdminAction, AdminError, AdminField, AdminFieldKind, AdminFuture, AdminHistoryEntry,
46    AdminHistoryPage, AdminImportError, AdminImportReport, AdminImportRowResult, AdminModel,
47    CsvImportMode, ListParams, ListResult, SelectOption, SortDirection,
48};
49
50/// Common downstream imports for implementing admin models.
51pub mod prelude {
52    pub use crate::{
53        AdminError, AdminField, AdminFieldKind, AdminFuture, AdminHistoryEntry, AdminHistoryPage,
54        AdminImportRowResult, AdminModel, CsvImportMode, ListParams, ListResult, SelectOption,
55        SortDirection,
56    };
57}
58
59use std::borrow::Cow;
60use std::sync::Arc;
61
62use autumn_web::app::AppBuilder;
63use autumn_web::plugin::Plugin;
64use autumn_web::route_listing::RouteInfo;
65use autumn_web::runtime_config::RuntimeConfigService;
66
67/// The admin panel plugin.
68///
69/// Register models via `.register()` and the plugin will mount a full admin
70/// UI under the configured prefix (default: `/admin`).
71pub struct AdminPlugin {
72    registry: AdminRegistry,
73    prefix: String,
74    actuator_prefix: String,
75    auth_session_key: String,
76    require_role: Option<String>,
77    runtime_config: Option<Arc<RuntimeConfigService>>,
78    /// When `true`, every mutating action (create, update, destroy) on any
79    /// registered model requires step-up authentication before proceeding.
80    ///
81    /// Enables this with [`AdminPlugin::with_step_up_mutations`].
82    step_up_mutations: bool,
83    /// Freshness window for step-up checks on admin mutations (seconds).
84    /// Defaults to [`autumn_web::step_up::DEFAULT_MAX_AGE_SECS`].
85    /// Override with [`AdminPlugin::with_step_up_max_age`].
86    step_up_max_age_secs: u64,
87}
88
89impl AdminPlugin {
90    /// Create a new admin plugin with default settings.
91    ///
92    /// Mounts at `/admin` and requires the `"admin"` role in the session.
93    /// Links to the actuator UI under `/actuator`. Reads the user
94    /// identifier from session key `"user_id"` (Autumn's default
95    /// `auth.session_key`).
96    #[must_use]
97    pub fn new() -> Self {
98        Self {
99            registry: AdminRegistry::new(),
100            prefix: "/admin".to_owned(),
101            actuator_prefix: "/actuator".to_owned(),
102            auth_session_key: "user_id".to_owned(),
103            require_role: Some("admin".to_owned()),
104            runtime_config: None,
105            step_up_mutations: false,
106            step_up_max_age_secs: autumn_web::step_up::DEFAULT_MAX_AGE_SECS,
107        }
108    }
109
110    /// Override the URL prefix (default: `/admin`).
111    #[must_use]
112    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
113        self.prefix = prefix.into();
114        self
115    }
116
117    /// Override the actuator mount prefix that dashboard links/polling target
118    /// (default: `/actuator`). Must match `config.actuator.prefix` from your
119    /// autumn config โ€” the plugin cannot read it automatically because config
120    /// is loaded after `Plugin::build` runs.
121    #[must_use]
122    pub fn actuator_prefix(mut self, prefix: impl Into<String>) -> Self {
123        self.actuator_prefix = prefix.into();
124        self
125    }
126
127    /// Override the session key the role middleware reads to detect an
128    /// authenticated user. Default: `"user_id"`, matching Autumn's default
129    /// `auth.session_key`. Must match whatever your application populates
130    /// after login โ€” e.g. set this to `"uid"` if you configured
131    /// `auth.session_key = "uid"`.
132    ///
133    /// The plugin can't read `config.auth.session_key` automatically
134    /// because config is loaded after `Plugin::build` runs.
135    #[must_use]
136    pub fn auth_session_key(mut self, key: impl Into<String>) -> Self {
137        self.auth_session_key = key.into();
138        self
139    }
140
141    /// Set the required session role for accessing the admin panel.
142    ///
143    /// Pass `None` to disable role checks entirely. Authentication
144    /// (a populated `user_id` session key) is always required when a role
145    /// is set.
146    #[must_use]
147    pub fn require_role(mut self, role: impl Into<Option<String>>) -> Self {
148        self.require_role = role.into();
149        self
150    }
151
152    /// Register a model for admin management.
153    ///
154    /// The model must implement [`AdminModel`], which provides field metadata,
155    /// CRUD operations, and display configuration.
156    #[must_use]
157    pub fn register<M: AdminModel>(mut self, model: M) -> Self {
158        self.registry.register(model);
159        self
160    }
161
162    /// Enable the runtime config management page.
163    ///
164    /// Mounts `GET /config`, `POST /config/{key}/set`, `POST /config/{key}/unset`,
165    /// and `GET /config/{key}/history` under the admin prefix, and adds a
166    /// "Runtime Config" item to the sidebar navigation.
167    #[must_use]
168    pub fn with_runtime_config(mut self, svc: Arc<RuntimeConfigService>) -> Self {
169        self.runtime_config = Some(svc);
170        self
171    }
172
173    /// Require step-up (fresh) authentication before any mutating admin action.
174    ///
175    /// When enabled, every `POST` (create/update) and `DELETE` (destroy) request
176    /// to the admin panel is checked against the session's `last_strong_auth_at`
177    /// claim using the global step-up max-age configured in `[auth.step_up]`
178    /// (default: 5 minutes). Requests without a valid fresh-auth claim are
179    /// redirected to `/reauth?return_to=โ€ฆ` (HTML clients) or receive a
180    /// `401 step_up_required` problem-details response (JSON clients).
181    ///
182    /// Highly recommended for production admin panels to reduce the blast radius
183    /// of a hijacked admin session.
184    ///
185    /// # Example
186    ///
187    /// ```rust,ignore
188    /// AdminPlugin::new()
189    ///     .register(UserAdmin::default())
190    ///     .with_step_up_mutations()
191    /// ```
192    #[must_use]
193    pub const fn with_step_up_mutations(mut self) -> Self {
194        self.step_up_mutations = true;
195        self
196    }
197
198    /// Override the step-up freshness window for admin mutations.
199    ///
200    /// Only meaningful when [`with_step_up_mutations`](Self::with_step_up_mutations)
201    /// is also called. Calls `with_step_up_mutations` implicitly.
202    ///
203    /// # Example
204    ///
205    /// ```rust,ignore
206    /// AdminPlugin::new()
207    ///     .register(UserAdmin::default())
208    ///     .with_step_up_max_age(600) // 10-minute window
209    /// ```
210    #[must_use]
211    pub const fn with_step_up_max_age(mut self, secs: u64) -> Self {
212        self.step_up_mutations = true;
213        self.step_up_max_age_secs = secs;
214        self
215    }
216}
217
218impl Default for AdminPlugin {
219    fn default() -> Self {
220        Self::new()
221    }
222}
223
224impl Plugin for AdminPlugin {
225    fn name(&self) -> Cow<'static, str> {
226        Cow::Borrowed("autumn-admin-plugin")
227    }
228
229    fn build(self, app: AppBuilder) -> AppBuilder {
230        let Self {
231            registry,
232            prefix,
233            actuator_prefix,
234            auth_session_key,
235            require_role,
236            runtime_config,
237            step_up_mutations,
238            step_up_max_age_secs,
239        } = self;
240        let has_config = runtime_config.is_some();
241        // "config" slug only conflicts when the runtime-config routes are mounted.
242        assert!(
243            !(has_config && registry.get("config").is_some()),
244            "autumn-admin: model slug 'config' conflicts with the mounted runtime-config \
245             routes; rename the model or don't call with_runtime_config",
246        );
247        let registry = Arc::new(registry);
248        let router = routes::admin_router(
249            Arc::clone(&registry),
250            &prefix,
251            actuator_prefix.clone(),
252            auth_session_key.clone(),
253            require_role.clone(),
254            runtime_config,
255            step_up_mutations,
256            step_up_max_age_secs,
257        );
258
259        tracing::info!(
260            prefix = %prefix,
261            actuator_prefix = %actuator_prefix,
262            auth_session_key = %auth_session_key,
263            models = registry.model_count(),
264            role = require_role.as_deref().unwrap_or("<none>"),
265            step_up_mutations = %step_up_mutations,
266            "๐Ÿ‚ Autumn Admin mounted"
267        );
268
269        // Declare routes for `autumn routes` listing. The underlying Axum router
270        // is added via nest() which is opaque to route enumeration, so we
271        // explicitly register route metadata here.
272        let declared = admin_route_infos(&prefix, has_config);
273
274        app.nest(&prefix, router).declare_plugin_routes(declared)
275    }
276}
277
278/// Generate the route metadata list for this plugin's mounted routes.
279///
280/// `has_config` must match whether `with_runtime_config` was called; config
281/// routes are only mounted when a `RuntimeConfigService` is provided, so
282/// including them unconditionally would produce false route-collision signals.
283///
284/// Kept in sync with `routes::admin_router` โ€” update here when routes are
285/// added or removed from the admin router.
286pub(crate) fn admin_route_infos(prefix: &str, has_config: bool) -> Vec<RouteInfo> {
287    let mut entries: Vec<(&str, String)> = vec![
288        ("GET", prefix.to_string()),
289        ("GET", format!("{prefix}/jobs")),
290        ("GET", format!("{prefix}/jobs/counters")),
291        ("POST", format!("{prefix}/jobs/{{id}}/retry")),
292        ("POST", format!("{prefix}/jobs/{{id}}/discard")),
293        ("POST", format!("{prefix}/jobs/{{id}}/cancel")),
294    ];
295    if has_config {
296        entries.extend([
297            ("GET", format!("{prefix}/config")),
298            ("POST", format!("{prefix}/config/{{key}}/set")),
299            ("POST", format!("{prefix}/config/{{key}}/unset")),
300            ("GET", format!("{prefix}/config/{{key}}/history")),
301        ]);
302    }
303    entries.extend([
304        ("GET", format!("{prefix}/{{slug}}")),
305        ("POST", format!("{prefix}/{{slug}}")),
306        ("GET", format!("{prefix}/{{slug}}/new")),
307        ("GET", format!("{prefix}/{{slug}}/export.csv")),
308        ("GET", format!("{prefix}/{{slug}}/import")),
309        ("POST", format!("{prefix}/{{slug}}/import")),
310        ("GET", format!("{prefix}/{{slug}}/{{id}}")),
311        ("POST", format!("{prefix}/{{slug}}/{{id}}")),
312        ("DELETE", format!("{prefix}/{{slug}}/{{id}}")),
313        ("GET", format!("{prefix}/{{slug}}/{{id}}/edit")),
314        ("GET", format!("{prefix}/{{slug}}/{{id}}/history")),
315        ("POST", format!("{prefix}/{{slug}}/actions")),
316        ("GET", format!("{prefix}{}", &*routes::ADMIN_JS_PATH)),
317    ]);
318    entries
319        .into_iter()
320        .map(|(method, path)| RouteInfo {
321            method: method.to_owned(),
322            path,
323            handler: format!("admin::{}", method.to_lowercase()),
324            source: autumn_web::route_listing::RouteSource::User, // overwritten by declare_plugin_routes
325            middleware: vec![],
326            api_version: None,
327            status: None,
328            sunset_opt_out: None,
329        })
330        .collect()
331}
332
333// โ”€โ”€ Conformance reference tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
334//
335// These tests are the reference example for the Autumn plugin conformance
336// workflow documented in docs/plugins.md.  They use
337// `autumn_web::plugin_conformance` to verify that the admin plugin's declared
338// routes satisfy all conformance checks before publication.
339//
340// See docs/plugins.md ยง "Plugin conformance and publishing checklist" for the
341// equivalent `autumn plugin-check` CLI invocation.
342
343#[cfg(test)]
344mod conformance_tests {
345    use autumn_web::plugin_conformance::{ConformanceConfig, run_conformance};
346    use autumn_web::route_listing::{RouteInfo, RouteSource};
347
348    const PLUGIN_NAME: &str = "autumn-admin-plugin";
349
350    /// Build the routes that `AdminPlugin` contributes under `prefix`,
351    /// attributed to the plugin. Reuses `admin_route_infos` from the outer
352    /// module and overrides the source to `Plugin(PLUGIN_NAME)`.
353    fn admin_routes(prefix: &str) -> Vec<RouteInfo> {
354        admin_routes_with_config(prefix, true)
355    }
356
357    fn admin_routes_with_config(prefix: &str, has_config: bool) -> Vec<RouteInfo> {
358        super::admin_route_infos(prefix, has_config)
359            .into_iter()
360            .map(|mut r| {
361                r.source = RouteSource::Plugin(PLUGIN_NAME.to_owned());
362                r
363            })
364            .collect()
365    }
366
367    #[test]
368    fn admin_plugin_routes_are_attributed_to_plugin_name() {
369        let routes = admin_routes("/admin");
370        let result = autumn_web::plugin_conformance::check_route_attribution(PLUGIN_NAME, &routes);
371        assert_eq!(
372            result.status,
373            autumn_web::plugin_conformance::CheckStatus::Pass,
374            "route attribution failed: {}",
375            result.message
376        );
377    }
378
379    #[test]
380    fn admin_plugin_routes_live_under_admin_prefix() {
381        let routes = admin_routes("/admin");
382        let result =
383            autumn_web::plugin_conformance::check_route_prefix(PLUGIN_NAME, "/admin", &[], &routes);
384        assert_eq!(
385            result.status,
386            autumn_web::plugin_conformance::CheckStatus::Pass,
387            "prefix check failed: {}\n{:?}",
388            result.message,
389            result.diagnostics
390        );
391    }
392
393    #[test]
394    fn admin_plugin_declares_builtin_job_routes() {
395        let routes = admin_routes("/admin");
396        let declared: std::collections::HashSet<(&str, &str)> = routes
397            .iter()
398            .map(|route| (route.method.as_str(), route.path.as_str()))
399            .collect();
400
401        for (method, path) in [
402            ("GET", "/admin/jobs"),
403            ("GET", "/admin/jobs/counters"),
404            ("POST", "/admin/jobs/{id}/retry"),
405            ("POST", "/admin/jobs/{id}/discard"),
406            ("POST", "/admin/jobs/{id}/cancel"),
407        ] {
408            assert!(
409                declared.contains(&(method, path)),
410                "missing declared admin job route {method} {path}"
411            );
412        }
413    }
414
415    #[test]
416    fn admin_plugin_declares_builtin_config_routes_when_enabled() {
417        let routes = admin_routes_with_config("/admin", true);
418        let declared: std::collections::HashSet<(&str, &str)> = routes
419            .iter()
420            .map(|route| (route.method.as_str(), route.path.as_str()))
421            .collect();
422
423        for (method, path) in [
424            ("GET", "/admin/config"),
425            ("POST", "/admin/config/{key}/set"),
426            ("POST", "/admin/config/{key}/unset"),
427            ("GET", "/admin/config/{key}/history"),
428        ] {
429            assert!(
430                declared.contains(&(method, path)),
431                "missing declared admin config route {method} {path}"
432            );
433        }
434    }
435
436    #[test]
437    fn admin_plugin_omits_config_routes_when_disabled() {
438        let routes = admin_routes_with_config("/admin", false);
439        let declared: std::collections::HashSet<(&str, &str)> = routes
440            .iter()
441            .map(|route| (route.method.as_str(), route.path.as_str()))
442            .collect();
443
444        for (method, path) in [
445            ("GET", "/admin/config"),
446            ("POST", "/admin/config/{key}/set"),
447            ("POST", "/admin/config/{key}/unset"),
448            ("GET", "/admin/config/{key}/history"),
449        ] {
450            assert!(
451                !declared.contains(&(method, path)),
452                "config route {method} {path} should not be declared when has_config=false"
453            );
454        }
455    }
456
457    #[test]
458    fn admin_plugin_has_no_route_collisions_in_isolation() {
459        let routes = admin_routes("/admin");
460        let (result, _) = autumn_web::plugin_conformance::check_collisions(&routes);
461        assert_eq!(
462            result.status,
463            autumn_web::plugin_conformance::CheckStatus::Pass,
464            "unexpected collision: {}\n{:?}",
465            result.message,
466            result.diagnostics
467        );
468    }
469
470    #[test]
471    fn admin_plugin_sensitive_surfaces_declared_with_role_requirement() {
472        let routes = admin_routes("/admin");
473        let declared = vec![autumn_web::plugin_conformance::SensitiveRoute {
474            path_pattern: "/admin".to_owned(),
475            auth_mechanism: "Role: admin required via AdminPlugin::require_role \
476                             (default) or AdminPlugin::require_role(None) to disable"
477                .to_owned(),
478        }];
479        let result = autumn_web::plugin_conformance::check_sensitive_surfaces(
480            PLUGIN_NAME,
481            &routes,
482            &declared,
483        );
484        assert_eq!(
485            result.status,
486            autumn_web::plugin_conformance::CheckStatus::Pass,
487            "sensitive-surfaces check failed: {}",
488            result.message
489        );
490    }
491
492    #[test]
493    fn admin_plugin_sensitive_surfaces_fail_without_declaration() {
494        let routes = admin_routes("/admin");
495        let result =
496            autumn_web::plugin_conformance::check_sensitive_surfaces(PLUGIN_NAME, &routes, &[]);
497        assert_eq!(
498            result.status,
499            autumn_web::plugin_conformance::CheckStatus::Fail,
500            "expected FAIL when sensitive routes are undeclared"
501        );
502    }
503
504    #[test]
505    fn admin_plugin_passes_full_conformance_with_config() {
506        let routes = admin_routes("/admin");
507        let config = ConformanceConfig::new(PLUGIN_NAME)
508            .prefix("/admin")
509            .sensitive_route(
510                "/admin",
511                "Role: admin required via AdminPlugin::require_role",
512            );
513        let report = run_conformance(&config, &routes);
514        assert!(
515            report.passed(),
516            "AdminPlugin conformance failed:\n{}",
517            report.to_text_report()
518        );
519    }
520
521    #[test]
522    fn admin_plugin_collision_with_host_route_detected() {
523        let mut routes = admin_routes("/admin");
524        // Simulate a host app that accidentally defines GET /admin
525        routes.push(RouteInfo {
526            method: "GET".to_owned(),
527            path: "/admin".to_owned(),
528            handler: "host::admin_redirect".to_owned(),
529            source: RouteSource::User,
530            middleware: vec![],
531            api_version: None,
532            status: None,
533            sunset_opt_out: None,
534        });
535        let (result, diagnostics) = autumn_web::plugin_conformance::check_collisions(&routes);
536        assert_eq!(
537            result.status,
538            autumn_web::plugin_conformance::CheckStatus::Fail,
539            "expected collision to be detected"
540        );
541        let diag = &diagnostics[0];
542        assert_eq!(diag.method, "GET");
543        assert_eq!(diag.path, "/admin");
544        let sources: Vec<&str> = diag
545            .contributors
546            .iter()
547            .map(|c| c.source.as_str())
548            .collect();
549        assert!(
550            sources.contains(&"user"),
551            "missing user contributor: {sources:?}"
552        );
553        assert!(
554            sources.contains(&"plugin:autumn-admin-plugin"),
555            "missing plugin contributor: {sources:?}"
556        );
557    }
558
559    #[test]
560    fn admin_plugin_custom_prefix_passes_conformance() {
561        let routes = admin_routes("/backend");
562        let config = ConformanceConfig::new(PLUGIN_NAME)
563            .prefix("/backend")
564            .sensitive_route(
565                "/backend",
566                "Role: admin required via AdminPlugin::require_role",
567            );
568        let report = run_conformance(&config, &routes);
569        assert!(
570            report.passed(),
571            "AdminPlugin with custom prefix failed conformance:\n{}",
572            report.to_text_report()
573        );
574    }
575
576    #[test]
577    fn admin_plugin_double_registration_detected() {
578        // Simulate registering the admin plugin twice โ€” its routes appear twice.
579        let mut routes = admin_routes("/admin");
580        routes.extend(admin_routes("/admin"));
581        let result =
582            autumn_web::plugin_conformance::check_duplicate_registration(PLUGIN_NAME, &routes);
583        assert_eq!(
584            result.status,
585            autumn_web::plugin_conformance::CheckStatus::Fail,
586            "expected duplicate-registration FAIL when plugin installed twice"
587        );
588    }
589
590    #[test]
591    fn admin_plugin_single_registration_passes_duplicate_check() {
592        let routes = admin_routes("/admin");
593        let result =
594            autumn_web::plugin_conformance::check_duplicate_registration(PLUGIN_NAME, &routes);
595        assert_eq!(
596            result.status,
597            autumn_web::plugin_conformance::CheckStatus::Pass,
598            "single registration should pass: {}",
599            result.message
600        );
601    }
602}