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:
@@ -88,6 +88,7 @@ async fn seed_into(
|
||||
method: None,
|
||||
dispatch_mode: picloud_shared::DispatchMode::Sync,
|
||||
enabled: true,
|
||||
from_template: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
200
crates/manager-core/src/template_repo.rs
Normal file
200
crates/manager-core/src/template_repo.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user