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 auth_session_key.clone(),
326 require_role.clone(),
327 runtime_config,
328 step_up_mutations,
329 step_up_max_age_secs,
330 impersonation.is_some(),
331 );
332
333 tracing::info!(
334 prefix = %prefix,
335 actuator_prefix = %actuator_prefix,
336 auth_session_key = %auth_session_key,
337 models = registry.model_count(),
338 role = require_role.as_deref().unwrap_or("<none>"),
339 step_up_mutations = %step_up_mutations,
340 "🍂 Autumn Admin mounted"
341 );
342
343 let declared = admin_route_infos(
350 &prefix,
351 has_config,
352 require_role.as_deref(),
353 impersonation.is_some(),
354 );
355
356 let app = match impersonation {
357 Some(gate) => app.impersonation_gate(gate).state_initializer(|state| {
364 let audited = state
369 .extension::<autumn_web::audit::AuditLogger>()
370 .is_some_and(|logger| logger.is_enabled());
371 if !audited {
372 tracing::error!(
373 "🍂 Autumn Admin: impersonation is enabled but no audit sink is \
374 configured; every attempt will be refused. Register one with \
375 `AppBuilder::with_audit_sink(...)`."
376 );
377 }
378 }),
379 None => app,
380 };
381
382 app.nest(&prefix, router)
383 .declare_plugin_routes(declared)
384 .plugin_assets(&routes::ASSETS)
385 }
386}
387
388pub(crate) fn admin_route_infos(
408 prefix: &str,
409 has_config: bool,
410 require_role: Option<&str>,
411 has_impersonation: bool,
412) -> Vec<RouteInfo> {
413 use autumn_web::route_listing::RouteClassification;
414
415 let (classification, roles) = require_role.map_or_else(
416 || (RouteClassification::Public, Vec::new()),
417 |role| (RouteClassification::Gated, vec![role.to_owned()]),
418 );
419 let mut entries: Vec<(&str, String)> = vec![
420 ("GET", prefix.to_string()),
421 ("GET", format!("{prefix}/jobs")),
422 ("GET", format!("{prefix}/jobs/counters")),
423 ("POST", format!("{prefix}/jobs/{{id}}/retry")),
424 ("POST", format!("{prefix}/jobs/{{id}}/discard")),
425 ("POST", format!("{prefix}/jobs/{{id}}/cancel")),
426 ];
427 if has_config {
428 entries.extend([
429 ("GET", format!("{prefix}/config")),
430 ("POST", format!("{prefix}/config/{{key}}/set")),
431 ("POST", format!("{prefix}/config/{{key}}/unset")),
432 ("GET", format!("{prefix}/config/{{key}}/history")),
433 ]);
434 }
435 if has_impersonation {
436 entries.push(("POST", format!("{prefix}/impersonate")));
437 }
438 entries.extend([
439 ("GET", format!("{prefix}/{{slug}}")),
440 ("POST", format!("{prefix}/{{slug}}")),
441 ("GET", format!("{prefix}/{{slug}}/new")),
442 ("GET", format!("{prefix}/{{slug}}/export.csv")),
443 ("GET", format!("{prefix}/{{slug}}/import")),
444 ("POST", format!("{prefix}/{{slug}}/import")),
445 ("GET", format!("{prefix}/{{slug}}/{{id}}")),
446 ("POST", format!("{prefix}/{{slug}}/{{id}}")),
447 ("DELETE", format!("{prefix}/{{slug}}/{{id}}")),
448 ("GET", format!("{prefix}/{{slug}}/{{id}}/edit")),
449 ("GET", format!("{prefix}/{{slug}}/{{id}}/history")),
450 ("POST", format!("{prefix}/{{slug}}/actions")),
451 ]);
452 let ungated: Vec<(&str, String)> = if has_impersonation {
457 vec![("POST", format!("{prefix}/impersonate/stop"))]
458 } else {
459 Vec::new()
460 };
461 entries
462 .into_iter()
463 .map(|(method, path)| RouteInfo {
464 method: method.to_owned(),
465 path,
466 handler: format!("admin::{}", method.to_lowercase()),
467 source: autumn_web::route_listing::RouteSource::User, classification,
469 roles: roles.clone(),
470 ..Default::default()
471 })
472 .chain(ungated.into_iter().map(|(method, path)| RouteInfo {
473 method: method.to_owned(),
474 path,
475 handler: format!("admin::{}", method.to_lowercase()),
476 source: autumn_web::route_listing::RouteSource::User,
477 classification: RouteClassification::Public,
478 roles: Vec::new(),
479 ..Default::default()
480 }))
481 .collect()
482}
483
484#[cfg(test)]
485mod contract_tests {
486 use super::AdminPlugin;
487 use autumn_web::plugin::Plugin;
488
489 #[test]
490 fn contract_declares_lockstep_with_own_crate() {
491 let contract = AdminPlugin::new().contract().expect("a contract");
492 assert_eq!(contract.plugin, env!("CARGO_PKG_NAME"));
493 assert_eq!(
494 contract.plugin_version.as_deref(),
495 Some(env!("CARGO_PKG_VERSION"))
496 );
497 assert_eq!(
498 contract.autumn_web.as_deref(),
499 Some(autumn_web::plugin_contract::lockstep_range(env!("CARGO_PKG_VERSION")).as_str())
500 );
501 assert!(contract.experimental_surfaces.is_empty());
502 }
503}
504
505#[cfg(test)]
516mod conformance_tests {
517 use autumn_web::plugin_conformance::{ConformanceConfig, run_conformance};
518 use autumn_web::route_listing::{RouteInfo, RouteSource};
519
520 const PLUGIN_NAME: &str = "autumn-admin-plugin";
521
522 fn admin_routes(prefix: &str) -> Vec<RouteInfo> {
526 admin_routes_with_config(prefix, true)
527 }
528
529 fn admin_routes_with_config(prefix: &str, has_config: bool) -> Vec<RouteInfo> {
530 super::admin_route_infos(prefix, has_config, Some("admin"), true)
531 .into_iter()
532 .map(|mut r| {
533 r.source = RouteSource::Plugin(PLUGIN_NAME.to_owned());
534 r
535 })
536 .collect()
537 }
538
539 #[test]
540 fn admin_plugin_routes_are_attributed_to_plugin_name() {
541 let routes = admin_routes("/admin");
542 let result = autumn_web::plugin_conformance::check_route_attribution(PLUGIN_NAME, &routes);
543 assert_eq!(
544 result.status,
545 autumn_web::plugin_conformance::CheckStatus::Pass,
546 "route attribution failed: {}",
547 result.message
548 );
549 }
550
551 #[test]
552 fn admin_plugin_routes_live_under_admin_prefix() {
553 let routes = admin_routes("/admin");
554 let result =
555 autumn_web::plugin_conformance::check_route_prefix(PLUGIN_NAME, "/admin", &[], &routes);
556 assert_eq!(
557 result.status,
558 autumn_web::plugin_conformance::CheckStatus::Pass,
559 "prefix check failed: {}\n{:?}",
560 result.message,
561 result.diagnostics
562 );
563 }
564
565 #[test]
566 fn admin_plugin_declares_builtin_job_routes() {
567 let routes = admin_routes("/admin");
568 let declared: std::collections::HashSet<(&str, &str)> = routes
569 .iter()
570 .map(|route| (route.method.as_str(), route.path.as_str()))
571 .collect();
572
573 for (method, path) in [
574 ("GET", "/admin/jobs"),
575 ("GET", "/admin/jobs/counters"),
576 ("POST", "/admin/jobs/{id}/retry"),
577 ("POST", "/admin/jobs/{id}/discard"),
578 ("POST", "/admin/jobs/{id}/cancel"),
579 ] {
580 assert!(
581 declared.contains(&(method, path)),
582 "missing declared admin job route {method} {path}"
583 );
584 }
585 }
586
587 #[test]
588 fn admin_plugin_declares_builtin_config_routes_when_enabled() {
589 let routes = admin_routes_with_config("/admin", true);
590 let declared: std::collections::HashSet<(&str, &str)> = routes
591 .iter()
592 .map(|route| (route.method.as_str(), route.path.as_str()))
593 .collect();
594
595 for (method, path) in [
596 ("GET", "/admin/config"),
597 ("POST", "/admin/config/{key}/set"),
598 ("POST", "/admin/config/{key}/unset"),
599 ("GET", "/admin/config/{key}/history"),
600 ] {
601 assert!(
602 declared.contains(&(method, path)),
603 "missing declared admin config route {method} {path}"
604 );
605 }
606 }
607
608 #[test]
609 fn admin_plugin_omits_config_routes_when_disabled() {
610 let routes = admin_routes_with_config("/admin", false);
611 let declared: std::collections::HashSet<(&str, &str)> = routes
612 .iter()
613 .map(|route| (route.method.as_str(), route.path.as_str()))
614 .collect();
615
616 for (method, path) in [
617 ("GET", "/admin/config"),
618 ("POST", "/admin/config/{key}/set"),
619 ("POST", "/admin/config/{key}/unset"),
620 ("GET", "/admin/config/{key}/history"),
621 ] {
622 assert!(
623 !declared.contains(&(method, path)),
624 "config route {method} {path} should not be declared when has_config=false"
625 );
626 }
627 }
628
629 #[test]
634 fn admin_plugin_declared_routes_classify_gated_with_role() {
635 use autumn_web::route_listing::RouteClassification;
636
637 let routes = super::admin_route_infos("/admin", true, Some("admin"), false);
638 assert!(!routes.is_empty());
639 for r in &routes {
640 assert_eq!(
641 r.classification,
642 RouteClassification::Gated,
643 "admin route {} {} should be Gated, got {:?}",
644 r.method,
645 r.path,
646 r.classification
647 );
648 assert_eq!(
649 r.roles,
650 vec!["admin".to_owned()],
651 "admin route {} {} should carry the required role",
652 r.method,
653 r.path
654 );
655 }
656 }
657
658 #[test]
664 fn impersonation_routes_are_declared_only_when_enabled() {
665 use autumn_web::route_listing::RouteClassification;
666
667 let off = super::admin_route_infos("/admin", false, Some("admin"), false);
668 assert!(
669 !off.iter().any(|r| r.path.contains("/impersonate")),
670 "no impersonation routes without the opt-in"
671 );
672
673 let on = super::admin_route_infos("/admin", false, Some("admin"), true);
674 let begin = on
675 .iter()
676 .find(|r| r.path == "/admin/impersonate")
677 .expect("begin route declared");
678 assert_eq!(begin.method, "POST");
679 assert_eq!(begin.classification, RouteClassification::Gated);
680 assert_eq!(begin.roles, vec!["admin".to_owned()]);
681
682 let stop = on
683 .iter()
684 .find(|r| r.path == "/admin/impersonate/stop")
685 .expect("revert route declared");
686 assert_eq!(stop.method, "POST");
687 assert_eq!(
688 stop.classification,
689 RouteClassification::Public,
690 "the revert route is ungated on purpose"
691 );
692 assert!(stop.roles.is_empty());
693 }
694
695 #[test]
699 fn admin_plugin_declared_routes_classify_public_when_role_disabled() {
700 use autumn_web::route_listing::RouteClassification;
701
702 let routes = super::admin_route_infos("/admin", true, None, false);
703 assert!(!routes.is_empty());
704 for r in &routes {
705 assert_eq!(
706 r.classification,
707 RouteClassification::Public,
708 "admin route {} {} should be Public when auth disabled",
709 r.method,
710 r.path
711 );
712 assert!(r.roles.is_empty(), "public route should carry no roles");
713 }
714 }
715
716 #[test]
717 fn admin_plugin_has_no_route_collisions_in_isolation() {
718 let routes = admin_routes("/admin");
719 let (result, _) = autumn_web::plugin_conformance::check_collisions(&routes);
720 assert_eq!(
721 result.status,
722 autumn_web::plugin_conformance::CheckStatus::Pass,
723 "unexpected collision: {}\n{:?}",
724 result.message,
725 result.diagnostics
726 );
727 }
728
729 #[test]
730 fn admin_plugin_sensitive_surfaces_declared_with_role_requirement() {
731 let routes = admin_routes("/admin");
732 let declared = vec![autumn_web::plugin_conformance::SensitiveRoute {
733 path_pattern: "/admin".to_owned(),
734 auth_mechanism: "Role: admin required via AdminPlugin::require_role \
735 (default) or AdminPlugin::require_role(None) to disable"
736 .to_owned(),
737 }];
738 let result = autumn_web::plugin_conformance::check_sensitive_surfaces(
739 PLUGIN_NAME,
740 &routes,
741 &declared,
742 );
743 assert_eq!(
744 result.status,
745 autumn_web::plugin_conformance::CheckStatus::Pass,
746 "sensitive-surfaces check failed: {}",
747 result.message
748 );
749 }
750
751 #[test]
752 fn admin_plugin_sensitive_surfaces_fail_without_declaration() {
753 let routes = admin_routes("/admin");
754 let result =
755 autumn_web::plugin_conformance::check_sensitive_surfaces(PLUGIN_NAME, &routes, &[]);
756 assert_eq!(
757 result.status,
758 autumn_web::plugin_conformance::CheckStatus::Fail,
759 "expected FAIL when sensitive routes are undeclared"
760 );
761 }
762
763 #[test]
764 fn admin_plugin_passes_full_conformance_with_config() {
765 let routes = admin_routes("/admin");
766 let config = ConformanceConfig::new(PLUGIN_NAME)
767 .prefix("/admin")
768 .sensitive_route(
769 "/admin",
770 "Role: admin required via AdminPlugin::require_role",
771 );
772 let report = run_conformance(&config, &routes);
773 assert!(
774 report.passed(),
775 "AdminPlugin conformance failed:\n{}",
776 report.to_text_report()
777 );
778 }
779
780 #[test]
781 fn admin_plugin_collision_with_host_route_detected() {
782 let mut routes = admin_routes("/admin");
783 routes.push(RouteInfo {
785 method: "GET".to_owned(),
786 path: "/admin".to_owned(),
787 handler: "host::admin_redirect".to_owned(),
788 source: RouteSource::User,
789 ..Default::default()
790 });
791 let (result, diagnostics) = autumn_web::plugin_conformance::check_collisions(&routes);
792 assert_eq!(
793 result.status,
794 autumn_web::plugin_conformance::CheckStatus::Fail,
795 "expected collision to be detected"
796 );
797 let diag = &diagnostics[0];
798 assert_eq!(diag.method, "GET");
799 assert_eq!(diag.path, "/admin");
800 let sources: Vec<&str> = diag
801 .contributors
802 .iter()
803 .map(|c| c.source.as_str())
804 .collect();
805 assert!(
806 sources.contains(&"user"),
807 "missing user contributor: {sources:?}"
808 );
809 assert!(
810 sources.contains(&"plugin:autumn-admin-plugin"),
811 "missing plugin contributor: {sources:?}"
812 );
813 }
814
815 #[test]
816 fn admin_plugin_custom_prefix_passes_conformance() {
817 let routes = admin_routes("/backend");
818 let config = ConformanceConfig::new(PLUGIN_NAME)
819 .prefix("/backend")
820 .sensitive_route(
821 "/backend",
822 "Role: admin required via AdminPlugin::require_role",
823 );
824 let report = run_conformance(&config, &routes);
825 assert!(
826 report.passed(),
827 "AdminPlugin with custom prefix failed conformance:\n{}",
828 report.to_text_report()
829 );
830 }
831
832 #[test]
833 fn admin_plugin_double_registration_detected() {
834 let mut routes = admin_routes("/admin");
836 routes.extend(admin_routes("/admin"));
837 let result =
838 autumn_web::plugin_conformance::check_duplicate_registration(PLUGIN_NAME, &routes);
839 assert_eq!(
840 result.status,
841 autumn_web::plugin_conformance::CheckStatus::Fail,
842 "expected duplicate-registration FAIL when plugin installed twice"
843 );
844 }
845
846 #[test]
847 fn admin_plugin_single_registration_passes_duplicate_check() {
848 let routes = admin_routes("/admin");
849 let result =
850 autumn_web::plugin_conformance::check_duplicate_registration(PLUGIN_NAME, &routes);
851 assert_eq!(
852 result.status,
853 autumn_web::plugin_conformance::CheckStatus::Pass,
854 "single registration should pass: {}",
855 result.message
856 );
857 }
858}