pub mod accounts;
pub mod agent_config;
pub mod agent_groups;
pub mod agent_installer;
pub mod agent_logs;
pub mod agent_meta;
pub mod agent_releases;
pub mod agents;
pub mod analytics;
pub mod app_packages;
pub mod audit;
pub mod checks;
pub mod collect;
pub mod exec;
pub mod executions;
pub mod fleet_perf;
pub mod freeze;
pub mod group_contacts;
pub mod group_defs;
pub mod group_sql;
pub mod health;
pub mod host_perf;
pub mod inventory;
pub mod jetstream_status;
pub mod jobs;
pub mod notifications;
pub mod obs_events;
pub mod password_setup;
pub mod permission_groups;
pub mod process_perf;
pub mod query;
pub mod remote;
pub mod results;
pub mod run;
pub mod schedules;
pub mod schemas;
pub mod script_objects;
pub mod scripts;
pub mod server;
pub mod server_settings;
pub mod sql_like;
pub mod time_bounds;
pub mod view_sql;
pub mod views;
pub mod yaml_body;
use axum::Router;
use axum::extract::{DefaultBodyLimit, FromRef, State};
use axum::http::StatusCode;
use axum::routing::{delete, get, patch, post, put};
use kanade_shared::feature::Feature;
use regex::Regex;
use sqlx::SqlitePool;
const PUBLISH_BODY_LIMIT: usize = 64 * 1024 * 1024;
#[cfg(target_pointer_width = "64")]
const APP_PACKAGE_BODY_LIMIT: usize = 8 * 1024 * 1024 * 1024;
#[cfg(not(target_pointer_width = "64"))]
const APP_PACKAGE_BODY_LIMIT: usize = usize::MAX;
const SCRIPT_OBJECT_BODY_LIMIT: usize = 4 * 1024 * 1024;
#[derive(Clone)]
pub struct AppState {
pub pool: SqlitePool,
pub query_pool: SqlitePool,
pub nats: async_nats::Client,
pub commands: std::sync::Arc<crate::command_publisher::CommandPublisher>,
pub jetstream: async_nats::jetstream::Context,
pub explode_spec_cache: crate::projector::spec_cache::ExplodeSpecCache,
pub sql_view_cache: view_sql::SqlViewCache,
pub group_cache: group_sql::GroupCache,
pub mailer: Option<std::sync::Arc<crate::mail::Mailer>>,
pub public_url: Option<String>,
pub nats_url: String,
pub login_throttle: std::sync::Arc<crate::login_throttle::LoginThrottle>,
}
impl FromRef<AppState> for SqlitePool {
fn from_ref(state: &AppState) -> Self {
state.pool.clone()
}
}
pub fn router(state: AppState) -> Router {
let base = Router::new()
.route("/health", get(health))
.route("/api/version", get(version))
.route("/api/command-signing", get(command_signing))
.route("/api/auth/login", post(accounts::login))
.route("/api/auth/me", get(accounts::me))
.route("/api/auth/change-password", post(accounts::change_password))
.route("/api/auth/mfa/init", post(accounts::mfa_init))
.route("/api/auth/mfa/verify", post(accounts::mfa_verify))
.route("/api/auth/mfa/disable", post(accounts::mfa_disable))
.route(
"/api/auth/password-setup/{token}",
get(password_setup::get_token).post(password_setup::set_password),
)
.route(
"/api/auth/forgot-password",
post(password_setup::forgot_password),
)
.route("/api/remote/{pc_id}/ws", get(remote::ws))
.route("/api/agents", get(agents::list))
.route("/api/agents/versions", get(agents::versions))
.route("/api/agents/meta-keys", get(agents::meta_keys))
.route("/api/agents/meta", get(agents::meta_bulk))
.route("/api/agents/{pc_id}", get(agents::detail))
.route("/api/agents/{pc_id}/perf", get(host_perf::perf))
.route("/api/perf/fleet", get(fleet_perf::fleet))
.route("/api/perf/top", get(fleet_perf::top))
.route("/api/obs_events", get(obs_events::list))
.route("/api/obs_events/kinds", get(obs_events::kinds))
.route("/api/obs_events/lane_seeds", get(obs_events::lane_seeds))
.route("/api/obs_events/sources", get(obs_events::sources))
.route("/api/obs_events/recent", get(obs_events::recent))
.route("/api/analytics", get(analytics::get))
.route(
"/api/perf/active-investigations",
get(fleet_perf::active_investigations),
)
.route(
"/api/agents/{pc_id}/processes",
get(process_perf::processes),
)
.route(
"/api/agents/{pc_id}/processes/timeline",
get(process_perf::timeline),
)
.route("/api/agents/{pc_id}/groups", get(agent_groups::list_groups))
.route("/api/agents/{pc_id}/meta", get(agent_meta::get_meta))
.route("/api/groups", get(agent_groups::list_all_groups))
.route(
"/api/groups/{name}/email",
get(group_contacts::get_contacts),
)
.route(
"/api/agents/{pc_id}/effective_config",
get(agent_config::effective),
)
.route("/api/config", get(agent_config::get_global))
.route("/api/config/defaults", get(agent_config::defaults))
.route("/api/groups/{name}/config", get(agent_config::get_group))
.route(
"/api/groups/{name}/config/inherited",
get(agent_config::group_inherited),
)
.route("/api/pcs/{pc_id}/config", get(agent_config::get_pc))
.route(
"/api/pcs/{pc_id}/config/inherited",
get(agent_config::pc_inherited),
)
.route("/api/results", get(results::list))
.route("/api/results/{result_id}", get(results::detail))
.route("/api/results/{result_id}/tail", get(results::tail))
.route("/api/executions", get(executions::list))
.route("/api/executions/{exec_id}", get(executions::detail))
.route("/api/audit", get(audit::list))
.route("/api/schedules", get(schedules::list))
.route("/api/schedules/upcoming", get(schedules::upcoming))
.route("/api/freeze", get(freeze::get))
.route("/api/server-settings", get(server_settings::get))
.route(
"/api/server-settings/defaults",
get(server_settings::defaults),
)
.route("/api/scripts/status", get(scripts::list_status))
.route("/api/jobs", get(jobs::list))
.route("/api/jobs/{id}/yaml", get(jobs::get_yaml))
.route("/api/views", get(views::list))
.route("/api/views/{id}/yaml", get(views::get_yaml))
.route("/api/group-defs", get(group_defs::list))
.route("/api/group-defs/{id}/yaml", get(group_defs::get_yaml))
.route("/api/group-defs/{id}/members", get(group_defs::members))
.route("/api/schedules/{id}/yaml", get(schedules::get_yaml))
.route("/api/schedules/{id}/preview", get(schedules::preview))
.route("/api/schedules/{id}/status", get(schedules::status))
.route("/api/schedules/coverage", get(schedules::coverage_summary))
.route("/api/schedules/{id}/coverage", get(schedules::coverage))
.route("/api/schemas/manifest.json", get(schemas::manifest_schema))
.route("/api/schemas/schedule.json", get(schemas::schedule_schema))
.route("/api/schemas/view.json", get(schemas::view_schema))
.route(
"/api/schemas/group-def.json",
get(schemas::group_def_schema),
)
.route("/api/jetstream/status", get(jetstream_status::status))
.route("/api/health/fleet", get(health::fleet))
.route("/api/health/scan_durations", get(health::scan_durations))
.route("/api/inventory/jobs", get(inventory::list_jobs))
.route(
"/api/inventory/by-job/{manifest_id}",
get(inventory::list_for_job),
)
.route(
"/api/inventory/{manifest_id}/search/{field}",
get(inventory::search),
)
.route(
"/api/inventory/{manifest_id}/search-scalars",
get(inventory::search_scalars),
)
.route(
"/api/inventory/{manifest_id}/history/pc/{pc_id}",
get(inventory::history_for_pc),
)
.route(
"/api/inventory/{manifest_id}/history/search",
get(inventory::fleet_history_search),
)
.route(
"/api/inventory/{manifest_id}/history/first_seen",
get(inventory::first_seen),
)
.route("/api/inventory/{pc_id}", get(inventory::list_for_pc))
.route("/api/checks", get(checks::list_all))
.route(
"/api/notifications/{id}/ack_status",
get(notifications::ack_status),
)
.route("/api/notifications/{id}", get(notifications::detail))
.route("/api/notifications", get(notifications::list_sent))
.route("/api/agents/{pc_id}/logs", get(agent_logs::tail))
.route("/api/agents/releases", get(agent_releases::list_releases))
.route("/api/agents/installer", get(agent_installer::installer))
.route(
"/api/agents/installer.ps1",
get(agent_installer::installer_ps1),
)
.route(
"/api/agents/installer.sh",
get(agent_installer::installer_sh),
)
.route("/api/app-packages", get(app_packages::list_packages))
.route(
"/api/app-packages/{name}/{version}",
get(app_packages::download),
)
.route("/api/script-objects", get(script_objects::list_objects))
.route(
"/api/script-objects/{name}/{version}",
get(script_objects::download),
)
.route("/api/collect/bundles", get(collect::list_bundles))
.route("/api/collect/bundles/{*key}", get(collect::download_bundle));
let operator = Router::new()
.route("/api/agents/{pc_id}", delete(agents::delete))
.route(
"/api/agents/{pc_id}/groups",
put(agent_groups::set_groups).post(agent_groups::add_group),
)
.route(
"/api/agents/{pc_id}/groups/{group}",
delete(agent_groups::remove_group),
)
.route("/api/agents/{pc_id}/meta", put(agent_meta::put_meta))
.route(
"/api/agents/{pc_id}/meta/key",
put(agent_meta::set_key).delete(agent_meta::remove_key),
)
.route(
"/api/config",
put(agent_config::put_global).delete(agent_config::delete_global),
)
.route(
"/api/config/fields/{field}",
put(agent_config::set_field_global).delete(agent_config::unset_field_global),
)
.route(
"/api/groups/{name}/config",
put(agent_config::put_group).delete(agent_config::delete_group),
)
.route(
"/api/groups/{name}/config/fields/{field}",
put(agent_config::set_field_group).delete(agent_config::unset_field_group),
)
.route(
"/api/groups/{name}/email",
put(group_contacts::put_contacts),
)
.route("/api/server-settings", put(server_settings::put))
.route(
"/api/server-settings/support-codes/{scope}",
put(server_settings::put_support_code).delete(server_settings::delete_support_code),
)
.route("/api/server/restart", post(server::restart))
.route(
"/api/pcs/{pc_id}/config",
put(agent_config::put_pc).delete(agent_config::delete_pc),
)
.route(
"/api/pcs/{pc_id}/config/fields/{field}",
put(agent_config::set_field_pc).delete(agent_config::unset_field_pc),
)
.route("/api/exec/{job_id}", post(exec::create))
.route("/api/notifications", post(notifications::publish))
.route(
"/api/notifications/{id}/recall",
post(notifications::recall),
)
.route("/api/notifications/{id}", patch(notifications::edit))
.route("/api/schedules", post(schedules::create))
.route("/api/schedules/{id}", delete(schedules::delete))
.route("/api/views", post(views::create))
.route("/api/views/{id}", delete(views::delete))
.route("/api/group-defs", post(group_defs::create))
.route("/api/group-defs/{id}", delete(group_defs::delete))
.route("/api/schedules/{id}/disable", post(schedules::disable))
.route("/api/schedules/{id}/enable", post(schedules::enable))
.route("/api/freeze", put(freeze::set).delete(freeze::clear))
.route("/api/run", post(run::run))
.route("/api/agents/{pc_id}/ping", post(run::ping))
.route("/api/scripts/{cmd_id}/revoke", post(scripts::revoke))
.route("/api/scripts/{cmd_id}/unrevoke", post(scripts::unrevoke))
.route("/api/jobs", post(jobs::create))
.route("/api/jobs/{id}", delete(jobs::delete))
.route("/api/checks/{check_name}", delete(checks::clear))
.route("/api/jobs/{job_id}/kill", post(jobs::kill))
.route(
"/api/agents/releases/{version}",
delete(agent_releases::delete_release),
)
.route("/api/agents/rollout", post(agent_releases::rollout))
.route(
"/api/agents/publish",
post(agent_releases::publish).layer(DefaultBodyLimit::max(PUBLISH_BODY_LIMIT)),
)
.route(
"/api/app-packages/{name}/{version}",
post(app_packages::publish)
.delete(app_packages::delete_package)
.layer(DefaultBodyLimit::max(APP_PACKAGE_BODY_LIMIT)),
)
.route(
"/api/script-objects/{name}/{version}",
post(script_objects::publish)
.delete(script_objects::delete_object)
.layer(DefaultBodyLimit::max(SCRIPT_OBJECT_BODY_LIMIT)),
)
.route(
"/api/collect/bundles/{*key}",
axum::routing::delete(collect::delete_bundle),
)
.route_layer(axum::middleware::from_fn(crate::auth::require_operator));
let admin = Router::new()
.route("/api/accounts", get(accounts::list).post(accounts::create))
.route(
"/api/accounts/{username}",
patch(accounts::update).delete(accounts::delete),
)
.route(
"/api/accounts/{username}/reset-link",
post(accounts::reset_link),
)
.route(
"/api/permission-groups",
get(permission_groups::list).post(permission_groups::create),
)
.route(
"/api/permission-groups/{name}",
patch(permission_groups::update).delete(permission_groups::delete),
)
.route("/api/query", post(query::execute))
.route_layer(axum::middleware::from_fn(crate::auth::require_admin));
base.merge(operator)
.merge(admin)
.with_state(state)
.layer(axum::middleware::from_fn(crate::auth::require_features))
.fallback(crate::web::serve)
}
pub fn feature_for_path(path: &str) -> Option<Feature> {
Some(match path {
"/api/inventory/jobs"
| "/api/inventory/by-job/{manifest_id}"
| "/api/inventory/{manifest_id}/search/{field}"
| "/api/inventory/{manifest_id}/search-scalars"
| "/api/inventory/{manifest_id}/history/pc/{pc_id}"
| "/api/inventory/{manifest_id}/history/search"
| "/api/inventory/{manifest_id}/history/first_seen"
| "/api/inventory/{pc_id}" => Feature::Inventory,
"/api/checks" | "/api/checks/{check_name}" => Feature::Compliance,
"/api/analytics" => Feature::Analytics,
"/api/results"
| "/api/results/{result_id}"
| "/api/results/{result_id}/tail"
| "/api/executions"
| "/api/executions/{exec_id}" => Feature::Activity,
"/api/obs_events"
| "/api/obs_events/kinds"
| "/api/obs_events/lane_seeds"
| "/api/obs_events/sources" => Feature::Events,
"/api/audit" => Feature::Audit,
"/api/remote/{pc_id}/ws" => Feature::Remote,
"/api/agents/{pc_id}/logs" => Feature::Logs,
"/api/collect/bundles" | "/api/collect/bundles/{*key}" => Feature::Collect,
"/api/jobs"
| "/api/jobs/{id}/yaml"
| "/api/jobs/{id}"
| "/api/jobs/{job_id}/kill"
| "/api/scripts/status"
| "/api/scripts/{cmd_id}/revoke"
| "/api/scripts/{cmd_id}/unrevoke" => Feature::Jobs,
"/api/schedules"
| "/api/schedules/coverage"
| "/api/schedules/{id}/yaml"
| "/api/schedules/{id}/preview"
| "/api/schedules/{id}/status"
| "/api/schedules/{id}/coverage"
| "/api/schedules/{id}"
| "/api/schedules/{id}/disable"
| "/api/schedules/{id}/enable" => Feature::Schedules,
"/api/views" | "/api/views/{id}/yaml" | "/api/views/{id}" => Feature::Views,
"/api/group-defs"
| "/api/group-defs/{id}/yaml"
| "/api/group-defs/{id}/members"
| "/api/group-defs/{id}" => Feature::Groups,
"/api/notifications"
| "/api/notifications/{id}"
| "/api/notifications/{id}/ack_status"
| "/api/notifications/{id}/recall" => Feature::Notifications,
"/api/agents/releases"
| "/api/agents/releases/{version}"
| "/api/agents/rollout"
| "/api/agents/publish" => Feature::Rollout,
"/api/agents/installer" | "/api/agents/installer.ps1" | "/api/agents/installer.sh" => {
Feature::AgentInstall
}
"/api/app-packages"
| "/api/app-packages/{name}/{version}"
| "/api/script-objects"
| "/api/script-objects/{name}/{version}" => Feature::Apps,
"/api/groups" | "/api/groups/{name}/email" => Feature::Groups,
"/api/agents/{pc_id}/effective_config"
| "/api/config"
| "/api/config/defaults"
| "/api/config/fields/{field}"
| "/api/groups/{name}/config"
| "/api/groups/{name}/config/fields/{field}"
| "/api/groups/{name}/config/inherited"
| "/api/pcs/{pc_id}/config"
| "/api/pcs/{pc_id}/config/fields/{field}"
| "/api/pcs/{pc_id}/config/inherited" => Feature::Config,
"/api/jetstream/status" => Feature::Jetstream,
"/api/run" => Feature::Run,
"/api/exec/{job_id}" => Feature::Exec,
"/api/server-settings"
| "/api/server-settings/defaults"
| "/api/server-settings/support-codes/{scope}"
| "/api/server/restart" => Feature::Settings,
"/api/accounts"
| "/api/accounts/{username}"
| "/api/accounts/{username}/reset-link"
| "/api/permission-groups"
| "/api/permission-groups/{name}"
| "/api/query" => Feature::Accounts,
_ => return None,
})
}
pub fn shared_owner_features(path: &str) -> Option<&'static [Feature]> {
const PICKER_OWNERS: [Feature; 10] = [
Feature::Run,
Feature::Exec,
Feature::Inventory,
Feature::Activity,
Feature::Events,
Feature::Logs,
Feature::Analytics,
Feature::Notifications,
Feature::Rollout,
Feature::Config,
];
const META_COLUMN_OWNERS: [Feature; 5] = [
Feature::Inventory,
Feature::Activity,
Feature::Events,
Feature::Compliance,
Feature::Collect,
];
match path {
"/api/agents" => Some(&PICKER_OWNERS),
"/api/agents/meta-keys" | "/api/agents/meta" => Some(&META_COLUMN_OWNERS),
"/api/groups" => Some(&[
Feature::Groups,
Feature::Config,
Feature::Exec,
Feature::Rollout,
]),
"/api/jobs" => Some(&[Feature::Jobs, Feature::Exec]),
_ => None,
}
}
async fn health() -> &'static str {
"ok"
}
#[derive(serde::Serialize)]
struct VersionResponse {
version: &'static str,
}
async fn version() -> axum::Json<VersionResponse> {
axum::Json(VersionResponse {
version: env!("CARGO_PKG_VERSION"),
})
}
#[derive(serde::Serialize)]
struct CommandSigningResponse {
kid: Option<String>,
fingerprint: Option<String>,
}
async fn command_signing(State(st): State<AppState>) -> axum::Json<CommandSigningResponse> {
let (kid, fingerprint) = match st.commands.identity_parts() {
Some((kid, fp)) => (Some(kid.to_string()), Some(fp)),
None => (None, None),
};
axum::Json(CommandSigningResponse { kid, fingerprint })
}
pub(crate) fn compile(opt: Option<&str>) -> Result<Option<Regex>, (StatusCode, String)> {
match opt.map(str::trim).filter(|s| !s.is_empty()) {
Some(s) => Regex::new(s)
.map(Some)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("invalid regex `{s}`: {e}"))),
None => Ok(None),
}
}
#[cfg(test)]
mod feature_map_tests {
use super::*;
#[test]
fn gated_routes_map_to_their_feature() {
assert_eq!(
feature_for_path("/api/inventory/jobs"),
Some(Feature::Inventory)
);
assert_eq!(feature_for_path("/api/checks"), Some(Feature::Compliance));
assert_eq!(
feature_for_path("/api/results/{result_id}"),
Some(Feature::Activity)
);
assert_eq!(feature_for_path("/api/audit"), Some(Feature::Audit));
assert_eq!(
feature_for_path("/api/collect/bundles/{*key}"),
Some(Feature::Collect)
);
assert_eq!(
feature_for_path("/api/jetstream/status"),
Some(Feature::Jetstream)
);
assert_eq!(
feature_for_path("/api/server-settings"),
Some(Feature::Settings)
);
assert_eq!(
feature_for_path("/api/server-settings/support-codes/{scope}"),
Some(Feature::Settings)
);
assert_eq!(feature_for_path("/api/query"), Some(Feature::Accounts));
assert_eq!(
feature_for_path("/api/agents/installer"),
Some(Feature::AgentInstall)
);
assert_eq!(
feature_for_path("/api/agents/installer.ps1"),
Some(Feature::AgentInstall)
);
assert_eq!(
feature_for_path("/api/agents/installer.sh"),
Some(Feature::AgentInstall)
);
assert_eq!(feature_for_path("/api/group-defs"), Some(Feature::Groups));
assert_eq!(
feature_for_path("/api/group-defs/{id}/members"),
Some(Feature::Groups)
);
assert_eq!(feature_for_path("/api/scripts/status"), Some(Feature::Jobs));
assert_eq!(
feature_for_path("/api/scripts/{cmd_id}/revoke"),
Some(Feature::Jobs)
);
assert_eq!(
feature_for_path("/api/scripts/{cmd_id}/unrevoke"),
Some(Feature::Jobs)
);
assert_eq!(
feature_for_path("/api/schedules/coverage"),
Some(Feature::Schedules)
);
assert_eq!(
feature_for_path("/api/server-settings/defaults"),
Some(Feature::Settings)
);
for path in [
"/api/config/defaults",
"/api/groups/{name}/config/inherited",
"/api/pcs/{pc_id}/config/inherited",
"/api/agents/{pc_id}/effective_config",
] {
assert_eq!(feature_for_path(path), Some(Feature::Config), "{path}");
}
assert_eq!(feature_for_path("/api/jobs"), Some(Feature::Jobs));
assert_eq!(feature_for_path("/api/groups"), Some(Feature::Groups));
}
#[test]
fn commons_routes_are_ungated() {
assert_eq!(feature_for_path("/api/version"), None);
assert_eq!(feature_for_path("/api/auth/me"), None);
for path in [
"/api/agents",
"/api/agents/meta-keys",
"/api/agents/meta",
"/api/agents/{pc_id}",
"/api/perf/fleet",
"/api/obs_events/recent",
"/api/schedules/upcoming",
] {
assert_eq!(feature_for_path(path), None, "{path}");
}
assert_eq!(feature_for_path("/api/something-new"), None);
}
#[test]
fn shared_owner_routes_cover_every_embedding_page() {
let picker_pages = [
Feature::Run,
Feature::Exec,
Feature::Inventory,
Feature::Activity,
Feature::Events,
Feature::Logs,
Feature::Analytics,
Feature::Notifications,
Feature::Rollout,
Feature::Config,
];
let owners = shared_owner_features("/api/agents").expect("gated");
for feature in picker_pages {
assert!(owners.contains(&feature), "{feature:?} missing");
}
assert_eq!(owners.len(), picker_pages.len());
assert!(!owners.contains(&Feature::Compliance));
let meta_owners = shared_owner_features("/api/agents/meta-keys").expect("gated");
for feature in [
Feature::Inventory,
Feature::Activity,
Feature::Events,
Feature::Compliance,
Feature::Collect,
] {
assert!(meta_owners.contains(&feature), "{feature:?} missing");
}
assert_eq!(meta_owners.len(), 5);
assert_eq!(shared_owner_features("/api/agents/meta"), Some(meta_owners));
assert!(!meta_owners.contains(&Feature::Run));
assert!(!meta_owners.contains(&Feature::Config));
assert_eq!(
shared_owner_features("/api/groups"),
Some(
&[
Feature::Groups,
Feature::Config,
Feature::Exec,
Feature::Rollout
][..]
)
);
assert_eq!(
shared_owner_features("/api/jobs"),
Some(&[Feature::Jobs, Feature::Exec][..])
);
for path in [
"/api/agents",
"/api/agents/meta-keys",
"/api/agents/meta",
"/api/groups",
"/api/jobs",
] {
let owners = shared_owner_features(path).expect("gated");
for feature in [
Feature::Audit,
Feature::Jetstream,
Feature::Settings,
Feature::Accounts,
] {
assert!(!owners.contains(&feature), "{feature:?} in {path}");
}
}
assert_eq!(shared_owner_features("/api/agents/{pc_id}"), None);
assert_eq!(shared_owner_features("/api/audit"), None);
assert_eq!(shared_owner_features("/api/schedules/coverage"), None);
}
#[test]
fn read_and_mutation_share_the_gate() {
assert_eq!(feature_for_path("/api/config"), Some(Feature::Config));
}
#[test]
fn config_field_routes_are_gated_by_the_config_feature() {
for path in [
"/api/config/fields/{field}",
"/api/groups/{name}/config/fields/{field}",
"/api/pcs/{pc_id}/config/fields/{field}",
] {
assert_eq!(feature_for_path(path), Some(Feature::Config), "{path}");
}
}
}
#[cfg(test)]
mod spa_route_tests {
use super::*;
use axum::http::Method;
use regex::Regex;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
const PAGE_FEATURES: &[(&str, Option<Feature>)] = &[
("Account.tsx", None),
("Accounts.tsx", Some(Feature::Accounts)),
("Activity.tsx", Some(Feature::Activity)),
("AgentDetail.tsx", None),
("AgentInstall.tsx", Some(Feature::AgentInstall)),
("Agents.tsx", None),
("Analytics.tsx", Some(Feature::Analytics)),
("Apps.tsx", Some(Feature::Apps)),
("Audit.tsx", Some(Feature::Audit)),
("ChangePassword.tsx", None),
("Collect.tsx", Some(Feature::Collect)),
("Compliance.tsx", Some(Feature::Compliance)),
("Config.tsx", Some(Feature::Config)),
("Dashboard.tsx", None),
("Events.tsx", Some(Feature::Events)),
("Exec.tsx", Some(Feature::Exec)),
("Groups.tsx", Some(Feature::Groups)),
("Inventory.tsx", Some(Feature::Inventory)),
("JetStream.tsx", Some(Feature::Jetstream)),
("Jobs.tsx", Some(Feature::Jobs)),
("Login.tsx", None),
("Logs.tsx", Some(Feature::Logs)),
("NotificationDetail.tsx", Some(Feature::Notifications)),
("Notifications.tsx", Some(Feature::Notifications)),
("PasswordSetup.tsx", None),
("Placeholder.tsx", None),
("RemoteScreen.tsx", Some(Feature::Remote)),
("ResultDetail.tsx", Some(Feature::Activity)),
("Rollout.tsx", Some(Feature::Rollout)),
("Run.tsx", Some(Feature::Run)),
("Schedules.tsx", Some(Feature::Schedules)),
("Search.tsx", None),
("Settings.tsx", Some(Feature::Settings)),
("Views.tsx", Some(Feature::Views)),
];
const DYNAMIC_CALL_SITES: &[(&str, &str, &[&str])] = &[
(
"AgentInstall.tsx",
"AgentInstall.tsx",
&["/api/agents/installer"],
),
(
"Apps.tsx",
"Apps.tsx",
&["/api/app-packages", "/api/script-objects"],
),
(
"Collect.tsx",
"Collect.tsx",
&["/api/collect/bundles/{*key}"],
),
(
"Config.tsx",
"Config.tsx",
&[
"/api/groups/{name}/config",
"/api/groups/{name}/config/inherited",
"/api/pcs/{pc_id}/config",
"/api/pcs/{pc_id}/config/inherited",
],
),
(
"Inventory.tsx",
"Inventory.tsx",
&["/api/inventory/{manifest_id}/history/pc/{pc_id}"],
),
(
"Search.tsx",
"Inventory.tsx",
&[
"/api/inventory/{manifest_id}/search-scalars",
"/api/inventory/{manifest_id}/search/{field}",
],
),
("YamlEditorDialog.tsx", "Jobs.tsx", &["/api/jobs/{id}/yaml"]),
(
"YamlEditorDialog.tsx",
"Schedules.tsx",
&["/api/schedules/{id}/yaml"],
),
(
"YamlEditorDialog.tsx",
"Views.tsx",
&["/api/views/{id}/yaml"],
),
(
"YamlEditorDialog.tsx",
"Groups.tsx",
&["/api/group-defs/{id}/yaml"],
),
];
const KNOWN_UNREACHABLE: &[(&str, &str, &str)] = &[(
"Activity.tsx",
"/api/jobs/{}/kill",
"the Activity page's per-row stop posts the Jobs-owned kill route, so \
an activity-only operator is refused. Deliberate for now: killing is \
the Jobs page's capability, and widening it would hand that to every \
activity-only group. If the button should instead hide itself for an \
account without Jobs, that is the fix — not an entry in a table.",
)];
const META_COLUMN_ROUTES: [&str; 2] = ["/api/agents/meta-keys", "/api/agents/meta"];
fn web_src() -> Option<PathBuf> {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("web/src");
dir.is_dir().then_some(dir)
}
fn is_test_source(name: &str) -> bool {
name.contains(".ct.")
|| name.contains(".test.")
|| name.contains(".screenshot.")
|| name.contains("harness")
}
fn normalize(url: &str) -> Option<String> {
let interpolation = Regex::new(r"\$\{[^}]*\}").expect("regex");
let segment = Regex::new(r"\{[^}]*\}").expect("regex");
let collapsed = interpolation.replace_all(url, "{}");
let without_query = collapsed.split('?').next().unwrap_or_default();
let collapsed = segment.replace_all(without_query, "{}");
let trimmed = collapsed.trim_end_matches('/');
let resolved = match trimmed.strip_suffix("{}") {
Some(head) if !head.ends_with('/') => head.to_string(),
_ => trimmed.to_string(),
};
let leftover = resolved.replace("{}", "");
if leftover.contains('{') || leftover.contains('}') || leftover.contains('$') {
return None;
}
Some(resolved)
}
struct Fetch {
url: Option<String>,
method: Method,
}
fn options_after(url_tail: &str) -> &str {
let mut depth: i32 = 0;
let mut quote: Option<u8> = None;
for (i, byte) in url_tail.bytes().enumerate() {
match quote {
Some(open) if byte == open => quote = None,
Some(_) => {}
None => match byte {
b'\'' | b'"' | b'`' => quote = Some(byte),
b'(' | b'{' | b'[' => depth += 1,
b')' if depth == 0 => return &url_tail[..i],
b')' | b'}' | b']' => depth -= 1,
_ => {}
},
}
}
url_tail
}
fn method_of(after_url: &str) -> Method {
let options = options_after(after_url);
for (verb, method) in [
("POST", Method::POST),
("PUT", Method::PUT),
("PATCH", Method::PATCH),
("DELETE", Method::DELETE),
] {
if options.contains(&format!("method: '{verb}'"))
|| options.contains(&format!("method: \"{verb}\""))
{
return method;
}
}
Method::GET
}
fn fetches(src: &str) -> Vec<Fetch> {
let helper = Regex::new(r"\bapiFetch(?:Paged|Text|Blob)?\b").expect("regex");
let mut out = Vec::new();
for found in helper.find_iter(src) {
let before = &src[found.start().saturating_sub(32)..found.start()];
if before.contains("function ") {
continue;
}
let mut rest = &src[found.end()..];
rest = rest.trim_start();
if rest.starts_with('<') {
let mut depth = 0usize;
let mut end = None;
for (i, byte) in rest.bytes().enumerate() {
match byte {
b'<' => depth += 1,
b'>' => {
depth -= 1;
if depth == 0 {
end = Some(i);
break;
}
}
_ => {}
}
}
let Some(close) = end else {
continue;
};
rest = rest[close + 1..].trim_start();
}
if !rest.starts_with('(') {
continue;
}
let arg = rest[1..].trim_start();
let (url, after_url) = match arg.chars().next() {
Some(quote @ ('`' | '\'' | '"')) => match arg[1..].find(quote) {
Some(end) => (Some(arg[1..1 + end].to_string()), &arg[2 + end..]),
None => (None, arg),
},
_ => (None, arg),
};
out.push(Fetch {
url,
method: method_of(after_url),
});
}
out
}
fn route_patterns() -> HashMap<String, String> {
let src = include_str!("mod.rs");
let start = src.find("pub fn feature_for_path").expect("route table");
let end = start + src[start..].find("async fn health").expect("end of tables");
let literal = Regex::new(r#""(/api/[^"]*)""#).expect("regex");
let mut candidates: BTreeMap<String, Vec<String>> = BTreeMap::new();
for found in literal.captures_iter(&src[start..end]) {
let pattern = found[1].to_string();
let Some(key) = normalize(&pattern) else {
continue;
};
candidates.entry(key).or_default().push(pattern);
}
candidates
.into_iter()
.map(|(key, spellings)| {
let known = spellings
.iter()
.find(|p| feature_for_path(p).is_some() || shared_owner_features(p).is_some())
.unwrap_or(&spellings[0])
.clone();
(key, known)
})
.collect()
}
fn resolve_import(from: &Path, spec: &str, web: &Path) -> Option<PathBuf> {
let base = if let Some(rest) = spec.strip_prefix("@/") {
web.join(rest)
} else if spec.starts_with('.') {
from.parent()?.join(spec)
} else {
return None;
};
[
base.with_extension("tsx"),
base.with_extension("ts"),
base.join("index.tsx"),
base.join("index.ts"),
]
.into_iter()
.find(|candidate| candidate.is_file())
}
fn closure(page: &Path, web: &Path) -> Vec<PathBuf> {
let imported = Regex::new(r#"from\s+['"]([^'"]+)['"]"#).expect("regex");
let mut seen = BTreeSet::new();
let mut pending = vec![page.to_path_buf()];
let mut out = Vec::new();
while let Some(file) = pending.pop() {
if !seen.insert(file.clone()) {
continue;
}
let Ok(src) = std::fs::read_to_string(&file) else {
continue;
};
for found in imported.captures_iter(&src) {
if let Some(dependency) = resolve_import(&file, &found[1], web) {
if !seen.contains(&dependency) {
pending.push(dependency);
}
}
}
out.push(file);
}
out
}
#[test]
fn a_restricted_page_can_fetch_what_it_fetches() {
let Some(web) = web_src() else {
eprintln!("web/src absent (published crate) — SPA route guard skipped");
return;
};
let pages = web.join("pages");
let mut declared: Vec<&str> = PAGE_FEATURES.iter().map(|(name, _)| *name).collect();
declared.sort_unstable();
let mut present: Vec<String> = std::fs::read_dir(&pages)
.expect("web/src/pages")
.flatten()
.filter_map(|entry| entry.file_name().to_str().map(str::to_string))
.filter(|name| name.ends_with(".tsx") && !is_test_source(name))
.collect();
present.sort_unstable();
assert_eq!(
declared, present,
"PAGE_FEATURES has drifted from web/src/pages"
);
let patterns = route_patterns();
let mut dynamic: BTreeSet<(String, String)> = BTreeSet::new();
let mut violations: Vec<String> = Vec::new();
let mut exempted: BTreeSet<(String, String)> = BTreeSet::new();
for (page_name, feature) in PAGE_FEATURES.iter().copied() {
let Some(feature) = feature else {
continue;
};
let page = pages.join(page_name);
let files = closure(&page, &web);
let wants_meta_columns = files.iter().any(|file| {
let name = file
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
name != "table.tsx"
&& std::fs::read_to_string(file)
.map(|src| src.contains("metaColumns"))
.unwrap_or(false)
});
for file in files {
let Some(file_name) = file.file_name().and_then(|n| n.to_str()) else {
continue;
};
let file_name = file_name.to_string();
let Ok(src) = std::fs::read_to_string(&file) else {
continue;
};
for fetch in fetches(&src) {
let Some(url) = fetch
.url
.as_deref()
.filter(|u| u.starts_with("/api/"))
.and_then(normalize)
else {
dynamic.insert((page_name.to_string(), file_name.clone()));
continue;
};
if file_name == "table.tsx"
&& META_COLUMN_ROUTES.contains(&url.as_str())
&& !wants_meta_columns
{
continue;
}
let pattern = patterns.get(&url).cloned().unwrap_or_else(|| url.clone());
let denial = crate::auth::feature_denial(
Some(&[feature]),
&fetch.method,
Some(&pattern),
);
let exempt = KNOWN_UNREACHABLE
.iter()
.find(|(exempt_page, exempt_url, _)| {
*exempt_page == page_name && *exempt_url == url
})
.map(|(_, _, why)| *why);
match (denial, exempt) {
(Some(_), Some(_)) => {
exempted.insert((page_name.to_string(), url));
}
(Some(message), None) => violations.push(format!(
"{page_name} ({}) {file_name} fetches {} {url} -> {pattern}: {message}",
feature.as_str(),
fetch.method,
)),
(None, Some(why)) => violations.push(format!(
"{page_name} can now reach {url} — drop it from KNOWN_UNREACHABLE \
(it was listed because {why})"
)),
(None, None) => {}
}
}
}
}
assert!(
violations.is_empty(),
"a route a restricted page fetches must be reachable for that page's feature \
(gate it in feature_for_path, list it in shared_owner_features, or exempt it \
in KNOWN_UNREACHABLE with a reason):\n {}",
violations.join("\n ")
);
for (page_name, url, _) in KNOWN_UNREACHABLE {
assert!(
exempted.contains(&(page_name.to_string(), url.to_string())),
"KNOWN_UNREACHABLE lists {page_name} / {url}, which is no longer denied — drop it"
);
}
let declared: BTreeSet<(String, String)> = DYNAMIC_CALL_SITES
.iter()
.map(|(file, page, _)| ((*page).to_string(), (*file).to_string()))
.collect();
assert_eq!(
dynamic, declared,
"the set of call sites whose fetch URL is computed changed — make it a literal, or \
list it in DYNAMIC_CALL_SITES with the routes it can reach"
);
for (file, page_name, routes) in DYNAMIC_CALL_SITES {
let feature = PAGE_FEATURES
.iter()
.find(|(name, _)| name == page_name)
.and_then(|(_, feature)| *feature)
.unwrap_or_else(|| {
panic!("DYNAMIC_CALL_SITES names {page_name}, which is not a restrictable page")
});
for route in *routes {
let pattern = normalize(route)
.and_then(|key| patterns.get(&key).cloned())
.unwrap_or_else(|| (*route).to_string());
assert!(
crate::auth::feature_denial(Some(&[feature]), &Method::GET, Some(&pattern))
.is_none(),
"{page_name} ({}) reaches {route} through {file}, which its own feature must \
open — gate it in feature_for_path, or widen reads in shared_owner_features",
feature.as_str()
);
}
}
}
}