Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 82 additions & 12 deletions autumn-admin-plugin/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,8 +269,11 @@ impl Plugin for AdminPlugin {

// Declare routes for `autumn routes` listing. The underlying Axum router
// is added via nest() which is opaque to route enumeration, so we
// explicitly register route metadata here.
let declared = admin_route_infos(&prefix, has_config);
// explicitly register route metadata here. Because the declared routes'
// paths all fall under the nest prefix, `autumn routes audit` treats this
// mount as covered (enumerable) instead of an omitted, unprovable raw
// router that would false-fail the gate.
let declared = admin_route_infos(&prefix, has_config, require_role.as_deref());

app.nest(&prefix, router).declare_plugin_routes(declared)
}
Expand All @@ -284,7 +287,28 @@ impl Plugin for AdminPlugin {
///
/// Kept in sync with `routes::admin_router` — update here when routes are
/// added or removed from the admin router.
pub(crate) fn admin_route_infos(prefix: &str, has_config: bool) -> Vec<RouteInfo> {
///
/// `require_role` mirrors [`AdminPlugin::require_role`]: the whole admin router
/// is wrapped in role-check middleware, so every declared route inherits that
/// posture. When a role is required the routes classify [`Gated`] (carrying the
/// role) rather than `Unclassified`; when the plugin's auth is explicitly
/// disabled (`None`) they classify [`Public`]. Without this, the default admin
/// plugin would false-fail `autumn routes audit` out of the box, since declared
/// routes otherwise inherit `Unclassified` from `RouteInfo::default()`.
///
/// [`Gated`]: autumn_web::route_listing::RouteClassification::Gated
/// [`Public`]: autumn_web::route_listing::RouteClassification::Public
pub(crate) fn admin_route_infos(
prefix: &str,
has_config: bool,
require_role: Option<&str>,
) -> Vec<RouteInfo> {
use autumn_web::route_listing::RouteClassification;

let (classification, roles) = require_role.map_or_else(
|| (RouteClassification::Public, Vec::new()),
|role| (RouteClassification::Gated, vec![role.to_owned()]),
);
let mut entries: Vec<(&str, String)> = vec![
("GET", prefix.to_string()),
("GET", format!("{prefix}/jobs")),
Expand Down Expand Up @@ -323,10 +347,9 @@ pub(crate) fn admin_route_infos(prefix: &str, has_config: bool) -> Vec<RouteInfo
path,
handler: format!("admin::{}", method.to_lowercase()),
source: autumn_web::route_listing::RouteSource::User, // overwritten by declare_plugin_routes
middleware: vec![],
api_version: None,
status: None,
sunset_opt_out: None,
classification,
roles: roles.clone(),
..Default::default()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mark declared admin routes as gated when guarded

When the default AdminPlugin is installed, admin_router wraps these routes in the role-check middleware, but this metadata now inherits RouteInfo::default() for the new audit fields, whose classification is Unclassified. run_dump_routes_mode appends declared_routes directly, so autumn routes audit will fail every protected admin route in apps using the default admin plugin; populate the classification/roles from the plugin's require_role setting (or mark public when auth is disabled) instead of leaving the default.

Useful? React with 👍 / 👎.

})
.collect()
}
Expand Down Expand Up @@ -356,7 +379,7 @@ mod conformance_tests {
}

fn admin_routes_with_config(prefix: &str, has_config: bool) -> Vec<RouteInfo> {
super::admin_route_infos(prefix, has_config)
super::admin_route_infos(prefix, has_config, Some("admin"))
.into_iter()
.map(|mut r| {
r.source = RouteSource::Plugin(PLUGIN_NAME.to_owned());
Expand Down Expand Up @@ -455,6 +478,56 @@ mod conformance_tests {
}
}

/// The default admin plugin guards its whole router with role-check
/// middleware, so its declared routes must classify `Gated` (carrying the
/// role) — not `Unclassified` — or `autumn routes audit` would false-fail
/// every protected admin route out of the box (#1604).
#[test]
fn admin_plugin_declared_routes_classify_gated_with_role() {
use autumn_web::route_listing::RouteClassification;

let routes = super::admin_route_infos("/admin", true, Some("admin"));
assert!(!routes.is_empty());
for r in &routes {
assert_eq!(
r.classification,
RouteClassification::Gated,
"admin route {} {} should be Gated, got {:?}",
r.method,
r.path,
r.classification
);
assert_eq!(
r.roles,
vec!["admin".to_owned()],
"admin route {} {} should carry the required role",
r.method,
r.path
);
}
}

/// When the plugin's auth is explicitly disabled (`require_role(None)`) the
/// declared routes classify `Public` — still a proven posture, so the audit
/// gate passes rather than flagging them `Unclassified`.
#[test]
fn admin_plugin_declared_routes_classify_public_when_role_disabled() {
use autumn_web::route_listing::RouteClassification;

let routes = super::admin_route_infos("/admin", true, None);
assert!(!routes.is_empty());
for r in &routes {
assert_eq!(
r.classification,
RouteClassification::Public,
"admin route {} {} should be Public when auth disabled",
r.method,
r.path
);
assert!(r.roles.is_empty(), "public route should carry no roles");
}
}

#[test]
fn admin_plugin_has_no_route_collisions_in_isolation() {
let routes = admin_routes("/admin");
Expand Down Expand Up @@ -528,10 +601,7 @@ mod conformance_tests {
path: "/admin".to_owned(),
handler: "host::admin_redirect".to_owned(),
source: RouteSource::User,
middleware: vec![],
api_version: None,
status: None,
sunset_opt_out: None,
..Default::default()
});
let (result, diagnostics) = autumn_web::plugin_conformance::check_collisions(&routes);
assert_eq!(
Expand Down
5 changes: 1 addition & 4 deletions autumn-cli/src/generate/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,10 +242,7 @@ mod conformance_tests {{
path: "/autumn-{name_kebab}-plugin".to_owned(),
handler: "autumn_{name_snake}_plugin::index".to_owned(),
source: RouteSource::Plugin("autumn-{name_kebab}-plugin".to_owned()),
middleware: vec![],
api_version: None,
status: None,
sunset_opt_out: None,
..Default::default()
}},
];

Expand Down
179 changes: 170 additions & 9 deletions autumn-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ mod plugin_check;
mod process;
mod release;
mod routes;
mod routes_audit;
mod scaling_driver;
mod seed;
mod serve;
Expand All @@ -58,6 +59,36 @@ pub enum CheckSubcommands {
},
}

/// Subcommands for `autumn routes`.
#[derive(Subcommand, Clone, Debug, PartialEq, Eq)]
pub enum RoutesSubcommands {
/// Prove route authentication coverage at build time (issue #1604).
///
/// Compiles the app, classifies every route from its macro-expanded auth
/// posture, and emits a stable-ordered security manifest. Exits non-zero
/// when any route is unclassified — i.e. neither framework-owned, guarded
/// (`#[secured]` / `#[authorize]`), nor explicitly `#[public]` — naming each
/// offending route so it can be closed. This is the CI gate.
Audit {
/// Package to inspect (for workspaces).
#[arg(short, long)]
package: Option<String>,
/// Binary target to inspect (for packages with multiple bin targets).
#[arg(long, value_name = "BIN")]
bin: Option<String>,
/// Write the JSON security manifest to this file path.
#[arg(long, value_name = "PATH")]
manifest: Option<String>,
/// Emit the JSON manifest to stdout instead of the human summary.
#[arg(long)]
json: bool,
/// Reserved for tightening the gate; fail-on-unclassified is already the
/// default behavior.
#[arg(long)]
strict: bool,
},
}

/// Subcommands for `autumn i18n`.
#[derive(Subcommand, Clone, Debug, PartialEq, Eq)]
pub enum I18nSubcommands {
Expand Down Expand Up @@ -719,6 +750,9 @@ enum Commands {
/// Hide framework-internal routes (`/actuator/*`, probes, htmx assets).
#[arg(long)]
user_only: bool,
/// Optional subcommand (e.g. `audit`). When omitted, lists routes.
#[command(subcommand)]
command: Option<RoutesSubcommands>,
},

/// Measure and gate dev-loop latency for `autumn dev`.
Expand Down Expand Up @@ -2720,15 +2754,38 @@ fn run_command(command: Commands) {
filter,
method,
user_only,
} => run_routes_command(
package.as_deref(),
bin.as_deref(),
&format,
prefix.as_deref(),
filter.as_deref(),
&method,
user_only,
),
command,
} => match command {
Some(RoutesSubcommands::Audit {
package: audit_package,
bin: audit_bin,
manifest,
json,
strict,
}) => {
// Fall back to the parent `routes` target flags so both
// `autumn routes -p blog audit` and `autumn routes audit -p blog`
// select the same binary in multi-target workspaces.
let package = audit_package.or(package);
let bin = audit_bin.or(bin);
routes_audit::run(&routes_audit::AuditOptions {
package: package.as_deref(),
bin: bin.as_deref(),
Comment on lines +2772 to +2773

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor parent route target flags for audit

With the existing autumn routes -p blog audit ordering, clap stores -p blog on the parent Commands::Routes, but this branch shadows it with the audit subcommand's absent package and passes None to compile_binary/find_binary. In workspaces that audits the wrong target or errors with multiple binaries unless users move the same flag after audit; fall back to the parent package/bin when the subcommand values are unset.

Useful? React with 👍 / 👎.

manifest: manifest.as_deref(),
json,
strict,
});
}
None => run_routes_command(
package.as_deref(),
bin.as_deref(),
&format,
prefix.as_deref(),
filter.as_deref(),
&method,
user_only,
),
},
Commands::Release(cmd) => run_release_command(cmd),
Commands::Token(cmd) => match cmd {
TokenCommands::Issue {
Expand Down Expand Up @@ -5014,6 +5071,7 @@ mod tests {
filter,
method,
user_only,
command,
} => {
assert!(package.is_none());
assert!(bin.is_none());
Expand All @@ -5022,6 +5080,7 @@ mod tests {
assert!(filter.is_none());
assert!(method.is_empty());
assert!(!user_only);
assert!(command.is_none());
}
_ => panic!("expected Routes command"),
}
Expand Down Expand Up @@ -5165,6 +5224,7 @@ mod tests {
filter,
method,
user_only,
command,
} => {
assert_eq!(package.as_deref(), Some("blog"));
assert!(bin.is_none());
Expand All @@ -5173,6 +5233,7 @@ mod tests {
assert_eq!(filter.as_deref(), Some("/api"));
assert_eq!(method, vec!["GET", "POST"]);
assert!(user_only);
assert!(command.is_none());
}
_ => panic!("expected Routes command"),
}
Expand Down Expand Up @@ -5204,6 +5265,106 @@ mod tests {
}
}

#[test]
fn parse_routes_audit_subcommand() {
let cli = Cli::try_parse_from([
"autumn",
"routes",
"audit",
"--manifest",
"target/security.json",
"--json",
"-p",
"blog",
])
.unwrap();
match cli.command {
Commands::Routes {
command:
Some(RoutesSubcommands::Audit {
package,
manifest,
json,
strict,
..
}),
..
} => {
assert_eq!(package.as_deref(), Some("blog"));
assert_eq!(manifest.as_deref(), Some("target/security.json"));
assert!(json);
assert!(!strict);
}
_ => panic!("expected Routes audit subcommand"),
}
}

/// Parent `-p`/`--bin` flags placed before the `audit` subcommand land on
/// the parent `Routes` variant, not on `Audit`. The dispatch falls back to
/// them (`audit.package.or(parent.package)`), so `autumn routes -p blog
/// audit` and `autumn routes audit -p blog` resolve the same target (#1604).
#[test]
fn parse_routes_parent_package_flows_into_audit() {
// `-p blog` BEFORE `audit`: parent carries it, audit's own is None.
let cli = Cli::try_parse_from(["autumn", "routes", "-p", "blog", "audit"]).unwrap();
match cli.command {
Commands::Routes {
package: parent_package,
bin: parent_bin,
command:
Some(RoutesSubcommands::Audit {
package: audit_package,
bin: audit_bin,
..
}),
..
} => {
assert_eq!(parent_package.as_deref(), Some("blog"));
assert_eq!(audit_package, None);
// The fallback the dispatch applies.
let resolved_package = audit_package.or(parent_package);
let resolved_bin = audit_bin.or(parent_bin);
assert_eq!(resolved_package.as_deref(), Some("blog"));
assert_eq!(resolved_bin, None);
}
_ => panic!("expected Routes audit subcommand"),
}
}

/// The subcommand's own `-p`/`--bin` (placed after `audit`) still win — the
/// fallback only fills in when the audit value is `None`.
#[test]
fn parse_routes_audit_own_package_takes_precedence() {
let cli =
Cli::try_parse_from(["autumn", "routes", "-p", "blog", "audit", "-p", "shop"]).unwrap();
match cli.command {
Commands::Routes {
package: parent_package,
command:
Some(RoutesSubcommands::Audit {
package: audit_package,
..
}),
..
} => {
assert_eq!(parent_package.as_deref(), Some("blog"));
assert_eq!(audit_package.as_deref(), Some("shop"));
let resolved = audit_package.or(parent_package);
assert_eq!(resolved.as_deref(), Some("shop"));
}
_ => panic!("expected Routes audit subcommand"),
}
}

#[test]
fn parse_routes_without_subcommand_lists() {
let cli = Cli::try_parse_from(["autumn", "routes"]).unwrap();
match cli.command {
Commands::Routes { command, .. } => assert!(command.is_none()),
_ => panic!("expected Routes command"),
}
}

// ── autumn doctor tests ────────────────────────────────────────────────

#[test]
Expand Down
Loading
Loading