1mod 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
50pub 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
67pub 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 step_up_mutations: bool,
83 step_up_max_age_secs: u64,
87}
88
89impl AdminPlugin {
90 #[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 #[must_use]
112 pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
113 self.prefix = prefix.into();
114 self
115 }
116
117 #[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 #[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 #[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 #[must_use]
157 pub fn register<M: AdminModel>(mut self, model: M) -> Self {
158 self.registry.register(model);
159 self
160 }
161
162 #[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 #[must_use]
193 pub const fn with_step_up_mutations(mut self) -> Self {
194 self.step_up_mutations = true;
195 self
196 }
197
198 #[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 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(®istry),
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 let declared = admin_route_infos(&prefix, has_config);
273
274 app.nest(&prefix, router).declare_plugin_routes(declared)
275 }
276}
277
278pub(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, middleware: vec![],
326 api_version: None,
327 status: None,
328 sunset_opt_out: None,
329 })
330 .collect()
331}
332
333#[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 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 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 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}