A group's template suppression is "coarse by reference" (path for routes, handler-name for triggers), and both resolution points dropped ANY inherited row matching that reference across the whole subtree — including one a NEARER descendant group deliberately re-declared. So an ancestor group G that declines a far-ancestor's `/x` (or `audit` handler) would also silently kill a child group H's OWN `/x` / `audit`-bound trigger at the same reference, violating the documented "an owner can only decline what it inherits, never a descendant's own rows" invariant. Fix: gate each decline on the chain DEPTH of the suppressor vs the target's owner — a suppressor at depth `d_s` may only decline a row whose owner is strictly ABOVE it (`target_depth > d_s`): - Routes: `list_route_suppressions` now returns `(app, path, suppressor_depth)`; the rebuild folds it to the min depth per `(app, path)` and `compile_effective_routes` skips an inherited route only when `route.depth > suppressor_depth`. (This also subsumes the old `depth > 0` inherited-only gate.) - Triggers: the dispatch anti-join gains `AND sc.depth < c.depth` (the suppressor's chain depth below the trigger owner's), correlating on the outer `chain c` that every kv/docs/files/pubsub match query already binds. An app's own suppression is depth 0 → still declines anything it inherits; a group's suppression declines only what that group itself inherits. `sealed` still overrides. No schema change. Pinned by a new `compile_effective_routes` unit test (a depth-1 descendant route survives a depth-2 suppression that declines a depth-3 template) and a new `group_suppression` DB test (same for triggers); all existing suppression / sealed / template journeys stay green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
918 lines
34 KiB
Rust
918 lines
34 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, ScriptOwner};
|
|
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::{EffectiveRoute, 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))?;
|
|
// Phase 4: routes bind to app-owned scripts; a group-owned script
|
|
// (`app_id: None`) is not addressable here (binding lands in C3), so it
|
|
// reads as a missing script.
|
|
let app_id = script
|
|
.app_id
|
|
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
|
require(
|
|
state.authz.as_ref(),
|
|
&principal,
|
|
Capability::AppRead(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))?;
|
|
// Phase 4: only app-owned scripts are route-bindable for now (group
|
|
// binding is C3); a group script reads as missing here.
|
|
let app_id = script
|
|
.app_id
|
|
.ok_or(RouteApiError::ScriptNotFound(script_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 {
|
|
owner: ScriptOwner::App(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,
|
|
// Routes are created active; toggling is a dedicated path.
|
|
enabled: true,
|
|
// Sealing is a group-template property (§11 tail); an interactively
|
|
// created app route is never sealed.
|
|
sealed: false,
|
|
})
|
|
.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))?;
|
|
// §11 tail: the interactive route API is app-scoped. A group-owned route
|
|
// TEMPLATE (`app_id` None) is managed declaratively via apply, not
|
|
// addressable here — treat it as not found.
|
|
let route_app_id = route.app_id.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> {
|
|
rebuild_route_table(state.routes.as_ref(), &state.table).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Rebuild the entire in-memory [`RouteTable`] from the database, expanding
|
|
/// §11 tail group route TEMPLATES into every descendant app's slice.
|
|
///
|
|
/// This is the single chokepoint for route-table refresh — called from route
|
|
/// CRUD, the declarative apply reconcile, startup, and (full-live invalidation)
|
|
/// every tree mutation that changes inheritance (app create/reparent/delete,
|
|
/// group reparent). It reads the live expansion (`list_effective`) so a group
|
|
/// template lands in the slice of each app whose ancestor chain contains the
|
|
/// owning group, and nowhere else — the chain walk is the isolation boundary.
|
|
pub async fn rebuild_route_table(
|
|
routes: &dyn RouteRepository,
|
|
table: &RouteTable,
|
|
) -> Result<(), ScriptRepositoryError> {
|
|
let effective = routes.list_effective().await?;
|
|
// §11 tail: route suppressions keyed `(app_id, path)` → the SMALLEST
|
|
// suppressor depth seen for that key. An inherited route is dropped only
|
|
// when its owner is above the suppressor (`route_depth > suppressor_depth`),
|
|
// so a group's decline of an ancestor template never clobbers a nearer
|
|
// descendant's own route at the same path.
|
|
let mut suppressed: std::collections::HashMap<(AppId, String), i32> =
|
|
std::collections::HashMap::new();
|
|
for (app, path, depth) in routes.list_route_suppressions().await? {
|
|
suppressed
|
|
.entry((app, path))
|
|
.and_modify(|d| *d = (*d).min(depth))
|
|
.or_insert(depth);
|
|
}
|
|
let compiled = compile_effective_routes(&effective, &suppressed);
|
|
table.replace_all(compiled);
|
|
Ok(())
|
|
}
|
|
|
|
/// Compile the §11 tail effective expansion into per-app match slices, applying
|
|
/// **nearest-owner-wins shadowing**: for a given app, if the same binding tuple
|
|
/// (method + host + path) is owned at more than one chain level — e.g. the app
|
|
/// declares its own `/x` and an ancestor group also templates `/x` — the
|
|
/// nearest owner (lowest `depth`) wins and the farther one is dropped. Unlike
|
|
/// triggers (which fan out to every match), a route resolves to a single
|
|
/// winner, so identical bindings MUST dedupe; **non-identical** bindings (the
|
|
/// app's `/users/:id` + the group's `/users/admin`) coexist and the matcher's
|
|
/// existing precedence picks per request.
|
|
///
|
|
/// This is the single production entry point: app-only installs feed it routes
|
|
/// at `depth 0` (one per app, no templates), so it subsumes the former app-only
|
|
/// compile path. Disabled routes are dropped (§4.3) and un-compilable rows
|
|
/// skipped-with-warning (see [`compile_one`]).
|
|
///
|
|
/// **Disabled + inherited (§4.3 semantic):** the `enabled` filter runs *before*
|
|
/// the shadow check, so a **disabled** own-route does NOT claim its binding — an
|
|
/// enabled ancestor-group template at the same binding then falls through and
|
|
/// serves (disabled = "indistinguishable from absent"). To actually 404 an
|
|
/// inherited route, a descendant SUPPRESSES its path (§11 tail, the
|
|
/// `suppressed_paths` set) — the deliberate per-app opt-out.
|
|
///
|
|
/// `suppressed_paths` maps `(app_id, path)` → the smallest suppressor depth for
|
|
/// that key. An INHERITED route (`depth > 0`) is skipped only when its owner is
|
|
/// strictly ABOVE the suppressor (`route.depth > suppressor_depth`) — so an
|
|
/// app's own suppression (depth 0) declines anything inherited, a group's
|
|
/// suppression declines only what that group itself inherits (owned above it),
|
|
/// and a nearer descendant's own re-declaration at the same path SURVIVES an
|
|
/// ancestor's suppression. The `route.depth > suppressor_depth` test also
|
|
/// subsumes the "inherited only" gate (a suppressor at depth `d` can only touch
|
|
/// rows at depth `> d ≥ 0`, never its own depth-0 route).
|
|
///
|
|
/// Requires `rows` ordered by `(effective_app_id, depth ASC)` — which
|
|
/// [`RouteRepository::list_effective`] guarantees — so first-seen is nearest.
|
|
#[must_use]
|
|
#[allow(clippy::implicit_hasher)] // every caller uses the default hasher
|
|
pub fn compile_effective_routes(
|
|
rows: &[EffectiveRoute],
|
|
suppressed_paths: &std::collections::HashMap<(AppId, String), i32>,
|
|
) -> Vec<CompiledRoute> {
|
|
let mut seen: std::collections::HashSet<(AppId, String)> = std::collections::HashSet::new();
|
|
let mut out = Vec::new();
|
|
for er in rows {
|
|
// A disabled route (§4.3) is dropped from the match table entirely, so a
|
|
// request to it 404s indistinguishably from an absent route.
|
|
if !er.route.enabled {
|
|
continue;
|
|
}
|
|
// §11 tail: an inherited route whose path this app suppresses is
|
|
// dropped — but ONLY if the route's owner is above the suppressor
|
|
// (`route.depth > suppressor_depth`), so a descendant group's own
|
|
// re-declaration at the same path isn't over-declined by an ancestor's
|
|
// suppression. A `sealed` template is non-suppressible.
|
|
if !er.route.sealed
|
|
&& suppressed_paths
|
|
.get(&(er.effective_app_id, er.route.path.clone()))
|
|
.is_some_and(|&suppressor_depth| er.depth > suppressor_depth)
|
|
{
|
|
continue;
|
|
}
|
|
// A nearer owner already claimed this binding for this app → shadow.
|
|
if !seen.insert((er.effective_app_id, binding_key(&er.route))) {
|
|
continue;
|
|
}
|
|
if let Some(compiled) = compile_one(&er.route, er.effective_app_id) {
|
|
out.push(compiled);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The shadow-identity of a route within one app's slice: the binding tuple a
|
|
/// request resolves against (method + host + path), matching the per-owner DB
|
|
/// unique index. Two routes with the same key are "the same endpoint" and only
|
|
/// the nearest owner's survives.
|
|
fn binding_key(r: &Route) -> String {
|
|
let method = r
|
|
.method
|
|
.as_deref()
|
|
.map_or("ANY".to_string(), str::to_uppercase);
|
|
let host_kind = match r.host_kind {
|
|
HostKind::Any => "any",
|
|
HostKind::Strict => "strict",
|
|
HostKind::Wildcard => "wildcard",
|
|
};
|
|
let path_kind = match r.path_kind {
|
|
PathKind::Exact => "exact",
|
|
PathKind::Prefix => "prefix",
|
|
PathKind::Param => "param",
|
|
};
|
|
format!("{method} {host_kind} {} {path_kind} {}", r.host, r.path)
|
|
}
|
|
|
|
/// Parse one stored route into a `CompiledRoute` bound to `app_id` (the
|
|
/// effective app — its own for app routes, the firing descendant for a group
|
|
/// template).
|
|
///
|
|
/// **Lenient by design (H1).** A row that fails to parse is *skipped with a
|
|
/// warning*, not propagated as an error. The motivating case: a path that was
|
|
/// valid when created but became reserved under a later, stricter validation
|
|
/// (the case-insensitive reserved-prefix check) — but this also covers any
|
|
/// other parse failure. A single un-compilable row must never take down the
|
|
/// data plane: this runs at startup (where a hard error aborts boot) and on
|
|
/// every table rebuild after an edit (where it would fail an unrelated CRUD
|
|
/// op). A skipped route simply doesn't match; the warning tells the operator to
|
|
/// delete or fix it (and migration 0044 sweeps the reserved-path offenders on
|
|
/// upgrade).
|
|
fn compile_one(r: &Route, app_id: AppId) -> Option<CompiledRoute> {
|
|
match compile_route(r, app_id) {
|
|
Ok(compiled) => Some(compiled),
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
route_id = %r.id,
|
|
app_id = %app_id,
|
|
path = %r.path,
|
|
error = %e,
|
|
"skipping un-compilable stored route — it will not match; \
|
|
delete or fix it"
|
|
);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
fn compile_route(r: &Route, app_id: AppId) -> Result<CompiledRoute, pattern::ParseError> {
|
|
Ok(CompiledRoute {
|
|
route_id: r.id,
|
|
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,
|
|
})
|
|
}
|
|
|
|
/// 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.
|
|
pub(crate) 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()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use picloud_shared::DispatchMode;
|
|
use uuid::Uuid;
|
|
|
|
fn route_with_path(path: &str) -> Route {
|
|
Route {
|
|
id: Uuid::new_v4(),
|
|
app_id: Some(AppId::from(Uuid::new_v4())),
|
|
group_id: None,
|
|
script_id: ScriptId::from(Uuid::new_v4()),
|
|
host_kind: HostKind::Any,
|
|
host: String::new(),
|
|
host_param_name: None,
|
|
path_kind: PathKind::Exact,
|
|
path: path.to_string(),
|
|
method: None,
|
|
dispatch_mode: DispatchMode::default(),
|
|
enabled: true,
|
|
sealed: false,
|
|
created_at: chrono::Utc::now(),
|
|
}
|
|
}
|
|
|
|
/// Wrap an app-owned route as its own `depth 0` effective row — the shape
|
|
/// `list_effective` produces for an app-only install (no group templates).
|
|
fn eff(route: &Route) -> EffectiveRoute {
|
|
EffectiveRoute {
|
|
effective_app_id: route.app_id.expect("test route is app-owned"),
|
|
depth: 0,
|
|
route: route.clone(),
|
|
}
|
|
}
|
|
|
|
/// No route suppressions — the common case for these compile tests.
|
|
fn no_suppress() -> std::collections::HashMap<(AppId, String), i32> {
|
|
std::collections::HashMap::new()
|
|
}
|
|
|
|
/// Build an `EffectiveRoute` at an explicit chain depth (for suppression
|
|
/// tests that need an inherited row).
|
|
fn eff_at(route: &Route, effective_app_id: AppId, depth: i32) -> EffectiveRoute {
|
|
EffectiveRoute {
|
|
effective_app_id,
|
|
depth,
|
|
route: route.clone(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn compile_effective_skips_uncompilable_rows_instead_of_failing() {
|
|
// H1 regression guard: a stored route whose path is now reserved
|
|
// (creatable before the case-insensitive reserved-prefix fix) must
|
|
// be skipped, not abort the whole compile — otherwise one legacy
|
|
// row bricks startup (the effective rebuild runs in `build_app`).
|
|
let good_a = route_with_path("/ok");
|
|
let bad = route_with_path("/API/v2/x"); // now reserved, case-insensitive
|
|
let good_b = route_with_path("/items");
|
|
let rows = [eff(&good_a), eff(&bad), eff(&good_b)];
|
|
|
|
let compiled = compile_effective_routes(&rows, &no_suppress());
|
|
|
|
let ids: Vec<Uuid> = compiled.iter().map(|c| c.route_id).collect();
|
|
assert_eq!(compiled.len(), 2, "the reserved row must be dropped");
|
|
assert!(ids.contains(&good_a.id));
|
|
assert!(ids.contains(&good_b.id));
|
|
assert!(
|
|
!ids.contains(&bad.id),
|
|
"a reserved-path route must be skipped, never abort the compile"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn disabled_route_is_dropped_from_compiled_table() {
|
|
// §4.3: a disabled route is excluded from the match table, so a
|
|
// request to it 404s indistinguishably from an absent route.
|
|
let active = route_with_path("/on");
|
|
let mut disabled = route_with_path("/off");
|
|
disabled.enabled = false;
|
|
let compiled = compile_effective_routes(&[eff(&active), eff(&disabled)], &no_suppress());
|
|
let ids: Vec<Uuid> = compiled.iter().map(|c| c.route_id).collect();
|
|
assert_eq!(ids, vec![active.id], "only the enabled route compiles");
|
|
}
|
|
|
|
#[test]
|
|
fn nearest_owner_shadows_identical_binding() {
|
|
// An app's own route (depth 0) and an ancestor-group template (depth 1)
|
|
// with the SAME binding collapse to the nearer one; a different binding
|
|
// coexists. Mirrors the live `group_route_templates` integration test
|
|
// at the pure-compile layer.
|
|
let app = AppId::from(Uuid::new_v4());
|
|
let own = route_with_path("/x"); // app's own /x
|
|
let mut template = route_with_path("/x"); // group template, same binding
|
|
template.app_id = None;
|
|
template.group_id = Some(picloud_shared::GroupId::from(Uuid::new_v4()));
|
|
let other_template = route_with_path("/y"); // group template, different path
|
|
|
|
let rows = [
|
|
EffectiveRoute {
|
|
effective_app_id: app,
|
|
depth: 0,
|
|
route: own.clone(),
|
|
},
|
|
EffectiveRoute {
|
|
effective_app_id: app,
|
|
depth: 1,
|
|
route: template.clone(),
|
|
},
|
|
EffectiveRoute {
|
|
effective_app_id: app,
|
|
depth: 1,
|
|
route: other_template.clone(),
|
|
},
|
|
];
|
|
let ids: Vec<Uuid> = compile_effective_routes(&rows, &no_suppress())
|
|
.iter()
|
|
.map(|c| c.route_id)
|
|
.collect();
|
|
assert!(ids.contains(&own.id), "the app's own /x must win");
|
|
assert!(
|
|
!ids.contains(&template.id),
|
|
"the inherited /x template must be shadowed by the app's own"
|
|
);
|
|
assert!(
|
|
ids.contains(&other_template.id),
|
|
"a non-identical /y template must coexist"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn group_suppression_does_not_over_decline_a_nearer_descendants_own_route() {
|
|
// Audit fix #3: a suppression declared at depth 2 (an ancestor group G)
|
|
// must decline only routes inherited from ABOVE it (a far ancestor GG's
|
|
// template at depth 3), NOT a nearer descendant group H's own
|
|
// re-declaration of the same path at depth 1. Both /x routes carry
|
|
// DIFFERENT bindings (GET vs POST) so shadowing doesn't hide the bug —
|
|
// the suppression, keyed by path, is what would wrongly drop H's route.
|
|
let app = AppId::from(Uuid::new_v4());
|
|
let mut gg = route_with_path("/x"); // far ancestor's template
|
|
gg.method = Some("GET".into());
|
|
gg.app_id = None;
|
|
gg.group_id = Some(picloud_shared::GroupId::from(Uuid::new_v4()));
|
|
let mut h = route_with_path("/x"); // nearer descendant group's OWN route
|
|
h.method = Some("POST".into());
|
|
h.app_id = None;
|
|
h.group_id = Some(picloud_shared::GroupId::from(Uuid::new_v4()));
|
|
|
|
// Ordered nearest-first, as list_effective guarantees.
|
|
let rows = [eff_at(&h, app, 1), eff_at(&gg, app, 3)];
|
|
// G suppresses /x at depth 2.
|
|
let mut suppressed = std::collections::HashMap::new();
|
|
suppressed.insert((app, "/x".to_string()), 2);
|
|
|
|
let ids: Vec<Uuid> = compile_effective_routes(&rows, &suppressed)
|
|
.iter()
|
|
.map(|c| c.route_id)
|
|
.collect();
|
|
assert!(
|
|
!ids.contains(&gg.id),
|
|
"the far-ancestor template (depth 3 > 2) must be declined"
|
|
);
|
|
assert!(
|
|
ids.contains(&h.id),
|
|
"the nearer descendant's own /x (depth 1 < 2) must NOT be over-declined"
|
|
);
|
|
}
|
|
}
|