feat(hierarchies): route templates — group-declared, per-app fan-out (M4a)

§4.5 of the groups/project-tool design. A group declares a route ONCE; the
tree apply fans it out into one concrete per-app_id route on every descendant
app — instantiation, not inheritance (the cross-app isolation boundary is
unchanged; expanded rows stay app-owned).

- Migration 0053: `route_templates` table (group-owned) + `routes.from_template`
  provenance column + index.
- `template_repo.rs`: tx-aware CRUD + chain-load (mirrors group_repo/route_repo).
- Manifest: `[group]` `[[route_templates]]`; build_bundle emits them; rejected
  on an app node (422).
- Apply: group nodes reconcile template rows (create/update/delete via the
  plan); tree Phase B expands each chain template into a concrete route per
  descendant app node — placeholders {app_slug}/{env}/{var:NAME} resolved per
  app (unknown var/placeholder = hard error), script bound nearest-owner-wins.
  Idempotent via from_template (diffed create/update-as-replace/delete; re-apply
  is no-churn, --prune reaps expansions). Collision with a hand-declared route,
  or between two templates, is a hard error. Each RESOLVED expansion is
  validated like a hand-declared route (reserved-path, path/host parse,
  host-claim) so a {var:}-injected path or unclaimed strict host can't slip in.
- Plan: route-template diff at the group node + blast-radius (descendant app
  count in this apply); state_token folds each template (edit trips StateMoved).
- Authz: declaring → GroupScriptsWrite; an app receiving expansions →
  AppWriteRoute (gated even with no hand-declared routes).
- Tests: tests/templates.rs (fan-out + per-slug + idempotent-stable-ids +
  prune-reap + collision-rejected); placeholder/validation unit tests; schema
  golden reblessed.

Reviewed (no CRITICAL/HIGH; isolation core verified sound). Closed the two
validation-parity MEDIUMs (host-claim + reserved-path/pattern on expansions)
and added an out-of-apply-descendant prune warning. Scope: expansion targets
app nodes in the tree apply (not yet cross-repo descendants); {var:NAME} uses
committed vars. M4b (trigger templates) reuses this engine next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-28 15:58:41 +02:00
parent 193336a8a6
commit 0396866698
18 changed files with 1504 additions and 37 deletions

View File

@@ -88,6 +88,7 @@ async fn seed_into(
method: None,
dispatch_mode: picloud_shared::DispatchMode::Sync,
enabled: true,
from_template: None,
})
.await?;

View File

@@ -202,6 +202,10 @@ struct TreeApplyRequest {
/// Take over declared groups owned by another project (group-admin gated).
#[serde(default)]
allow_takeover: bool,
/// Selected environment (§4.5, M4a): the value substituted for the `{env}`
/// placeholder when expanding route templates. The CLI passes `--env`.
#[serde(default)]
env: Option<String>,
}
async fn tree_plan_handler(
@@ -237,6 +241,7 @@ async fn tree_apply_handler(
req.expected_token.as_deref(),
req.project_key.as_deref(),
req.allow_takeover,
req.env.as_deref(),
)
.await?;
Ok(Json(report))
@@ -261,6 +266,20 @@ async fn authz_tree(
Some(prune) => {
require_app_node_writes(svc, principal, app_id, &node.bundle, prune)
.await?;
// Template expansion (§4.5, M4a) writes routes INTO this
// app, so the actor needs AppWriteRoute when the app's
// chain carries route templates — even if the app itself
// declares no routes. Without this, expanding routes into
// an app a principal can't write would slip the gate.
if app_receives_template_expansions(svc, app_id, bundle).await? {
require(
svc.authz.as_ref(),
principal,
Capability::AppWriteRoute(app_id),
)
.await
.map_err(map_authz)?;
}
}
None => {
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
@@ -467,8 +486,13 @@ async fn require_group_node_writes(
bundle: &Bundle,
prune: bool,
) -> Result<(), ApplyError> {
// Extension points gate on the script-write tier (see the app variant).
if prune || !bundle.scripts.is_empty() || !bundle.extension_points.is_empty() {
// Extension points and route templates gate on the script-write tier (see
// the app variant) — both are code-binding declarations, not viewer-tier.
if prune
|| !bundle.scripts.is_empty()
|| !bundle.extension_points.is_empty()
|| !bundle.route_templates.is_empty()
{
require(
svc.authz.as_ref(),
principal,
@@ -489,6 +513,59 @@ async fn require_group_node_writes(
Ok(())
}
/// Will route-template expansion (§4.5, M4a) write routes into `app_id` during
/// this tree apply? True iff the app's ancestor chain carries a route template,
/// counting both committed templates and ones declared by a group node in THIS
/// bundle. Drives the `AppWriteRoute` gate for expansion recipients.
async fn app_receives_template_expansions(
svc: &ApplyService,
app_id: AppId,
bundle: &TreeBundle,
) -> Result<bool, ApplyError> {
let Some(app) = svc
.apps
.get_by_id(app_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
else {
return Ok(false);
};
let ancestors = svc
.groups
.ancestors(app.group_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
let ancestor_ids: std::collections::HashSet<GroupId> = ancestors.iter().map(|g| g.id).collect();
// Committed templates on any ancestor group.
for gid in &ancestor_ids {
if !crate::template_repo::list_for_group(&svc.pool, *gid)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
.is_empty()
{
return Ok(true);
}
}
// Templates newly declared by a group node in this bundle that is an
// ancestor of the app (existing groups only — a to-create group has no apps).
for node in &bundle.nodes {
if node.kind == NodeKind::Group && !node.bundle.route_templates.is_empty() {
if let Some(g) = svc
.groups
.get_by_slug(&node.slug)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
{
if ancestor_ids.contains(&g.id) {
return Ok(true);
}
}
}
}
Ok(false)
}
/// Resolve a slug-or-id path param to a `GroupId`, mapping miss → 404.
async fn resolve_group_id(
groups: &dyn GroupRepository,

File diff suppressed because it is too large Load Diff

View File

@@ -80,6 +80,7 @@ pub mod secrets_api;
pub mod secrets_repo;
pub mod secrets_service;
pub mod ssrf;
pub mod template_repo;
pub mod topic_repo;
pub mod topics_api;
pub mod trigger_config;

View File

@@ -241,6 +241,8 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
dispatch_mode: input.dispatch_mode,
// Routes are created active; toggling is a dedicated path.
enabled: true,
// Hand-declared via the interactive API — not a template expansion.
from_template: None,
})
.await?;
refresh_table(&state).await?;

View File

@@ -23,6 +23,10 @@ pub struct NewRoute {
pub dispatch_mode: DispatchMode,
/// Three-state lifecycle (§4.3). Create active by default.
pub enabled: bool,
/// Provenance (§4.5, M4): the route-template id this row was expanded from,
/// or `None` for a hand-declared route. Defaults `None` everywhere except
/// template expansion.
pub from_template: Option<Uuid>,
}
#[async_trait]
@@ -175,8 +179,8 @@ pub(crate) async fn insert_route_tx(
let res = sqlx::query_as::<_, RouteRow>(
"INSERT INTO routes ( \
app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, dispatch_mode, enabled \
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \
path_kind, path, method, dispatch_mode, enabled, from_template \
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, dispatch_mode, enabled, created_at",
)
@@ -190,6 +194,7 @@ pub(crate) async fn insert_route_tx(
.bind(input.method.as_deref())
.bind(input.dispatch_mode.as_str())
.bind(input.enabled)
.bind(input.from_template)
.fetch_one(&mut **tx)
.await;
match res {

View File

@@ -0,0 +1,200 @@
//! CRUD over `route_templates` (§4.5, M4a) — route declarations owned by a
//! group that the apply engine fans out into one concrete `routes` row per
//! descendant app. Mirrors the tx-accepting free-function pattern of
//! `route_repo`/`group_repo`: pool variants for the read/diff path, `_tx`
//! variants for the transactional apply (upsert/delete and chain expansion).
use picloud_shared::{DispatchMode, HostKind, PathKind};
use sqlx::PgPool;
use uuid::Uuid;
use crate::apply_service::RouteTemplateSpec;
use crate::group_repo::GroupRepositoryError as Error;
use picloud_shared::GroupId;
/// A persisted route template, decoded with its enum fields parsed — ready for
/// the diff (by name) and for expansion (synthesizing a concrete route).
#[derive(Debug, Clone)]
pub struct RouteTemplate {
pub id: Uuid,
pub group_id: GroupId,
pub name: String,
pub script_name: String,
pub method: Option<String>,
pub host_kind: HostKind,
pub host: String,
pub host_param_name: Option<String>,
pub path_kind: PathKind,
pub path: String,
pub dispatch_mode: DispatchMode,
pub enabled: bool,
}
#[derive(sqlx::FromRow)]
struct RouteTemplateRow {
id: Uuid,
group_id: Uuid,
name: String,
script_name: String,
method: Option<String>,
host_kind: String,
host: String,
host_param_name: Option<String>,
path_kind: String,
path: String,
dispatch_mode: String,
enabled: bool,
}
impl From<RouteTemplateRow> for RouteTemplate {
fn from(r: RouteTemplateRow) -> Self {
Self {
id: r.id,
group_id: r.group_id.into(),
name: r.name,
script_name: r.script_name,
method: r.method,
host_kind: match r.host_kind.as_str() {
"strict" => HostKind::Strict,
"wildcard" => HostKind::Wildcard,
_ => HostKind::Any,
},
host: r.host,
host_param_name: r.host_param_name,
path_kind: match r.path_kind.as_str() {
"prefix" => PathKind::Prefix,
"param" => PathKind::Param,
_ => PathKind::Exact,
},
path: r.path,
dispatch_mode: DispatchMode::from_wire(&r.dispatch_mode).unwrap_or(DispatchMode::Sync),
enabled: r.enabled,
}
}
}
const COLS: &str = "id, group_id, name, script_name, method, host_kind, host, \
host_param_name, path_kind, path, dispatch_mode, enabled";
const fn host_kind_str(k: HostKind) -> &'static str {
match k {
HostKind::Any => "any",
HostKind::Strict => "strict",
HostKind::Wildcard => "wildcard",
}
}
const fn path_kind_str(k: PathKind) -> &'static str {
match k {
PathKind::Exact => "exact",
PathKind::Prefix => "prefix",
PathKind::Param => "param",
}
}
/// A group's OWN route templates (read/diff path).
pub(crate) async fn list_for_group(
pool: &PgPool,
group_id: GroupId,
) -> Result<Vec<RouteTemplate>, Error> {
let rows = sqlx::query_as::<_, RouteTemplateRow>(&format!(
"SELECT {COLS} FROM route_templates WHERE group_id = $1 ORDER BY LOWER(name)"
))
.bind(group_id.into_inner())
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
/// Every route template owned by any group in `group_ids` (expansion path,
/// in-tx so it sees templates just reconciled earlier in this apply).
pub(crate) async fn list_for_groups_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_ids: &[GroupId],
) -> Result<Vec<RouteTemplate>, Error> {
if group_ids.is_empty() {
return Ok(Vec::new());
}
let ids: Vec<Uuid> = group_ids.iter().map(|g| g.into_inner()).collect();
let rows = sqlx::query_as::<_, RouteTemplateRow>(&format!(
"SELECT {COLS} FROM route_templates WHERE group_id = ANY($1) ORDER BY LOWER(name)"
))
.bind(&ids)
.fetch_all(&mut **tx)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
/// Insert a new route template (diff Op::Create).
pub(crate) async fn insert_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
spec: &RouteTemplateSpec,
) -> Result<(), Error> {
let r = &spec.route;
sqlx::query(
"INSERT INTO route_templates ( \
group_id, name, script_name, method, host_kind, host, \
host_param_name, path_kind, path, dispatch_mode, enabled \
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
)
.bind(group_id.into_inner())
.bind(&spec.name)
.bind(&r.script)
.bind(r.method.as_deref())
.bind(host_kind_str(r.host_kind))
.bind(&r.host)
.bind(r.host_param_name.as_deref())
.bind(path_kind_str(r.path_kind))
.bind(&r.path)
.bind(r.dispatch_mode.as_str())
.bind(r.enabled)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Replace an existing route template's fields, keyed by `(group, lower(name))`
/// (diff Op::Update).
pub(crate) async fn update_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
spec: &RouteTemplateSpec,
) -> Result<(), Error> {
let r = &spec.route;
sqlx::query(
"UPDATE route_templates SET \
script_name = $3, method = $4, host_kind = $5, host = $6, \
host_param_name = $7, path_kind = $8, path = $9, dispatch_mode = $10, \
enabled = $11, updated_at = NOW() \
WHERE group_id = $1 AND LOWER(name) = LOWER($2)",
)
.bind(group_id.into_inner())
.bind(&spec.name)
.bind(&r.script)
.bind(r.method.as_deref())
.bind(host_kind_str(r.host_kind))
.bind(&r.host)
.bind(r.host_param_name.as_deref())
.bind(path_kind_str(r.path_kind))
.bind(&r.path)
.bind(r.dispatch_mode.as_str())
.bind(r.enabled)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Delete a route template by name (diff Op::Delete under `--prune`).
pub(crate) async fn delete_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
name: &str,
) -> Result<(), Error> {
sqlx::query("DELETE FROM route_templates WHERE group_id = $1 AND LOWER(name) = LOWER($2)")
.bind(group_id.into_inner())
.bind(name)
.execute(&mut **tx)
.await?;
Ok(())
}