Every admin endpoint now resolves Capability for the loaded resource and calls authz::require(...) before mutating. Forbidden → 403; every handler State carries an Arc<dyn AuthzRepo>, plumbed from the new PostgresAppMembersRepository in the picloud binary. * api.rs (scripts): AppRead/AppWriteScript/AppLogRead bound to script.app_id after load. List branches on instance_role: Member → list_for_user, others → list (or ?app= filtered). * apps_api.rs: InstanceCreateApp on POST; AppRead on get/list_domains; AppAdmin on patch/delete/slug:check; AppManageDomains on create_domain/delete_domain. list_apps membership-filters for Member. * admin_users_api.rs: InstanceManageUsers on every endpoint. Mint + PATCH refuse to grant Owner unless the caller is already Owner (CannotEscalate / 422), on top of the existing last-owner guard. * route_admin.rs: AppRead on list/check/match; AppWriteRoute on create/delete bound to the route's actual app_id (added a RouteRepository::get(uuid) lookup so delete binds correctly). * AppRepository + ScriptRepository gain list_for_user(user_id) for membership-filtered listings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
561 lines
18 KiB
Rust
561 lines
18 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>,
|
|
}
|
|
|
|
#[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?;
|
|
|
|
// 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,
|
|
})
|
|
.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(),
|
|
})
|
|
})
|
|
.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()
|
|
}
|
|
}
|