Files
PiCloud/crates/manager-core/src/route_admin.rs
MechaCat02 c6211a73b9 feat(v1.1.3-modules): reject module scripts from routes + triggers; tighten cross-app trigger check
- `POST /api/v1/admin/scripts/{id}/routes` returns 400 when the
  target script is `kind=module`. Modules have no entry point — they
  are imported, not invoked.
- `POST /api/v1/admin/apps/{id}/triggers/{kv,docs,dead_letter}` gain
  a shared `validate_trigger_target` that loads the target script
  and rejects when:
  - the script doesn't exist
  - the script belongs to a different app  (latent v1.1.1/v1.1.2 gap
    where triggers could target a script in any app — closed here)
  - the script is `kind=module`
- `TriggersState` grows a `scripts: Arc<dyn ScriptRepository>` field
  so handlers can load the target script.
- Trigger-create test helpers split into `state_with` (empty script
  repo — for tests asserting upstream errors) and
  `state_with_endpoint` (pre-populated — for tests asserting
  successful creation). `InMemoryScriptRepo` added to the test
  module.

Workspace builds; full test suite (~440 tests) green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 22:15:53 +02:00

580 lines
19 KiB
Rust

//! Admin endpoints for routes. Mounted under `/api/v1/admin` alongside
//! the script CRUD endpoints; the picloud binary wires the
//! `RouteTable` shared with the orchestrator dispatcher in here so
//! writes invalidate the in-memory snapshot.
use std::sync::Arc;
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::{delete, get, post},
Extension, Json, Router,
};
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
use picloud_shared::{AppId, HostKind, PathKind, Principal, Route, ScriptId};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::app_domain_repo::AppDomainRepository;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
use crate::repo::{ScriptRepository, ScriptRepositoryError};
use crate::route_repo::{NewRoute, RouteRepository};
pub struct RouteAdminState<RR, SR> {
pub routes: Arc<RR>,
/// Used to resolve `script_id → app_id` when creating routes (the
/// route inherits the script's app) and to scope conflict checks.
pub scripts: Arc<SR>,
/// Used to validate the route's host against the parent app's
/// declared domain claims.
pub domains: Arc<dyn AppDomainRepository>,
pub table: Arc<RouteTable>,
/// Capability gate — Phase 3.5.
pub authz: Arc<dyn AuthzRepo>,
}
impl<RR, SR> Clone for RouteAdminState<RR, SR> {
fn clone(&self) -> Self {
Self {
routes: self.routes.clone(),
scripts: self.scripts.clone(),
domains: self.domains.clone(),
table: self.table.clone(),
authz: self.authz.clone(),
}
}
}
pub fn route_admin_router<RR, SR>(state: RouteAdminState<RR, SR>) -> Router
where
RR: RouteRepository + 'static,
SR: ScriptRepository + 'static,
{
Router::new()
.route(
"/scripts/{id}/routes",
get(list_routes::<RR, SR>).post(create_route::<RR, SR>),
)
.route("/routes/{route_id}", delete(delete_route::<RR, SR>))
.route("/routes:check", post(check_route::<RR, SR>))
.route("/routes:match", post(match_route::<RR, SR>))
.with_state(state)
}
// ----------------------------------------------------------------------------
// DTOs
// ----------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct CreateRouteRequest {
pub host_kind: HostKind,
#[serde(default)]
pub host: String,
#[serde(default)]
pub host_param_name: Option<String>,
pub path_kind: PathKind,
pub path: String,
pub method: Option<String>,
/// Per-route dispatch mode (v1.1.1). Defaults to `Sync` when
/// omitted so older clients aren't broken. `Async` routes return
/// `202 Accepted` immediately and run the script in the
/// background via the dispatcher.
#[serde(default)]
pub dispatch_mode: picloud_shared::DispatchMode,
}
#[derive(Debug, Deserialize)]
pub struct CheckRouteRequest {
/// Required: which app's route table this hypothetical route would
/// join. Conflict checks are strictly intra-app (cross-app route
/// errors would leak tenant info — see blueprint §11.5).
pub app_id: AppId,
pub host_kind: HostKind,
#[serde(default)]
pub host: String,
pub path_kind: PathKind,
pub path: String,
pub method: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct CheckRouteResponse {
pub ok: bool,
pub conflicting_route: Option<Route>,
pub conflict_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct MatchRouteRequest {
/// Which app's route table to dispatch against. The dashboard's
/// route-preview tester always knows the current app context.
pub app_id: AppId,
pub url: String,
#[serde(default = "default_method")]
pub method: String,
}
fn default_method() -> String {
"GET".into()
}
#[derive(Debug, Serialize)]
pub struct MatchRouteResponse {
pub matched: Option<MatchedRoute>,
}
#[derive(Debug, Serialize)]
pub struct MatchedRoute {
pub route_id: Uuid,
pub script_id: ScriptId,
pub params: std::collections::BTreeMap<String, String>,
pub rest: Option<String>,
pub host_param: Option<(String, String)>,
}
// ----------------------------------------------------------------------------
// Handlers
// ----------------------------------------------------------------------------
async fn list_routes<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR, SR>>,
Extension(principal): Extension<Principal>,
Path(script_id): Path<ScriptId>,
) -> Result<Json<Vec<Route>>, RouteApiError> {
let script = state
.scripts
.get(script_id)
.await?
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
require(
state.authz.as_ref(),
&principal,
Capability::AppRead(script.app_id),
)
.await?;
Ok(Json(state.routes.list_for_script(script_id).await?))
}
async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR, SR>>,
Extension(principal): Extension<Principal>,
Path(script_id): Path<ScriptId>,
Json(input): Json<CreateRouteRequest>,
) -> Result<(StatusCode, Json<Route>), RouteApiError> {
let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?;
pattern::parse_host(
input.host_kind,
&input.host,
input.host_param_name.as_deref(),
)?;
// Look up the script's owning app — every route inherits it.
let script = state
.scripts
.get(script_id)
.await?
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
let app_id = script.app_id;
require(
state.authz.as_ref(),
&principal,
Capability::AppWriteRoute(app_id),
)
.await?;
// v1.1.3: module scripts have no executable entry point — they're
// libraries imported by other scripts. Reject route bindings here
// before we touch the routes table.
if script.kind == picloud_shared::ScriptKind::Module {
return Err(RouteApiError::BadRequest(format!(
"script {script_id} has kind=module; modules are imported, \
not bound to routes — switch the script to kind=endpoint \
or attach this route to a different script"
)));
}
// Validate the route's host is consistent with one of the app's
// domain claims. `HostKind::Any` is always permitted (catches every
// host the app already owns). Specific hosts must match a claim.
validate_route_host_against_app(state.domains.as_ref(), app_id, input.host_kind, &input.host)
.await?;
// Within-app conflict check (cross-app is impossible by construction).
let existing = state.routes.list_for_app(app_id).await?;
if let Some((conflicting, reason)) = first_conflict(
&existing,
input.host_kind,
&input.host,
input.path_kind,
&normalized_path,
input.method.as_deref(),
)? {
return Err(RouteApiError::Conflict {
conflicting_route: Box::new(conflicting),
reason,
});
}
let created = state
.routes
.create(NewRoute {
app_id,
script_id,
host_kind: input.host_kind,
host: input.host,
host_param_name: input.host_param_name,
path_kind: input.path_kind,
path: normalized_path,
method: input.method,
dispatch_mode: input.dispatch_mode,
})
.await?;
refresh_table(&state).await?;
Ok((StatusCode::CREATED, Json(created)))
}
async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR, SR>>,
Extension(principal): Extension<Principal>,
Path(route_id): Path<Uuid>,
) -> Result<StatusCode, RouteApiError> {
// Resolve the route's app before we delete, so the capability
// binds to the actual route's app_id (not a path param).
let route = state
.routes
.get(route_id)
.await?
.ok_or(RouteApiError::RouteNotFound(route_id))?;
require(
state.authz.as_ref(),
&principal,
Capability::AppWriteRoute(route.app_id),
)
.await?;
state.routes.delete(route_id).await?;
refresh_table(&state).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn check_route<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR, SR>>,
Extension(principal): Extension<Principal>,
Json(input): Json<CheckRouteRequest>,
) -> Result<Json<CheckRouteResponse>, RouteApiError> {
// routes:check is read-only — peeking at a hypothetical conflict
// is bounded by AppRead on the target app (otherwise members
// could probe other apps).
require(
state.authz.as_ref(),
&principal,
Capability::AppRead(input.app_id),
)
.await?;
let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?;
pattern::parse_host(input.host_kind, &input.host, None)?;
let existing = state.routes.list_for_app(input.app_id).await?;
let conflict = first_conflict(
&existing,
input.host_kind,
&input.host,
input.path_kind,
&normalized_path,
input.method.as_deref(),
)?;
Ok(Json(match conflict {
None => CheckRouteResponse {
ok: true,
conflicting_route: None,
conflict_reason: None,
},
Some((route, reason)) => CheckRouteResponse {
ok: false,
conflicting_route: Some(route),
conflict_reason: Some(reason),
},
}))
}
async fn match_route<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR, SR>>,
Extension(principal): Extension<Principal>,
Json(input): Json<MatchRouteRequest>,
) -> Result<Json<MatchRouteResponse>, RouteApiError> {
require(
state.authz.as_ref(),
&principal,
Capability::AppRead(input.app_id),
)
.await?;
let parsed = url::Url::parse(&input.url)
.map_err(|e| RouteApiError::BadRequest(format!("invalid url: {e}")))?;
let host = parsed.host_str().unwrap_or("").to_string();
let path = parsed.path().to_string();
let result = state
.table
.match_request_for_app(input.app_id, &host, &input.method, &path);
Ok(Json(MatchRouteResponse {
matched: result.map(|r| MatchedRoute {
route_id: r.matched.route_id,
script_id: r.matched.script_id,
params: r.params,
rest: r.rest,
host_param: r.host_param,
}),
}))
}
// ----------------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------------
/// Validate the raw user-typed path string and return it verbatim if
/// it parses cleanly. Prefix normalization (`/echo/*` → `/echo/`)
/// happens only in memory at compile time; persisted strings stay in
/// the form the user submitted so re-parses are idempotent.
fn parse_and_normalize_path(kind: PathKind, raw: &str) -> Result<String, pattern::ParseError> {
pattern::parse_path(kind, raw)?;
Ok(raw.to_string())
}
#[allow(clippy::type_complexity)]
fn first_conflict(
existing: &[Route],
host_kind: HostKind,
host: &str,
path_kind: PathKind,
path: &str,
method: Option<&str>,
) -> Result<Option<(Route, String)>, RouteApiError> {
let new_host = pattern::parse_host(host_kind, host, None)?;
let new_path = pattern::parse_path(path_kind, path)?;
for r in existing {
let r_host = pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?;
if !conflict::hosts_overlap(&new_host, &r_host) {
continue;
}
if !conflict::methods_overlap(method, r.method.as_deref()) {
continue;
}
let r_path = pattern::parse_path(r.path_kind, &r.path)?;
if let Some(reason) = conflict::conflicts(&new_path, &r_path) {
return Ok(Some((r.clone(), format!("{reason:?}"))));
}
}
Ok(None)
}
async fn refresh_table<RR: RouteRepository, SR: ScriptRepository>(
state: &RouteAdminState<RR, SR>,
) -> Result<(), RouteApiError> {
let rows = state.routes.list_all().await?;
let compiled = compile_routes(&rows)?;
state.table.replace_all(compiled);
Ok(())
}
pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::ParseError> {
rows.iter()
.map(|r| {
Ok(CompiledRoute {
route_id: r.id,
app_id: r.app_id,
script_id: r.script_id,
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
path: pattern::parse_path(r.path_kind, &r.path)?,
method: r.method.clone(),
dispatch_mode: r.dispatch_mode,
})
})
.collect()
}
/// Validate that a new route's (host_kind, host) is consistent with at
/// least one of the parent app's domain claims. `HostKind::Any` is
/// always permitted — it catches every host the app already owns.
async fn validate_route_host_against_app(
domains: &dyn AppDomainRepository,
app_id: AppId,
host_kind: HostKind,
host: &str,
) -> Result<(), RouteApiError> {
if matches!(host_kind, HostKind::Any) {
return Ok(());
}
let claims = domains.list_for_app(app_id).await?;
if claims.is_empty() {
return Err(RouteApiError::HostNotClaimed {
host: host.to_string(),
available_claims: vec![],
});
}
let host_lower = host.to_ascii_lowercase();
for claim in &claims {
let claim_lower = claim.pattern.to_ascii_lowercase();
match (host_kind, claim.shape) {
// Strict route under exact claim: must match exactly.
(HostKind::Strict, picloud_shared::DomainShape::Exact) => {
if host_lower == claim_lower {
return Ok(());
}
}
// Strict route under wildcard/parameterized: must end with
// ".<suffix>" where the claim's suffix is the part after
// `*.` or `{...}.`.
(
HostKind::Strict,
picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
) => {
let suffix = claim_lower
.split_once('.')
.map(|(_, s)| s.to_string())
.unwrap_or_default();
let needle = format!(".{suffix}");
if !suffix.is_empty() && host_lower.ends_with(&needle) {
return Ok(());
}
}
// Wildcard route: must match a wildcard or parameterized
// claim with identical suffix.
(
HostKind::Wildcard,
picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
) => {
let claim_suffix = claim_lower
.split_once('.')
.map(|(_, s)| s.to_string())
.unwrap_or_default();
if claim_suffix == host_lower {
return Ok(());
}
}
// Wildcard route under exact claim: not allowed (would
// shadow other apps' subdomains the operator didn't claim).
(HostKind::Wildcard, picloud_shared::DomainShape::Exact) => {}
(HostKind::Any, _) => unreachable!("handled above"),
}
}
Err(RouteApiError::HostNotClaimed {
host: host.to_string(),
available_claims: claims.into_iter().map(|c| c.pattern).collect(),
})
}
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
#[derive(Debug, thiserror::Error)]
pub enum RouteApiError {
#[error("route conflicts with existing route ({reason})")]
Conflict {
conflicting_route: Box<Route>,
reason: String,
},
#[error("invalid route: {0}")]
Pattern(#[from] pattern::ParseError),
#[error("bad request: {0}")]
BadRequest(String),
#[error("script not found: {0}")]
ScriptNotFound(ScriptId),
#[error("route not found: {0}")]
RouteNotFound(Uuid),
#[error("host {host:?} is not claimed by this app")]
HostNotClaimed {
host: String,
available_claims: Vec<String>,
},
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("repository error: {0}")]
Repo(#[from] ScriptRepositoryError),
}
impl From<AuthzDenied> for RouteApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl IntoResponse for RouteApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::Conflict {
conflicting_route,
reason,
} => (
StatusCode::CONFLICT,
serde_json::json!({
"error": self.to_string(),
"reason": reason,
"conflicting_route": &**conflicting_route,
}),
),
Self::Pattern(_) | Self::BadRequest(_) => (
StatusCode::UNPROCESSABLE_ENTITY,
serde_json::json!({ "error": self.to_string() }),
),
Self::ScriptNotFound(_)
| Self::RouteNotFound(_)
| Self::Repo(ScriptRepositoryError::NotFound(_)) => (
StatusCode::NOT_FOUND,
serde_json::json!({ "error": self.to_string() }),
),
Self::Forbidden => (
StatusCode::FORBIDDEN,
serde_json::json!({ "error": self.to_string() }),
),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "route authz repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
serde_json::json!({ "error": "internal error" }),
)
}
Self::HostNotClaimed {
host,
available_claims,
} => (
StatusCode::UNPROCESSABLE_ENTITY,
serde_json::json!({
"error": self.to_string(),
"host": host,
"available_claims": available_claims,
}),
),
Self::Repo(ScriptRepositoryError::Conflict(_)) => (
StatusCode::CONFLICT,
serde_json::json!({ "error": self.to_string() }),
),
Self::Repo(ScriptRepositoryError::Db(e)) => {
tracing::error!(error = %e, "route admin db error");
(
StatusCode::INTERNAL_SERVER_ERROR,
serde_json::json!({ "error": "internal error" }),
)
}
};
(status, Json(body)).into_response()
}
}