diff --git a/autumn-admin-plugin/src/lib.rs b/autumn-admin-plugin/src/lib.rs index 2af8c3282..d69913d2f 100644 --- a/autumn-admin-plugin/src/lib.rs +++ b/autumn-admin-plugin/src/lib.rs @@ -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) } @@ -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 { +/// +/// `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 { + 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")), @@ -323,10 +347,9 @@ pub(crate) fn admin_route_infos(prefix: &str, has_config: bool) -> Vec Vec { - 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()); @@ -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"); @@ -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!( diff --git a/autumn-cli/src/generate/plugin.rs b/autumn-cli/src/generate/plugin.rs index eda9704a1..77ee77d7e 100644 --- a/autumn-cli/src/generate/plugin.rs +++ b/autumn-cli/src/generate/plugin.rs @@ -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() }}, ]; diff --git a/autumn-cli/src/main.rs b/autumn-cli/src/main.rs index 6c1bfec1f..be684e11e 100644 --- a/autumn-cli/src/main.rs +++ b/autumn-cli/src/main.rs @@ -33,6 +33,7 @@ mod plugin_check; mod process; mod release; mod routes; +mod routes_audit; mod scaling_driver; mod seed; mod serve; @@ -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, + /// Binary target to inspect (for packages with multiple bin targets). + #[arg(long, value_name = "BIN")] + bin: Option, + /// Write the JSON security manifest to this file path. + #[arg(long, value_name = "PATH")] + manifest: Option, + /// 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 { @@ -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, }, /// Measure and gate dev-loop latency for `autumn dev`. @@ -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(), + 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 { @@ -5014,6 +5071,7 @@ mod tests { filter, method, user_only, + command, } => { assert!(package.is_none()); assert!(bin.is_none()); @@ -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"), } @@ -5165,6 +5224,7 @@ mod tests { filter, method, user_only, + command, } => { assert_eq!(package.as_deref(), Some("blog")); assert!(bin.is_none()); @@ -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"), } @@ -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] diff --git a/autumn-cli/src/routes_audit.rs b/autumn-cli/src/routes_audit.rs new file mode 100644 index 000000000..6851e94eb --- /dev/null +++ b/autumn-cli/src/routes_audit.rs @@ -0,0 +1,567 @@ +//! `autumn routes audit` -- prove route authentication coverage at build time. +//! +//! Runs the same dump-routes pipeline as [`crate::routes`], reads the +//! build-time security classification the framework attaches to every route +//! (derived from macro-expanded `#[secured]` / `#[authorize]` / `#[public]` +//! posture), and emits a stable-ordered, machine-readable security manifest. +//! +//! The command is a CI gate: it exits non-zero when any route is +//! `Unclassified` — i.e. a route that is neither framework-owned, guarded, nor +//! explicitly declared public — naming each offending route so the gap can be +//! closed by adding a guard or a `#[public]` marker. +//! +//! This is the first *dimension* of the security manifest described in issue +//! #1604; the `provenance: "provable"` tag on each classification lets #1627 +//! grow additional dimensions without breaking the schema. + +use std::process::Command; + +use autumn_web::route_listing::OMITTED_ROUTES_MARKER; +use serde::{Deserialize, Serialize}; + +use crate::routes; + +/// Schema version of the emitted manifest. Bumped only on breaking changes to +/// the document shape. +pub const MANIFEST_SCHEMA_VERSION: u32 = 1; + +/// Options controlling `autumn routes audit`. +pub struct AuditOptions<'a> { + pub package: Option<&'a str>, + /// Binary target name for packages that expose multiple bin targets. + pub bin: Option<&'a str>, + /// Write the JSON manifest to this file path (in addition to any stdout). + pub manifest: Option<&'a str>, + /// Emit the JSON manifest to stdout instead of the human report. + pub json: bool, + /// Reserved: tighten the gate in future revisions. The default posture + /// (fail on any unclassified route) already applies without it. + pub strict: bool, +} + +/// A single route as read back from the app's dumped route listing. +/// +/// The dumped JSON carries more than this (versioning, middleware, …); only the +/// fields relevant to the security manifest are deserialized here. Unknown +/// fields are ignored, and missing ones fall back to their defaults so the audit +/// stays forward/backward compatible with the dump format. +#[derive(Debug, Clone, Deserialize)] +pub struct AuditRoute { + pub method: String, + pub path: String, + /// Handler function name (serialized as `handler` in the dump). + pub handler: String, + #[serde(default)] + pub source: String, + #[serde(default)] + pub classification: String, + #[serde(default)] + pub roles: Vec, + #[serde(default)] + pub scopes: Vec, + #[serde(default)] + pub policy: bool, + #[serde(default)] + pub module: Option, +} + +impl AuditRoute { + /// Whether this route lacks a proven security posture. Anything that is not + /// framework-owned, guarded, or explicitly public fails the gate — including + /// an empty/unknown classification from an older dump. + #[must_use] + pub fn is_unclassified(&self) -> bool { + !matches!( + self.classification.as_str(), + "framework" | "gated" | "public" + ) + } +} + +// ── Manifest document ─────────────────────────────────────────────────────── + +/// Top-level security manifest. +#[derive(Debug, Serialize)] +pub struct Manifest { + pub schema_version: u32, + pub dimensions: Dimensions, +} + +/// Manifest dimensions. Only `routes` exists today; additional provable +/// dimensions can be added here without breaking existing consumers. +#[derive(Debug, Serialize)] +pub struct Dimensions { + pub routes: Vec, +} + +/// One route entry in the manifest's `routes` dimension. +#[derive(Debug, Serialize)] +pub struct ManifestRoute { + pub path: String, + pub method: String, + pub name: String, + pub classification: String, + pub roles: Vec, + pub scopes: Vec, + pub policy: bool, + pub source: String, + /// How the classification was established. `"provable"` means it was + /// derived from macro-expanded code (route + auth posture). + pub provenance: &'static str, +} + +/// Build a stable-ordered security manifest from the audited routes. +/// +/// Routes are ordered by `(path, method)` so the manifest is diff-friendly and +/// reproducible across runs. +#[must_use] +pub fn build_manifest(routes: &[AuditRoute]) -> Manifest { + let mut entries: Vec = routes + .iter() + .map(|r| ManifestRoute { + path: r.path.clone(), + method: r.method.clone(), + name: r.handler.clone(), + classification: r.classification.clone(), + roles: r.roles.clone(), + scopes: r.scopes.clone(), + policy: r.policy, + source: r.source.clone(), + provenance: "provable", + }) + .collect(); + entries.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.method.cmp(&b.method))); + + Manifest { + schema_version: MANIFEST_SCHEMA_VERSION, + dimensions: Dimensions { routes: entries }, + } +} + +/// Serialize a manifest to pretty JSON. +#[must_use] +pub fn manifest_json(manifest: &Manifest) -> String { + serde_json::to_string_pretty(manifest).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}")) +} + +/// The subset of routes that fail the audit gate. +#[must_use] +pub fn unclassified_routes(routes: &[AuditRoute]) -> Vec<&AuditRoute> { + routes.iter().filter(|r| r.is_unclassified()).collect() +} + +/// Exit code for the gate: `0` when every route is classified, `1` when at +/// least one route is unclassified. +#[must_use] +pub fn audit_exit_code(routes: &[AuditRoute]) -> i32 { + i32::from(routes.iter().any(AuditRoute::is_unclassified)) +} + +/// Number of raw routers the dumped listing omitted, as reported by the app's +/// `AUTUMN_DUMP_ROUTES` stderr marker ([`OMITTED_ROUTES_MARKER`]). +/// +/// Routers added via `AppBuilder::merge()`/`nest()` are opaque and cannot be +/// enumerated, so they never appear in the parsed stdout listing. Their auth +/// posture is therefore unprovable — the audit gate must fail rather than emit a +/// manifest that silently drops them. +#[must_use] +pub fn parse_omitted_count(stderr: &str) -> usize { + let marker = OMITTED_ROUTES_MARKER.trim(); + // Take the last matching marker line; the dump emits at most one per run. + stderr + .lines() + .filter_map(|line| line.trim().strip_prefix(marker)) + .filter_map(|rest| rest.trim().parse::().ok()) + .next_back() + .unwrap_or(0) +} + +/// Hard-failure diagnostic for omitted (unenumerable) raw routers. These defeat +/// the coverage proof, so the gate fails and tells the user how to make the +/// routes provable. +#[must_use] +pub fn format_omitted_diagnostic(count: usize) -> String { + format!( + "\u{2717} {count} raw router(s) added via `AppBuilder::merge()`/`nest()` \ + are not enumerable and were omitted from the route listing.\n\ + Route auth coverage can't be proven while these exist. Mount routes via \ + `routes![]` (or a plugin's `declare_plugin_routes`) so they are visible \ + and classifiable.\n" + ) +} + +/// Human diagnostic naming every unclassified route (method + path + handler, +/// with the handler's module when known). +#[must_use] +pub fn format_diagnostic(unresolved: &[&AuditRoute]) -> String { + use std::fmt::Write as _; + + let mut out = String::new(); + let _ = writeln!( + out, + "\u{2717} {} route(s) have no proven auth posture:", + unresolved.len() + ); + for r in unresolved { + let module = r + .module + .as_deref() + .map(|m| format!(" [{m}]")) + .unwrap_or_default(); + let _ = writeln!( + out, + " {method:<6} {path} (handler `{handler}`{module})", + method = r.method, + path = r.path, + handler = r.handler, + ); + } + out.push_str( + "\nAdd a guard (`#[secured]` / `#[authorize]`) or mark the route \ + deliberately open with `#[public]`.\n", + ); + out +} + +/// One-line-per-bucket summary of the classification breakdown. +#[must_use] +pub fn format_summary(routes: &[AuditRoute]) -> String { + let mut framework = 0usize; + let mut gated = 0usize; + let mut public = 0usize; + let mut unclassified = 0usize; + for r in routes { + match r.classification.as_str() { + "framework" => framework += 1, + "gated" => gated += 1, + "public" => public += 1, + _ => unclassified += 1, + } + } + format!( + "{total} route(s): {gated} gated, {public} public, {framework} framework, \ + {unclassified} unclassified", + total = routes.len(), + ) +} + +/// Run `autumn routes audit`. +pub fn run(opts: &AuditOptions<'_>) { + eprintln!("\u{1F342} autumn routes audit\n"); + routes::compile_binary(opts.package, opts.bin); + let binary = routes::find_binary(opts.package, opts.bin); + + // Capture stderr (rather than inheriting it) so we can detect the app's + // omitted-routes marker; forward it verbatim afterwards so warnings stay + // visible to the user. + let output = Command::new(&binary) + .env("AUTUMN_DUMP_ROUTES", "1") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap_or_else(|e| { + eprintln!("\u{2717} Failed to run {}: {e}", binary.display()); + std::process::exit(1); + }); + + let stderr = String::from_utf8_lossy(&output.stderr); + eprint!("{stderr}"); + let omitted = parse_omitted_count(&stderr); + + if !output.status.success() { + eprintln!( + "\u{2717} Binary exited with status {} while dumping routes", + output.status + ); + std::process::exit(output.status.code().unwrap_or(1)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let routes: Vec = serde_json::from_str(&stdout).unwrap_or_else(|e| { + eprintln!("\u{2717} Failed to parse route listing JSON: {e}"); + eprintln!("Raw output: {stdout}"); + std::process::exit(1); + }); + + let manifest = build_manifest(&routes); + let json = manifest_json(&manifest); + + if let Some(path) = opts.manifest { + if let Err(e) = std::fs::write(path, format!("{json}\n")) { + eprintln!("\u{2717} Failed to write manifest to {path}: {e}"); + std::process::exit(1); + } + eprintln!("\u{2713} Wrote security manifest \u{2192} {path}"); + } + + if opts.json { + println!("{json}"); + } else { + println!("{}", format_summary(&routes)); + } + + let unresolved = unclassified_routes(&routes); + if unresolved.is_empty() { + if !opts.json && omitted == 0 { + eprintln!("\u{2713} All routes are classified."); + } + } else { + eprint!("\n{}", format_diagnostic(&unresolved)); + if opts.strict { + eprintln!("(strict mode)"); + } + } + + if omitted > 0 { + eprint!("\n{}", format_omitted_diagnostic(omitted)); + } + + // Fail the gate on either unclassified routes or omitted (unprovable) ones. + let failed = omitted > 0 || audit_exit_code(&routes) != 0; + std::process::exit(i32::from(failed)); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn route(method: &str, path: &str, handler: &str, classification: &str) -> AuditRoute { + AuditRoute { + method: method.to_owned(), + path: path.to_owned(), + handler: handler.to_owned(), + source: "user".to_owned(), + classification: classification.to_owned(), + roles: vec![], + scopes: vec![], + policy: false, + module: None, + } + } + + // ── classification / gate ──────────────────────────────────────────────── + + #[test] + fn is_unclassified_true_for_unclassified_and_unknown() { + assert!(route("POST", "/w", "create", "unclassified").is_unclassified()); + assert!(route("POST", "/w", "create", "").is_unclassified()); + assert!(route("POST", "/w", "create", "bogus").is_unclassified()); + } + + #[test] + fn is_unclassified_false_for_known_postures() { + assert!(!route("GET", "/w", "list", "gated").is_unclassified()); + assert!(!route("GET", "/w", "list", "public").is_unclassified()); + assert!(!route("GET", "/health", "health", "framework").is_unclassified()); + } + + #[test] + fn exit_code_is_nonzero_iff_any_route_unclassified() { + let clean = vec![ + route("GET", "/a", "a", "public"), + route("GET", "/b", "b", "gated"), + route("GET", "/health", "health", "framework"), + ]; + assert_eq!(audit_exit_code(&clean), 0); + + let mut dirty = clean; + dirty.push(route("POST", "/widgets", "create_widget", "unclassified")); + assert_eq!(audit_exit_code(&dirty), 1); + } + + #[test] + fn unclassified_routes_returns_only_the_offenders() { + let routes = vec![ + route("GET", "/a", "a", "public"), + route("POST", "/widgets", "create_widget", "unclassified"), + route("DELETE", "/things/{id}", "delete_thing", ""), + ]; + let unresolved = unclassified_routes(&routes); + assert_eq!(unresolved.len(), 2); + assert!(unresolved.iter().any(|r| r.handler == "create_widget")); + assert!(unresolved.iter().any(|r| r.handler == "delete_thing")); + } + + // ── diagnostic ─────────────────────────────────────────────────────────── + + #[test] + fn diagnostic_names_the_offending_route() { + let mut r = route("POST", "/widgets", "create_widget", "unclassified"); + r.module = Some("myapp::widgets".to_owned()); + let unresolved = vec![&r]; + let msg = format_diagnostic(&unresolved); + assert!(msg.contains("POST"), "{msg}"); + assert!(msg.contains("/widgets"), "{msg}"); + assert!(msg.contains("create_widget"), "{msg}"); + assert!(msg.contains("myapp::widgets"), "{msg}"); + assert!(msg.contains("#[public]"), "{msg}"); + } + + // ── manifest shape / stability ─────────────────────────────────────────── + + #[test] + fn manifest_is_schema_shaped_and_provable() { + let routes = vec![route("GET", "/a", "a", "gated")]; + let manifest = build_manifest(&routes); + assert_eq!(manifest.schema_version, MANIFEST_SCHEMA_VERSION); + assert_eq!(manifest.dimensions.routes.len(), 1); + assert_eq!(manifest.dimensions.routes[0].provenance, "provable"); + + let json: serde_json::Value = serde_json::from_str(&manifest_json(&manifest)).unwrap(); + assert_eq!(json["schema_version"], 1); + let entry = &json["dimensions"]["routes"][0]; + for key in [ + "path", + "method", + "name", + "classification", + "roles", + "scopes", + "policy", + "source", + "provenance", + ] { + assert!(entry.get(key).is_some(), "manifest route missing `{key}`"); + } + } + + #[test] + fn manifest_routes_are_stable_ordered_by_path_then_method() { + let routes = vec![ + route("POST", "/posts", "create", "gated"), + route("GET", "/posts", "list", "public"), + route("GET", "/about", "about", "public"), + ]; + let manifest = build_manifest(&routes); + let order: Vec<(&str, &str)> = manifest + .dimensions + .routes + .iter() + .map(|r| (r.path.as_str(), r.method.as_str())) + .collect(); + assert_eq!( + order, + vec![("/about", "GET"), ("/posts", "GET"), ("/posts", "POST")] + ); + } + + #[test] + fn manifest_carries_roles_scopes_policy() { + let mut r = route("POST", "/admin", "admin_action", "gated"); + r.roles = vec!["admin".to_owned()]; + r.scopes = vec!["posts:write".to_owned()]; + r.policy = true; + let manifest = build_manifest(&[r]); + let entry = &manifest.dimensions.routes[0]; + assert_eq!(entry.roles, vec!["admin"]); + assert_eq!(entry.scopes, vec!["posts:write"]); + assert!(entry.policy); + } + + // ── falsifiability (#1604): red → green over the parsed dump ────────────── + + /// A dumped listing containing one deliberately-unannotated mutating + /// handler fails the gate and names it; re-classifying that route as either + /// `gated` (guarded) or `public` turns the gate green. + #[test] + fn audit_gate_red_then_green() { + let dump = |cls: &str| { + vec![ + route("GET", "/health", "health", "framework"), + route("POST", "/widgets", "create_widget", cls), + ] + }; + + // Red: the mutating route is unclassified. + let red = dump("unclassified"); + assert_eq!(audit_exit_code(&red), 1); + let unresolved = unclassified_routes(&red); + assert_eq!(unresolved.len(), 1); + assert!(format_diagnostic(&unresolved).contains("create_widget")); + + // Green via a guard. + assert_eq!(audit_exit_code(&dump("gated")), 0); + // Green via #[public]. + assert_eq!(audit_exit_code(&dump("public")), 0); + } + + // ── omitted routes (raw merge/nest routers) ────────────────────────────── + + #[test] + fn parse_omitted_count_reads_the_marker() { + let stderr = format!( + "\u{1F342} autumn routes\n\ + [autumn routes] warning: 2 raw router(s) added via .merge()/.nest() ...\n\ + {OMITTED_ROUTES_MARKER}2\n" + ); + assert_eq!(parse_omitted_count(&stderr), 2); + } + + #[test] + fn parse_omitted_count_zero_when_no_marker() { + assert_eq!(parse_omitted_count(""), 0); + assert_eq!(parse_omitted_count("some unrelated warning\n"), 0); + } + + /// A dump that omits raw routers (marker present, count > 0) must fail the + /// gate even when every *visible* route is fully classified — the omitted + /// routes are unprovable, so the audit can't pass silently. + #[test] + fn omitted_routes_fail_the_gate_even_when_visible_routes_are_clean() { + // All visible routes are classified: audit_exit_code alone would pass. + let visible = vec![ + route("GET", "/health", "health", "framework"), + route("GET", "/posts", "list", "public"), + ]; + assert_eq!(audit_exit_code(&visible), 0); + + // But the child reported an omitted raw router on stderr. + let stderr = format!("{OMITTED_ROUTES_MARKER}1\n"); + let omitted = parse_omitted_count(&stderr); + assert_eq!(omitted, 1); + + // The combined gate (mirroring `run`) must fail. + let failed = omitted > 0 || audit_exit_code(&visible) != 0; + assert!(failed, "omitted routes must hard-fail the audit gate"); + + // And the diagnostic explains why and how to fix it. + let diag = format_omitted_diagnostic(omitted); + assert!(diag.contains("merge()"), "{diag}"); + assert!(diag.contains("routes!["), "{diag}"); + } + + /// The mirror of the case above (#1604): a raw router mounted with covering + /// declarations — as the first-party `AdminPlugin` does via the documented + /// `nest(prefix, router).declare_plugin_routes(routes)` pattern — is + /// enumerable, so the app emits NO omitted marker (`hidden == 0`) because a + /// declared route falls under the nest prefix. `parse_omitted_count` returns + /// 0 and, with clean visible routes, the combined gate must PASS. Previously + /// the nested admin router still tripped the marker and false-failed the + /// audit. + #[test] + fn declared_nest_emits_no_marker_and_passes_the_gate() { + // Visible routes include the declared admin endpoints, all classified. + let visible = vec![ + route("GET", "/health", "health", "framework"), + route("GET", "/admin", "admin::index", "gated"), + route("POST", "/admin/users", "admin::create", "gated"), + ]; + assert_eq!(audit_exit_code(&visible), 0); + + // A declared-covered nest emits no marker, so no omitted count is + // reported even though a raw router was mounted. + let stderr = "\u{1F342} autumn routes\n"; + let omitted = parse_omitted_count(stderr); + assert_eq!( + omitted, 0, + "a declared-covered nest must not emit the marker" + ); + + // The combined gate (mirroring `run`) must pass. + let failed = omitted > 0 || audit_exit_code(&visible) != 0; + assert!( + !failed, + "a declared-covered admin nest must not false-fail the audit gate" + ); + } +} diff --git a/autumn-macros/src/api_doc.rs b/autumn-macros/src/api_doc.rs index 2749e74c4..b83df8818 100644 --- a/autumn-macros/src/api_doc.rs +++ b/autumn-macros/src/api_doc.rs @@ -663,6 +663,54 @@ pub fn extract_secured_info(input_fn: &syn::ItemFn) -> (bool, TokenStream, Token (false, quote! { &[] }, quote! { &[] }) } +/// Detect an explicit `#[public]` marker on a handler. +/// +/// Mirrors [`extract_secured_info`]'s attribute/marker duality: +/// 1. `#[public]` (or `#[autumn_web::public]`) still present as an attribute, +/// which happens when the route macro is outermost and `#[public]` has not +/// expanded yet. +/// 2. The `__AUTUMN_PUBLIC` marker const emitted into the handler body when +/// `#[public]` expanded before the route macro. +pub fn is_public(input_fn: &syn::ItemFn) -> bool { + for attr in &input_fn.attrs { + if attr.path().is_ident("public") + || attr + .path() + .segments + .last() + .is_some_and(|s| s.ident == "public") + { + return true; + } + } + has_public_marker_in_stmts(&input_fn.block.stmts) +} + +fn has_public_marker_in_stmts(stmts: &[syn::Stmt]) -> bool { + stmts.iter().any(has_public_marker_in_stmt) +} + +fn has_public_marker_in_stmt(stmt: &syn::Stmt) -> bool { + match stmt { + syn::Stmt::Item(syn::Item::Const(item_const)) => item_const.ident == "__AUTUMN_PUBLIC", + syn::Stmt::Expr(expr, _) => has_public_marker_in_expr(expr), + syn::Stmt::Local(local) => local + .init + .as_ref() + .is_some_and(|init| has_public_marker_in_expr(&init.expr)), + _ => false, + } +} + +fn has_public_marker_in_expr(expr: &syn::Expr) -> bool { + match expr { + syn::Expr::Block(block) => has_public_marker_in_stmts(&block.block.stmts), + syn::Expr::Async(block) => has_public_marker_in_stmts(&block.block.stmts), + syn::Expr::Unsafe(block) => has_public_marker_in_stmts(&block.block.stmts), + _ => false, + } +} + fn extract_secured_roles_marker(input_fn: &syn::ItemFn) -> Option> { extract_secured_roles_marker_from_stmts(&input_fn.block.stmts) } @@ -1015,4 +1063,33 @@ mod tests { }; assert_eq!(extract_secured_scopes_marker(&input_fn), None); } + + // ── #[public] detection (#1604) ────────────────────────────────────────── + + #[test] + fn is_public_detects_attribute() { + let input_fn: syn::ItemFn = syn::parse_quote! { + #[public] + async fn handler() {} + }; + assert!(is_public(&input_fn)); + } + + #[test] + fn is_public_detects_body_marker() { + let input_fn: syn::ItemFn = syn::parse_quote! { + async fn handler() { + const __AUTUMN_PUBLIC: () = (); + } + }; + assert!(is_public(&input_fn)); + } + + #[test] + fn is_public_false_for_unannotated() { + let input_fn: syn::ItemFn = syn::parse_quote! { + async fn handler() {} + }; + assert!(!is_public(&input_fn)); + } } diff --git a/autumn-macros/src/lib.rs b/autumn-macros/src/lib.rs index acfcf4e07..a6910a553 100644 --- a/autumn-macros/src/lib.rs +++ b/autumn-macros/src/lib.rs @@ -36,6 +36,7 @@ mod one_off_tasks_macro; mod param_helpers; mod parse; mod paths_macro; +mod public; mod repository; mod route; mod routes_macro; @@ -651,6 +652,29 @@ pub fn secured(attr: TokenStream, item: TokenStream) -> TokenStream { secured::secured_macro(attr.into(), item.into()).into() } +/// Declare a route handler as deliberately public (unauthenticated). +/// +/// `#[public]` injects no runtime guard — it is a compile-time *marker* that +/// records intent. The route macros surface it as `ApiDoc::public`, which the +/// build-time security classifier (`autumn routes audit`) uses to distinguish +/// a route that is *meant* to be open from one whose auth posture was simply +/// never declared. Applying it makes an otherwise-unclassified route pass the +/// audit gate, exactly like adding a [`#[secured]`](macro@secured) guard does. +/// +/// # Example +/// +/// ```ignore +/// use autumn_web::prelude::*; +/// +/// #[get("/health")] +/// #[public] +/// async fn health() -> &'static str { "ok" } +/// ``` +#[proc_macro_attribute] +pub fn public(attr: TokenStream, item: TokenStream) -> TokenStream { + public::public_macro(attr.into(), item.into()).into() +} + /// Require fresh ("step-up") authentication before a route handler runs. /// /// The handler is guarded by a freshness check on the session's diff --git a/autumn-macros/src/public.rs b/autumn-macros/src/public.rs new file mode 100644 index 000000000..ccc8948a3 --- /dev/null +++ b/autumn-macros/src/public.rs @@ -0,0 +1,72 @@ +//! `#[public]` proc macro implementation. +//! +//! `#[public]` is a *marker* attribute: it declares that a handler is +//! deliberately unauthenticated. Unlike [`#[secured]`](crate::secured) it +//! injects no runtime guard and does not rewrite the handler signature — it +//! only emits a `__AUTUMN_PUBLIC` marker const that the route macros +//! (`#[get]`, `#[post]`, …) read back to populate `ApiDoc::public`. +//! +//! The marker mirrors `#[secured]`'s `__AUTUMN_SECURED_ROLES` mechanism so the +//! route macro can detect the declaration whether `#[public]` sits above or +//! below the route attribute in the stack. + +use proc_macro2::{Span, TokenStream}; +use quote::quote; +use syn::{ItemFn, parse_quote}; + +pub fn public_macro(attr: TokenStream, item: TokenStream) -> TokenStream { + if attr.into_iter().next().is_some() { + return syn::Error::new(Span::call_site(), "#[public] does not take any arguments") + .to_compile_error(); + } + + let mut input_fn: ItemFn = match syn::parse2(item) { + Ok(f) => f, + Err(err) => return err.to_compile_error(), + }; + + // Prepend the marker const so a route macro that expands *after* this one + // can read the declaration back out of the handler body. When the route + // macro is outermost instead, it detects the `#[public]` attribute directly + // (see `api_doc::is_public`). + let marker: syn::Stmt = parse_quote! { + // Route macros read this marker when #[public] expands before #[get]/#[post]/etc. + const __AUTUMN_PUBLIC: () = (); + }; + input_fn.block.stmts.insert(0, marker); + + quote! { #input_fn } +} + +#[cfg(test)] +mod tests { + use quote::quote; + + use super::public_macro; + + #[test] + fn injects_public_marker() { + let generated = + public_macro(quote! {}, quote! { async fn h() -> &'static str { "ok" } }).to_string(); + assert!( + generated.contains("__AUTUMN_PUBLIC"), + "public marker must be emitted: {generated}" + ); + } + + #[test] + fn preserves_handler_signature() { + let generated = + public_macro(quote! {}, quote! { async fn h() -> &'static str { "ok" } }).to_string(); + // No return-type rewrite and no injected extractors — the marker only. + assert!(generated.contains("async fn h")); + assert!(!generated.contains("Response")); + assert!(!generated.contains("__autumn_session")); + } + + #[test] + fn rejects_arguments() { + let generated = public_macro(quote! { "oops" }, quote! { async fn h() {} }).to_string(); + assert!(generated.contains("compile_error")); + } +} diff --git a/autumn-macros/src/route.rs b/autumn-macros/src/route.rs index e865a839c..2a8a75a5b 100644 --- a/autumn-macros/src/route.rs +++ b/autumn-macros/src/route.rs @@ -147,6 +147,7 @@ pub fn route_macro( let response_body = api_doc::schema_option(api_doc::infer_response_body(&input_fn)); let query_schema = api_doc::schema_option(api_doc::infer_query_params(&input_fn)); let (secured, required_roles, required_scopes) = api_doc::extract_secured_info(&input_fn); + let is_public = api_doc::is_public(&input_fn); let has_feature_flag = has_feature_flag_attr || has_expanded_feature_flag_gate(&input_fn); let body_guarded_replay = secured || has_authorize_guard(&input_fn) @@ -222,6 +223,8 @@ pub fn route_macro( api_version: #api_version_expr, sunset_opt_out: #sunset_opt_out_val, has_policy: #has_policy_val, + public: #is_public, + module_path: ::core::module_path!(), #api_doc_fields }, repository: ::core::option::Option::None, @@ -724,6 +727,58 @@ mod tests { ); } + #[test] + fn route_macro_defaults_public_false() { + let generated = route_macro( + "POST", + "post", + quote! { "/widgets" }, + quote! { async fn create_widget() -> &'static str { "ok" } }, + ) + .to_string(); + assert!( + generated.contains("public : false"), + "an unannotated route must record public = false: {generated}" + ); + // The handler's module path is captured for audit diagnostics. + assert!(generated.contains("module_path")); + } + + #[test] + fn route_macro_marks_public_when_public_attribute_present() { + // Ordering A: `#[post]` outermost, `#[public]` still an attribute below. + let generated = route_macro( + "POST", + "post", + quote! { "/pricing" }, + quote! { + #[public] + async fn pricing() -> &'static str { "free" } + }, + ) + .to_string(); + assert!( + generated.contains("public : true"), + "a #[public] handler must record public = true: {generated}" + ); + } + + #[test] + fn route_macro_marks_public_from_expanded_marker() { + // Ordering B: `#[public]` written above `#[post]`, so it expands first and + // injects the `__AUTUMN_PUBLIC` marker into the body; the route macro must + // recognize the marker. + let public_fn = crate::public::public_macro( + quote! {}, + quote! { async fn pricing() -> &'static str { "free" } }, + ); + let generated = route_macro("POST", "post", quote! { "/pricing" }, public_fn).to_string(); + assert!( + generated.contains("public : true"), + "a route macro over an expanded #[public] marker must record public = true: {generated}" + ); + } + #[test] fn route_macro_parses_timeout_off_disabled() { let generated = route_macro( diff --git a/autumn-macros/src/static_route.rs b/autumn-macros/src/static_route.rs index b5d285bc3..2c65bebcf 100644 --- a/autumn-macros/src/static_route.rs +++ b/autumn-macros/src/static_route.rs @@ -129,6 +129,13 @@ pub fn static_get_macro(attr: TokenStream, item: TokenStream) -> TokenStream { let static_meta_name = format_ident!("__autumn_static_meta_{}", fn_name); let vis = &input_fn.vis; + // Honor a `#[public]` marker on the handler so the route audit gate can + // classify this static route as `public` rather than `unclassified`. + // Mirrors the standard `#[get]`/`#[post]` route macro (see + // `crate::route`), which is the only place `is_public` was previously + // wired. + let is_public = crate::api_doc::is_public(&input_fn); + // Build the revalidate expression let revalidate_expr = attrs.revalidate.map_or_else( || quote! { ::core::option::Option::None }, @@ -183,6 +190,8 @@ pub fn static_get_macro(attr: TokenStream, item: TokenStream) -> TokenStream { required_roles: &[], register_schemas: ::core::option::Option::None, api_version: ::core::option::Option::None, + public: #is_public, + module_path: ::core::module_path!(), ..::core::default::Default::default() }, repository: ::core::option::Option::None, @@ -212,3 +221,58 @@ pub fn static_get_macro(attr: TokenStream, item: TokenStream) -> TokenStream { } } } + +#[cfg(test)] +mod tests { + use quote::quote; + + use super::static_get_macro; + + #[test] + fn static_get_defaults_public_false() { + let generated = static_get_macro( + quote! { "/about" }, + quote! { async fn about() -> &'static str { "about" } }, + ) + .to_string(); + assert!( + generated.contains("public : false"), + "an unannotated static route must record public = false: {generated}" + ); + // The handler's module path is captured for audit diagnostics. + assert!(generated.contains("module_path")); + } + + #[test] + fn static_get_marks_public_when_public_attribute_present() { + // Ordering A: `#[static_get]` outermost, `#[public]` still an attribute below. + let generated = static_get_macro( + quote! { "/about" }, + quote! { + #[public] + async fn about() -> &'static str { "about" } + }, + ) + .to_string(); + assert!( + generated.contains("public : true"), + "a #[public] static route must record public = true: {generated}" + ); + } + + #[test] + fn static_get_marks_public_from_expanded_marker() { + // Ordering B: `#[public]` written above `#[static_get]`, so it expands + // first and injects the `__AUTUMN_PUBLIC` marker into the body; the + // static route macro must recognize the marker. + let public_fn = crate::public::public_macro( + quote! {}, + quote! { async fn about() -> &'static str { "about" } }, + ); + let generated = static_get_macro(quote! { "/about" }, public_fn).to_string(); + assert!( + generated.contains("public : true"), + "a static route macro over an expanded #[public] marker must record public = true: {generated}" + ); + } +} diff --git a/autumn-macros/src/ws.rs b/autumn-macros/src/ws.rs index 985e44af4..7863a86bc 100644 --- a/autumn-macros/src/ws.rs +++ b/autumn-macros/src/ws.rs @@ -62,6 +62,13 @@ pub fn ws_macro(attr: TokenStream, item: TokenStream) -> TokenStream { let upgrade_name = format_ident!("__autumn_ws_upgrade_{}", fn_name); let route_info_name = format_ident!("__autumn_route_info_{}", fn_name); + // Honor a `#[public]` marker on the handler so the route audit gate can + // classify this WebSocket route as `public` rather than `unclassified`. + // Mirrors the standard `#[get]`/`#[post]` route macro (see + // `crate::route`), which is the only place `is_public` was previously + // wired. + let is_public = crate::api_doc::is_public(&input_fn); + // Separate user params into AppState params (supplied from extracted state) // and extractor params (become Axum extractors on the upgrade handler). let mut extractor_params = Vec::new(); @@ -150,6 +157,8 @@ pub fn ws_macro(attr: TokenStream, item: TokenStream) -> TokenStream { required_roles: &[], register_schemas: ::core::option::Option::None, api_version: ::core::option::Option::None, + public: #is_public, + module_path: ::core::module_path!(), ..::core::default::Default::default() }, repository: ::core::option::Option::None, @@ -180,3 +189,58 @@ pub fn ws_macro(attr: TokenStream, item: TokenStream) -> TokenStream { } } } + +#[cfg(test)] +mod tests { + use quote::quote; + + use super::ws_macro; + + #[test] + fn ws_defaults_public_false() { + let generated = ws_macro( + quote! { "/echo" }, + quote! { async fn echo() -> impl WsHandler { |socket| async move {} } }, + ) + .to_string(); + assert!( + generated.contains("public : false"), + "an unannotated ws route must record public = false: {generated}" + ); + // The handler's module path is captured for audit diagnostics. + assert!(generated.contains("module_path")); + } + + #[test] + fn ws_marks_public_when_public_attribute_present() { + // Ordering A: `#[ws]` outermost, `#[public]` still an attribute below. + let generated = ws_macro( + quote! { "/echo" }, + quote! { + #[public] + async fn echo() -> impl WsHandler { |socket| async move {} } + }, + ) + .to_string(); + assert!( + generated.contains("public : true"), + "a #[public] ws route must record public = true: {generated}" + ); + } + + #[test] + fn ws_marks_public_from_expanded_marker() { + // Ordering B: `#[public]` written above `#[ws]`, so it expands first and + // injects the `__AUTUMN_PUBLIC` marker into the body; the ws route macro + // must recognize the marker. + let public_fn = crate::public::public_macro( + quote! {}, + quote! { async fn echo() -> impl WsHandler { |socket| async move {} } }, + ); + let generated = ws_macro(quote! { "/echo" }, public_fn).to_string(); + assert!( + generated.contains("public : true"), + "a ws route macro over an expanded #[public] marker must record public = true: {generated}" + ); + } +} diff --git a/autumn/src/app.rs b/autumn/src/app.rs index 918b38f66..c3f506d04 100644 --- a/autumn/src/app.rs +++ b/autumn/src/app.rs @@ -155,6 +155,61 @@ pub fn app() -> AppBuilder { } } +/// Count the raw routers omitted from `autumn routes` output because their +/// endpoints can't be enumerated — the value `autumn routes audit` treats as a +/// hard failure (an unprovable route defeats the coverage guarantee). +/// +/// Every `.merge()` router is rootless — it has no mount prefix to match +/// declarations against — so it is always opaque and always counts. A `.nest()` +/// router carries a mount prefix, so it is treated as **covered** (enumerable, +/// not omitted) when at least one declared route (from +/// [`declare_plugin_routes`](AppBuilder::declare_plugin_routes)) has a path that +/// falls under that prefix. This makes the documented +/// `app.nest(prefix, router).declare_plugin_routes(routes)` pattern audit-clean +/// without any dedicated bookkeeping: the declared routes prove the mount. +/// +/// Soundness (fail-closed) is preserved: a bare `nest(prefix, raw_router)` with +/// no declared route under `prefix` stays uncovered and counts, and every +/// `merge()` counts unconditionally. +fn omitted_router_count<'a>( + merge_routers: usize, + nest_prefixes: impl IntoIterator, + declared_routes: &[crate::route_listing::RouteInfo], +) -> usize { + let uncovered_nests = nest_prefixes + .into_iter() + .filter(|prefix| !nest_prefix_is_covered(prefix, declared_routes)) + .count(); + merge_routers + uncovered_nests +} + +/// A nested mount at `prefix` is "covered" when at least one declared route's +/// path falls under that prefix, proving the nested router's endpoints are +/// enumerable in the `autumn routes` listing. +fn nest_prefix_is_covered( + prefix: &str, + declared_routes: &[crate::route_listing::RouteInfo], +) -> bool { + declared_routes + .iter() + .any(|route| path_is_under_prefix(&route.path, prefix)) +} + +/// Whether `path` is mounted under `prefix` — i.e. equal to the prefix or a +/// descendant of it at a path-segment boundary (`/admin` covers `/admin` and +/// `/admin/users`, but not `/administrators`). A root prefix (`/` or empty) +/// covers everything. +fn path_is_under_prefix(path: &str, prefix: &str) -> bool { + let prefix = prefix.trim_end_matches('/'); + if prefix.is_empty() { + return true; + } + path == prefix + || path + .strip_prefix(prefix) + .is_some_and(|rest| rest.starts_with('/')) +} + type StartupHookFuture = Pin> + Send>>; type StartupHook = Box StartupHookFuture + Send + Sync>; type StateInitializer = Box; @@ -1330,6 +1385,16 @@ impl AppBuilder { /// Routes are automatically attributed to the current plugin when called from /// within a plugin's `build()` method. The `source` field of each supplied /// `RouteInfo` is overwritten with that attribution. + /// + /// Declaring routes also makes a [`nest`](Self::nest) mount *coverage-clean* + /// for `autumn routes audit`: a nested router is normally opaque and counts + /// as an omitted, unprovable router that hard-fails the gate, but when at + /// least one declared route's path falls under the nest's prefix, the mount + /// is treated as enumerable and no longer counts. So the documented + /// `app.nest(prefix, router).declare_plugin_routes(routes)` pattern — with + /// `routes` covering everything the raw router serves under `prefix` — passes + /// the audit. A bare `nest`/`merge` with no covering declaration stays + /// opaque and still fails closed. #[must_use] pub fn declare_plugin_routes( mut self, @@ -4298,14 +4363,30 @@ impl AppBuilder { } // Raw Axum routers registered via .merge()/.nest() are opaque: there is - // no public API to enumerate their routes. Always warn so callers know - // some routes may be missing even if declare_plugin_routes was used. - let hidden = merge_routers.len() + nest_routers.len(); + // no public API to enumerate their routes, so they are omitted from the + // listing and hard-fail `autumn routes audit` (their auth posture can't + // be proven). The exception is a `.nest(prefix, router)` whose endpoints + // were declared via `declare_plugin_routes` — when a declared route's + // path falls under the nest prefix, those endpoints ARE enumerable + // (folded into `declared_routes`) and must not be counted as omitted. + // Every `.merge()` is rootless and always counts; a bare `.nest()` with + // no covering declaration stays opaque and counts. + let hidden = omitted_router_count( + merge_routers.len(), + nest_routers.iter().map(|(prefix, _)| prefix.as_str()), + &declared_routes, + ); if hidden > 0 { eprintln!( "[autumn routes] warning: {hidden} raw router(s) added via \ .merge()/.nest() are not enumerable and are omitted from this listing" ); + // Machine-readable marker consumed by `autumn routes audit` to + // hard-fail the coverage gate: omitted routes can't be proven. + eprintln!( + "{marker}{hidden}", + marker = crate::route_listing::OMITTED_ROUTES_MARKER + ); } let (config, _telemetry_guard) = @@ -7816,6 +7897,164 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use tower::ServiceExt; + // ── omitted-router accounting for `autumn routes audit` ────────────────── + + /// Compute the omitted-router count for a builder using the same inputs + /// `run_dump_routes_mode` feeds `omitted_router_count`: the merge count, the + /// nest prefixes, and the declared routes that prove nest coverage. + fn omitted_for(builder: &AppBuilder) -> usize { + omitted_router_count( + builder.merge_routers.len(), + builder + .nest_routers + .iter() + .map(|(prefix, _)| prefix.as_str()), + &builder.declared_routes, + ) + } + + /// Regression (#1604): the DOCUMENTED plugin pattern — + /// `app.nest(prefix, router).declare_plugin_routes(routes)`, which the + /// first-party `AdminPlugin` uses — declares route metadata whose paths fall + /// under the nest prefix. That coverage makes the nested raw router + /// enumerable, so it must NOT be counted among the opaque, omitted routers + /// that hard-fail the audit gate. Before the fix, prefix coverage was + /// ignored and the mere presence of the nested raw router pushed + /// `hidden > 0`, false-failing the audit even though every admin route was + /// declared and classified. + #[test] + fn documented_nest_then_declare_is_not_counted_as_omitted() { + let raw = + axum::Router::::new().route("/ping", axum::routing::get(|| async { "pong" })); + let declared = vec![crate::route_listing::RouteInfo { + method: "GET".to_owned(), + path: "/admin/ping".to_owned(), + handler: "admin::ping".to_owned(), + ..Default::default() + }]; + + // The plain documented pattern: nest the raw router, then declare its + // covering route metadata. No dedicated `nest_declared` bookkeeping. + let builder = app().nest("/admin", raw).declare_plugin_routes(declared); + + // The raw router is still mounted (serving path is unchanged) … + assert_eq!(builder.nest_routers.len(), 1); + // … and its declared metadata was folded into `declared_routes`. + assert_eq!(builder.declared_routes.len(), 1); + + // ⇒ zero omitted routers: the declared route `/admin/ping` falls under + // the `/admin` nest prefix, so the mount is covered and the audit gate + // must NOT fire. + assert_eq!( + omitted_for(&builder), + 0, + "a nest whose endpoints are declared is enumerable and must not count as omitted", + ); + } + + /// The soundness guarantee must survive: a raw router mounted via bare + /// `nest()` or `merge()` without covering declarations is unenumerable and + /// must still count as omitted so `autumn routes audit` fails closed. + #[test] + fn undeclared_nest_and_merge_still_count_as_omitted() { + let raw_nest = + axum::Router::::new().route("/x", axum::routing::get(|| async { "x" })); + let raw_merge = + axum::Router::::new().route("/y", axum::routing::get(|| async { "y" })); + + let builder = app().nest("/v2", raw_nest).merge(raw_merge); + + assert_eq!(builder.nest_routers.len(), 1); + assert_eq!(builder.merge_routers.len(), 1); + // Nothing was declared, so nothing covers the nest. + assert!(builder.declared_routes.is_empty()); + + assert_eq!( + omitted_for(&builder), + 2, + "an undeclared nest and a merge are both opaque and must be reported", + ); + } + + /// An undeclared `merge()` is rootless — it cannot be prefix-matched — so it + /// stays omitted even when unrelated declared routes exist. Guards against a + /// declaration for one mount silently covering an unrelated raw `merge()`. + #[test] + fn declared_routes_do_not_cover_a_rootless_merge() { + let raw_merge = + axum::Router::::new().route("/y", axum::routing::get(|| async { "y" })); + + let builder = + app() + .merge(raw_merge) + .declare_plugin_routes(vec![crate::route_listing::RouteInfo { + method: "GET".to_owned(), + path: "/admin/ok".to_owned(), + handler: "admin::ok".to_owned(), + ..Default::default() + }]); + + assert_eq!( + omitted_for(&builder), + 1, + "a merge has no prefix to match declarations against and must always count", + ); + } + + /// A declared nest alongside an *undeclared* nest: only the undeclared one + /// is omitted. Prefix-matching must cover the `/admin` mount (a declared + /// route falls under it) without spilling onto the unrelated `/raw` mount + /// (no declared route falls under it). + #[test] + fn mixed_declared_and_undeclared_nests_count_only_the_undeclared() { + let declared_raw = + axum::Router::::new().route("/ok", axum::routing::get(|| async { "ok" })); + let undeclared_raw = axum::Router::::new() + .route("/opaque", axum::routing::get(|| async { "opaque" })); + + let builder = app() + .nest("/admin", declared_raw) + .declare_plugin_routes(vec![crate::route_listing::RouteInfo { + method: "GET".to_owned(), + path: "/admin/ok".to_owned(), + handler: "admin::ok".to_owned(), + ..Default::default() + }]) + .nest("/raw", undeclared_raw); + + assert_eq!(builder.nest_routers.len(), 2); + assert_eq!(builder.declared_routes.len(), 1); + assert_eq!( + omitted_for(&builder), + 1, + "only the bare nest() is omitted; the declared mount is covered", + ); + } + + /// A declared route whose path merely *shares a leading substring* with a + /// nest prefix (`/administrators` vs `/admin`) must NOT cover the nest: + /// prefix-matching honours path-segment boundaries, so this bare nest still + /// counts as omitted and the audit fails closed. + #[test] + fn prefix_match_respects_path_segment_boundaries() { + let raw = axum::Router::::new().route("/x", axum::routing::get(|| async { "x" })); + + let builder = app().nest("/admin", raw).declare_plugin_routes(vec![ + crate::route_listing::RouteInfo { + method: "GET".to_owned(), + path: "/administrators".to_owned(), + handler: "other::index".to_owned(), + ..Default::default() + }, + ]); + + assert_eq!( + omitted_for(&builder), + 1, + "`/administrators` is not under the `/admin` nest prefix; the nest stays omitted", + ); + } + #[test] fn is_dump_jobs_mode_only_true_for_exactly_one() { // `autumn jobs manifest` sets AUTUMN_DUMP_JOBS=1 to select the manifest diff --git a/autumn/src/lib.rs b/autumn/src/lib.rs index 17696ab91..d76b7e4ea 100644 --- a/autumn/src/lib.rs +++ b/autumn/src/lib.rs @@ -1053,6 +1053,25 @@ pub use auth::API_TOKEN_MIGRATIONS; /// ``` pub use autumn_macros::secured; +/// Declare a route handler as deliberately public (unauthenticated). +/// +/// A compile-time marker that records intent: it injects no runtime guard and +/// leaves the handler signature untouched, but surfaces on the route's +/// [`ApiDoc::public`](crate::openapi::ApiDoc::public) so the build-time security +/// classifier (`autumn routes audit`) treats the route as an explicit opt-out +/// of authentication rather than an oversight. +/// +/// # Example +/// +/// ```rust,no_run +/// use autumn_web::prelude::*; +/// +/// #[get("/pricing")] +/// #[public] +/// async fn pricing() -> &'static str { "free" } +/// ``` +pub use autumn_macros::public; + /// Require fresh ("step-up") authentication before a route handler runs. /// /// The handler is guarded by a freshness check on the session's diff --git a/autumn/src/openapi.rs b/autumn/src/openapi.rs index 9f6f34bda..c256c2b9c 100644 --- a/autumn/src/openapi.rs +++ b/autumn/src/openapi.rs @@ -77,8 +77,8 @@ use serde::{Deserialize, Serialize}; /// any [`#[api_doc(...)]`](crate::api_doc) overrides. #[derive(Clone, Debug, Default)] // A flat, generated metadata descriptor; the independent boolean flags -// (hidden, secured, sunset_opt_out, has_policy, mcp_tool, mcp_exclude) each -// model a distinct, orthogonal route property, so grouping them into a +// (hidden, secured, sunset_opt_out, has_policy, public, mcp_tool, mcp_exclude) +// each model a distinct, orthogonal route property, so grouping them into a // sub-struct would obscure rather than clarify. #[allow(clippy::struct_excessive_bools)] pub struct ApiDoc { @@ -126,6 +126,17 @@ pub struct ApiDoc { pub sunset_opt_out: bool, /// Whether this route uses dynamic policy authorization. pub has_policy: bool, + /// True when the handler is explicitly declared public via `#[public]`. + /// + /// Populated by the route macros from the `#[public]` marker. Used by the + /// route-listing security classifier (`autumn routes audit`) to + /// distinguish a *deliberately* open route from one whose auth posture was + /// simply never declared. + pub public: bool, + /// Module path of the handler (`module_path!()` captured at the handler's + /// definition site), used to name a route in security-audit diagnostics. + /// Empty for routes constructed without the route macros. + pub module_path: &'static str, /// True when the endpoint opts in to MCP tool exposure via /// `#[api_doc(mcp)]`. Opt-in is per-endpoint and never implicit. pub mcp_tool: bool, diff --git a/autumn/src/plugin_conformance.rs b/autumn/src/plugin_conformance.rs index 634b5b041..fd5c4f8ce 100644 --- a/autumn/src/plugin_conformance.rs +++ b/autumn/src/plugin_conformance.rs @@ -617,10 +617,7 @@ mod tests { path: path.to_owned(), handler: format!("{}_handler", path.trim_start_matches('/').replace('/', "_")), source, - middleware: vec![], - api_version: None, - status: None, - sunset_opt_out: None, + ..Default::default() } } diff --git a/autumn/src/prelude.rs b/autumn/src/prelude.rs index 5051eceff..261aaa6a6 100644 --- a/autumn/src/prelude.rs +++ b/autumn/src/prelude.rs @@ -32,8 +32,8 @@ pub use autumn_macros::ws; /// HTTP method route macros, main macro, and route collection. pub use autumn_macros::{ api_doc, authorize, cached, delete, event, feature_flag, get, job, jobs, listener, listeners, - main, oauth2_callback, one_off_tasks, patch, paths, post, put, routes, scheduled, secured, - service, static_get, static_routes, step_up, task, tasks, throttle, + main, oauth2_callback, one_off_tasks, patch, paths, post, public, put, routes, scheduled, + secured, service, static_get, static_routes, step_up, task, tasks, throttle, }; #[cfg(feature = "mail")] pub use autumn_macros::{mail_previews, mailer, mailer_preview}; diff --git a/autumn/src/route_listing.rs b/autumn/src/route_listing.rs index beecf77cf..802aef159 100644 --- a/autumn/src/route_listing.rs +++ b/autumn/src/route_listing.rs @@ -9,11 +9,26 @@ use serde::{Deserialize, Serialize}; use crate::app::ScopedGroup; use crate::route::Route; +/// Machine-readable stderr marker for omitted (unenumerable) raw routers. +/// +/// Emitted by the `AUTUMN_DUMP_ROUTES` dump when raw routers registered via +/// [`AppBuilder::merge`](crate::app::AppBuilder::merge) / +/// [`AppBuilder::nest`](crate::app::AppBuilder::nest) are omitted from the JSON +/// route listing (they are opaque and cannot be enumerated). The decimal count +/// of omitted routers follows the marker on the same line. +/// +/// This is a process-boundary protocol: `autumn routes audit` runs the built +/// binary as a child, scans its stderr for this marker, and hard-fails the +/// coverage gate when the count is non-zero — omitted routes can carry unproven +/// auth posture, so a manifest that silently drops them would defeat the gate. +pub const OMITTED_ROUTES_MARKER: &str = "[autumn:omitted-routes] "; + /// Where a route was registered: by the user application, by a named plugin, /// or by the framework itself (probes, actuator, htmx assets, dev reload). -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum RouteSource { /// Registered directly by the user application. + #[default] User, /// Registered by a named autumn plugin (e.g. `"admin"` for autumn-admin-plugin). Plugin(String), @@ -21,6 +36,66 @@ pub enum RouteSource { Framework, } +/// Security posture of a route. +/// +/// Derived at build time from the handler's macro-expanded +/// [`ApiDoc`](crate::openapi::ApiDoc) and its [`RouteSource`]. Emitted alongside +/// every route in the `autumn routes audit` manifest so route authentication +/// coverage can be gated in CI. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum RouteClassification { + /// Owned by the framework (probes, actuator, htmx assets, docs). Exempt + /// from the audit gate — these are pre-attributed and never require an + /// explicit `#[secured]`/`#[public]` declaration. + Framework, + /// Guarded by authentication (`#[secured]`) and/or dynamic policy + /// authorization (`#[authorize]`). + Gated, + /// Explicitly declared unauthenticated via `#[public]`. + Public, + /// No security posture could be proven for this route. This is the state + /// the audit gate fails on. + #[default] + Unclassified, +} + +impl RouteClassification { + /// Stable lowercase tag used in serialized manifests. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Framework => "framework", + Self::Gated => "gated", + Self::Public => "public", + Self::Unclassified => "unclassified", + } + } +} + +impl std::fmt::Display for RouteClassification { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl Serialize for RouteClassification { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for RouteClassification { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Ok(match s.as_str() { + "framework" => Self::Framework, + "gated" => Self::Gated, + "public" => Self::Public, + _ => Self::Unclassified, + }) + } +} + impl std::fmt::Display for RouteSource { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -51,7 +126,7 @@ impl<'de> Deserialize<'de> for RouteSource { } /// Metadata for a single mounted route, suitable for display and JSON export. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct RouteInfo { /// HTTP method (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `WS`, etc.). pub method: String, @@ -73,6 +148,118 @@ pub struct RouteInfo { /// Whether this route opts out of sunset 410 Gone response #[serde(skip_serializing_if = "Option::is_none")] pub sunset_opt_out: Option, + /// Build-time security classification derived from the handler's auth + /// posture and registration source. Consumed by `autumn routes audit`. + #[serde(default)] + pub classification: RouteClassification, + /// Roles required by `#[secured("role")]`, carried for `Gated` routes. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub roles: Vec, + /// Scopes required by `#[secured(scopes = [...])]`, carried for `Gated` routes. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub scopes: Vec, + /// Whether the route is guarded by dynamic policy authorization + /// (`#[authorize]`), carried for `Gated` routes. + #[serde(default, skip_serializing_if = "is_false")] + pub policy: bool, + /// Module path of the handler (from `module_path!()`), used to name a + /// route in audit diagnostics. `None` for routes without a known module. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub module: Option, +} + +/// `skip_serializing_if` helper: elide `false` booleans from JSON output. +#[allow(clippy::trivially_copy_pass_by_ref)] +const fn is_false(b: &bool) -> bool { + !*b +} + +impl RouteInfo { + /// Construct a framework-owned `GET` route entry. Framework routes are + /// pre-classified and exempt from the audit gate. + fn framework_get(path: String, handler: &str) -> Self { + Self { + method: "GET".to_owned(), + path, + handler: handler.to_owned(), + source: RouteSource::Framework, + classification: RouteClassification::Framework, + ..Self::default() + } + } +} + +/// Derive a route's security classification (and carried posture) from its +/// registration source and macro-expanded [`ApiDoc`](crate::openapi::ApiDoc). +/// +/// Precedence: framework routes are always [`RouteClassification::Framework`]; +/// otherwise a route guarded by `#[secured]` or `#[authorize]` is +/// [`RouteClassification::Gated`] (carrying its roles/scopes/policy); an +/// explicit `#[public]` is [`RouteClassification::Public`]; anything left is +/// [`RouteClassification::Unclassified`]. +/// +/// Repository auto-API routes (`#[repository(api = ..., policy = ...)]`) carry +/// their record-level authorization guard on the route's +/// [`RepositoryApiMeta`](crate::route::RepositoryApiMeta), *not* on the +/// handler's [`ApiDoc`](crate::openapi::ApiDoc): the generated CRUD handlers +/// build their `ApiDoc` with `..Default::default()` and never set +/// `secured`/`has_policy`. So a `policy = ...` repository must be classified +/// from `repository.has_policy` — otherwise a policy-protected CRUD endpoint +/// would fail the audit gate as `Unclassified` despite enforcing authorization. +fn classify( + source: &RouteSource, + api_doc: &crate::openapi::ApiDoc, + repository: Option<&crate::route::RepositoryApiMeta>, +) -> (RouteClassification, Vec, Vec, bool) { + if matches!(source, RouteSource::Framework) { + return ( + RouteClassification::Framework, + Vec::new(), + Vec::new(), + false, + ); + } + let repo_has_policy = repository.is_some_and(|r| r.has_policy); + // A repository auto-API declared with `scope = ...` (but no `policy = ...`) + // enforces the registered scope on its generated list handler at runtime, + // yet leaves both the handler `ApiDoc` and `RepositoryApiMeta::has_policy` + // at their defaults. Treat the presence of a scope guard as gated too, so + // these scope-protected `GET /` routes are not false-failed as + // `Unclassified`. No scope *name* is recorded on the meta (only a + // type-erased registry probe), so there is nothing to carry into `scopes`. + let repo_has_scope = repository.is_some_and(|r| r.scope_check.is_some()); + if api_doc.secured || api_doc.has_policy || repo_has_policy || repo_has_scope { + let roles = api_doc + .required_roles + .iter() + .map(|s| (*s).to_owned()) + .collect(); + let scopes = api_doc + .required_scopes + .iter() + .map(|s| (*s).to_owned()) + .collect(); + return ( + RouteClassification::Gated, + roles, + scopes, + api_doc.has_policy || repo_has_policy, + ); + } + if api_doc.public { + return (RouteClassification::Public, Vec::new(), Vec::new(), false); + } + ( + RouteClassification::Unclassified, + Vec::new(), + Vec::new(), + false, + ) +} + +/// The handler's module path, when the route macros captured one. +fn module_of(api_doc: &crate::openapi::ApiDoc) -> Option { + (!api_doc.module_path.is_empty()).then(|| api_doc.module_path.to_owned()) } /// Helper type alias representing version name, status string, and sunset opt-out flag. @@ -139,6 +326,8 @@ pub fn collect_route_infos( let source = route_sources.get(i).cloned().unwrap_or(RouteSource::User); let (api_version, status, sunset_opt_out) = resolve_status(route.name, route.api_version, route.sunset_opt_out)?; + let (classification, roles, scopes, policy) = + classify(&source, &route.api_doc, route.repository.as_ref()); infos.push(RouteInfo { method: route.method.to_string(), path: route.path.to_owned(), @@ -148,6 +337,11 @@ pub fn collect_route_infos( api_version, status, sunset_opt_out, + classification, + roles, + scopes, + policy, + module: module_of(&route.api_doc), }); } @@ -156,6 +350,8 @@ pub fn collect_route_infos( let full_path = join_scope_path(&group.prefix, route.path); let (api_version, status, sunset_opt_out) = resolve_status(route.name, route.api_version, route.sunset_opt_out)?; + let (classification, roles, scopes, policy) = + classify(&group.source, &route.api_doc, route.repository.as_ref()); infos.push(RouteInfo { method: route.method.to_string(), path: full_path, @@ -165,6 +361,11 @@ pub fn collect_route_infos( api_version, status, sunset_opt_out, + classification, + roles, + scopes, + policy, + module: module_of(&route.api_doc), }); } } @@ -189,16 +390,7 @@ pub(crate) fn append_framework_routes( (config.health.path.as_str(), "health"), ] { if probe_paths.insert(path) { - infos.push(RouteInfo { - method: "GET".to_owned(), - path: path.to_owned(), - handler: name.to_owned(), - source: RouteSource::Framework, - middleware: Vec::new(), - api_version: None, - status: None, - sunset_opt_out: None, - }); + infos.push(RouteInfo::framework_get(path.to_owned(), name)); } } @@ -207,60 +399,27 @@ pub(crate) fn append_framework_routes( config.actuator.sensitive, config.actuator.prometheus, ) { - infos.push(RouteInfo { - method: "GET".to_owned(), - path, - handler: "actuator".to_owned(), - source: RouteSource::Framework, - middleware: Vec::new(), - api_version: None, - status: None, - sunset_opt_out: None, - }); + infos.push(RouteInfo::framework_get(path, "actuator")); } #[cfg(feature = "htmx")] { - infos.push(RouteInfo { - method: "GET".to_owned(), - path: crate::htmx::HTMX_JS_PATH.to_owned(), - handler: "htmx".to_owned(), - source: RouteSource::Framework, - middleware: Vec::new(), - api_version: None, - status: None, - sunset_opt_out: None, - }); - infos.push(RouteInfo { - method: "GET".to_owned(), - path: crate::htmx::HTMX_CSRF_JS_PATH.to_owned(), - handler: "htmx_csrf".to_owned(), - source: RouteSource::Framework, - middleware: Vec::new(), - api_version: None, - status: None, - sunset_opt_out: None, - }); - infos.push(RouteInfo { - method: "GET".to_owned(), - path: crate::htmx::IDIOMORPH_JS_PATH.to_owned(), - handler: "idiomorph".to_owned(), - source: RouteSource::Framework, - middleware: Vec::new(), - api_version: None, - status: None, - sunset_opt_out: None, - }); - infos.push(RouteInfo { - method: "GET".to_owned(), - path: crate::htmx::HTMX_SSE_JS_PATH.to_owned(), - handler: "htmx_sse".to_owned(), - source: RouteSource::Framework, - middleware: Vec::new(), - api_version: None, - status: None, - sunset_opt_out: None, - }); + infos.push(RouteInfo::framework_get( + crate::htmx::HTMX_JS_PATH.to_owned(), + "htmx", + )); + infos.push(RouteInfo::framework_get( + crate::htmx::HTMX_CSRF_JS_PATH.to_owned(), + "htmx_csrf", + )); + infos.push(RouteInfo::framework_get( + crate::htmx::IDIOMORPH_JS_PATH.to_owned(), + "idiomorph", + )); + infos.push(RouteInfo::framework_get( + crate::htmx::HTMX_SSE_JS_PATH.to_owned(), + "htmx_sse", + )); } #[cfg(feature = "mail")] @@ -279,16 +438,7 @@ pub(crate) fn append_framework_routes( "mail_preview_template", ), ] { - infos.push(RouteInfo { - method: "GET".to_owned(), - path: path.to_owned(), - handler: handler.to_owned(), - source: RouteSource::Framework, - middleware: Vec::new(), - api_version: None, - status: None, - sunset_opt_out: None, - }); + infos.push(RouteInfo::framework_get(path.to_owned(), handler)); } } @@ -300,16 +450,7 @@ pub(crate) fn append_framework_routes( (crate::stories::STORIES_PATH, "story_gallery_index"), ("/_stories/{slug}", "story_gallery_story"), ] { - infos.push(RouteInfo { - method: "GET".to_owned(), - path: path.to_owned(), - handler: handler.to_owned(), - source: RouteSource::Framework, - middleware: Vec::new(), - api_version: None, - status: None, - sunset_opt_out: None, - }); + infos.push(RouteInfo::framework_get(path.to_owned(), handler)); } } @@ -321,30 +462,15 @@ pub(crate) fn append_framework_routes( (inspector_path.as_str(), "inspector_index"), (inspector_detail_path.as_str(), "inspector_detail"), ] { - infos.push(RouteInfo { - method: "GET".to_owned(), - path: path.to_owned(), - handler: handler.to_owned(), - source: RouteSource::Framework, - middleware: Vec::new(), - api_version: None, - status: None, - sunset_opt_out: None, - }); + infos.push(RouteInfo::framework_get(path.to_owned(), handler)); } } // Static file serving is unconditionally mounted at /static. - infos.push(RouteInfo { - method: "GET".to_owned(), - path: "/static/{*path}".to_owned(), - handler: "static_files".to_owned(), - source: RouteSource::Framework, - middleware: Vec::new(), - api_version: None, - status: None, - sunset_opt_out: None, - }); + infos.push(RouteInfo::framework_get( + "/static/{*path}".to_owned(), + "static_files", + )); } /// Append `OpenAPI` documentation routes (`/v3/api-docs`, `/swagger-ui`). @@ -355,27 +481,12 @@ pub(crate) fn append_openapi_routes( infos: &mut Vec, openapi: &crate::openapi::OpenApiConfig, ) { - infos.push(RouteInfo { - method: "GET".to_owned(), - path: openapi.openapi_json_path.clone(), - handler: "openapi_json".to_owned(), - source: RouteSource::Framework, - middleware: Vec::new(), - api_version: None, - status: None, - sunset_opt_out: None, - }); + infos.push(RouteInfo::framework_get( + openapi.openapi_json_path.clone(), + "openapi_json", + )); if let Some(ui_path) = &openapi.swagger_ui_path { - infos.push(RouteInfo { - method: "GET".to_owned(), - path: ui_path.clone(), - handler: "swagger_ui".to_owned(), - source: RouteSource::Framework, - middleware: Vec::new(), - api_version: None, - status: None, - sunset_opt_out: None, - }); + infos.push(RouteInfo::framework_get(ui_path.clone(), "swagger_ui")); } } @@ -392,16 +503,7 @@ pub(crate) fn append_dev_reload_routes(infos: &mut Vec) { "dev_live_reload_js", ), ] { - infos.push(RouteInfo { - method: "GET".to_owned(), - path: path.to_owned(), - handler: handler.to_owned(), - source: RouteSource::Framework, - middleware: Vec::new(), - api_version: None, - status: None, - sunset_opt_out: None, - }); + infos.push(RouteInfo::framework_get(path.to_owned(), handler)); } } } @@ -447,6 +549,55 @@ mod tests { } fn make_route(method: Method, path: &'static str, name: &'static str) -> Route { + make_route_with(method, path, name, dummy_api_doc()) + } + + /// Build a [`RepositoryApiMeta`](crate::route::RepositoryApiMeta) with the + /// given `has_policy`, mirroring what the `#[repository]` macro emits for a + /// `policy = ...` (or bare) auto-API. + fn repo_meta(has_policy: bool) -> crate::route::RepositoryApiMeta { + crate::route::RepositoryApiMeta { + resource_type_name: "Post", + api_path: "/api/posts", + has_policy, + policy_check: None, + scope_check: None, + } + } + + /// Build a [`RepositoryApiMeta`](crate::route::RepositoryApiMeta) for a + /// `scope = ...` (but no `policy = ...`) auto-API: `has_policy` stays + /// `false`, but the list handler carries a `scope_check` probe. + fn repo_meta_scope_only() -> crate::route::RepositoryApiMeta { + fn probe(_: &crate::authorization::PolicyRegistry) -> bool { + true + } + crate::route::RepositoryApiMeta { + resource_type_name: "Post", + api_path: "/api/posts", + has_policy: false, + policy_check: None, + scope_check: Some(probe), + } + } + + fn make_repo_route( + method: Method, + path: &'static str, + name: &'static str, + repository: Option, + ) -> Route { + let mut route = make_route_with(method, path, name, dummy_api_doc()); + route.repository = repository; + route + } + + fn make_route_with( + method: Method, + path: &'static str, + name: &'static str, + api_doc: crate::openapi::ApiDoc, + ) -> Route { async fn handler() -> &'static str { "ok" } @@ -455,7 +606,7 @@ mod tests { path, handler: get(handler), name, - api_doc: dummy_api_doc(), + api_doc, repository: None, idempotency: crate::route::RouteIdempotency::Direct, timeout: crate::route::RouteTimeout::Inherit, @@ -586,6 +737,206 @@ mod tests { assert_eq!(infos[0].source, RouteSource::User); } + // ── classification (#1604) ────────────────────────────────────────────── + + #[test] + fn classify_framework_source_is_framework() { + let (c, roles, scopes, policy) = classify(&RouteSource::Framework, &dummy_api_doc(), None); + assert_eq!(c, RouteClassification::Framework); + assert!(roles.is_empty() && scopes.is_empty() && !policy); + } + + #[test] + fn classify_secured_is_gated_and_carries_posture() { + let api_doc = crate::openapi::ApiDoc { + secured: true, + required_roles: &["admin"], + required_scopes: &["posts:write"], + ..dummy_api_doc() + }; + let (c, roles, scopes, policy) = classify(&RouteSource::User, &api_doc, None); + assert_eq!(c, RouteClassification::Gated); + assert_eq!(roles, vec!["admin"]); + assert_eq!(scopes, vec!["posts:write"]); + assert!(!policy); + } + + #[test] + fn classify_policy_is_gated_and_carries_policy_flag() { + let api_doc = crate::openapi::ApiDoc { + has_policy: true, + ..dummy_api_doc() + }; + let (c, _roles, _scopes, policy) = classify(&RouteSource::User, &api_doc, None); + assert_eq!(c, RouteClassification::Gated); + assert!(policy); + } + + #[test] + fn classify_public_is_public() { + let api_doc = crate::openapi::ApiDoc { + public: true, + ..dummy_api_doc() + }; + let (c, _, _, _) = classify(&RouteSource::User, &api_doc, None); + assert_eq!(c, RouteClassification::Public); + } + + #[test] + fn classify_unannotated_is_unclassified() { + let (c, _, _, _) = classify(&RouteSource::User, &dummy_api_doc(), None); + assert_eq!(c, RouteClassification::Unclassified); + } + + /// A repository CRUD route generated by `#[repository(api = ..., policy = + /// ...)]` carries its record-level authorization guard on + /// [`RepositoryApiMeta::has_policy`](crate::route::RepositoryApiMeta), + /// while its handler `ApiDoc` is left at defaults (never `secured`/ + /// `has_policy`). Such a route must classify `Gated` — not + /// `Unclassified` — so the audit gate does not flag a policy-protected + /// endpoint as unauthenticated. (#1604) + #[test] + fn classify_repository_policy_is_gated() { + let repo = repo_meta(true); + let (c, roles, scopes, policy) = + classify(&RouteSource::User, &dummy_api_doc(), Some(&repo)); + assert_eq!(c, RouteClassification::Gated); + assert!( + policy, + "repository has_policy must surface as policy = true" + ); + assert!(roles.is_empty() && scopes.is_empty()); + } + + /// A repository auto-API declared with `scope = ...` (but no `policy = + /// ...`) enforces the registered scope on its generated list handler, + /// recorded as `RepositoryApiMeta::scope_check = Some(..)` while + /// `has_policy` stays `false`. Such a route must classify `Gated` — not + /// `Unclassified` — so the audit gate does not flag a scope-protected list + /// endpoint as unauthenticated. No scope *name* is recorded on the meta, so + /// `scopes` stays empty and `policy` stays `false` (a scope is not a + /// policy). (#1604) + #[test] + fn classify_repository_scope_only_is_gated() { + let repo = repo_meta_scope_only(); + let (c, roles, scopes, policy) = + classify(&RouteSource::User, &dummy_api_doc(), Some(&repo)); + assert_eq!(c, RouteClassification::Gated); + assert!( + !policy, + "a repository scope guard is not a policy: policy must stay false" + ); + assert!( + roles.is_empty() && scopes.is_empty(), + "no scope name is recorded on the meta, so scopes stays empty" + ); + } + + /// A repository route without a policy (`#[repository(api = ...)]`, no + /// `policy = ...`) has `has_policy == false` and stays `Unclassified`: + /// that unauthenticated CRUD form is exactly what the audit gate should + /// keep flagging. (#1604) + #[test] + fn classify_repository_without_policy_is_unclassified() { + let repo = repo_meta(false); + let (c, _, _, policy) = classify(&RouteSource::User, &dummy_api_doc(), Some(&repo)); + assert_eq!(c, RouteClassification::Unclassified); + assert!(!policy); + } + + /// Keystone falsifiability check (#1604) at the library level: an + /// unannotated mutating handler classifies as `Unclassified` (the audit + /// gate's failure state), and adding *either* a `#[secured]` guard or a + /// `#[public]` marker flips it to a passing classification. + #[test] + fn unclassified_route_turns_green_when_guarded_or_public() { + // Red: no auth posture declared. + let route = make_route_with(Method::POST, "/widgets", "create_widget", dummy_api_doc()); + let infos = collect_route_infos(&[route], &[RouteSource::User], &[], &[]).unwrap(); + assert_eq!(infos[0].classification, RouteClassification::Unclassified); + + // Green via #[secured]: carries the declared role. + let secured_doc = crate::openapi::ApiDoc { + secured: true, + required_roles: &["admin"], + ..dummy_api_doc() + }; + let route = make_route_with(Method::POST, "/widgets", "create_widget", secured_doc); + let infos = collect_route_infos(&[route], &[RouteSource::User], &[], &[]).unwrap(); + assert_eq!(infos[0].classification, RouteClassification::Gated); + assert_eq!(infos[0].roles, vec!["admin"]); + + // Green via #[public]. + let public_doc = crate::openapi::ApiDoc { + public: true, + ..dummy_api_doc() + }; + let route = make_route_with(Method::POST, "/widgets", "create_widget", public_doc); + let infos = collect_route_infos(&[route], &[RouteSource::User], &[], &[]).unwrap(); + assert_eq!(infos[0].classification, RouteClassification::Public); + } + + /// End-to-end through `collect_route_infos`: a policy-protected repository + /// CRUD route classifies `Gated` (carrying `policy = true`) rather than + /// `Unclassified`, so it does not fail the audit gate. A bare repository + /// route (no policy) stays `Unclassified`. (#1604) + #[test] + fn collect_repository_policy_route_is_gated() { + let route = make_repo_route( + Method::POST, + "/api/posts", + "posts_create", + Some(repo_meta(true)), + ); + let infos = collect_route_infos(&[route], &[RouteSource::User], &[], &[]).unwrap(); + assert_eq!(infos[0].classification, RouteClassification::Gated); + assert!(infos[0].policy); + + let bare = make_repo_route( + Method::POST, + "/api/posts", + "posts_create", + Some(repo_meta(false)), + ); + let infos = collect_route_infos(&[bare], &[RouteSource::User], &[], &[]).unwrap(); + assert_eq!(infos[0].classification, RouteClassification::Unclassified); + assert!(!infos[0].policy); + } + + #[test] + fn collect_carries_handler_module_when_present() { + let api_doc = crate::openapi::ApiDoc { + public: true, + module_path: "myapp::widgets", + ..dummy_api_doc() + }; + let route = make_route_with(Method::GET, "/widgets", "list_widgets", api_doc); + let infos = collect_route_infos(&[route], &[RouteSource::User], &[], &[]).unwrap(); + assert_eq!(infos[0].module.as_deref(), Some("myapp::widgets")); + } + + #[test] + fn framework_get_helper_is_exempt() { + let info = RouteInfo::framework_get("/actuator/health".to_owned(), "actuator"); + assert_eq!(info.classification, RouteClassification::Framework); + assert_eq!(info.source, RouteSource::Framework); + assert_eq!(info.method, "GET"); + } + + #[test] + fn route_classification_serializes_to_lowercase_tag() { + assert_eq!( + serde_json::to_string(&RouteClassification::Gated).unwrap(), + "\"gated\"" + ); + assert_eq!( + serde_json::to_string(&RouteClassification::Unclassified).unwrap(), + "\"unclassified\"" + ); + let decoded: RouteClassification = serde_json::from_str("\"public\"").unwrap(); + assert_eq!(decoded, RouteClassification::Public); + } + // ── sort_route_infos ─────────────────────────────────────────────────── #[test] @@ -600,6 +951,7 @@ mod tests { api_version: None, status: None, sunset_opt_out: None, + ..Default::default() }, RouteInfo { method: "GET".to_owned(), @@ -610,6 +962,7 @@ mod tests { api_version: None, status: None, sunset_opt_out: None, + ..Default::default() }, RouteInfo { method: "GET".to_owned(), @@ -620,6 +973,7 @@ mod tests { api_version: None, status: None, sunset_opt_out: None, + ..Default::default() }, ]; sort_route_infos(&mut infos); @@ -642,6 +996,7 @@ mod tests { api_version: None, status: None, sunset_opt_out: None, + ..Default::default() }, RouteInfo { method: "GET".to_owned(), @@ -652,6 +1007,7 @@ mod tests { api_version: None, status: None, sunset_opt_out: None, + ..Default::default() }, ]; sort_route_infos(&mut infos); @@ -836,6 +1192,7 @@ mod tests { api_version: None, status: None, sunset_opt_out: None, + ..Default::default() }; let json = serde_json::to_string(&info).unwrap(); let decoded: RouteInfo = serde_json::from_str(&json).unwrap();