1mod auth;
36pub mod experiments;
37pub mod feature_flags;
38mod impersonation;
39mod registry;
40mod routes;
41mod templates;
42pub mod tokens;
43mod traits;
44
45pub use impersonation::{AdminImpersonation, impersonation_banner_for};
46pub use registry::AdminRegistry;
47pub use templates::{IMPERSONATION_BANNER_CSS, ImpersonationBanner, impersonation_banner};
48pub use traits::{
49 AdminAction, AdminError, AdminField, AdminFieldKind, AdminFuture, AdminHistoryEntry,
50 AdminHistoryPage, AdminImportError, AdminImportReport, AdminImportRowResult, AdminModel,
51 CsvImportMode, ListParams, ListResult, SelectOption, SortDirection,
52 dispatch_restore_purge_or_unhandled,
53};
54
55pub mod prelude {
57 pub use crate::{
58 AdminError, AdminField, AdminFieldKind, AdminFuture, AdminHistoryEntry, AdminHistoryPage,
59 AdminImportRowResult, AdminModel, CsvImportMode, ListParams, ListResult, SelectOption,
60 SortDirection, dispatch_restore_purge_or_unhandled,
61 };
62}
63
64use std::borrow::Cow;
65use std::sync::Arc;
66
67use autumn_web::app::AppBuilder;
68use autumn_web::auth::impersonation::ImpersonationGate;
69use autumn_web::plugin::Plugin;
70use autumn_web::route_listing::RouteInfo;
71use autumn_web::runtime_config::RuntimeConfigService;
72
73pub struct AdminPlugin {
78 registry: AdminRegistry,
79 prefix: String,
80 actuator_prefix: String,
81 auth_session_key: String,
82 require_role: Option<String>,
83 runtime_config: Option<Arc<RuntimeConfigService>>,
84 step_up_mutations: bool,
89 step_up_max_age_secs: u64,
93 impersonation: Option<ImpersonationGate>,
97}
98
99impl AdminPlugin {
100 #[must_use]
107 pub fn new() -> Self {
108 Self {
109 registry: AdminRegistry::new(),
110 prefix: "/admin".to_owned(),
111 actuator_prefix: "/actuator".to_owned(),
112 auth_session_key: "user_id".to_owned(),
113 require_role: Some("admin".to_owned()),
114 runtime_config: None,
115 step_up_mutations: false,
116 step_up_max_age_secs: autumn_web::step_up::DEFAULT_MAX_AGE_SECS,
117 impersonation: None,
118 }
119 }
120
121 #[must_use]
123 pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
124 self.prefix = prefix.into();
125 self
126 }
127
128 #[must_use]
133 pub fn actuator_prefix(mut self, prefix: impl Into<String>) -> Self {
134 self.actuator_prefix = prefix.into();
135 self
136 }
137
138 #[must_use]
147 pub fn auth_session_key(mut self, key: impl Into<String>) -> Self {
148 self.auth_session_key = key.into();
149 self
150 }
151
152 #[must_use]
158 pub fn require_role(mut self, role: impl Into<Option<String>>) -> Self {
159 self.require_role = role.into();
160 self
161 }
162
163 #[must_use]
168 pub fn register<M: AdminModel>(mut self, model: M) -> Self {
169 self.registry.register(model);
170 self
171 }
172
173 #[must_use]
179 pub fn with_runtime_config(mut self, svc: Arc<RuntimeConfigService>) -> Self {
180 self.runtime_config = Some(svc);
181 self
182 }
183
184 #[must_use]
204 pub const fn with_step_up_mutations(mut self) -> Self {
205 self.step_up_mutations = true;
206 self
207 }
208
209 #[must_use]
222 pub const fn with_step_up_max_age(mut self, secs: u64) -> Self {
223 self.step_up_mutations = true;
224 self.step_up_max_age_secs = secs;
225 self
226 }
227
228 #[must_use]
267 pub fn with_impersonation(mut self, gate: ImpersonationGate) -> Self {
268 self.impersonation = Some(gate);
269 self
270 }
271}
272
273impl Default for AdminPlugin {
274 fn default() -> Self {
275 Self::new()
276 }
277}
278
279impl Plugin for AdminPlugin {
280 fn contract(&self) -> Option<autumn_web::plugin_contract::PluginContract> {
283 Some(autumn_web::plugin_contract::lockstep_contract(
284 env!("CARGO_PKG_NAME"),
285 env!("CARGO_PKG_VERSION"),
286 ))
287 }
288
289 fn name(&self) -> Cow<'static, str> {
290 Cow::Borrowed("autumn-admin-plugin")
291 }
292
293 fn build(self, app: AppBuilder) -> AppBuilder {
294 let Self {
295 registry,
296 prefix,
297 actuator_prefix,
298 auth_session_key,
299 require_role,
300 runtime_config,
301 step_up_mutations,
302 step_up_max_age_secs,
303 impersonation,
304 } = self;
305 let has_config = runtime_config.is_some();
306 assert!(
308 !(has_config && registry.get("config").is_some()),
309 "autumn-admin: model slug 'config' conflicts with the mounted runtime-config \
310 routes; rename the model or don't call with_runtime_config",
311 );
312 assert!(
316 !(impersonation.is_some() && registry.get("impersonate").is_some()),
317 "autumn-admin: model slug 'impersonate' conflicts with the mounted \
318 impersonation routes; rename the model or don't call with_impersonation",
319 );
320 let registry = Arc::new(registry);
321 let router = routes::admin_router(
322 Arc::clone(®istry),
323 &prefix,
324 actuator_prefix.clone(),
325 routes::AdminGuards {
326 auth_session_key: auth_session_key.clone(),
327 require_role: require_role.clone(),
328 step_up_mutations,
329 step_up_max_age_secs,
330 },
331 runtime_config,
332 impersonation.is_some(),
333 );
334
335 tracing::info!(
336 prefix = %prefix,
337 actuator_prefix = %actuator_prefix,
338 auth_session_key = %auth_session_key,
339 models = registry.model_count(),
340 role = require_role.as_deref().unwrap_or("<none>"),
341 step_up_mutations = %step_up_mutations,
342 "🍂 Autumn Admin mounted"
343 );
344
345 let declared = admin_route_infos(
352 &prefix,
353 has_config,
354 require_role.as_deref(),
355 impersonation.is_some(),
356 );
357
358 let app = match impersonation {
359 Some(gate) => app.impersonation_gate(gate).state_initializer(|state| {
366 let audited = state
371 .extension::<autumn_web::audit::AuditLogger>()
372 .is_some_and(|logger| logger.is_enabled());
373 if !audited {
374 tracing::error!(
375 "🍂 Autumn Admin: impersonation is enabled but no audit sink is \
376 configured; every attempt will be refused. Register one with \
377 `AppBuilder::with_audit_sink(...)`."
378 );
379 }
380 }),
381 None => app,
382 };
383
384 app.nest(&prefix, router)
385 .declare_plugin_routes(declared)
386 .plugin_assets(&routes::ASSETS)
387 }
388}
389
390pub(crate) fn admin_route_infos(
410 prefix: &str,
411 has_config: bool,
412 require_role: Option<&str>,
413 has_impersonation: bool,
414) -> Vec<RouteInfo> {
415 use autumn_web::route_listing::RouteClassification;
416
417 let (classification, roles) = require_role.map_or_else(
418 || (RouteClassification::Public, Vec::new()),
419 |role| (RouteClassification::Gated, vec![role.to_owned()]),
420 );
421 let mut entries: Vec<(&str, String)> = vec![
422 ("GET", prefix.to_string()),
423 ("GET", format!("{prefix}/jobs")),
424 ("GET", format!("{prefix}/jobs/counters")),
425 ("POST", format!("{prefix}/jobs/{{id}}/retry")),
426 ("POST", format!("{prefix}/jobs/{{id}}/discard")),
427 ("POST", format!("{prefix}/jobs/{{id}}/cancel")),
428 ];
429 if has_config {
430 entries.extend([
431 ("GET", format!("{prefix}/config")),
432 ("POST", format!("{prefix}/config/{{key}}/set")),
433 ("POST", format!("{prefix}/config/{{key}}/unset")),
434 ("GET", format!("{prefix}/config/{{key}}/history")),
435 ]);
436 }
437 if has_impersonation {
438 entries.push(("POST", format!("{prefix}/impersonate")));
439 }
440 entries.extend([
441 ("GET", format!("{prefix}/{{slug}}")),
442 ("POST", format!("{prefix}/{{slug}}")),
443 ("GET", format!("{prefix}/{{slug}}/new")),
444 ("GET", format!("{prefix}/{{slug}}/export.csv")),
445 ("GET", format!("{prefix}/{{slug}}/import")),
446 ("POST", format!("{prefix}/{{slug}}/import")),
447 ("GET", format!("{prefix}/{{slug}}/{{id}}")),
448 ("POST", format!("{prefix}/{{slug}}/{{id}}")),
449 ("DELETE", format!("{prefix}/{{slug}}/{{id}}")),
450 ("GET", format!("{prefix}/{{slug}}/{{id}}/edit")),
451 ("GET", format!("{prefix}/{{slug}}/{{id}}/history")),
452 ("POST", format!("{prefix}/{{slug}}/actions")),
453 ]);
454 let ungated: Vec<(&str, String)> = if has_impersonation {
459 vec![("POST", format!("{prefix}/impersonate/stop"))]
460 } else {
461 Vec::new()
462 };
463 entries
464 .into_iter()
465 .map(|(method, path)| RouteInfo {
466 method: method.to_owned(),
467 path,
468 handler: format!("admin::{}", method.to_lowercase()),
469 source: autumn_web::route_listing::RouteSource::User, classification,
471 roles: roles.clone(),
472 ..Default::default()
473 })
474 .chain(ungated.into_iter().map(|(method, path)| RouteInfo {
475 method: method.to_owned(),
476 path,
477 handler: format!("admin::{}", method.to_lowercase()),
478 source: autumn_web::route_listing::RouteSource::User,
479 classification: RouteClassification::Public,
480 roles: Vec::new(),
481 ..Default::default()
482 }))
483 .collect()
484}
485
486#[cfg(test)]
487mod contract_tests {
488 use super::AdminPlugin;
489 use autumn_web::plugin::Plugin;
490
491 #[test]
492 fn contract_declares_lockstep_with_own_crate() {
493 let contract = AdminPlugin::new().contract().expect("a contract");
494 assert_eq!(contract.plugin, env!("CARGO_PKG_NAME"));
495 assert_eq!(
496 contract.plugin_version.as_deref(),
497 Some(env!("CARGO_PKG_VERSION"))
498 );
499 assert_eq!(
500 contract.autumn_web.as_deref(),
501 Some(autumn_web::plugin_contract::lockstep_range(env!("CARGO_PKG_VERSION")).as_str())
502 );
503 assert!(contract.experimental_surfaces.is_empty());
504 }
505}
506
507#[cfg(test)]
518mod conformance_tests {
519 use autumn_web::plugin_conformance::{ConformanceConfig, run_conformance};
520 use autumn_web::route_listing::{RouteInfo, RouteSource};
521
522 const PLUGIN_NAME: &str = "autumn-admin-plugin";
523
524 fn admin_routes(prefix: &str) -> Vec<RouteInfo> {
528 admin_routes_with_config(prefix, true)
529 }
530
531 fn admin_routes_with_config(prefix: &str, has_config: bool) -> Vec<RouteInfo> {
532 super::admin_route_infos(prefix, has_config, Some("admin"), true)
533 .into_iter()
534 .map(|mut r| {
535 r.source = RouteSource::Plugin(PLUGIN_NAME.to_owned());
536 r
537 })
538 .collect()
539 }
540
541 #[test]
542 fn admin_plugin_routes_are_attributed_to_plugin_name() {
543 let routes = admin_routes("/admin");
544 let result = autumn_web::plugin_conformance::check_route_attribution(PLUGIN_NAME, &routes);
545 assert_eq!(
546 result.status,
547 autumn_web::plugin_conformance::CheckStatus::Pass,
548 "route attribution failed: {}",
549 result.message
550 );
551 }
552
553 #[test]
554 fn admin_plugin_routes_live_under_admin_prefix() {
555 let routes = admin_routes("/admin");
556 let result =
557 autumn_web::plugin_conformance::check_route_prefix(PLUGIN_NAME, "/admin", &[], &routes);
558 assert_eq!(
559 result.status,
560 autumn_web::plugin_conformance::CheckStatus::Pass,
561 "prefix check failed: {}\n{:?}",
562 result.message,
563 result.diagnostics
564 );
565 }
566
567 #[test]
568 fn admin_plugin_declares_builtin_job_routes() {
569 let routes = admin_routes("/admin");
570 let declared: std::collections::HashSet<(&str, &str)> = routes
571 .iter()
572 .map(|route| (route.method.as_str(), route.path.as_str()))
573 .collect();
574
575 for (method, path) in [
576 ("GET", "/admin/jobs"),
577 ("GET", "/admin/jobs/counters"),
578 ("POST", "/admin/jobs/{id}/retry"),
579 ("POST", "/admin/jobs/{id}/discard"),
580 ("POST", "/admin/jobs/{id}/cancel"),
581 ] {
582 assert!(
583 declared.contains(&(method, path)),
584 "missing declared admin job route {method} {path}"
585 );
586 }
587 }
588
589 #[test]
590 fn admin_plugin_declares_builtin_config_routes_when_enabled() {
591 let routes = admin_routes_with_config("/admin", true);
592 let declared: std::collections::HashSet<(&str, &str)> = routes
593 .iter()
594 .map(|route| (route.method.as_str(), route.path.as_str()))
595 .collect();
596
597 for (method, path) in [
598 ("GET", "/admin/config"),
599 ("POST", "/admin/config/{key}/set"),
600 ("POST", "/admin/config/{key}/unset"),
601 ("GET", "/admin/config/{key}/history"),
602 ] {
603 assert!(
604 declared.contains(&(method, path)),
605 "missing declared admin config route {method} {path}"
606 );
607 }
608 }
609
610 #[test]
611 fn admin_plugin_omits_config_routes_when_disabled() {
612 let routes = admin_routes_with_config("/admin", false);
613 let declared: std::collections::HashSet<(&str, &str)> = routes
614 .iter()
615 .map(|route| (route.method.as_str(), route.path.as_str()))
616 .collect();
617
618 for (method, path) in [
619 ("GET", "/admin/config"),
620 ("POST", "/admin/config/{key}/set"),
621 ("POST", "/admin/config/{key}/unset"),
622 ("GET", "/admin/config/{key}/history"),
623 ] {
624 assert!(
625 !declared.contains(&(method, path)),
626 "config route {method} {path} should not be declared when has_config=false"
627 );
628 }
629 }
630
631 #[test]
636 fn admin_plugin_declared_routes_classify_gated_with_role() {
637 use autumn_web::route_listing::RouteClassification;
638
639 let routes = super::admin_route_infos("/admin", true, Some("admin"), false);
640 assert!(!routes.is_empty());
641 for r in &routes {
642 assert_eq!(
643 r.classification,
644 RouteClassification::Gated,
645 "admin route {} {} should be Gated, got {:?}",
646 r.method,
647 r.path,
648 r.classification
649 );
650 assert_eq!(
651 r.roles,
652 vec!["admin".to_owned()],
653 "admin route {} {} should carry the required role",
654 r.method,
655 r.path
656 );
657 }
658 }
659
660 #[test]
666 fn impersonation_routes_are_declared_only_when_enabled() {
667 use autumn_web::route_listing::RouteClassification;
668
669 let off = super::admin_route_infos("/admin", false, Some("admin"), false);
670 assert!(
671 !off.iter().any(|r| r.path.contains("/impersonate")),
672 "no impersonation routes without the opt-in"
673 );
674
675 let on = super::admin_route_infos("/admin", false, Some("admin"), true);
676 let begin = on
677 .iter()
678 .find(|r| r.path == "/admin/impersonate")
679 .expect("begin route declared");
680 assert_eq!(begin.method, "POST");
681 assert_eq!(begin.classification, RouteClassification::Gated);
682 assert_eq!(begin.roles, vec!["admin".to_owned()]);
683
684 let stop = on
685 .iter()
686 .find(|r| r.path == "/admin/impersonate/stop")
687 .expect("revert route declared");
688 assert_eq!(stop.method, "POST");
689 assert_eq!(
690 stop.classification,
691 RouteClassification::Public,
692 "the revert route is ungated on purpose"
693 );
694 assert!(stop.roles.is_empty());
695 }
696
697 #[test]
701 fn admin_plugin_declared_routes_classify_public_when_role_disabled() {
702 use autumn_web::route_listing::RouteClassification;
703
704 let routes = super::admin_route_infos("/admin", true, None, false);
705 assert!(!routes.is_empty());
706 for r in &routes {
707 assert_eq!(
708 r.classification,
709 RouteClassification::Public,
710 "admin route {} {} should be Public when auth disabled",
711 r.method,
712 r.path
713 );
714 assert!(r.roles.is_empty(), "public route should carry no roles");
715 }
716 }
717
718 #[test]
719 fn admin_plugin_has_no_route_collisions_in_isolation() {
720 let routes = admin_routes("/admin");
721 let (result, _) = autumn_web::plugin_conformance::check_collisions(&routes);
722 assert_eq!(
723 result.status,
724 autumn_web::plugin_conformance::CheckStatus::Pass,
725 "unexpected collision: {}\n{:?}",
726 result.message,
727 result.diagnostics
728 );
729 }
730
731 #[test]
732 fn admin_plugin_sensitive_surfaces_declared_with_role_requirement() {
733 let routes = admin_routes("/admin");
734 let declared = vec![autumn_web::plugin_conformance::SensitiveRoute {
735 path_pattern: "/admin".to_owned(),
736 auth_mechanism: "Role: admin required via AdminPlugin::require_role \
737 (default) or AdminPlugin::require_role(None) to disable"
738 .to_owned(),
739 }];
740 let result = autumn_web::plugin_conformance::check_sensitive_surfaces(
741 PLUGIN_NAME,
742 &routes,
743 &declared,
744 );
745 assert_eq!(
746 result.status,
747 autumn_web::plugin_conformance::CheckStatus::Pass,
748 "sensitive-surfaces check failed: {}",
749 result.message
750 );
751 }
752
753 #[test]
754 fn admin_plugin_sensitive_surfaces_fail_without_declaration() {
755 let routes = admin_routes("/admin");
756 let result =
757 autumn_web::plugin_conformance::check_sensitive_surfaces(PLUGIN_NAME, &routes, &[]);
758 assert_eq!(
759 result.status,
760 autumn_web::plugin_conformance::CheckStatus::Fail,
761 "expected FAIL when sensitive routes are undeclared"
762 );
763 }
764
765 #[test]
766 fn admin_plugin_passes_full_conformance_with_config() {
767 let routes = admin_routes("/admin");
768 let config = ConformanceConfig::new(PLUGIN_NAME)
769 .prefix("/admin")
770 .sensitive_route(
771 "/admin",
772 "Role: admin required via AdminPlugin::require_role",
773 );
774 let report = run_conformance(&config, &routes);
775 assert!(
776 report.passed(),
777 "AdminPlugin conformance failed:\n{}",
778 report.to_text_report()
779 );
780 }
781
782 #[test]
783 fn admin_plugin_collision_with_host_route_detected() {
784 let mut routes = admin_routes("/admin");
785 routes.push(RouteInfo {
787 method: "GET".to_owned(),
788 path: "/admin".to_owned(),
789 handler: "host::admin_redirect".to_owned(),
790 source: RouteSource::User,
791 ..Default::default()
792 });
793 let (result, diagnostics) = autumn_web::plugin_conformance::check_collisions(&routes);
794 assert_eq!(
795 result.status,
796 autumn_web::plugin_conformance::CheckStatus::Fail,
797 "expected collision to be detected"
798 );
799 let diag = &diagnostics[0];
800 assert_eq!(diag.method, "GET");
801 assert_eq!(diag.path, "/admin");
802 let sources: Vec<&str> = diag
803 .contributors
804 .iter()
805 .map(|c| c.source.as_str())
806 .collect();
807 assert!(
808 sources.contains(&"user"),
809 "missing user contributor: {sources:?}"
810 );
811 assert!(
812 sources.contains(&"plugin:autumn-admin-plugin"),
813 "missing plugin contributor: {sources:?}"
814 );
815 }
816
817 #[test]
818 fn admin_plugin_custom_prefix_passes_conformance() {
819 let routes = admin_routes("/backend");
820 let config = ConformanceConfig::new(PLUGIN_NAME)
821 .prefix("/backend")
822 .sensitive_route(
823 "/backend",
824 "Role: admin required via AdminPlugin::require_role",
825 );
826 let report = run_conformance(&config, &routes);
827 assert!(
828 report.passed(),
829 "AdminPlugin with custom prefix failed conformance:\n{}",
830 report.to_text_report()
831 );
832 }
833
834 #[test]
835 fn admin_plugin_double_registration_detected() {
836 let mut routes = admin_routes("/admin");
838 routes.extend(admin_routes("/admin"));
839 let result =
840 autumn_web::plugin_conformance::check_duplicate_registration(PLUGIN_NAME, &routes);
841 assert_eq!(
842 result.status,
843 autumn_web::plugin_conformance::CheckStatus::Fail,
844 "expected duplicate-registration FAIL when plugin installed twice"
845 );
846 }
847
848 #[test]
849 fn admin_plugin_single_registration_passes_duplicate_check() {
850 let routes = admin_routes("/admin");
851 let result =
852 autumn_web::plugin_conformance::check_duplicate_registration(PLUGIN_NAME, &routes);
853 assert_eq!(
854 result.status,
855 autumn_web::plugin_conformance::CheckStatus::Pass,
856 "single registration should pass: {}",
857 result.message
858 );
859 }
860}