1mod auth;
36pub mod experiments;
37pub mod feature_flags;
38mod registry;
39mod routes;
40mod templates;
41pub mod tokens;
42mod traits;
43
44pub use registry::AdminRegistry;
45pub use traits::{
46 AdminAction, AdminError, AdminField, AdminFieldKind, AdminFuture, AdminHistoryEntry,
47 AdminHistoryPage, AdminImportError, AdminImportReport, AdminImportRowResult, AdminModel,
48 CsvImportMode, ListParams, ListResult, SelectOption, SortDirection,
49};
50
51pub mod prelude {
53 pub use crate::{
54 AdminError, AdminField, AdminFieldKind, AdminFuture, AdminHistoryEntry, AdminHistoryPage,
55 AdminImportRowResult, AdminModel, CsvImportMode, ListParams, ListResult, SelectOption,
56 SortDirection,
57 };
58}
59
60use std::borrow::Cow;
61use std::sync::Arc;
62
63use autumn_web::app::AppBuilder;
64use autumn_web::plugin::Plugin;
65use autumn_web::route_listing::RouteInfo;
66use autumn_web::runtime_config::RuntimeConfigService;
67
68pub struct AdminPlugin {
73 registry: AdminRegistry,
74 prefix: String,
75 actuator_prefix: String,
76 auth_session_key: String,
77 require_role: Option<String>,
78 runtime_config: Option<Arc<RuntimeConfigService>>,
79 step_up_mutations: bool,
84 step_up_max_age_secs: u64,
88}
89
90impl AdminPlugin {
91 #[must_use]
98 pub fn new() -> Self {
99 Self {
100 registry: AdminRegistry::new(),
101 prefix: "/admin".to_owned(),
102 actuator_prefix: "/actuator".to_owned(),
103 auth_session_key: "user_id".to_owned(),
104 require_role: Some("admin".to_owned()),
105 runtime_config: None,
106 step_up_mutations: false,
107 step_up_max_age_secs: autumn_web::step_up::DEFAULT_MAX_AGE_SECS,
108 }
109 }
110
111 #[must_use]
113 pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
114 self.prefix = prefix.into();
115 self
116 }
117
118 #[must_use]
123 pub fn actuator_prefix(mut self, prefix: impl Into<String>) -> Self {
124 self.actuator_prefix = prefix.into();
125 self
126 }
127
128 #[must_use]
137 pub fn auth_session_key(mut self, key: impl Into<String>) -> Self {
138 self.auth_session_key = key.into();
139 self
140 }
141
142 #[must_use]
148 pub fn require_role(mut self, role: impl Into<Option<String>>) -> Self {
149 self.require_role = role.into();
150 self
151 }
152
153 #[must_use]
158 pub fn register<M: AdminModel>(mut self, model: M) -> Self {
159 self.registry.register(model);
160 self
161 }
162
163 #[must_use]
169 pub fn with_runtime_config(mut self, svc: Arc<RuntimeConfigService>) -> Self {
170 self.runtime_config = Some(svc);
171 self
172 }
173
174 #[must_use]
194 pub const fn with_step_up_mutations(mut self) -> Self {
195 self.step_up_mutations = true;
196 self
197 }
198
199 #[must_use]
212 pub const fn with_step_up_max_age(mut self, secs: u64) -> Self {
213 self.step_up_mutations = true;
214 self.step_up_max_age_secs = secs;
215 self
216 }
217}
218
219impl Default for AdminPlugin {
220 fn default() -> Self {
221 Self::new()
222 }
223}
224
225impl Plugin for AdminPlugin {
226 fn name(&self) -> Cow<'static, str> {
227 Cow::Borrowed("autumn-admin-plugin")
228 }
229
230 fn build(self, app: AppBuilder) -> AppBuilder {
231 let Self {
232 registry,
233 prefix,
234 actuator_prefix,
235 auth_session_key,
236 require_role,
237 runtime_config,
238 step_up_mutations,
239 step_up_max_age_secs,
240 } = self;
241 let has_config = runtime_config.is_some();
242 assert!(
244 !(has_config && registry.get("config").is_some()),
245 "autumn-admin: model slug 'config' conflicts with the mounted runtime-config \
246 routes; rename the model or don't call with_runtime_config",
247 );
248 let registry = Arc::new(registry);
249 let router = routes::admin_router(
250 Arc::clone(®istry),
251 &prefix,
252 actuator_prefix.clone(),
253 auth_session_key.clone(),
254 require_role.clone(),
255 runtime_config,
256 step_up_mutations,
257 step_up_max_age_secs,
258 );
259
260 tracing::info!(
261 prefix = %prefix,
262 actuator_prefix = %actuator_prefix,
263 auth_session_key = %auth_session_key,
264 models = registry.model_count(),
265 role = require_role.as_deref().unwrap_or("<none>"),
266 step_up_mutations = %step_up_mutations,
267 "๐ Autumn Admin mounted"
268 );
269
270 let declared = admin_route_infos(&prefix, has_config, require_role.as_deref());
277
278 app.nest(&prefix, router).declare_plugin_routes(declared)
279 }
280}
281
282pub(crate) fn admin_route_infos(
302 prefix: &str,
303 has_config: bool,
304 require_role: Option<&str>,
305) -> Vec<RouteInfo> {
306 use autumn_web::route_listing::RouteClassification;
307
308 let (classification, roles) = require_role.map_or_else(
309 || (RouteClassification::Public, Vec::new()),
310 |role| (RouteClassification::Gated, vec![role.to_owned()]),
311 );
312 let mut entries: Vec<(&str, String)> = vec![
313 ("GET", prefix.to_string()),
314 ("GET", format!("{prefix}/jobs")),
315 ("GET", format!("{prefix}/jobs/counters")),
316 ("POST", format!("{prefix}/jobs/{{id}}/retry")),
317 ("POST", format!("{prefix}/jobs/{{id}}/discard")),
318 ("POST", format!("{prefix}/jobs/{{id}}/cancel")),
319 ];
320 if has_config {
321 entries.extend([
322 ("GET", format!("{prefix}/config")),
323 ("POST", format!("{prefix}/config/{{key}}/set")),
324 ("POST", format!("{prefix}/config/{{key}}/unset")),
325 ("GET", format!("{prefix}/config/{{key}}/history")),
326 ]);
327 }
328 entries.extend([
329 ("GET", format!("{prefix}/{{slug}}")),
330 ("POST", format!("{prefix}/{{slug}}")),
331 ("GET", format!("{prefix}/{{slug}}/new")),
332 ("GET", format!("{prefix}/{{slug}}/export.csv")),
333 ("GET", format!("{prefix}/{{slug}}/import")),
334 ("POST", format!("{prefix}/{{slug}}/import")),
335 ("GET", format!("{prefix}/{{slug}}/{{id}}")),
336 ("POST", format!("{prefix}/{{slug}}/{{id}}")),
337 ("DELETE", format!("{prefix}/{{slug}}/{{id}}")),
338 ("GET", format!("{prefix}/{{slug}}/{{id}}/edit")),
339 ("GET", format!("{prefix}/{{slug}}/{{id}}/history")),
340 ("POST", format!("{prefix}/{{slug}}/actions")),
341 ("GET", format!("{prefix}{}", *routes::ADMIN_JS_PATH)),
342 ]);
343 entries
344 .into_iter()
345 .map(|(method, path)| RouteInfo {
346 method: method.to_owned(),
347 path,
348 handler: format!("admin::{}", method.to_lowercase()),
349 source: autumn_web::route_listing::RouteSource::User, classification,
351 roles: roles.clone(),
352 ..Default::default()
353 })
354 .collect()
355}
356
357#[cfg(test)]
368mod conformance_tests {
369 use autumn_web::plugin_conformance::{ConformanceConfig, run_conformance};
370 use autumn_web::route_listing::{RouteInfo, RouteSource};
371
372 const PLUGIN_NAME: &str = "autumn-admin-plugin";
373
374 fn admin_routes(prefix: &str) -> Vec<RouteInfo> {
378 admin_routes_with_config(prefix, true)
379 }
380
381 fn admin_routes_with_config(prefix: &str, has_config: bool) -> Vec<RouteInfo> {
382 super::admin_route_infos(prefix, has_config, Some("admin"))
383 .into_iter()
384 .map(|mut r| {
385 r.source = RouteSource::Plugin(PLUGIN_NAME.to_owned());
386 r
387 })
388 .collect()
389 }
390
391 #[test]
392 fn admin_plugin_routes_are_attributed_to_plugin_name() {
393 let routes = admin_routes("/admin");
394 let result = autumn_web::plugin_conformance::check_route_attribution(PLUGIN_NAME, &routes);
395 assert_eq!(
396 result.status,
397 autumn_web::plugin_conformance::CheckStatus::Pass,
398 "route attribution failed: {}",
399 result.message
400 );
401 }
402
403 #[test]
404 fn admin_plugin_routes_live_under_admin_prefix() {
405 let routes = admin_routes("/admin");
406 let result =
407 autumn_web::plugin_conformance::check_route_prefix(PLUGIN_NAME, "/admin", &[], &routes);
408 assert_eq!(
409 result.status,
410 autumn_web::plugin_conformance::CheckStatus::Pass,
411 "prefix check failed: {}\n{:?}",
412 result.message,
413 result.diagnostics
414 );
415 }
416
417 #[test]
418 fn admin_plugin_declares_builtin_job_routes() {
419 let routes = admin_routes("/admin");
420 let declared: std::collections::HashSet<(&str, &str)> = routes
421 .iter()
422 .map(|route| (route.method.as_str(), route.path.as_str()))
423 .collect();
424
425 for (method, path) in [
426 ("GET", "/admin/jobs"),
427 ("GET", "/admin/jobs/counters"),
428 ("POST", "/admin/jobs/{id}/retry"),
429 ("POST", "/admin/jobs/{id}/discard"),
430 ("POST", "/admin/jobs/{id}/cancel"),
431 ] {
432 assert!(
433 declared.contains(&(method, path)),
434 "missing declared admin job route {method} {path}"
435 );
436 }
437 }
438
439 #[test]
440 fn admin_plugin_declares_builtin_config_routes_when_enabled() {
441 let routes = admin_routes_with_config("/admin", true);
442 let declared: std::collections::HashSet<(&str, &str)> = routes
443 .iter()
444 .map(|route| (route.method.as_str(), route.path.as_str()))
445 .collect();
446
447 for (method, path) in [
448 ("GET", "/admin/config"),
449 ("POST", "/admin/config/{key}/set"),
450 ("POST", "/admin/config/{key}/unset"),
451 ("GET", "/admin/config/{key}/history"),
452 ] {
453 assert!(
454 declared.contains(&(method, path)),
455 "missing declared admin config route {method} {path}"
456 );
457 }
458 }
459
460 #[test]
461 fn admin_plugin_omits_config_routes_when_disabled() {
462 let routes = admin_routes_with_config("/admin", false);
463 let declared: std::collections::HashSet<(&str, &str)> = routes
464 .iter()
465 .map(|route| (route.method.as_str(), route.path.as_str()))
466 .collect();
467
468 for (method, path) in [
469 ("GET", "/admin/config"),
470 ("POST", "/admin/config/{key}/set"),
471 ("POST", "/admin/config/{key}/unset"),
472 ("GET", "/admin/config/{key}/history"),
473 ] {
474 assert!(
475 !declared.contains(&(method, path)),
476 "config route {method} {path} should not be declared when has_config=false"
477 );
478 }
479 }
480
481 #[test]
486 fn admin_plugin_declared_routes_classify_gated_with_role() {
487 use autumn_web::route_listing::RouteClassification;
488
489 let routes = super::admin_route_infos("/admin", true, Some("admin"));
490 assert!(!routes.is_empty());
491 for r in &routes {
492 assert_eq!(
493 r.classification,
494 RouteClassification::Gated,
495 "admin route {} {} should be Gated, got {:?}",
496 r.method,
497 r.path,
498 r.classification
499 );
500 assert_eq!(
501 r.roles,
502 vec!["admin".to_owned()],
503 "admin route {} {} should carry the required role",
504 r.method,
505 r.path
506 );
507 }
508 }
509
510 #[test]
514 fn admin_plugin_declared_routes_classify_public_when_role_disabled() {
515 use autumn_web::route_listing::RouteClassification;
516
517 let routes = super::admin_route_infos("/admin", true, None);
518 assert!(!routes.is_empty());
519 for r in &routes {
520 assert_eq!(
521 r.classification,
522 RouteClassification::Public,
523 "admin route {} {} should be Public when auth disabled",
524 r.method,
525 r.path
526 );
527 assert!(r.roles.is_empty(), "public route should carry no roles");
528 }
529 }
530
531 #[test]
532 fn admin_plugin_has_no_route_collisions_in_isolation() {
533 let routes = admin_routes("/admin");
534 let (result, _) = autumn_web::plugin_conformance::check_collisions(&routes);
535 assert_eq!(
536 result.status,
537 autumn_web::plugin_conformance::CheckStatus::Pass,
538 "unexpected collision: {}\n{:?}",
539 result.message,
540 result.diagnostics
541 );
542 }
543
544 #[test]
545 fn admin_plugin_sensitive_surfaces_declared_with_role_requirement() {
546 let routes = admin_routes("/admin");
547 let declared = vec![autumn_web::plugin_conformance::SensitiveRoute {
548 path_pattern: "/admin".to_owned(),
549 auth_mechanism: "Role: admin required via AdminPlugin::require_role \
550 (default) or AdminPlugin::require_role(None) to disable"
551 .to_owned(),
552 }];
553 let result = autumn_web::plugin_conformance::check_sensitive_surfaces(
554 PLUGIN_NAME,
555 &routes,
556 &declared,
557 );
558 assert_eq!(
559 result.status,
560 autumn_web::plugin_conformance::CheckStatus::Pass,
561 "sensitive-surfaces check failed: {}",
562 result.message
563 );
564 }
565
566 #[test]
567 fn admin_plugin_sensitive_surfaces_fail_without_declaration() {
568 let routes = admin_routes("/admin");
569 let result =
570 autumn_web::plugin_conformance::check_sensitive_surfaces(PLUGIN_NAME, &routes, &[]);
571 assert_eq!(
572 result.status,
573 autumn_web::plugin_conformance::CheckStatus::Fail,
574 "expected FAIL when sensitive routes are undeclared"
575 );
576 }
577
578 #[test]
579 fn admin_plugin_passes_full_conformance_with_config() {
580 let routes = admin_routes("/admin");
581 let config = ConformanceConfig::new(PLUGIN_NAME)
582 .prefix("/admin")
583 .sensitive_route(
584 "/admin",
585 "Role: admin required via AdminPlugin::require_role",
586 );
587 let report = run_conformance(&config, &routes);
588 assert!(
589 report.passed(),
590 "AdminPlugin conformance failed:\n{}",
591 report.to_text_report()
592 );
593 }
594
595 #[test]
596 fn admin_plugin_collision_with_host_route_detected() {
597 let mut routes = admin_routes("/admin");
598 routes.push(RouteInfo {
600 method: "GET".to_owned(),
601 path: "/admin".to_owned(),
602 handler: "host::admin_redirect".to_owned(),
603 source: RouteSource::User,
604 ..Default::default()
605 });
606 let (result, diagnostics) = autumn_web::plugin_conformance::check_collisions(&routes);
607 assert_eq!(
608 result.status,
609 autumn_web::plugin_conformance::CheckStatus::Fail,
610 "expected collision to be detected"
611 );
612 let diag = &diagnostics[0];
613 assert_eq!(diag.method, "GET");
614 assert_eq!(diag.path, "/admin");
615 let sources: Vec<&str> = diag
616 .contributors
617 .iter()
618 .map(|c| c.source.as_str())
619 .collect();
620 assert!(
621 sources.contains(&"user"),
622 "missing user contributor: {sources:?}"
623 );
624 assert!(
625 sources.contains(&"plugin:autumn-admin-plugin"),
626 "missing plugin contributor: {sources:?}"
627 );
628 }
629
630 #[test]
631 fn admin_plugin_custom_prefix_passes_conformance() {
632 let routes = admin_routes("/backend");
633 let config = ConformanceConfig::new(PLUGIN_NAME)
634 .prefix("/backend")
635 .sensitive_route(
636 "/backend",
637 "Role: admin required via AdminPlugin::require_role",
638 );
639 let report = run_conformance(&config, &routes);
640 assert!(
641 report.passed(),
642 "AdminPlugin with custom prefix failed conformance:\n{}",
643 report.to_text_report()
644 );
645 }
646
647 #[test]
648 fn admin_plugin_double_registration_detected() {
649 let mut routes = admin_routes("/admin");
651 routes.extend(admin_routes("/admin"));
652 let result =
653 autumn_web::plugin_conformance::check_duplicate_registration(PLUGIN_NAME, &routes);
654 assert_eq!(
655 result.status,
656 autumn_web::plugin_conformance::CheckStatus::Fail,
657 "expected duplicate-registration FAIL when plugin installed twice"
658 );
659 }
660
661 #[test]
662 fn admin_plugin_single_registration_passes_duplicate_check() {
663 let routes = admin_routes("/admin");
664 let result =
665 autumn_web::plugin_conformance::check_duplicate_registration(PLUGIN_NAME, &routes);
666 assert_eq!(
667 result.status,
668 autumn_web::plugin_conformance::CheckStatus::Pass,
669 "single registration should pass: {}",
670 result.message
671 );
672 }
673}